콘텐츠로 이동

(KO) PostgreSQL WAL 송신자/수신자 — 스트리밍 복제 전송 계층

목차

스트리밍 복제(streaming replication)는 모든 데이터베이스 시스템이 답해야 할 질문에 대한 하나의 해답이다. 질문은 이렇다. 대기(standby) 서버가 주(primary) 서버의 영속 상태를 어떻게 따라잡는가? 설계 공간은 크게 세 계열로 나뉜다.

  1. 로그 적재(log shipping) — 완성된 WAL 세그먼트 파일을 가득 찬 뒤에 대기 서버로 복사한다. 복구는 파일을 순서대로 재실행한다. 단순하지만, 대기 서버는 언제나 최대 한 세그먼트(기본 16 MB)만큼 뒤처질 수 있고, 미전송 WAL은 페일오버 시 손실된다.

  2. 스트리밍 복제(streaming replication) — 대기 서버가 주 서버에 지속 TCP 연결로 접속해 WAL 레코드를 거의 실시간으로 수신한다. 네트워크 RTT와 대기 서버의 적용 속도가 한계인 수 밀리초 수준의 지연이 가능하다.

  3. 공유 스토리지 / 반동기(semi-synchronous) — 주 서버와 대기 서버가 SAN 볼륨을 공유하거나, 주 서버가 커밋 반환 전에 대기 서버의 WAL 수신 확인을 기다린다. 로그 적재의 지연 격차를 제로로 줄이지만, 커밋 지연이 복제 링크에 결합된다.

PostgreSQL 9.0이 도입한 스트리밍 복제는 두 번째 계열이다. 이론적 토대는 postgres-xlog-wal.md에서 설명하는 WAL 내구성 척추와 동일하다. LSN은 추가 전용 WAL 스트림 안의 바이트 오프셋이며, 대기 서버는 단지 그 스트림의 특정 시작 위치부터의 소비자다. walsender / walreceiver의 모든 설계 결정은 이 단순한 관찰에서 흘러나온다.

스트리밍 복제가 단순 WAL 재실행 위에 추가하는 두 개의 보조 아이디어가 있다.

복제 슬롯(replication slot). 슬롯이 없으면 주 서버는 대기 서버가 얼마나 뒤처졌는지 알 수 없다. 대기 서버가 아직 필요로 하는 WAL 세그먼트가 재활용될 수 있으며, 그러면 대기 서버는 치명적으로 뒤처진다. 물리 복제 슬롯은 대기 서버의 확인된 플러시 LSN을 기록하고 그 아래의 WAL 재활용을 막는다. 슬롯 메커니즘은 postgres-replication-slots.md에서 정의한다. walsender는 START_REPLICATION에 슬롯 이름이 지정된 경우 슬롯을 획득하고 해제한다.

핫스탠바이 피드백(hot-standby feedback). hot_standby = on으로 쿼리를 실행 중인 대기 서버는 주 서버가 대기 서버가 아직 읽고 있는 행을 vacuum할 때 쿼리가 취소될 수 있다. 핫스탠바이 피드백은 대기 서버가 자신의 가장 오래된 활성 xmin을 주 서버에 다시 광고하는 메커니즘이다. 이렇게 하면 대기 서버의 트랜잭션이 끝날 때까지 주 서버가 해당 행을 제거하지 않는다. 이는 순수한 복제 프로토콜 추가이며, 주 서버의 스토리지 계층이나 잠금 계층에는 보이지 않는다.

Database Internals(Petrov, ch. 11, “Replication and Consistency”)는 스트리밍 모델을 주 서버가 로그 생산자이고 대기 서버가 로그 소비자인 두 노드 파이프라인으로 설명한다. 전송 계층의 역할은 각 로그 레코드를 정확히 한 번, 순서대로 전달하고 내구성 확인이 상류로 흘러가게 하는 것이다. walsender/walreceiver의 모든 설계 요소는 그 파이프라인의 한 조각이다.

스트리밍 복제 구현자가 선택하는 설계 공간의 네 축은 다음과 같다.

  1. 동기 vs. 비동기COMMIT이 대기 서버의 확인을 기다리는가? PostgreSQL은 둘 다 지원한다. synchronous_commit = on은 대기 서버의 flush(또는 apply) 확인을 기다리고, = off 또는 = remote_write는 지연 대신 내구성을 교환한다.

  2. 물리 vs. 논리 — 스트림이 원시 WAL 바이트(주 서버와 동일한 페이지 레이아웃)를 전달하는가, 아니면 디코딩된 변경 레코드(행 단위, 스키마 버전 간 사용 가능)를 전달하는가? PostgreSQL은 같은 walsender 프로세스에서 둘 다 지원한다. 전송 계층은 동일하고 데이터 소스만 다르다(XLogSendPhysical vs. XLogSendLogical).

  3. 전송 결합 — 복제 채널이 전용 프로세스인가, 스레드인가, 아니면 프로세스 내 큐인가? PostgreSQL은 대기 서버마다 전용 walsender 프로세스를 사용하고, 대기 서버 측에 전용 walreceiver 프로세스를 사용한다. 이는 postmaster 포크(fork) 모델에 맞는 선택이다.

스트리밍 복제 구현은 거의 모두 동일한 공학적 관례로 수렴한다. 이를 먼저 정리해 두면, §“PostgreSQL의 접근법”에서 PostgreSQL의 구체적 기호들이 공유된 플레이북 안의 한 선택 집합으로 읽힌다.

복제본마다 전용 프로세스(또는 스레드)

섹션 제목: “복제본마다 전용 프로세스(또는 스레드)”

로그 적재는 일괄 처리되어 파일 복사 데몬이 처리할 수 있다. 스트리밍은 그렇지 않다. 송신 루프는 새 WAL, 대기 서버 응답, 킵얼라이브 타임아웃에 1초 미만 단위로 반응해야 한다. 범용적인 패턴은 연결된 복제본마다 전용 복제 송신 프로세스(또는 스레드)를 두는 것이다. 그래야 각 송신자의 이벤트 루프가 독립적이고, 느리거나 연결이 끊긴 복제본이 다른 복제본을 막지 않는다.

핸드셰이크 단계가 있는 지속 연결

섹션 제목: “핸드셰이크 단계가 있는 지속 연결”

