프로젝트
약 8분 분량 학습 프로젝트/토이 DB

DB 내부 ⑨: 복제, 복구의 redo를 스트림으로, base backup + WAL 스트리밍까지

Database InternalsReplicationWALPostgreSQLDistributed SystemsC
목차

0. 들어가며: 처음으로 노드를 하나 더

여기까지의 엔진은 단일 노드였습니다. 노드가 죽으면 서비스도 죽습니다. 첫 번째 탈출구가 복제(replication)입니다. primary의 변경을 replica가 뒤따라오게 해서, 읽기를 분산하고 장애 대비 사본을 두는 것입니다.

이 편의 순서는 세 겹입니다: ① 복제의 핵심 통찰(놀랍게도 3편의 WAL이 이미 다 준비해 뒀습니다) → ② 그걸 진짜 소켓에 올리기 → ③ 진짜 엔진에 배선해 “커밋이 복제본에서 SELECT되기까지”. 각 단계에서 실제로 밟은 함정이 그대로 교훈이 됩니다.

1. 복제는 거의 공짜다: WAL이 이미 스트림이니까

3편의 no-force WAL에서 커밋은 이런 로그를 남깁니다:

REC_PAGE(pid, after-image) ← 이 트랜잭션이 바꾼 페이지의 최종 모습
REC_PAGE(pid, after-image)
REC_COMMIT ← 내구성 지점 (fsync)

그리고 크래시 복구의 redo pass가 하는 일은 정확히 “커밋 마커가 붙은 구간의 after-image들을 순서대로 재적용”이었습니다. 여기서 결정적인 관찰:

replica가 할 일 = 그 redo를, ‘파괴적 일회성 복구’가 아니라 ‘증분·연속’으로 돌리는 것.

primary의 WAL을 계속 tail하면서 커밋된 구간이 도착할 때마다 after-image를 자기 데이터 파일에 적용하면, replica는 primary를 뒤따라옵니다. PostgreSQL 스트리밍 복제에서 walreceiver가 WAL을 받아 디스크에 쓰고 startup process가 replay하는 것과 같은 구조입니다. 이미 있는 redo를 다른 각도로 다시 쓴 것일 뿐, 새 알고리즘이 아닙니다. “no-force로 로그가 진실의 원천이 되면 복제·PITR까지 받친다”던 3편의 문장이 여기서 현금화됩니다.

WAL 로그 시핑 복제: replica가 커밋된 after-image를 tail하며 재생, 미완 꼬리는 보류

apply 루프의 정확성 규칙은 둘뿐입니다:

  • 커밋 마커까지 모았다가 적용: 미완의 꼬리(커밋 안 된 구간)는 절대 적용하지 않고 다음 호출로 미룬다. replica는 뒤처질 순 있어도 틀리면 안 된다.
  • LSN 필터: 각 커밋의 LSN을 기억해(applied_lsn), 이미 적용한 커밋이 다시 오면 건너뛴다. 이 멱등성이 아래층의 온갖 재전송을 안전하게 만든다(다음 절에서 바로 써먹습니다).

2. 소켓에 올리기: walsender / walreceiver

같은 머신의 파일 tail을 진짜 네트워크로 바꿉니다. 이때의 핵심 결정은 apply 로직(replica.c)을 한 줄도 안 고치는 것입니다. 전송 계층은 “primary의 WAL 바이트를 replica의 로컬 로그 파일로 옮기는 일”만 하고, 옮겨진 로그에 기존 apply를 그대로 돌립니다. 계층 분리가 또 배당금을 줍니다.

함정 1: 스트림엔 경계가 없다. 소켓은 바이트 스트림이라 “여기서 한 덩어리가 끝난다”는 표시가 없습니다. [길이 8B][그 길이만큼의 WAL 바이트] 프레임을 씌우고, 부분 read/write를 write_all/read_exact 루프로 처리합니다. 그리고 primary가 체크포인트로 로그를 truncate하면 sent_off를 0으로 되감아 다시 흘려보내는데, 중복 전송이지만 안전합니다. 1절의 LSN 필터가 이미 적용한 커밋을 걸러 주는 덕분입니다. 전송 계층이 정확성을 걱정하지 않는 건, 정확성이 아래층에 이미 박혀 있기 때문입니다.

