Logical Replication의 전송 메커니즘: 개념 정리와 #6047 패치 리뷰
publisher의 DELETE가 subscriber에서 어느 행을 지우는지부터 REPLICA IDENTITY와 wire 프로토콜, pubviaroot에서 정합성이 깨지는 #6047 사례까지 코드 경로를 따라 정리합니다.
#6047 관련 내용은 현재 진행 중인 CommitFest 논의를 정리한 것이며 확정된 결론이 아닙니다.
Logical replication 은 한 PostgreSQL 서버(publisher)에서 일어난 테이블 변경을 다른 PostgreSQL 서버(subscriber)에 똑같이 재생해 주는 기능입니다. 디스크를 통째로 복사하는 물리 복제와 달리, INSERT·UPDATE·DELETE 같은 행 단위 변경만 골라 논리적으로 전달합니다.
그런데 쓰다 보면 의문이 하나 생깁니다. publisher 에서 DELETE FROM p WHERE a = 2 한 줄이 실행됐을 때, subscriber 는 자기 테이블에서 어느 행을 지워야 할지 어떻게 알까요. 두 서버는 완전히 별개라서 같은 데이터라도 행이 저장된 물리적 위치가 서로 다릅니다. 결국 publisher 가 “지운 행이 무엇이었는지” 를 값으로 알려줘야 하는데, 그 값을 무엇으로 정할지가 바로 REPLICA IDENTITY 입니다.
그래서 INSERT·UPDATE·DELETE 가 각각 어떤 정보를 싣고 subscriber 까지 가는지, 그 경로를 처음부터 끝까지 따라가 봤습니다. 앞부분은 공식 문서로 확인되는 개념과 프로토콜을, 뒷부분은 그 경로를 따라가다 만난 실제 CommitFest 패치 한 건(#6047)을 리뷰로 남겨 둡니다.
설명에는 작은 예시 테이블 하나를 계속 쓰겠습니다. 컬럼이 둘인 테이블 p(a, b) 이고, a 가 행을 식별하는 키, b 는 일반 값이라고 하겠습니다. 지금 (a=2, b=20) 행 하나가 들어 있다고 보면 됩니다.
사전 지식: Replica Identity
먼저 배경 하나가 필요합니다. PostgreSQL 은 테이블에 변경을 반영하기 전에, 그 변경 내용을 WAL(Write-Ahead Log)에 먼저 적습니다. 원래는 장애가 났을 때 복구하기 위한 장치인데, logical replication 은 바로 이 WAL 을 읽어 subscriber 로 흘려보냅니다. 그래서 “무엇이 복제되는가” 는 곧 “무엇이 WAL 에 적히는가” 와 같은 질문이 됩니다.
INSERT 는 간단합니다. 새로 생긴 행을 통째로 적어 그대로 보내면 됩니다. 까다로운 쪽은 UPDATE 와 DELETE 입니다. subscriber 가 “어느 행을” 고치거나 지울지 찾으려면, 그 행을 알아볼 값이 WAL 에 함께 적혀 있어야 합니다. 이때 함께 남기는 변경 전 행 정보를 old tuple 이라 부르고, 여기에 무엇을 담을지 정하는 규칙이 REPLICA IDENTITY 입니다.
설정값은 네 가지이고, 각각 old tuple 을 WAL 에 얼마나 남기는지가 다릅니다.
| 모드 | WAL에 기록되는 old tuple | 비고 |
|---|---|---|
DEFAULT | PK 컬럼만 | 기본값 |
USING INDEX idx | 해당 unique index 컬럼만 | NOT NULL unique index 필요 |
FULL | 행 전체 | WAL 증가 + subscriber 탐색 비용 |
NOTHING | 없음 | UPDATE/DELETE 발행 불가 |
표에서 한 가지 짚어둘 점이 있습니다. unique key(UK)가 따로 있어도 기본 설정인 DEFAULT 는 그것을 자동으로 쓰지 않습니다. DEFAULT 가 식별자로 보는 것은 primary key 뿐이고, UK 를 쓰려면 USING INDEX 로 그 인덱스를 직접 지정해야 합니다. 이때 지정하는 인덱스는 unique 이고, partial(부분 인덱스)이 아니며, deferrable 이 아니고, 컬럼이 모두 NOT NULL 이어야 합니다. 아예 마땅한 키가 없으면 FULL 로 행 전체를 통째로 식별자처럼 쓸 수 있습니다.
예시 테이블 p 가 a 에 unique index 를 두고 USING INDEX 로 지정했다고 하겠습니다. 그러면 old tuple 에는 키인 a 값만 담기고 b 는 빠집니다. 이 “키만 담긴 old tuple” 이 뒤에서 계속 등장합니다.
전체 그림: 네 단계의 파이프라인
publisher 가 변경 하나를 subscriber 로 보내기까지, publisher 내부에서 크게 네 단계를 거칩니다.
① 실행 시점에 replica identity 가 old tuple 의 정보량을 고정합니다. 뒤따르는 단계는 그 정보를 운반할 뿐입니다.
각 단계를 풀어 보면 이렇습니다. ① 실행 단계에서 PostgreSQL 은 행을 실제로 바꾸면서, 동시에 “이 행을 나중에 어떻게 식별할지”(즉 replica identity 에 따른 old tuple)를 함께 뽑아 둡니다. ② 그 변경을 WAL 레코드로 디스크에 적습니다. ③ 나중에 별도 프로세스가 이 WAL 을 읽어 트랜잭션 단위로 재조립합니다. 이 과정을 logical decoding 이라 부릅니다. ④ 마지막으로 pgoutput 이라는 출력 플러그인이 그 변경을 subscriber 가 알아들을 수 있는 네트워크 메시지(wire 프로토콜)로 바꿔 보냅니다. 여기까지 ①~④ 는 모두 publisher 쪽에서 일어나는 일이고, 받은 메시지를 자기 테이블에 적용하는 일만 subscriber 쪽에서 합니다. 그 일을 맡는 것이 뒤에서 만날 apply worker 입니다.
여기서 가장 중요한 사실은, old tuple 에 무엇을 담을지가 ① 실행 시점에 이미 정해져 박제된다는 점입니다. 이후 단계는 그 내용을 운반만 할 뿐, 한 번 WAL 에 남기지 않은 정보는 뒤에서 아무리 애써도 되살릴 수 없습니다.
무엇보다 저는 정보량이 결정되는 ① 단계와, 그것이 실제 바이트로 나가는 ④ 단계에 집중해 보았습니다. ②(WAL 기록)와 ③(재조립)은 내용을 그대로 옮길 뿐 정보량을 바꾸지 않기 때문입니다.
INSERT: 가장 단순한 경우
INSERT 부터 보겠습니다. subscriber 입장에서 INSERT 는 그냥 새 행 하나를 추가하는 일이라, “어느 행인지 찾는” 과정이 없습니다. 그래서 old tuple 이라는 개념 자체가 필요 없습니다. publisher 는 새 행을 통째로 담아 보내고, 그게 전부입니다.
프로토콜로 보면 Insert 메시지는 'N' 이라는 바이트로 시작합니다. 이 'N' 은 “뒤따라오는 데이터가 새 튜플이다” 라는 표시이고, replica identity 설정은 아예 참조하지 않습니다.
세 연산이 각각 무엇을 보내고 subscriber 가 무엇을 하는지, 한눈에 비교되도록 아래 그림에 정리해 봤습니다. 지금은 INSERT 행만 이해되면 충분하고, UPDATE·DELETE 행은 다음 절들에서 하나씩 풀어 가겠습니다.
왼쪽은 publisher 가 보내는 내용, 오른쪽은 subscriber 가 하는 일입니다. old tuple 에 무엇을 담을지는 replica identity 가, 그 행을 어떻게 찾을지는 subscriber 의 인덱스 유무가 정합니다.
old tuple은 어떻게 만들어지나
이제 UPDATE·DELETE 의 핵심인 old tuple 이 실제로 어떻게 만들어지는지 보겠습니다. PostgreSQL 내부에서 이 일을 하는 함수가 ExtractReplicaIdentity() 입니다. 이 함수는 테이블의 replica identity 설정을 보고 old tuple 을 만드는데, FULL 이면 행 전체를 그대로 쓰고, 인덱스 기반(DEFAULT·USING INDEX)이면 키 컬럼만 채운 새 튜플을 만듭니다.
여기서 흔히 오해하는 지점이 있습니다. 인덱스 기반일 때 키가 아닌 컬럼(non-key)이 old tuple 에서 빠지는 게 아니라, 자리는 그대로 두고 값만 NULL 로 채워진다는 점입니다. 예시로 보면, (a=2, b=20) 행을 USING INDEX (a) 로 지우면 old tuple 은 (a=2, b=NULL) 이 됩니다. b 가 사라진 게 아니라 NULL 로 바뀐 것입니다. 사소해 보이지만, 이 차이가 뒤에서 silent divergence 의 씨앗이 됩니다.
UPDATE: old tuple은 조건부로 실린다
UPDATE 는 조금 미묘합니다. 바뀐 뒤의 값(new tuple)은 언제나 보내지만, 바뀌기 전 값(old tuple)은 항상 보내지는 않습니다.
공식 프로토콜 문서는 Update 메시지의 old tuple 부분을 세 갈래로 규정합니다. 'K' 조각은 업데이트가 replica identity 키 컬럼을 바꿨을 때만 붙고, 'O' 조각은 테이블의 replica identity 가 FULL 일 때만 붙습니다. 그리고 한 메시지가 'K' 와 'O' 를 동시에 가질 수는 없습니다. 둘 다 없을 수는 있습니다.
말로 풀면 세 경우입니다.
- replica identity 가
FULL이면'O'(행 전체)가 실립니다. FULL이 아닌데 키 컬럼을 바꾸는 UPDATE 이면'K'(키)가 실립니다. 예를 들어a값을 2 에서 3 으로 바꾸는 경우입니다.- 키는 그대로 두고 다른 값만 바꾸는 UPDATE 이면 old tuple 이 아예 없습니다. new tuple 만 가고, subscriber 는 그 안에 든 키 값으로 대상 행을 찾습니다.
세 번째 경우가 성립하는 이유는 ① 실행 단계로 거슬러 올라갑니다. PostgreSQL 은 키 컬럼이 실제로 바뀐 경우에만 old tuple 을 WAL 에 남깁니다. 키가 그대로면 new tuple 안에 이미 같은 키가 들어 있으니, old tuple 을 따로 남길 필요가 없는 것입니다.
DELETE: 식별 정보만 보낸다
DELETE 는 UPDATE 와 반대입니다. 새 값이 없으니 new tuple 은 없고, 지울 행을 가리키는 old tuple 만 보냅니다. DELETE 는 지울 행을 늘 식별해야 하므로 old tuple 이 빠지는 경우가 없습니다. replica identity 가 FULL 이면 'O'(행 전체)가, 인덱스 기반(DEFAULT·USING INDEX)이면 'K'(키)가 실리며, 둘이 함께 오지는 않습니다. 그래서 평범한 PK 테이블의 DELETE 도 DEFAULT 라서 'K' 로 나갑니다. UPDATE 에서는 키를 바꿀 때만 'K' 가 등장했지만, DELETE 는 식별이 필수라 인덱스 기반이면 항상 'K' 라는 점이 다릅니다.
두 태그의 의미를 분명히 해두면 이렇습니다.
'K': 뒤따르는 튜플이 키(인덱스 또는 PK 컬럼)만 담고 있다는 표시.'O': 뒤따르는 튜플이 행 전체라는 표시.FULL일 때만 붙습니다.
이 구분은 외부 도구에게 특히 중요합니다. Debezium 같은 CDC(Change Data Capture) 도구는 이 복제 스트림을 그대로 받아 읽는데, 'O' 를 보면 “변경 전 행 전체가 왔구나” 하고 믿고 동작합니다. 이 믿음이 깨질 수 있다는 점이 뒤에서 다룰 #6047 의 출발점입니다.
wire format: 행이 바이트로 바뀌는 방식
여기서 한 단계 더 내려가 보겠습니다. publisher 가 튜플을 네트워크로 보내려면, 메모리 속 행을 바이트 나열로 바꿔야 합니다. 이 변환을 직렬화(serialization)라 하고, 그 결과로 한 튜플이 TupleData 라는 메시지 조각이 됩니다.
TupleData 안에서 각 컬럼은 자기 값 앞에 1바이트짜리 상태 마커를 답니다. 이 마커가 “이 컬럼이 지금 어떤 상태인지” 를 알려 줍니다. 종류는 네 가지입니다.
| 마커 | 의미 |
|---|---|
'n' | 이 컬럼은 NULL |
'u' | 변경되지 않은 TOAST 값 (실제 값은 전송하지 않음) |
't' | 텍스트 포맷 데이터 |
'b' | 바이너리 포맷 데이터 |
'u'(변경되지 않은 TOAST 값)는 지금 다루는 문제와 무관하니 신경 쓰지 않아도 됩니다.
직렬화 루프는 컬럼을 절대 건너뛰지 않습니다. 값이 없는 컬럼이라도 그 자리를 비우는 게 아니라, “이 컬럼은 NULL” 이라는 'n' 마커를 반드시 박아 넣습니다. 앞에서 ExtractReplicaIdentity() 가 non-key 컬럼을 NULL 로 채웠다고 했는데, 그 NULL 이 여기서 그대로 'n' 으로 나가는 것입니다. 왜 그냥 비우지 않고 NULL 로 채울까요. wire 포맷에는 “이 컬럼은 안 보냈다” 를 뜻하는 마커가 따로 없기 때문입니다. 컬럼에 붙일 수 있는 마커는 위 네 가지뿐이라, 값이 없는 컬럼을 표현할 길이 NULL('n') 밖에 없습니다. 그래서 subscriber 는 “안 보낸 컬럼” 과 “진짜 NULL 인 컬럼” 을 받은 바이트만으로는 구분할 수 없습니다.
예시 테이블 p 에서 (a=2, b=20) 행을 USING INDEX (a) 기준으로 지우면, wire 에는 이렇게 나갑니다. 맨 앞 D 는 Delete 메시지를 뜻하고, 그 뒤로 앞서 본 태그(K)와 컬럼별 마커(t, n)가 이어집니다.
1
2
3
4
D ... K t "2" n
│ │ └─ 두 번째 컬럼 b: NULL ('n')
│ └─ 첫 컬럼 a: 텍스트 "2"
└─ 태그 'K' (인덱스 기반 replica identity)
'K' 태그는 “키 기반으로 식별한다” 고 정직하게 선언하고, 실제 내용도 b 가 'n'(NULL)으로 옵니다. 선언과 내용이 서로 맞습니다.
subscriber: 받은 메시지로 행을 찾는 법
이제 받는 쪽입니다. subscriber 에는 복제 변경을 자기 테이블에 실제로 적용하는 apply worker 라는 프로세스가 있습니다. DELETE 나 UPDATE 를 적용하려면 먼저 로컬 테이블에서 대상 행을 찾아야 하는데, 이 찾는 방법이 크게 두 갈래로 갈립니다.
첫째는 인덱스 경로입니다. 로컬 테이블에 PK 나 replica identity 인덱스가 있으면, 받은 키 값으로 그 인덱스를 타고 곧장 행을 찾습니다. 이때는 키 컬럼만 비교하고 나머지는 보지 않습니다. 그래서 old tuple 의 b 가 NULL 이든 20 이든 상관없이 a=2 하나로 정확히 찾아냅니다.
둘째는 순차 경로입니다. subscriber 테이블은 publisher 와 독립적으로 만들어지기 때문에, publisher 의 키 인덱스가 subscriber 에도 있으리란 보장이 없습니다. 쓸 만한 인덱스가 없으면 테이블을 처음부터 끝까지 훑으면서, 받은 튜플과 각 행을 tuples_equal() 로 통째로 비교합니다. 문제는 이 비교가 모든 컬럼을 대조하고, NULL 을 “모르는 값” 이 아니라 “진짜 NULL” 로 취급한다는 점입니다. 한쪽만 NULL 이어도 곧바로 “다른 행” 으로 판정합니다.
여기서 함정이 터집니다. old tuple 이 (a=2, b=NULL) 로 왔는데 로컬 실제 행은 (a=2, b=20) 입니다. 인덱스 경로라면 a=2 로 찾고 끝이지만, 순차 경로는 b 까지 비교하다가 NULL 과 20 이 다르다며 “이런 행 없음” 이라고 결론 내립니다.
그리고 못 찾았을 때가 진짜 문제입니다. 에러가 나지 않습니다. apply worker 는 delete_missing 이라는 충돌을 로그로만 남기고 조용히 넘어갑니다.
1
2
LOG: conflict detected on relation "public.p": conflict=delete_missing
DETAIL: Could not find the row to be deleted: replica identity full (2, null).
로그에 full 이라고 찍혔다고 해서 publisher 가 FULL 설정이라는 뜻은 아닙니다. 이 문구는 subscriber 가 키 인덱스를 못 찾았을 때, 찾으려던 값을 통째로 로그에 적으면서 붙는 표현입니다. 그러니 이 문구가 보인다는 것 자체가 “subscriber 가 순차 경로로 빠졌다” 는 신호이기도 합니다.
결과적으로 publisher 에서는 행이 지워졌는데 subscriber 에는 그대로 남습니다. 두 데이터베이스가 에러 한 번 없이 조용히 어긋나는 silent divergence 입니다.
분기를 정하는 것은 태그가 아니다
여기서 직관과 어긋나는 사실이 하나 있습니다. subscriber 가 인덱스 경로와 순차 경로 중 무엇을 쓸지 정할 때, 방금 받은 'O' 나 'K' 태그는 보지 않는다는 점입니다. apply worker 는 그 태그를 “뒤에 old tuple 이 따라오는가” 를 판단하는 데만 쓰고, 읽고 나면 버립니다.
그럼 경로는 무엇으로 정해질까요. 오직 두 가지입니다. 로컬 테이블에 쓸 만한 인덱스가 있는지, 그리고 Relation 메시지로 미리 받아 둔 원격 테이블의 replica identity 선언이 무엇인지입니다. Relation 메시지는 데이터에 앞서 publisher 가 보내는 “테이블 명세서” 로, 컬럼 구성과 replica identity 설정(어느 컬럼이 키인지 포함)을 담습니다. 이 사실은 공식 문서로도 확인됩니다.
정리하면, “키만 보내기” 는 그 자체로 나쁜 방식이 아닙니다. subscriber 에 키 인덱스만 있으면 인덱스 경로가 키로 정확히 찾으니 완벽하게 동작합니다. 문제는 키 인덱스가 없어 순차 경로로 빠질 때만 생깁니다. 실무에서 “왜 어떤 subscriber 는 멀쩡하고 어떤 것은 데이터가 어긋나는가” 가 바로 이 차이에서 갈립니다.
리뷰: 파티션 테이블에서 어긋나는 지점 (#6047)
위 메커니즘을 따라가다 실제 CommitFest 패치 한 건을 만났고, 리뷰로 남겨 둡니다. 진행 중인 논의라 결론을 단정하기보다, 어떤 구조에서 문제가 드러나는지까지만 적어 봤습니다.
지금까지는 평범한 테이블을 가정했습니다. 파티션 테이블에서는 결이 한 겹 더 복잡해집니다. 파티션 테이블은 부모(root) 하나 아래에 실제 데이터가 든 자식 파티션(leaf)들이 달린 구조입니다. 대상은 여기에 publish_via_partition_root = true 를 켠 경우입니다. 이 옵션을 켜면 leaf 에서 일어난 변경을 root 이름으로 발행해서, subscriber 쪽 테이블 구조가 publisher 와 달라도 복제되게 해 줍니다. 이때 pgoutput 은 메시지를 만들 때 기준 테이블을 leaf 가 아니라 root 로 바꿉니다.
여기서 핵심 전제가 하나 있습니다. replica identity 는 파티션마다 따로 설정됩니다. 부모에 한 번 걸어 둔다고 기존 자식들에게 자동으로 내려가지 않습니다. ALTER TABLE ... REPLICA IDENTITY 가 자식으로 재귀하지 않기 때문입니다. 그래서 root 는 FULL 인데 어떤 leaf 는 USING INDEX 인, 서로 다른 설정이 한 트리 안에 섞이는 상황이 자연스럽게 생깁니다.
바로 이 섞임에서 세 가지 결정이 서로 다른 테이블을 기준으로 삼게 됩니다. 그 어긋남과 subscriber 에서의 결과를 아래 그림에 정리해 봤습니다. 그림의 c2 는 자식 파티션 하나, attrmap 은 부모와 자식의 컬럼 배치를 맞춰 주는 변환이라고 보면 됩니다.
→ subscriber
태그와 R 메시지는 root 기준, 튜플 내용만 leaf 기준입니다. (함수명만 표기, 라인 번호는 버전마다 달라 생략합니다.)
문제를 한 줄씩 떼어 보면 이렇습니다.
- old tuple 의 실제 내용은 leaf 기준입니다. 변경은 leaf 에서 일어났고, leaf 의 replica identity(
USING INDEX)에 따라 키만 담겨b는 NULL 입니다. 'O'와'K'태그는 root 기준입니다. root 가FULL이라'O'(행 전체)를 찍습니다.- Relation 메시지의 replica identity 선언도 root 기준이라
FULL로 나갑니다.
즉 메시지는 “이건 행 전체('O')” 라고 선언해 놓고, 실제로는 b 가 NULL 인 반쪽짜리 튜플을 담습니다. 프로토콜이 정한 'O' 의 의미와 내용이 어긋나는 것입니다. 그리고 subscriber 에 키 인덱스가 없으면 순차 경로로 빠지는데, 순차 경로는 태그와 무관하게 모든 컬럼을 비교하므로 (2, NULL) 과 (2, 20) 이 달라 행을 못 찾고 silent divergence 가 납니다. 반대로 키 인덱스가 있으면 인덱스 경로가 정상 삭제하므로, 이 발산은 무인덱스 subscriber 에서만 나타납니다.
흥미로운 점은, 'O' 를 'K' 로 바로잡는 것만으로는 이 발산이 사라지지 않는다는 것입니다. 앞에서 봤듯 subscriber 는 태그를 경로 선택에 쓰지 않기 때문입니다. 태그를 고치면 태그를 믿는 외부 CDC 도구는 보호되지만, PostgreSQL 자체 복제의 동작은 그대로입니다. 진짜로 정합성을 맞추려면 태그·Relation 선언·실제 내용 세 가지를 모두 leaf 기준으로 정렬하거나, 처음부터 root 와 leaf 의 replica identity 가 어긋나는 설정을 막아야 합니다. 어느 쪽이 옳은지는 아직 커뮤니티에서 정해지지 않았고, 추후 지속적으로 리뷰에 참여하며 저또한 견문과 지식을 넓혀나갈 예정입니다.
정리
INSERT·UPDATE·DELETE 가 subscriber 로 가는 길을 한 줄씩 요약하면 이렇습니다.
- INSERT 는 “어느 행” 이라는 질문이 없으니 새 행만 보냅니다. replica identity 와 무관합니다.
- UPDATE 는 새 값을 항상 보내고, old tuple 은
FULL이면'O', 키가 바뀌면'K', 키가 그대로면 보내지 않습니다. - DELETE 는 식별용 old tuple 만 보냅니다.
'O'냐'K'냐는 replica identity 가 정합니다.
이 모든 걸 관통하는 원리는 하나입니다. 무엇을 보낼지는 publisher 의 변경 시점에 replica identity 로 고정되고, subscriber 는 그것을 받아 로컬에 인덱스가 있으면 키로, 없으면 전 컬럼 비교로 행을 찾습니다. “키만 보내기” 는 양쪽 선언과 실제 내용이 맞고 subscriber 에 키 인덱스가 있는 한 충분하고 효율적입니다.
문제는 그 일치가 깨질 때입니다. 적어도 순차 경로에서는 PostgreSQL 이 에러 없이 조용히 실패합니다. #6047 은 바로 그 일치가 깨지는 한 가지 경우입니다.