대기 서버가 연결하고, 인증하고, 스트리밍을 시작하기 전에 핸드셰이크 교환을 수행한다. 주 서버 시스템을 식별하고, 선택적으로 타임라인을 협상하고, 시작 LSN을 선언한다. 핸드셰이크의 목적은 잘못된 클러스터나 이미 분기된 타임라인 등의 불일치를 조용히 스트리밍하는 대신 일찍 잡아내는 것이다.

기존 와이어 프로토콜 위의 COPY 모드 WAL 스트리밍

섹션 제목: “기존 와이어 프로토콜 위의 COPY 모드 WAL 스트리밍”

복제에 별도의 포트와 인증 경로를 두는 것을 피하기 위해 표준 클라이언트 연결 위에 복제를 다중화한다. 실용적인 선택은 START_REPLICATION 명령이 연결을 스트리밍 모드로 전환하는 것이다. PostgreSQL은 CopyBothResponse(양방향 COPY 하위 프로토콜)를 사용하고, 원시 WAL 바이트는 CopyData 메시지로 흐른다. 킵얼라이브와 확인 메시지가 같은 채널을 공유한다.

대기 서버의 진행 상황은 단일 숫자가 아니다. 서로 다른 속도로 전진하는 세 개의 순서 커서가 있다.

  • write — 대기 서버가 WAL을 로컬 디스크 버퍼에 기록했다.
  • flush — 대기 서버가 WAL을 안정 스토리지에 fsync했다.
  • apply — 대기 서버의 스타트업 프로세스가 이 LSN까지 재실행(replay)했다.

주 서버는 송신자마다(WalSnd 구조체에) 세 값을 모두 추적한다. flush는 내구성 결정에, apply는 지연 모니터링에 사용된다. 동기 복제는 synchronous_commit 설정에 따라 flush 또는 apply를 기다린다.

주 서버에 새 WAL이 없을 때 스트리밍 연결은 유휴 상태가 된다. 침묵은 죽은 연결과 구별할 수 없으므로, 양쪽이 주기적으로 킵얼라이브 메시지를 보낸다. 주 서버는 wal_sender_timeout / 2 동안 새 WAL이 없으면 킵얼라이브(replyRequested 플래그 포함)를 보내고, 대기 서버는 새 WAL을 플러시하거나 wal_receiver_status_interval이 경과할 때마다 상태 응답을 보낸다.

개념PostgreSQL 이름
복제 송신 프로세스walsender (B_WAL_SENDER BackendType)
복제 수신 프로세스walreceiver (B_WAL_RECEIVER BackendType)
핸드셰이크 명령IDENTIFY_SYSTEM, TIMELINE_HISTORY
스트림 시작 명령START_REPLICATION
스트리밍 하위 프로토콜CopyBothResponse (양방향 COPY)
WAL 데이터 메시지 (주→대기)'w' CopyData, (dataStart, walEnd, sendTime) 헤더
킵얼라이브 메시지 (주→대기)'k' CopyData, (walEnd, sendTime, replyRequested)
확인 메시지 (대기→주)'r' CopyData, (write, flush, apply, sendTime, replyRequested)
핫스탠바이 피드백 메시지'h' CopyData, (xmin, epoch, catalogXmin, ...)
송신자별 공유 메모리 슬롯WalSndCtl->walsnds[]WalSnd 구조체
수신자 공유 메모리 상태WalRcvData (전역 WalRcv)
수신자 상태 기계WalRcvState 열거형 (WALRCV_STOPPEDWALRCV_STOPPING)
송신자 상태 기계WalSndState 열거형 (WALSNDSTATE_STARTUPWALSNDSTATE_STOPPING)
수신자 플러시 포인터 (공유 메모리)WalRcv->flushedUpto
복제 슬롯 (WAL 보존)MyReplicationSlot (postgres-replication-slots.md 참고)

walsender는 일반 백엔드로 시작한다. postmaster가 들어오는 연결을 처리할 자식 프로세스를 fork()하고, 인증을 완료한 뒤에야 연결의 replication= 파라미터를 보고 walsender가 되어야 함을 판단한다. walsender.c 파일 헤더는 명확하게 말한다. “A walsender is similar to a regular backend, ie. there is a one-to-one relationship between a connection and a walsender process, but instead of processing SQL queries, it understands a small set of special replication-mode commands.”

복제 판단 이후 PostgresMain에서 호출되는 InitWalSender가 프로세스를 walsender로 표시하고 WalSndCtl->walsnds[] 배열에서 슬롯을 요청한다. 이 배열은 max_wal_senders * sizeof(WalSnd) 크기로 할당된다.

// WalSnd (struct) — src/include/replication/walsender_private.h
typedef struct WalSnd
{
pid_t pid; /* this walsender's PID, or 0 if not active */
WalSndState state; /* this walsender's state */
XLogRecPtr sentPtr; /* WAL has been sent up to this point */
bool needreload; /* does currently-open file need reloading? */
XLogRecPtr write; /* standby's write position */
XLogRecPtr flush; /* standby's flush position */
XLogRecPtr apply; /* standby's apply position */
TimeOffset writeLag;
TimeOffset flushLag;
TimeOffset applyLag;
int sync_standby_priority;
slock_t mutex;
TimestampTz replyTime;
ReplicationKind kind;
} WalSnd;

다섯 개의 상태 값이 송신자의 생애 주기를 나타낸다.

// WalSndState — src/include/replication/walsender_private.h
typedef enum WalSndState
{
WALSNDSTATE_STARTUP = 0,
WALSNDSTATE_BACKUP,
WALSNDSTATE_CATCHUP,
WALSNDSTATE_STREAMING,
WALSNDSTATE_STOPPING,
} WalSndState;

CATCHUP은 초기 스트리밍 상태다. 대기 서버가 주 서버보다 뒤처진 상태다. WalSndLoopWalSndCaughtUp이 참임을 확인하고 WalSndSetState로 전환할 때 STREAMING으로 바뀐다.

walsender의 메인 루프(tcopPostgresMain)는 복제 모드에서 수신하는 모든 메시지를 exec_replication_command로 처리한다. 이 함수는 전용 replication_scanner / replication_yyparse로 명령을 파싱한 뒤 다음을 처리한다.

  • IDENTIFY_SYSTEM — 클러스터 시스템 식별자, 타임라인, 플러시 LSN, 그리고 db 모드의 경우 데이터베이스 이름을 반환한다. 대기 서버는 이를 이용해 올바른 주 서버에 연결했는지 확인한다.
  • TIMELINE_HISTORY tli — 요청한 타임라인의 타임라인 히스토리 파일 내용을 반환한다. 대기 서버가 스위치오버를 따라갈 수 있도록 한다.
  • START_REPLICATION — 스트리밍 모드에 진입한다. 물리 스트림의 진입점은 StartReplication, 논리 스트림은 StartLogicalReplication이다.