그런데 되감아 다시 보낼 로그가 아직 남아 있어야 이 이야기가 성립합니다. 실제 PostgreSQL에서 문제가 되는 게 바로 이 지점입니다. replica가 느리거나 잠시 끊긴 사이 체크포인트가 오래된 WAL 세그먼트를 치워 버리면, replica가 다시 붙었을 때 이런 에러를 만납니다:

ERROR: requested WAL segment 0000000100000000000000XX has already been removed

§3에서 볼 landmine 1(“체크포인트가 WAL을 자른다”)이 실제 시스템에서 갖는 얼굴입니다. PG의 해법이 physical replication slot입니다. replica가 어디까지 받았는지를 primary가 기억하고, 그보다 뒤의 WAL 세그먼트를 지우지 않게 보존합니다. 반대급부도 정확히 그 보존입니다: replica가 죽었는데 슬롯이 남아 있으면 WAL이 무한히 쌓여 primary 디스크가 찹니다. 그래서 max_slot_wal_keep_size로 보존량에 상한을 둡니다.

함정 2: 읽기 fd에 쓸 순 없다. 여기서 실제로 교착에 빠졌습니다. replica는 로그를 읽기만 하니 O_RDONLY로 열었는데, 수신자가 받은 바이트를 그 fd에 쓰려니 실패했습니다. sender는 ack를, receiver는 다음 프레임을 영원히 기다리는 교착입니다. 교훈은 실제 시스템 구조 그대로입니다. 수신(쓰기)과 적용(읽기)은 다른 역할이니 fd도 다르다. PostgreSQL의 walreceiver(쓰는 자)와 startup process(재생하는 자)가 분리돼 있는 것과 같은 이유입니다.

TCP 복제: walsender가 길이 프레임으로 WAL 바이트를 보내고, walreceiver가 로컬 로그에 append, apply는 기존 replica 코드 그대로

3. 캡스톤: 진짜 커밋이 복제본에서 SELECT되기까지

여기까지는 “WAL 바이트가 옮겨지고 재생된다”였습니다. 진짜 목표는: 실제 엔진에서 INSERT가 커밋되면, 복제본을 열어 SELECT로 그 행이 보이는 것. 배선 코드 자체는 짧았는데, 진짜 어려움은 naive하게 짜면 조용히 깨지는 landmine을 아는 거였습니다. 코드를 짜기 전에 엔진의 WAL·테이블·카탈로그 구조를 먼저 지도로 그려 셋을 찾았습니다.

landmine 1: 재오픈이 WAL을 truncate하고 LSN을 리셋한다. wal_open은 열 때마다 복구를 돌리고 로그를 비우며 next_lsn을 1로 리셋합니다(3편의 “여는 것 자체가 체크포인트”). 그래서 primary를 재오픈하면 replica의 applied_lsn은 이미 10, 20인데 새 커밋이 lsn=1로 와서, 멱등성 필터가 신규 커밋을 조용히 전부 스킵합니다. 테스트는 초록인데 복제가 안 되는, 가장 무서운 종류의 버그입니다. → 대응: primary를 한 세션 내내 열어두고 살아있는 WAL을 tail한다(연속 스트리밍 모델). 참고로 실제 PostgreSQL은 LSN이 재시작을 넘어 영구히 단조 증가합니다. 리셋이 없으니 primary를 재시작해도 복제가 안 깨집니다. 내 엔진은 재오픈 시 LSN이 리셋되는 구조라, 켜둔 채로 유지하는 우회를 택한 것입니다.

landmine 2: base 스냅샷은 조용한 시점에만. 데이터 파일엔 커밋본뿐 아니라 steal미커밋 페이지도 섞일 수 있습니다. 활성 writer 도중에 복사하면 미커밋 데이터가 딸려옵니다. → writer가 없는 조용한 시점에만 복사.

landmine 3: 카탈로그의 낡은 next_txn. 복제된 행이 SELECT에 보이려면 xmin이 “커밋됨”으로 판정돼야 하는데(4편의 가시성), 그 기준인 next_txn이 카탈로그에 낡은 채로 복사되면 복제된 행이 미커밋으로 보여 사라집니다.next_txn을 확정해 쓴 뒤에 복사.

