PostgreSQL 복제는 하나의 철학이다
WAL 스트리밍부터 Logical Decoding, Patroni Split-Brain 방지, PgBouncer Transaction Mode까지 — PostgreSQL 복제 생태계 전체를 관통하는 설계 원칙을 추적한다.
- 01 PostgreSQL은 왜 연결마다 프로세스를 fork하는가
- 02 PostgreSQL은 왜 삭제해도 테이블이 줄지 않는가
- 03 PostgreSQL 인덱스는 왜 이렇게 많은가
- 04 PostgreSQL은 8KB 페이지를 어떻게 넘는가
- 05 PostgreSQL 쿼리 설계의 다섯 가지 축
- 06 PostgreSQL 복제는 하나의 철학이다
- 07 PostgreSQL 운영에서 설정이 먼저인가, 진단이 먼저인가
PostgreSQL의 복제 스택은 넓다. Streaming Replication, Hot Standby, Logical Replication, Patroni, PgBouncer — 이것들은 독립된 기능처럼 보이지만 하나의 질문에서 출발한다. “Primary가 쓴 것을 어떻게, 얼마나 빠르게, 어디까지 전달할 것인가?” 이 질문에 답하는 방식이 곧 각 컴포넌트의 설계 결정이다.
WAL이 모든 것의 출발점이다
Streaming Replication의 흐름은 단순하다. Primary가 트랜잭션을 커밋하면 WAL 레코드가 pg_wal/에 기록되고, WAL Sender 프로세스가 이를 TCP로 Standby에 밀어 넣는다. Standby의 WAL Receiver는 받은 WAL을 로컬에 저장하고, Startup 프로세스가 이를 Heap에 Redo한다.
이 파이프라인에는 4개의 LSN 체크포인트가 있다.
sent_lsn → write_lsn → flush_lsn → replay_lsn
(TCP 전송) (OS 버퍼) (디스크 fsync) (Heap 적용)
복제 지연은 pg_current_wal_lsn() - replay_lsn이다. 숫자가 클수록 Standby가 뒤처진다. pg_stat_replication의 replay_lag 컬럼이 이를 시간 단위로 보여준다.
동기/비동기 선택은 이 파이프라인의 어느 단계에서 Primary가 “OK”를 반환하느냐의 문제다. synchronous_commit = on이면 flush_lsn까지, remote_apply이면 replay_lsn까지 기다린다. 비동기는 로컬 fsync 후 즉시 반환한다. 네트워크 RTT만큼의 지연을 감수할 수 있는가 — 금융 거래라면 remote_apply, 웹 서비스라면 비동기가 합리적이다.
Slot은 Standby가 따라잡을 때까지 WAL을 보존한다. Standby가 영구 제거됐는데 Slot을 삭제하지 않으면 pg_wal/이 무한히 증가해 Primary 디스크가 꽉 찬다. pg_replication_slots의 retained_wal을 항상 모니터링하고, inactive Slot은 즉시 삭제해야 한다.
Hot Standby — 읽기와 Redo의 충돌
Standby에서 SELECT를 허용하는 것이 Hot Standby다. 여기서 첫 번째 긴장이 생긴다. Startup 프로세스가 WAL을 Redo하는 동안 읽기 쿼리가 같은 페이지에 접근하면 충돌이 발생한다.
가장 흔한 충돌은 VACUUM이다. Primary에서 Dead Tuple을 제거하는 WAL이 Standby에 도달했을 때, Standby의 읽기 쿼리가 그 튜플을 아직 보고 있다면 Redo가 30초(max_standby_streaming_delay 기본값) 대기 후 쿼리를 강제 취소한다. 애플리케이션 로그에 canceling statement due to conflict with recovery가 보이면 이 상황이다.
hot_standby_feedback = on은 Standby의 활성 스냅샷 xmin을 Primary에 주기적으로 전송해 Primary VACUUM이 그 xmin 이전 Dead Tuple을 제거하지 못하게 한다. VACUUM 충돌이 대폭 줄어드는 대신, Primary의 Table Bloat가 늘어날 수 있다. Standby에서 장기 보고서 쿼리를 실행한다면 hot_standby_feedback = on은 사실상 필수다.
Logical Replication — 물리를 넘어 논리로
Physical Replication은 WAL을 그대로 전달하므로 동일한 PostgreSQL 버전과 아키텍처가 필요하다. Logical Replication은 다르다. WAL에서 “어떤 행이 어떻게 바뀌었는가”를 추출해 행 수준의 논리적 변경으로 전달한다. wal_level = logical이 WAL에 컬럼 값을 포함시키고, pgoutput 플러그인이 이를 표준 프로토콜로 인코딩한다.
Publication/Subscription 모델은 명시적이다. Publisher는 어떤 테이블의 어떤 변경(INSERT/UPDATE/DELETE)을 공개할지 선언하고, Subscriber는 그것을 구독한다. PostgreSQL 15부터는 행 필터(WHERE)와 컬럼 목록도 지정할 수 있다.
-- Publisher: 활성 사용자의 변경만 공개
CREATE PUBLICATION pub_active FOR TABLE users WHERE (status = 'ACTIVE');
-- Subscriber: 구독 (테이블은 미리 수동 생성 필요)
CREATE SUBSCRIPTION sub_active
CONNECTION 'host=pub-host dbname=mydb user=repl_user'
PUBLICATION pub_active;
DDL은 복제되지 않는다. ALTER TABLE을 Publisher에서 실행하면 Subscriber에는 수동으로 적용해야 한다. Sequence도 복제되지 않는다. 이 제약이 Logical Replication의 핵심 운영 부담이다.
반면 이기종 버전 복제가 가능하다는 점이 강력하다. 메이저 버전 업그레이드(예: PG 14 → PG 16)를 무중단으로 수행할 수 있다. 구버전에서 Publication을 만들고, 신버전 인스턴스가 따라잡을 때까지 복제한 뒤, 짧은 유지보수 창에서 전환한다. 다운타임이 수 초 ~ 수 분으로 줄어든다.
CDC(Change Data Capture) 파이프라인도 Logical Replication 위에서 동작한다. Debezium이 pgoutput 플러그인으로 Logical Slot에 연결해 변경 이벤트를 Kafka 토픽으로 흘려보내고, Elasticsearch나 Redis가 이를 소비한다.
Patroni — 합의가 곧 고가용성이다
Streaming Replication은 복제를 제공하지만 자동 Failover는 제공하지 않는다. Primary가 다운되면 누군가 수동으로 Standby를 Promote해야 한다. Patroni는 이 문제를 etcd 같은 분산 합의 시스템으로 해결한다.
Leader Election의 핵심은 etcd의 Compare-And-Swap이다. 각 노드가 /service/<cluster>/leader 키에 자신을 등록하려 하고, 첫 번째로 성공한 노드만 Primary가 된다. Leader는 TTL(기본 30초) 내에 Heartbeat를 보내야 하고, 실패하면 키가 만료된다. Follower가 키의 사라짐을 감지하면 새 Leader Election이 시작된다.
T+0초: Primary 다운
T+30초: etcd Lease 만료, 새 Leader 선출 시작
T+35초: 최신 WAL을 가진 Standby가 Promote
T+40초: HAProxy/PgBouncer가 새 Primary 감지 → 트래픽 전환
총 다운타임은 3040초다. 설정에 따라 1020초까지 줄일 수 있다.
Split-Brain 방지가 핵심이다. 네트워크 파티션으로 구 Primary가 etcd와 연결을 잃었을 때, 구 Primary가 계속 쓰기를 받으면 두 Primary가 생긴다. Patroni는 Watchdog으로 이를 막는다. etcd와 연결이 끊기면 Patroni가 자신의 PostgreSQL을 즉시 중단한다. mode: required로 설정하면 이 동작이 강제된다.
etcd 클러스터 전체가 다운되면 Patroni는 보수적으로 동작한다 — PostgreSQL을 읽기 전용으로 전환하거나 중단한다. “etcd 없이는 Primary도 없다”는 원칙이다. 데이터 안전을 위해 가용성을 희생하는 선택이다. 따라서 etcd 클러스터도 반드시 3노드 이상으로 운영해야 한다.
PgBouncer — 연결은 비싸다
PostgreSQL은 연결당 프로세스를 fork한다. 연결 1000개면 프로세스 1000개다. 프로세스당 메모리가 10MB라면 10GB가 연결 오버헤드에만 쓰인다. PgBouncer는 애플리케이션과 PostgreSQL 사이에 서서 수천 개 클라이언트 연결을 수십 개 PostgreSQL 프로세스로 압축한다.
세 가지 모드가 있다. Session Mode는 클라이언트 세션 동안 PostgreSQL 연결이 고정된다. 세션 상태가 완전히 유지되지만 idle 연결도 PostgreSQL 프로세스를 점유한다. Transaction Mode는 트랜잭션이 끝나면 즉시 연결을 반환한다. 풀링 효과가 가장 크다. Statement Mode는 각 문장 실행 후 반환한다 — 트랜잭션을 지원하지 않으므로 실무에서 거의 쓰지 않는다.
Transaction Mode의 함정이 있다. PostgreSQL JDBC 드라이버는 같은 쿼리가 5번(prepareThreshold=5) 실행되면 서버 사이드 Prepared Statement를 생성한다. 트랜잭션이 끝나 연결이 교체되면 다른 연결에는 그 Prepared Statement가 없다. 결과는 prepared statement "S_1" does not exist 오류다.
해결은 간단하다. JDBC URL에 prepareThreshold=0을 추가해 서버 사이드 Prepared Statement를 비활성화하면 된다.
jdbc:postgresql://pgbouncer:5432/mydb?prepareThreshold=0
HikariCP와 함께 쓸 때 maximumPoolSize는 작게 설정한다. PgBouncer가 여러 앱 인스턴스의 연결을 통합 관리하므로, 앱 인스턴스당 5~10이면 충분하다. SHOW POOLS에서 cl_waiting > 0이 지속되면 default_pool_size를 늘려야 한다는 신호다.
정리
- WAL은 PostgreSQL 복제의 단일 진실 원천이다. Physical이든 Logical이든 모든 복제는 WAL에서 시작한다.
- 동기 복제는 “커밋 지연”과 “데이터 손실 없음”의 교환이다.
synchronous_commit레벨로 그 균형을 조정한다. - Logical Replication은 이기종 복제와 CDC를 열어준다. 대신 DDL과 Sequence는 수동 관리가 필요하다.
- Patroni는 etcd를 통해 “한 번에 하나의 Primary”를 보장한다. Split-Brain 방지가 핵심이고, etcd 고가용성이 전제 조건이다.
- PgBouncer Transaction Mode +
prepareThreshold=0이 현대 PostgreSQL 스택의 표준 조합이다.
복제 스택의 각 컴포넌트는 독립적으로 보이지만, 실제로는 “어디까지 신뢰할 것인