이 명령들은 SQL 파서와 분리된 소형 문법(repl_gram.y, repl_scanner.l)을 구성한다. 물리 walsender는 SQL을 처리하지 않는다.

StartReplication은 타임라인(현재 또는 과거)을 결정하고, CopyBothResponse를 보내 연결을 양방향 COPY 모드로 전환하며, sentPtr을 요청된 시작 위치로 설정하고, SyncRepInitConfig를 호출한 뒤 메인 송신 루프에 넘긴다.

// StartReplication — src/backend/replication/walsender.c
WalSndSetState(WALSNDSTATE_CATCHUP);
/* ... CopyBothResponse 전송 ... */
sentPtr = cmd->startpoint;
SpinLockAcquire(&MyWalSnd->mutex);
MyWalSnd->sentPtr = sentPtr;
SpinLockRelease(&MyWalSnd->mutex);
SyncRepInitConfig();
replication_active = true;
WalSndLoop(XLogSendPhysical);

WalSndLoop(send_data)는 내부 정상 상태 루프다. 양쪽이 CopyDone을 교환할 때까지 반복한다.

// WalSndLoop — src/backend/replication/walsender.c
for (;;)
{
ResetLatch(MyLatch);
CHECK_FOR_INTERRUPTS();
/* 설정 재로드 처리, 응답 확인 */
ProcessRepliesIfAny();
if (streamingDoneReceiving && streamingDoneSending && !pq_is_send_pending())
break;
if (!pq_is_send_pending())
send_data(); /* XLogSendPhysical 또는 XLogSendLogical */
else
WalSndCaughtUp = false;
if (pq_flush_if_writable() != 0)
WalSndShutdown();
/* 따라잡았을 때 CATCHUP -> STREAMING 전환 */
if (WalSndCaughtUp && !pq_is_send_pending())
{
if (MyWalSnd->state == WALSNDSTATE_CATCHUP)
WalSndSetState(WALSNDSTATE_STREAMING);
if (got_SIGUSR2)
WalSndDone(send_data);
}
WalSndCheckTimeOut();
WalSndKeepaliveIfNecessary();
/* 유휴 또는 출력 버퍼가 찰 때 래치 대기 */
WalSndWait(...);
}

루프의 구조는 이렇다. 출력 버퍼를 비우고, 더 많은 WAL로 채우고, 쓸 수 있는 만큼 플러시하고, 유휴 상태일 때 래치에서 잠든다.

flowchart TB
  A["WalSndLoop 반복 시작<br/>ResetLatch"] --> B["ProcessRepliesIfAny<br/>(대기 서버 메시지 수신)"]
  B --> C{양쪽 CopyDone?}
  C -- 예 --> EXIT["루프 탈출"]
  C -- 아니오 --> D{출력 버퍼 비어 있음?}
  D -- 예 --> E["send_data<br/>XLogSendPhysical 또는 XLogSendLogical"]
  D -- 아니오 --> F["WalSndCaughtUp = false"]
  E --> G["pq_flush_if_writable"]
  F --> G
  G --> H{WalSndCaughtUp이고<br/>버퍼 비어 있음?}
  H -- 예 --> I["CATCHUP이면: STREAMING으로 전환<br/>got_SIGUSR2이면: WalSndDone"]
  H -- 아니오 --> J["WalSndCheckTimeOut<br/>WalSndKeepaliveIfNecessary"]
  I --> J
  J --> K["WalSndWait 래치 대기<br/>(유휴 시 슬립)"]
  K --> A

그림 1 — 정상 상태 WalSndLoop 반복. 대기 서버 메시지를 수신하고, WAL을 보내고, 출력 버퍼를 플러시하며, 따라잡았을 때 래치에서 잠든다. CATCHUP → STREAMING 전환이 여기서 일어난다. (walsender.cWalSndLoop에서.)

XLogSendPhysical은 물리 스트림 데이터 소스다. 얼마나 멀리 안전하게 보낼 수 있는지 결정한 뒤 — 주 서버에서는 GetFlushRecPtr, 캐스케이딩 대기 서버에서는 GetStandbyFlushRecPtrWALRead로 디스크에서 WAL을 읽어 8 KB 페이지 단위로 스트리밍 프로토콜 헤더로 감싸고 pq_putmessage_noblock으로 큐에 넣는다.

// XLogSendPhysical (발췌) — src/backend/replication/walsender.c
/* 얼마나 보낼 수 있는가? */
SendRqstPtr = GetFlushRecPtr(NULL); /* 주 서버 경로 */
/* ... WAL을 output_message에 읽기 ... */
/* 헤더: dataStart (int64), walEnd (int64), sendTime (int64) */
resetStringInfo(&output_message);
pq_sendbyte(&output_message, 'w');
pq_sendint64(&output_message, sentPtr); /* dataStart */
pq_sendint64(&output_message, SendRqstPtr); /* walEnd */
pq_sendint64(&output_message, sendTime);
pq_sendbytes(&output_message, (char *) buf, nbytes);
pq_putmessage_noblock('d', ...);

'w' 메시지 타입 바이트는 WAL 데이터를 식별한다. dataStart는 페이로드 첫 바이트의 LSN이고, walEnd는 주 서버의 현재 플러시 포인터(대기 서버에게 “아직 이 이상은 없다”고 알린다)이며, sendTime은 복제 지연 측정에 사용된다.

ProcessRepliesIfAny는 대기 서버 연결에서 비차단 방식으로 메시지를 받아 처리한다. 두 가지 메시지 타입이 중요하다.

상태 업데이트('r') — 대기 서버가 write, flush, apply 위치를 보고한다. ProcessStandbyReplyMessage가 이를 풀어내어 스핀락 아래 WalSnd 구조체를 갱신하고, 캐스케이딩 대기 서버가 아닌 경우 SyncRepReleaseWaiters를 호출한다. 그래야 주 서버의 동기 커밋 대기자가 진행할 수 있다.