그래서 이건 base backup + WAL 스트리밍이다

세 landmine을 정리하면 필연적으로 실제 시스템과 같은 모델에 도달합니다:

  1. base 스냅샷: 어느 시점의 데이터 파일(+카탈로그+인덱스)을 복제본으로 복사. PostgreSQL의 pg_basebackup.
  2. WAL 스트리밍: 그 뒤의 커밋을 tail·재생. PostgreSQL의 streaming replication.

이건 우연이 아니라 구조가 강제하는 결과입니다. WAL은 재활용(recycle)되는 유한 로그입니다. 체크포인트가 지나면 오래된 세그먼트를 잘라내거나 재사용하기 때문에 그것만으론 전체 상태를 줄 수 없고, 진짜 상태의 원천은 데이터 파일입니다. 그래서 어느 시점의 데이터 파일을 기준으로 잡고 이후 로그를 얹는 수밖에 없습니다. 실제 DB가 base backup + WAL을 쓰는 이유를, 내 엔진에서 landmine을 밟으며 재발견한 셈입니다.

다만 한 가지는 정확히 해 두겠습니다. pg_basebackup의 진짜 핵심은 오히려 조용한 시점이 필요 없다는 데 있습니다. 쓰기가 진행 중인 데이터 파일을 그대로(fuzzy) 복사한 뒤, 백업 시작 체크포인트 이후의 WAL을 replay해서(full-page write의 도움으로 찢어진 페이지까지) 일관된 상태로 끌어올리고, 딸려온 미커밋 데이터는 MVCC 가시성과 clog가 걸러냅니다. writer가 없어야만 성립하는 내 엔진의 quiescent 스냅샷은 그 직전 단계인 셈입니다.

복제되는 DB: base 스냅샷 + 살아있는 WAL tail·재생, 복제본이 완전한 DB로 열려 SELECT를 서빙

끝-대-끝 검증: 실제 엔진에서 CREATE/INSERT 커밋 → 소켓으로 WAL 스트리밍 → 복제본을 완전한 DB로 열어 SELECTprimary와 행 단위로 동일.

4. 정직한 경계

  • 단방향, 커밋 후 전송(비동기): primary가 커밋을 클라이언트에 답한 뒤 replica로 가므로, primary가 그 직후 죽으면 replica엔 마지막 커밋이 없을 수 있습니다. PostgreSQL도 기본은 같습니다. 문서 표현 그대로 *“Streaming replication is asynchronous by default”*입니다. 이 갭을 메우는 게 동기 복제인데, 켜는 스위치는 synchronous_commit이 아니라 synchronous_standby_names입니다. standby를 거기 등록해야 비로소 동기 복제가 되고, 그때 synchronous_commit커밋 ack가 어느 지점까지 기다릴지의 레벨이 됩니다: remote_write(standby OS 버퍼 도달) < on(standby WAL flush, 디스크엔 있지만 아직 적용은 아니라서 replica 읽기엔 안 보일 수 있음) < remote_apply(적용까지, 이때야 replica에서 읽힘). quorum 지정도 됩니다(synchronous_standby_names = 'ANY 1 (s1, s2)'). 운영 함정 하나: 동기 standby가 전부 죽으면 primary의 커밋이 멈춥니다. 내구성 계약을 지킬 수 없기 때문입니다.
  • replica 읽기의 함정: 비동기 replica로 읽기를 분산하면 방금 쓴 데이터가 replica엔 아직 없을 수 있습니다(read-after-write 위반). 어디까지 갔는지는 pg_stat_replication이 sent/write/flush/replay 4단계 LSN으로 보여 주고, 세션 단위 보장이 필요하면 remote_apply를 쓰거나 replay LSN을 확인한 뒤 읽는 장치가 필요합니다.
  • failover 없음: primary가 죽었을 때 replica를 승격하고, 클라이언트를 갈아태우고, 옛 primary가 살아 돌아왔을 때(split-brain)를 다루는 건 전혀 다른 차원의 문제입니다. 게다가 비동기 replica를 승격하면 primary가 이미 ack한 커밋이 유실될 수 있습니다(RPO > 0). 그게 10편(Raft)의 주제입니다. “primary가 죽으면 누가 결정하는가”는 결국 합의(consensus) 문제입니다.
  • 물리 복제: 페이지 이미지를 나르는 물리(physical) 복제라, 이기종 버전·부분 테이블 복제가 안 됩니다. PostgreSQL의 logical replication이 그 다음 층.