// ProcessStandbyReplyMessage — src/backend/replication/walsender.c
writePtr = pq_getmsgint64(&reply_message);
flushPtr = pq_getmsgint64(&reply_message);
applyPtr = pq_getmsgint64(&reply_message);
/* ... 지연 시간 계산 ... */
SpinLockAcquire(&walsnd->mutex);
walsnd->write = writePtr;
walsnd->flush = flushPtr;
walsnd->apply = applyPtr;
walsnd->writeLag = writeLag;
walsnd->flushLag = flushLag;
walsnd->applyLag = applyLag;
SpinLockRelease(&walsnd->mutex);
if (!am_cascading_walsender)
SyncRepReleaseWaiters();

핫스탠바이 피드백('h') — 대기 서버가 가장 오래된 활성 xmin을 보고한다. ProcessStandbyHSFeedbackMessage가 슬롯이 활성 상태이면 PhysicalReplicationSlotNewXmin을 호출하거나, 직접 MyProc->xmin을 갱신한다. 이렇게 해야 대기 서버가 아직 필요로 하는 행을 주 서버의 vacuum이 제거하지 않는다.

sequenceDiagram
  participant P as 주 서버 (walsender)
  participant S as 대기 서버 (walreceiver)

  S->>P: START_REPLICATION LSN/TLI
  P->>S: CopyBothResponse
  loop 정상 상태 스트리밍
    P->>S: 'w' WAL 데이터 (dataStart, walEnd, sendTime)
    S->>P: 'r' 응답 (write, flush, apply, time)
    P->>S: 'k' 킵얼라이브 (walEnd, time, replyRequested)
    S->>P: 'r' 응답 (킵얼라이브에 의해 트리거)
    Note over S: hot_standby_feedback 켜짐
    S->>P: 'h' HS 피드백 (xmin, catalogXmin)
  end
  S->>P: CopyDone
  P->>S: CopyDone

그림 2 — 스트리밍 프로토콜 메시지 교환. 주 서버는 'w' WAL 메시지와 'k' 킵얼라이브를 보내고, 대기 서버는 'r' 상태 응답과 'h' 핫스탠바이 피드백을 보낸다. 양쪽이 CopyDone을 교환하여 스트림을 종료한다. (프로토콜은 PostgreSQL 문서와 walsender.c / walreceiver.c에서.)

walreceiver는 WalReceiverMain을 실행한다. 스타트업 프로세스가 아카이브/로컬 WAL을 소진하고 스트리밍이 설정된 경우 postmaster에 PMSIGNAL_START_WALRECEIVER를 보내 실행을 요청한다.

스타트업 프로세스는 신호를 보내기 전에 WalRcvData->conninfo, ->slotname, ->receiveStart를 채운다. WalReceiverMain은 공유 메모리의 WalRcvData 구조체에서 이 값들을 읽고, 자신을 WALRCV_STREAMING으로 표시한 뒤, 실제 libpq 전송 함수들을 얻기 위해 libpqwalreceiver를 동적으로 로드한다.

// WalReceiverMain — src/backend/replication/walreceiver.c
walrcv->pid = MyProcPid;
walrcv->walRcvState = WALRCV_STREAMING;
strlcpy(conninfo, walrcv->conninfo, MAXCONNINFO);
startpoint = walrcv->receiveStart;
startpointTLI = walrcv->receiveStartTLI;
SpinLockRelease(&walrcv->mutex);
load_file("libpqwalreceiver", false);
/* ... 주 서버에 연결 ... */
wrconn = walrcv_connect(conninfo, true, false, false, appname, &err);

IDENTIFY_SYSTEM 핸드셰이크로 시스템 식별자가 일치하는지 확인한 뒤, 루프는 walrcv_startstreaming(START_REPLICATION 발행)을 호출하고 walrcv_receive 루프에서 메시지를 수신하며 각각을 XLogWalRcvProcessMsg에 전달한다.

WalRcvData 공유 구조체는 walreceiver와 스타트업 프로세스, 그리고 캐스케이딩 walsender 사이의 통신 채널이다.

// WalRcvData (발췌) — src/include/replication/walreceiver.h
typedef struct
{
ProcNumber procno; /* walreceiver의 proc 번호 */
pid_t pid;
WalRcvState walRcvState;
ConditionVariable walRcvStoppedCV;
XLogRecPtr receiveStart; /* 스타트업이 시작점으로 지정한 위치 */
TimeLineID receiveStartTLI;
XLogRecPtr flushedUpto; /* pg_wal에 fsync된 마지막 바이트 */
TimeLineID receivedTLI;
XLogRecPtr latestChunkStart; /* 마지막 플러시 전 flushedUpto */
/* ... 타이밍, conninfo, slotname ... */
pg_atomic_uint64 writtenUpto; /* 기록됐지만 아직 fsync 안 된 위치 */
sig_atomic_t force_reply;
} WalRcvData;

flushedUpto는 핵심 진행 지표다. XLogWalRcvFlush 호출마다 스타트업 프로세스가 깨어나 WAL 재실행을 그 지점까지 진행할 수 있다. writtenUptoflushedUpto보다 앞서 진행하는 아토믹 변수다. 캐스케이딩 walsender가 아직 fsync되지 않은 기록된 WAL을 서비스할 수 있도록 한다.

XLogWalRcvProcessMsg, XLogWalRcvWrite, XLogWalRcvFlush

섹션 제목: “XLogWalRcvProcessMsg, XLogWalRcvWrite, XLogWalRcvFlush”

XLogWalRcvProcessMsg는 1바이트 메시지 타입으로 분기한다.

  • 'w'(WAL 데이터) — 세 개의 int64 헤더를 벗겨내고, 페이로드 바이트로 XLogWalRcvWrite를 호출한다.
  • 'k'(킵얼라이브) — walEndsendTime을 읽고, 필요한 경우 즉시 응답을 트리거한다.

XLogWalRcvWritepg_wal의 현재 WAL 세그먼트 파일에 바이트를 추가한다. 현재 세그먼트가 가득 차면 XLogFileInit으로 새 세그먼트를 열고, writtenUpto 아토믹을 갱신한다.

// XLogWalRcvWrite (발췌) — src/backend/replication/walreceiver.c
while (nbytes > 0)
{
if (recvFile < 0 || !XLByteInSeg(recptr, recvSegNo, wal_segment_size))
{
/* 새 세그먼트 열기/생성 */
XLByteToSeg(recptr, recvSegNo, wal_segment_size);
recvFile = XLogFileInit(recvSegNo, tli);
}
startoff = XLogSegmentOffset(recptr, wal_segment_size);
byteswritten = pg_pwrite(recvFile, buf, segbytes, (off_t) startoff);
/* ... 오류 처리 ... */
recptr += byteswritten;
nbytes -= byteswritten;
}
pg_atomic_write_u64(&WalRcv->writtenUpto, LogstreamResult.Write);

XLogWalRcvFlushissue_xlog_fsync로 현재 파일을 fsync하고, LogstreamResult.Flush를 전진시키며, 스타트업 프로세스와 캐스케이딩 walsender를 깨운다.

// XLogWalRcvFlush (발췌) — src/backend/replication/walreceiver.c
issue_xlog_fsync(recvFile, recvSegNo, tli);
LogstreamResult.Flush = LogstreamResult.Write;
SpinLockAcquire(&walrcv->mutex);
if (walrcv->flushedUpto < LogstreamResult.Flush)
{
walrcv->latestChunkStart = walrcv->flushedUpto;
walrcv->flushedUpto = LogstreamResult.Flush;
walrcv->receivedTLI = tli;
}
SpinLockRelease(&walrcv->mutex);
WakeupRecovery();
if (AllowCascadeReplication())
WalSndWakeup(true, false);
/* 주 서버에 응답 및 핫스탠바이 피드백 전송 */
XLogWalRcvSendReply(false, false);
XLogWalRcvSendHSFeedback(false);

WakeupRecovery()가 스타트업 프로세스의 래치에 신호를 보낸다. 스타트업 프로세스는 GetWalRcvFlushRecPtr을 읽고 그 LSN까지 복구를 진행한다.

WalRcvState 열거형은 스타트업 프로세스 관점에서 walreceiver의 생애를 추적한다.

stateDiagram-v2
  [*] --> WALRCV_STOPPED
  WALRCV_STOPPED --> WALRCV_STARTING : RequestXLogStreaming\n프로세스 실행
  WALRCV_STARTING --> WALRCV_STREAMING : WalReceiverMain\n초기화 완료
  WALRCV_STREAMING --> WALRCV_WAITING : 주 서버가 스트림 종료\n연결 유지
  WALRCV_WAITING --> WALRCV_RESTARTING : 스타트업이\n기존 프로세스 재개
  WALRCV_RESTARTING --> WALRCV_STREAMING : 프로세스 루프로\n재연결
  WALRCV_STREAMING --> WALRCV_STOPPING : ShutdownWalRcv\n또는 SIGTERM
  WALRCV_WAITING --> WALRCV_STOPPING : ShutdownWalRcv
  WALRCV_STOPPING --> WALRCV_STOPPED : WalRcvDie\nshmem_exit 시
  WALRCV_STOPPED --> [*]

그림 3 — WalRcvState 생애 주기. RequestXLogStreaming(스타트업 프로세스 호출)이 STOPPED → STARTING으로 전환하고 postmaster에 포크를 신호한다. WAITING 상태의 수신자가 이미 있으면 WAITING → RESTARTING으로 전환하고 기존 프로세스를 깨운다. 새 프로세스를 포크하지 않는다. (walreceiver.h의 상태, walreceiverfuncs.cwalreceiver.c의 전환.)

walreceiverfuncs.cRequestXLogStreaming은 스타트업 프로세스의 진입점이다. 스핀락 아래 WalRcvData를 채우고, 최초 시작이면 PMSIGNAL_START_WALRECEIVER를 postmaster에 보내고, 재시작이면 기존 수신자의 래치를 깨운다.

// RequestXLogStreaming — src/backend/replication/walreceiverfuncs.c
SpinLockAcquire(&walrcv->mutex);
if (walrcv->walRcvState == WALRCV_STOPPED)
{
launch = true;
walrcv->walRcvState = WALRCV_STARTING;
strlcpy(walrcv->conninfo, conninfo, MAXCONNINFO);
}
else
walrcv->walRcvState = WALRCV_RESTARTING;
walrcv->receiveStart = recptr;
walrcv->receiveStartTLI = tli;
walrcv_proc = walrcv->procno;
SpinLockRelease(&walrcv->mutex);
if (launch)
SendPostmasterSignal(PMSIGNAL_START_WALRECEIVER);
else if (walrcv_proc != INVALID_PROC_NUMBER)
SetLatch(&GetPGProcByNumber(walrcv_proc)->procLatch);

walreceiver는 libpq를 서버 바이너리에 직접 링크하지 않는다. 대신 walreceiver.c는 시작 시 load_file("libpqwalreceiver")로 채워지는 WalReceiverFunctions 함수 포인터 테이블에 위임한다. README의 첫 번째 단락이 이유를 설명한다. “The transport-specific part of walreceiver … is loaded dynamically to avoid having to link the main server binary with libpq.”

함수 포인터 테이블은 walreceiver.hWalReceiverFunctionsType으로 정의되고, 실제 libpq 구현은 replication/libpqwalreceiver/에 있다. 이 간접 참조 덕분에 이론적으로는 서드파티가 호환 공유 라이브러리를 제공해 대체 전송(예: 같은 호스트의 대기 서버를 위한 공유 메모리 채널)을 공급할 수 있다. 다만 현재 이 API는 “내부용”으로 기술된다.

postmaster 주도 종료는 일반 백엔드와 다르다. README가 설명하듯, walsender는 종료 전에 종료 체크포인트 레코드를 대기 서버에 전달해야 한다. 시퀀스는 다음과 같다.

  1. 모든 일반 백엔드가 종료된 뒤, checkpointer가 모든 walsender에 PROCSIG_WALSND_INIT_STOPPING을 보낸다.
  2. 각 walsender가 WALSNDSTATE_STOPPING으로 전환하고, 새 명령을 거부하며 준비 완료를 신호한다.
  3. checkpointer는 모든 walsender가 stopping 상태임을 확인한 뒤에야(WalSndWaitStopping) 종료 체크포인트를 시작한다.
  4. 종료 체크포인트가 완료되면 postmaster가 각 walsender에 SIGUSR2를 보낸다.
  5. WalSndDone이 남은 WAL을 플러시하고, 대기 서버 확인을 기다린 뒤 proc_exit(0)을 호출한다.

이 조율 덕분에 대기 서버는 종료 체크포인트 레코드를 받은 상태다. 깨끗한 주 서버 종료 후 승격된 대기 서버는 그 지점 이후를 재실행할 필요가 없다.