흔한 오해 정정: “synchronous_commit=on이면 replica에서도 바로 보인다?” 두 겹으로 틀립니다. 첫째, synchronous_standby_names가 비어 있으면 onprimary 로컬 WAL flush만 기다리고 replica는 전혀 기다리지 않습니다. 둘째, 동기 복제를 켰더라도 on은 standby가 커밋 레코드를 “flushed it to durable storage” 한 시점까지만 기다립니다. 내구성 보장일 뿐 replay 보장은 아니라서, 직후에 replica에서 SELECT하면 아직 안 보일 수 있습니다. 읽기 가시성까지 원하면 remote_apply(문서 정의로 “applied it, so that it has become visible to queries”)를 써야 합니다.

복제 보장을 “커밋 ack 시점에 어디까지 보장되는가(RPO)” 축으로 놓으면:

방식커밋 ack 시점의 보장유실 위험(RPO)
PG async (기본)primary 로컬 WAL flush만승격 시 최근 커밋 유실 가능
PG sync레벨별: remote_write(standby OS 버퍼) / on(standby WAL flush) / remote_apply(적용, 읽기 가시); quorum(ANY n) 가능on 이상이면 단일 장애에서 0
MySQL async binlog (기본)source의 binlog 기록만replica 유실 가능
MySQL semi-syncreplica 1대 이상이 binlog 수신(relay log) ack. AFTER_SYNC는 ack 후에 커밋을 공개, AFTER_COMMIT은 공개 후 ack(유실 창 존재)AFTER_SYNC면 근소

실무 안티패턴: ① replication slot을 만들어 두고 replica를 폐기하는 것. 슬롯이 WAL을 영원히 붙잡아 primary 디스크가 찹니다(pg_replication_slots를 모니터링하고 안 쓰는 슬롯은 지울 것). ② 쓰기 직후의 읽기를 무조건 replica로 라우팅하는 것. read-after-write가 깨져 “방금 저장했는데 없다”가 됩니다. 지연을 허용하는 읽기(목록·통계)부터 replica로 보내고, read-your-writes가 필요한 경로는 primary나 remote_apply 쪽에 남겨 둬야 합니다.

5. 정리

  • 복제 = redo의 스트림화. no-force WAL이 이미 커밋의 순차 스트림이라, replica는 복구의 redo를 증분·연속으로 돌릴 뿐. 새 알고리즘이 아니다.
  • 정확성 규칙 둘: 커밋 마커까지만 적용(뒤처져도 틀리지 않게), LSN 멱등성(중복 전송을 안전하게). 전송 계층은 이 위에서 마음 편히 재전송한다.
  • 소켓의 교훈: 스트림엔 경계가 없다(길이 프레이밍), 수신과 적용은 역할이 달라 fd도 다르다(실제 교착으로 배움).
  • landmine 셋: 재오픈의 WAL truncate/LSN 리셋, base의 미커밋 오염, 낡은 next_txn. 전부 “테스트는 초록인데 조용히 깨지는” 종류로, 구조 이해가 곧 안전판이었다.
  • 필연적 수렴: base backup + WAL 스트리밍, 즉 pg_basebackup + streaming replication의 모델로 구조가 강제로 데려간다.

실무/면접 포인트: 복제는 WAL redo의 스트림화로 요약됩니다. sync/async의 본질은 “커밋 ack가 어느 내구성 지점을 기다리는가”이고, PostgreSQL은 그걸 synchronous_commit 레벨(remote_write/on/remote_apply)로 세분화합니다. 동기 복제여도 remote_apply가 아니면 replica 읽기 가시성은 보장되지 않는다는 게 한 단계 깊은 포인트입니다.

다음 편은 이 복제의 최대 약점인 “primary가 죽으면?”을 정면으로 다룹니다. 리더 선출, 로그 합의, 그리고 리더가 죽어도 살아남는 HA 구성까지, Raft입니다.

참고 (1차 자료 우선)

프로필 사진
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.