프로세스/서브시스템별로 묶인 심볼 목록이다. 파일 경로는 /data/hgryoo/references/postgres/를 기준으로 한다.

walsender: 공유 메모리와 상태 (walsender_private.h, walsender.c)

섹션 제목: “walsender: 공유 메모리와 상태 (walsender_private.h, walsender.c)”
  • WalSndState (열거형) — WALSNDSTATE_STARTUP부터 WALSNDSTATE_STOPPING까지.
  • WalSnd (구조체) — 송신자 슬롯: pid, state, sentPtr, write, flush, apply, 지연 오프셋, mutex, replyTime, kind.
  • WalSndCtlData (구조체) — 공유 제어 블록: SyncRepQueue[], lsn[], sync_standbys_status, wal_flush_cv, wal_replay_cv, wal_confirm_rcv_cv, walsnds[] (유연 배열).
  • WalSndCtlWalSndCtlData의 전역 포인터.
  • MyWalSnd — 현재 프로세스의 WalSnd 슬롯.
  • WalSndShmemSize / WalSndShmemInitWalSndCtlData 공유 메모리 블록 할당/초기화.
  • WalSndSetStateMyWalSnd->state 전환 및 이벤트 로그.
  • NUM_SYNC_REP_WAIT_MODESyncRepQueue[] / lsn[] 배열 차원.
  • InitWalSenderWalSnd 슬롯 요청; PostgresMain에서 호출.
  • exec_replication_command — 복제 명령 문자열 파싱 및 처리; 복제 명령이 아니면 false 반환(db 모드 walsender의 SQL 통과).
  • IdentifySystemIDENTIFY_SYSTEM 처리; sysid, 타임라인, LSN, dbname 반환.
  • StartReplicationSTART_REPLICATION(물리) 처리; xlogreader 설정, 타임라인 결정, COPY 모드 진입, WalSndLoop(XLogSendPhysical) 호출.
  • StartLogicalReplicationSTART_REPLICATION(논리) 처리; WalSndLoop(XLogSendLogical) 호출.
  • WalSndLoop — 정상 상태 루프; send_data 처리, 킵얼라이브 관리, 응답 처리, catchup→streaming 전환 감지, 종료 처리.
  • XLogSendPhysicalSendRqstPtr 계산, WALRead로 WAL 읽기, 'w' 메시지 큐 추가.
  • XLogSendLogical — 논리 디코딩 컨텍스트에서 디코딩된 변경 당김; LogicalDecodingContext 출력 플러그인 콜백으로 WalSndWriteData 호출.
  • ProcessRepliesIfAny — 클라이언트 소켓의 비차단 수신; 'd' CopyData를 ProcessStandbyMessage로, CopyDoneTerminate 처리.
  • ProcessStandbyMessage / ProcessStandbyReplyMessage / ProcessStandbyHSFeedbackMessageWalSnd 슬롯 갱신; 동기 대기자 해제; 슬롯 xmin 갱신.
  • WalSndCheckTimeOutwal_sender_timeout 강제.
  • WalSndKeepalive / WalSndKeepaliveIfNecessary'k' 메시지 전송.
  • WalSndDone — 우아한 종료: 남은 WAL 드레인, 대기 서버 확인 대기, proc_exit(0).
  • WalSndWakeup / WalSndWakeupRequest / WalSndWakeupProcessRequests — WAL 플러시 경로에서의 컨디션 변수 / 래치 깨우기.
  • RequestXLogStreaming — 스타트업 프로세스 진입점: WalRcvData 채우기, 상태 전환, postmaster 신호 또는 기존 수신자 깨우기.
  • GetWalRcvFlushRecPtr — 스핀락 아래 flushedUpto(그리고 선택적으로 latestChunkStart, receivedTLI) 반환.
  • WalRcvRunning / WalRcvStreaming — 상태 조건자; WALRCV_STARTUP_TIMEOUT 감지 및 강제 종료.
  • ShutdownWalRcv — SIGTERM 전송 및 walRcvStoppedCV 대기.
  • WalReceiverMain — 메인 프로세스 함수: AuxiliaryProcessMainCommon, WalRcvData 요청, libpqwalreceiver 로드, 연결, 핸드셰이크, 스트림 루프.
  • WalRcvWaitForStartPosition — 주 서버가 연결 유지 상태로 스트림을 종료할 때, WALRCV_RESTARTING 또는 종료를 기다림.
  • XLogWalRcvProcessMsg'w'XLogWalRcvWrite로, 'k'를 킵얼라이브 응답으로 분기.
  • XLogWalRcvWrite — 세그먼트 정렬된 pg_pwrite 루프; writtenUpto 아토믹 갱신.
  • XLogWalRcvFlushissue_xlog_fsync, flushedUpto 전진, 스타트업 프로세스 및 캐스케이딩 walsender 신호, 응답 전송.
  • XLogWalRcvSendReply'r' 상태 응답 생성 및 전송.
  • XLogWalRcvSendHSFeedbackhot_standby_feedback이 켜진 경우 'h' 피드백 생성 및 전송.
  • WalRcvState (열거형) — WALRCV_STOPPED, WALRCV_STARTING, WALRCV_STREAMING, WALRCV_WAITING, WALRCV_RESTARTING, WALRCV_STOPPING.
  • WalRcvData (구조체) — procno, pid, walRcvState, walRcvStoppedCV, receiveStart/TLI, flushedUpto, receivedTLI, latestChunkStart, writtenUpto(아토믹), force_reply, conninfo, slotname, 타이밍 필드.
  • WalRcv — 단일 WalRcvData 공유 메모리 블록의 전역 포인터.
  • WalReceiverFunctionsType / WalReceiverFunctionslibpqwalreceiver가 채우는 디스패치 테이블.
  • AllowCascadeReplication() — 매크로: EnableHotStandby && max_wal_senders > 0.

위치 힌트 (2026-06-05 기준, REL_18 273fe94)

섹션 제목: “위치 힌트 (2026-06-05 기준, REL_18 273fe94)”
심볼파일
WalSndState (열거형)src/include/replication/walsender_private.h24
WalSnd (구조체)src/include/replication/walsender_private.h41
WalSndCtlData (구조체)src/include/replication/walsender_private.h80
WalRcvState (열거형)src/include/replication/walreceiver.h46
WalRcvData (구조체)src/include/replication/walreceiver.h56
AllowCascadeReplication (매크로)src/include/replication/walreceiver.h42
am_walsendersrc/include/replication/walsender.h22
max_wal_senderssrc/backend/replication/walsender.c126
InitWalSendersrc/backend/replication/walsender.c297
IdentifySystemsrc/backend/replication/walsender.c397
StartLogicalReplicationsrc/backend/replication/walsender.c1447
exec_replication_commandsrc/backend/replication/walsender.c1990
ProcessRepliesIfAnysrc/backend/replication/walsender.c2246
ProcessStandbyReplyMessagesrc/backend/replication/walsender.c2423
ProcessStandbyHSFeedbackMessagesrc/backend/replication/walsender.c2611
WalSndCheckTimeOutsrc/backend/replication/walsender.c2779
WalSndLoopsrc/backend/replication/walsender.c2806
InitWalSenderSlotsrc/backend/replication/walsender.c2948
XLogSendPhysicalsrc/backend/replication/walsender.c3118
WalSndDonesrc/backend/replication/walsender.c3521
WalSndShmemSizesrc/backend/replication/walsender.c3669
WalSndShmemInitsrc/backend/replication/walsender.c3681
WalSndSetStatesrc/backend/replication/walsender.c3869
WalSndKeepalivesrc/backend/replication/walsender.c4094
WalSndKeepaliveIfNecessarysrc/backend/replication/walsender.c4117
WalReceiverMainsrc/backend/replication/walreceiver.c152
WalRcvWaitForStartPositionsrc/backend/replication/walreceiver.c645
XLogWalRcvProcessMsgsrc/backend/replication/walreceiver.c819
XLogWalRcvWritesrc/backend/replication/walreceiver.c890
XLogWalRcvFlushsrc/backend/replication/walreceiver.c985
WalRcvRunningsrc/backend/replication/walreceiverfuncs.c76
WalRcvStreamingsrc/backend/replication/walreceiverfuncs.c127
RequestXLogStreamingsrc/backend/replication/walreceiverfuncs.c246
GetWalRcvFlushRecPtrsrc/backend/replication/walreceiverfuncs.c336
  • walsender는 전용 보조 프로세스가 아니라 백엔드 변형이다. walsender.c 파일 헤더에서 확인했다. “A walsender is similar to a regular backend, ie. there is a one-to-one relationship between a connection and a walsender process.” InitWalSenderreplication= 파라미터 감지 후 PostgresMain에서 호출된다. BackendTypeB_WAL_SENDER다.

  • max_wal_senders는 기본값 10인 GUC이며, WalSndCtl 공유 메모리 블록의 크기는 이 값에 비례한다. walsender.c 126행: int max_wal_senders = 10. WalSndShmemSizesizeof(WalSndCtlData) + max_wal_senders * sizeof(WalSnd)를 반환한다(3674행). max_wal_senders를 변경하려면 서버 재시작이 필요하다. 공유 메모리는 postmaster 시작 시 할당되기 때문이다.

  • 스트리밍 프로토콜은 표준 와이어 프로토콜 위의 CopyBoth 모드를 사용한다. StartReplication에서 확인했다. pq_beginmessage(&buf, PqMsg_CopyBothResponse)WalSndLoop 진입 전에 전송된다. WAL 데이터 메시지는 pq_putmessage_noblock('d', ...)(CopyData)를 사용한다.

  • 세 개의 LSN 커서(write / flush / apply)가 대기 서버에서 보고되고 스핀락 아래 WalSnd에 저장된다. ProcessStandbyReplyMessage에서 확인했다. writePtr, flushPtr, applyPtr'r' 메시지에서 풀려나 SpinLockAcquire(&walsnd->mutex) 아래 walsnd->write, ->flush, ->apply에 저장된다.

  • flushedUptoXLogWalRcvFlushissue_xlog_fsync 이후에만 갱신된다. WalRcvDataflushedUpto 전진은 스핀락 안에서 issue_xlog_fsync 반환 후에 이루어진다. 쓰기는 더 일찍(잠금 없이) writtenUpto 아토믹을 전진시켜 캐스케이딩 walsender가 비동기 데이터를 서비스할 수 있게 하면서도, flushedUpto 보증은 명확하게 유지한다.

  • flushedUpto 갱신 직후 WakeupRecovery()가 호출된다. XLogWalRcvFlush에서 확인했다. 스핀락 해제와 flushedUpto 전진 직후에 WakeupRecovery() 호출이 온다. 스타트업 프로세스가 WAL 재실행을 진행할 수 있음을 알리는 메커니즘이다.

  • walreceiver는 load_file로 libpq를 동적으로 로드한다. WalReceiverMain에서 확인했다. load_file("libpqwalreceiver", false) 다음에 WalReceiverFunctions != NULL임을 단언하는 코드가 온다. README가 이유를 설명한다. 서버 바이너리를 libpq와 링크하지 않기 위해서다.

  • 주 서버가 연결을 유지하면서 스트리밍을 종료하면 walreceiver는 WALRCV_WAITING 상태에서 스타트업 프로세스의 새 지시를 기다린다. WalReceiverMain의 루프에서 확인했다. walrcv_endstreaming 반환 후 WalRcvWaitForStartPosition을 호출하는데, 여기서 WALRCV_RESTARTING이 설정되거나 종료가 요청될 때까지 대기한다. 스타트업 프로세스가 RequestXLogStreaming을 다시 호출하면 WALRCV_RESTARTING으로 설정하고 수신자의 래치를 깨운다. 새 프로세스를 포크하지 않는다.

  • 종료 조율이 walsender 준비 완료를 기다린 뒤 종료 체크포인트를 시작한다. 파일 헤더 주석과 WalSndInitStopping / WalSndWaitStopping에서 확인했다. checkpointer가 WalSndInitStopping을 호출(각 walsender에 PROCSIG_WALSND_INIT_STOPPING 전송)한 뒤 WalSndWaitStopping을 호출하는데, 모든 walsender가 WALSNDSTATE_STOPPING 또는 WALSNDSTATE_STARTUP 상태가 될 때까지 루프를 돈다. 그 이후에야 checkpointer가 종료 체크포인트를 진행한다.

  1. writtenUpto가 캐스케이딩 walsender에 찢어진(torn) WAL을 서비스할 위험이 있는가? writtenUptoXLogWalRcvFlush가 호출되기 전에 XLogWalRcvWrite에서 전진하므로, 캐스케이딩 walsender가 pwrite()됐지만 아직 fsync()되지 않은 바이트를 읽을 수 있다. 대기 서버가 fsync 전에 크래시하면 그 바이트가 손실될 수 있는데, 캐스케이딩 walsender는 이미 하류로 전송했을 수 있다. 조사 경로: XLogSendPhysical이 캐스케이딩 송신자에서 호출하는 GetStandbyFlushRecPtrGetWalRcvFlushRecPtr이 같은 포인터인지 다른지 확인.

  2. hot_standby_feedback과 슬롯 xmin이 모두 활성일 때 어떻게 상호작용하는가? ProcessStandbyHSFeedbackMessageMyReplicationSlot이 설정된 경우 PhysicalReplicationSlotNewXmin을 호출하고, 직접 MyProc->xmin도 설정한다. 전역 xmin 지평 계산(GetOldestXmin)에서 슬롯의 effective_xmin과 proc의 xmin이 어떻게 기여하는지 명확하지 않다. 조사 경로: ReplicationSlotsComputeRequiredXmin을 읽고 두 값이 지평에 어떻게 기여하는지 추적.

  3. WALRCV_STARTUP_TIMEOUT: walreceiver 시작이 느릴 때 어떻게 되는가? WalRcvRunningWalRcvStreaming 모두 WALRCV_STARTING 상태가 WALRCV_STARTUP_TIMEOUT(5초, 컴파일 타임 상수)을 넘어 지속되면 강제로 WALRCV_STOPPED로 전환한다. 스타트업 프로세스가 스트리밍을 다시 요청한다. 주 서버에 연결할 수 없는 경우 밀집 루프가 생길 위험이 있는가? 조사 경로: xlogrecovery.c에서 RequestXLogStreaming 재호출을 추적.

PostgreSQL 너머 — 비교 설계와 연구 프런티어

섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프런티어”
  • MySQL/InnoDB Group Replication과 Galera(동기 멀티마스터) — PostgreSQL의 walsender/walreceiver는 주 서버에서 대기 서버로의 단방향 파이프다. Group Replication과 Galera는 인증 기반 프로토콜(Paxos / Galera 복제)을 사용해 모든 노드가 쓰기 순서에 참여한다. 이 비교는 PostgreSQL이 단순한 단방향 파이프 대신 멀티마스터 링을 택하지 않음으로써 무엇을 교환하는지 명확히 한다. 쓰기 순서의 PostgreSQL 유사물은 여러 동기 대기 서버를 둔 synchronous_commit = remote_apply이지만, 여전히 단일 주 서버를 둔다.

  • Raft 기반 복제(CockroachDB, etcd 기반 PostgreSQL HA) — Raft는 리더 선출과 로그 복제를 단일 프로토콜로 제공한다. PostgreSQL은 이 관심사를 분리한다. WAL 스트리밍은 로그 전송을 담당하고, Patroni/Stolon/repmgr이 외부에서 리더 선출을 처리한다. 이 분리가 Raft의 로그 추가, 커밋 쿼럼, 리더 임대 기본 개념에 어떻게 매핑되는지에 대한 노트는 postgres-evolution-replication.md 아크에 유용할 것이다.

  • 논리 복제 vs. 물리 스트리밍 — 이 문서는 둘이 공유하는 전송 계층만 다룬다. WAL 바이트를 디코딩된 행 변경으로 변환하는 논리 디코딩 경로(XLogSendLogical, LogicalDecodingContext)는 postgres-logical-decoding.md의 주제다. 핵심 교차 참조: walsender.cStartLogicalReplication이 물리 경로와 동일한 WalSndLoop를 호출한다. 전송 계층은 동일하고 데이터 소스만 다르다.

  • 복제 지연과 지연 추적 메커니즘walsender.cLagTrackerWriteLagTrackerRead가 각 WAL 위치가 언제 전송됐고 대기 서버가 언제 확인했는지 기록한다. 결과인 writeLag, flushLag, applyLag 필드가 pg_stat_replication에 공급된다. 지연 추정 정확도(샘플링 편향, CommitDelay와의 상호작용, 대용량 트랜잭션 지연 귀속 방식)에 대한 전용 노트는 postgres-overview-replication-ha.md의 개요를 보완할 것이다.

인트리(in-tree) 설계 문서:

  • src/backend/replication/README — walreceiver IPC, walsender IPC, walsender–walreceiver 프로토콜(“See manual”).

소스 파일 (REL_18_STABLE, 커밋 273fe94):

  • src/backend/replication/walsender.c — walsender 프로세스: 명령 처리, 송신 루프, 대기 서버 응답 처리, 킵얼라이브, 종료.
  • src/backend/replication/walreceiver.c — walreceiver 프로세스: 메인 루프, 메시지 처리, pg_wal 기록/플러시, 응답 전송.
  • src/backend/replication/walreceiverfuncs.c — 스타트업 프로세스 API: RequestXLogStreaming, GetWalRcvFlushRecPtr, WalRcvRunning, WalRcvStreaming, ShutdownWalRcv.
  • src/include/replication/walsender.h — 공개 walsender API, GUC 선언, WalSndWakeupRequest 매크로.
  • src/include/replication/walsender_private.hWalSndState, WalSnd, WalSndCtlData.
  • src/include/replication/walreceiver.hWalRcvState, WalRcvData, WalReceiverFunctionsType.

교과서:

  • Database Internals(Petrov), ch. 11 — 복제, 일관성, 리더와 팔로워 역할, 로그 적재 vs. 스트리밍.

교차 참조 (메커니즘 소유권이 다른 곳에 있음 — 여기서 중복하지 않음):

  • postgres-xlog-wal.md — WAL 레코드 형식, LSN, 내구성 파이프라인; walsender가 GetFlushRecPtr로 읽는 플러시 포인터.
  • postgres-replication-slots.md — 슬롯 생성, WAL 보존, xmin 지평 관리.
  • postgres-logical-decoding.md — 디코딩된 변경 스트림; WalSndLoop를 공유하는 논리 경로.
  • postgres-synchronous-replication.mdSyncRepReleaseWaiters, synchronous_standby_names 정책, 커밋 대기 경로.
  • postgres-overview-replication-ha.md — 모든 replication-ha 문서에 걸친 읽기 순서가 있는 서브카테고리 라우터.
  • postgres-architecture-overview.md — 스트리밍 복제를 가능하게 하는 WAL 중심 내구성 척추인 축 3.