PostgreSQL 읽기 레플리카: 지연은 버그가 아니라 “계약”이다 (실무 노트)
핵심 요약
한 줄 요약: 레플리카에 SELECT를 몰아넣기 전에 **“몇 초까지 옛날 데이터 OK인지”**를 제품·백엔드가 합의해야 합니다. 합의가 없으면 “가끔 데이터가 이상해요” 티켓이 무한 생성됩니다.
| 질문 | 답이 없으면 생기는 일 |
|---|---|
| 방금 쓴 글을 바로 읽어야 하나? | 레플리카에서 안 보임 → 유저 신고 |
| 대시보드는 30초 늦어도 되나? | 레플리카로 비용·부하 절감 가능 |
| 결제 직후 잔액은? | 무조건 primary 쪽으로 라우팅 규칙 필요 |
들어가며: “복제 됐는데 왜 안 보이죠?”
온콜 들어온 목소리가 떨리는 패턴이 있습니다. “방금 업데이트했는데 화면에 반영이 안 돼요.” 로그를 까보면 쓰기는 primary에 잘 갔고, 읽기는 습관처럼 읽기 전용 엔드포인트로 붙어 있는 경우가 많습니다. PostgreSQL이 잘못된 게 아니라, 비동기 복제의 물리적인 시간차를 앱이 모르는 겁니다.
itemSCV에서도 이벤트·리워드 쪽 테이블은 쓰기 직후 조회가 잦아서, 처음엔 “레플리카만 쓰자”고 했다가 특정 API만 primary로 되돌리는 식으로 여러 번 조정했습니다. 이 글은 그때 나온 합의용 언어와 체크리스트를 옮겨 적은 겁니다. 버전은 2026년 2월 기준이고, 매니지드 RDS·AlloyDB·자체 구축이든 개념은 같습니다.
이 글에서 말하는 복제는 대부분 물리 스트리밍 복제(WAL 스트림)를 전제로 합니다. 논리 복제·외부 CDC는 지연 특성·일관성 보장이 다르므로, 같은 표를 그대로 쓰면 안 됩니다.
1. 읽기 분리를 하는 진짜 이유
말은 “primary 부하 줄이기”로 하지만, 실무에서 얻는 건 보통 이 셋 중 하나입니다.
- CPU/IO 병목 분리: 집계·리포트 쿼리가 OLTP와 디스크를 뺏지 않게
- 가용성 버퍼: primary 장애 시 승격(failover) 스토리와 맞물림 (이 글에서는 깊게 안 파고, 읽기 라우팅에 집중)
- 배포·마이그레이션: 긴 읽기 전용 작업을 replica에서 돌리기
반대로 **“SELECT가 좀 느려서”**만으로 레플리카를 추가하면, 지연 이슈를 안 잡으면 체감 품질만 나빠질 수 있습니다.
1-1. 이럴 땐 replica부터 고민하지 말 것
| 상황 | 먼저 볼 것 |
|---|---|
| 단일 쿼리가 primary를 태움 | 실행 계획·인덱스·통계(ANALYZE)·파티셔닝 |
| 커넥션 수만 폭주 | 풀 크기·idle 타임아웃·앱 누수 |
| 디스크 IO 한계 | 인스턴스 클래스·스토리지 타입·불필요한 전체 스캔 |
replica는 쿼리를 빠르게 해 주지 않습니다. 느린 쿼리를 replica로 내면 거기서도 느리고, replay와 싸울 수 있습니다.
2. 비동기면 지연은 “고장”이 아니라 “간격”
스트리밍 복제를 쓰는 일반적인 구성에서, replica는 WAL을 따라잡는 따로 도는 프로세스입니다. primary에 쓰기 버스트가 오면 replica는 잠깐 뒤처집니다. 이게 정상입니다.
| 구분 | 한 줄 |
|---|---|
| primary | WAL을 쓰고 standby로 전송 |
| replica | 수신한 WAL을 재생(replay) 해서 데이터 파일을 따라감 |
| 지연 | 전송·디스크·replay 속도 차이로 생기는 시간·바이트 간격 |
팀에서 해야 할 일은 “0초 지연”을 말로만 요구하는 게 아니라:
- 제품 SLO: “목록 화면은 N초까지 지연 허용”처럼 숫자
- 라우팅 규칙: “유저 본인 리소스 조회는 primary” 같은 코드·미들웨어 규칙
을 같은 문서에 써 두는 겁니다. 없으면 DB가 항상 욕먹습니다.
동기 복제(synchronous_commit·동기 스탠바이)는 “읽기 지연”과 다른 축에서 쓰기 지연·가용성을 바꿉니다. 읽기 일관성만 문제라면 앱 라우팅이 대개 먼저입니다.
3. 앱 레벨에서 자주 쓰는 타협
A. “방금 쓴 건 primary” 패턴
세션·유저·주문 ID 단위로 쓰기 직후 짧은 시간만 primary로 붙는 방식입니다. 구현은 쿠키, Redis 플래그, API 게이트웨이 헤더 등 팀마다 다릅니다. 핵심은 **“누가 언제까지 fresh가 필요한지”**를 코드에 표현하는 것.
B. 읽기 전용 API는 replica 고정
대시보드, 내부 어드민, 배치 리포트처럼 약간 옛날 데이터 OK한 경로만 replica로 고정합니다. 이때도 캐시와 섞이면 또 꼬이니, 캐시 TTL과 lag SLO를 같이 봐야 합니다.
C. 트랜잭션 안에서의 함정
같은 트랜잭션에서 쓰고 읽을 때는 당연히 같은 세션·primary를 써야 합니다. 레플리카로 “같은 요청 안에서” 읽으려 하면 설계가 이미 어긋난 겁니다.
COMMIT 직후 같은 HTTP 요청 안에서 ORM이 자동으로 “읽기” 연결을 replica로 붙이는 프레임워크가 있습니다. 트랜잭션 블록 안이 아니어도 커밋 직후 조회는 RYW가 깨지기 쉬우니, 그 경로만큼은 명시적으로 primary를 타게 하는 게 안전합니다.
D. ORM·라우터에서 자주 나는 실수
| 실수 | 결과 |
|---|---|
| “읽기 전용” 플래그가 같은 커넥션 풀을 가리킴 | replica를 안 탐 |
| lazy load가 읽기 연결로 걸림 | 쓰기 직후 화면에서 행이 비어 보임 |
배치가 SELECT ... FOR UPDATE 없이 긴 읽기만 replica | 의도와 다르게 stale 의사결정 |
코드 리뷰에서 DSN 문자열이 두 개인지, 테스트에서 primary/replica 각각으로 smoke test를 도는지 확인하면 삽질이 줄어듭니다.
4. 모니터링: 0이 되길 바라지 말고, SLO를 걸어라
레플리카 모니터링에서 저는 **“lag이 0인지”**보다 **“우리가 약속한 N초를 넘었는지”**를 알람으로 겁니다.
환경마다 메트릭 이름이 다르니 숫자는 대략만 적습니다.
| 보는 것 | 왜 중요한가 |
|---|---|
| replay lag (시간 또는 바이트) | 버스트 쓰기·네트워크·디스크 이슈의 첫 신호 |
| replica 연결 수 | 풀러 없이 붙이면 replica가 먼저 죽음 |
| 오래 걸린 SELECT | max_standby_streaming_delay / 취소 정책과 연결 |
primary 쪽에서는 pg_stat_replication을, replica 입장에서는 복구 지연 관련 뷰·메트릭을 같이 보면 어디가 병목인지 빨리 갈립니다.
예시 (primary에서 세션 개념 확인용 - 버전에 따라 컬럼명 차이 있음):
replica 쪽에서 수신·재생 위치를 보고 싶을 때(버전에 따라 함수 가용 여부 확인):
replay_queue_bytes가 오래 크면 replay 쪽(CPU, 디스크, 장시간 쿼리로 인한 충돌)을 의심합니다. primary의 replay_lag_bytes와 둘 다 보면 네트워크 vs replay 병목을 가름하기 쉽습니다.
4-1. 알람은 “0”이 아니라 SLO·추세
| 알람 안티패턴 | 대신 |
|---|---|
| lag > 0 이면 페이지 | 버스트마다 울려 무시됨 |
| 고정 임계값 하나만 | 트래픽 증가 후 의미 상실 |
| replica CPU만 봄 | replay 지연은 WAL 큐에 쌓여 있을 수 있음 |
SLO의 N초(또는 N MB) 를 넘을 때만, 또는 p95가 며칠 연속 악화할 때 알리는 쪽이 유지됩니다.
운영자는 이 숫자를 **“항상 0”**으로 만들려고 하지 말고, **“우리 SLO 대비 몇 배인지”**로 읽는 게 정신 건강에 좋습니다.
5. 안티패턴 홀오브페임
- “읽기는 다 replica로” → 방금 가입한 유저만 로그인이 안 되는 미스터리
- lag 알람을 너무 빡세게 → 밤마다 울리다가 사람이 무시함
- replica에 무거운 VACUUM 대신 long query만 얹기 → replay와 싸움
- 합의 없이 ORM만 replica URL 바꿈 → 팀 전체가 원인을 한 시간 찾음
- 시퀀스·카운터를 유저에게 그대로 노출 → replica에서 “다음 번호”가 primary와 다르게 보일 수 있음(설계상 흔한 혼동)
- 배치를 replica에서 돌리며 “쓰기 없음”이라고 착각 → 임시 테이블·
COPY·일부 확장은 replica에서 막히거나 위험
| 증상 한 줄 | 의심할 것 |
|---|---|
| “가끔 행이 없어요” | RYW·캐시·replica lag |
| “replica만 5xx” | 커넥션 폭주·충돌 취소·인스턴스 한계 |
| “마이그레이션 후만” | 앱이 새 엔드포인트로 잘못 붙음·풀 warm-up |
6. 동기 복제 이야기는 한 줄만
“그럼 synchronous commit 쓰면 끝 아닌가?”라고 하면, 쓰기 지연과 가용성 트레이드오프가 바로 옵니다. 금융권 일부 시나리오에선 맞을 수 있지만, 일반 웹 서비스는 앱 라우팅 + 비동기 + SLO가 대부분 더 싸게 먹힙니다. 동기 복제를 켜기 전에 쓰기 p99부터 재보세요.
6-1. API 한 장 표 — “이 엔드포인트는 어디?”
회의에서 싸움이 줄어드는 건 표 한 장입니다. 아래는 가상의 이커머스 API를 예로 든 것입니다.
| API / 화면 | 읽기 대상 | 이유 |
|---|---|---|
POST /orders 후 GET /orders/:id | primary | 결제·재고 직후 일관성 |
| 상품 목록, 검색 | replica (SLO 30s) | 트래픽·캐시 미스 시 부하 분산 |
| “내 주문 목록” 첫 진입 | replica | 대부분 약간의 지연 OK |
| 주문 완료 직후 “주문 상세” | primary 또는 짧은 RYW 윈도우 | 유저가 방금 본 화면과 동일 기대 |
| 어드민 일별 매출 집계 | replica + 긴 timeout | OLTP와 디스크 분리 |
실전: 이 표를 OpenAPI 옆이나 Notion에 두고, PR 리뷰 때 **“이 SELECT replica 가도 되나?”**를 체크 질문으로 쓰면 됩니다.
6-2. Read-your-writes — “5초만 primary” 패턴 예시
구현은 프레임워크마다 다르지만, 계약은 같습니다: “쓰기 직후 T초 동안은 해당 유저·리소스 읽기를 primary로.”
| 방식 | 장점 | 주의 |
|---|---|---|
세션에 last_write_at + 미들웨어 | 단순 | 시계·다중 탭·모바일 앱 동시성 |
Redis user:123:last_write TTL 10s | 앱 서버 무상태에 적합 | Redis 장애 시 fallback 정책 |
API 응답에 X-Use-Primary-Until | 게이트웨이와 분리 가능 | 클라이언트 협조 필요 |
TTL은 복제 지연 p99 + 여유로 잡는 편이 안전합니다. “영원히 primary”로 가면 레플리카를 달아놓고 의미가 없어집니다.
6-3. replica에서만 터지는 것 — max_standby_streaming_delay
replica에서 긴 SELECT가 replay WAL을 막으면, PostgreSQL은 쿼리를 취소하거나 설정에 따라 다르게 동작합니다. 운영에서 본 메시지 패턴은 “canceling statement due to conflict with recovery” 류입니다.
| 할 일 | 설명 |
|---|---|
| 장시간 리포트는 primary 피크 아닌 시간 또는 전용 replica | OLTP replica와 보고용 replica 분리가 제일 깔끔 |
max_standby_streaming_delay 조정 | 팀 합의 없이 늘리면 복제 지연이 눈에 띄게 쌓일 수 있음 |
| vacuum 튜닝 | 오래된 xmin이 길게 잡히면 충돌이 늘어날 수 있음 (워크로드 의존) |
hot_standby_feedback 검토 | replica의 오래된 트랜잭션이 primary 쪽 vacuum을 막아 블로트·충돌을 키울 수 있음—트레이드오프 이해 후 켬 |
실전: “replica만 죽는다”고 하면 충돌 취소 로그부터 grep 해보세요. primary는 멀쩡한데 replica 5xx만 오르는 그림과 잘 맞습니다.
6-4. 커넥션 풀(PgBouncer 등)과 replica URL
앱이 replica 호스트로 커넥션을 폭발시키면, primary를 살려놓고 replica가 먼저 망가집니다.
| 체크 | 왜 |
|---|---|
| replica 쪽도 풀링 | numbackends가 인스턴스 한계에 붙는지 |
| ORM이 “읽기 전용” 세션을 진짜 다른 DSN으로 쓰는지 | 설정 이름만 바꾸고 같은 DB에 붙는 실수 |
| 배치 워커 풀 크기 | 한 박스에서 replica로 500커넥션 열면 끝 |
매니지드 RDS면 Reader 엔드포인트가 있어도, 앱 풀 × 인스턴스 대수를 곱한 값이 안전한지 한 번은 계산해 보세요.
6-5. 온콜 디버깅 순서 (stale 의심일 때)
| 순서 | 확인 |
|---|---|
| 1 | 해당 요청이 어느 DSN으로 갔는지 (로그·APM) |
| 2 | primary vs replica replay lag 메트릭이 SLO 이상인지 |
| 3 | 방금 대량 쓰기·마이그레이션·vacuum이 있었는지 |
| 4 | 캐시 TTL / CDN이 옛 응답을 주고 있지 않은지 (DB만 의심하지 말 것) |
| 5 | 동일 유저 쓰기 직후 읽기 경로가 표와 코드가 일치하는지 |
4번을 건너뛰면 한 시간 동안 PostgreSQL만 붙잡는 경우가 있습니다.
7. “같은 DB인데 왜 플랜이 다르게 나와?” — 통계·블로트·쿼리 플랜
replica의 데이터는 따라가지만, 플래너가 보는 통계는 primary와 완전히 같다고 가정하면 안 됩니다. ANALYZE 시점·샘플링·자동 vacuum 타이밍이 어긋나면 같은 쿼리가 replica에서만 풀 스캔 같은 선택을 할 수 있습니다.
| 점검 | 의미 |
|---|---|
replica에서 EXPLAIN (ANALYZE, BUFFERS) (부하 주의) | primary와 계획이 다르면 통계·캐시·설정 차이 추적 |
| 테이블 블로트·데드 튜플 비율 | replay·vacuum·장시간 쿼리와 연관 |
hot_standby_feedback 사용 여부 | primary vacuum이 지연되며 간접적으로 replica 쿼리에 영향 |
실무: “replica에서만 느리다”면 lag 이전에 실행 계획 diff를 한 번 찍어 보세요.
8. 페일오버·DNS·리더 엔드포인트
매니지드에서 장애 조치가 나면 읽기 엔드포인트가 가리키는 인스턴스가 바뀝니다. 앱은 DNS TTL·커넥션 풀 재수립 때문에 옛 주소를 붙잡는 경우가 있습니다.
| 체크 | 설명 |
|---|---|
| Reader 엔드포인트 vs 인스턴스 DNS | 장애 시 전환 동작이 문서상 어떻게 되는지 |
| 풀 idle timeout | 너무 길면 페일오버 후에도 죽은 소켓을 붙잡음 |
| 애플리케이션 재시도 | 일시적 “connection reset”을 재시도로 흡수할지 |
쓰기(primary) 쪽 페일오버는 RPO/RTO·승격 시간 이야기로 이어지고, 읽기 replica는 트래픽이 한꺼번에 살아난 노드로 몰리는 패턴도 같이 봐야 합니다.
9. 도입 전 한 장 체크리스트
레플리카를 띄우기 전에 아래를 표에 체크해 두면 회의가 짧아집니다.
- SLO: 화면별 허용 지연(초/MB) 숫자가 문서에 있음
- 라우팅 표: API·배치별 primary / replica / 조건부(RYW)
- 온콜 런북: stale 의심 시 1~5번 순서 + 로그 필드
- 모니터링: primary·replica 양쪽 lag, 커넥션, 충돌 취소 로그
- 풀: replica용 PgBouncer/풀 크기·인스턴스
max_connections대조 - 보고/배치: OLTP replica와 분리할지, 전용 replica가 있는지
- 페일오버: DNS·풀·재시도 정책 한 줄이라도 있음
맺으며
PostgreSQL 읽기 레플리카는 성능 스위치가 아니라 일관성 모델을 바꾸는 스위치에 가깝습니다. “복제됐다”는 말은 곧바로 읽힌다는 뜻이 아니고, 그 간격을 제품이 감당할지가 먼저입니다.
새 프로젝트면 레플리카 붙이기 전에 한 페이지짜리 표만이라도 만들어 보세요. 어떤 API는 primary, 어떤 API는 replica, 허용 지연은 몇 초. 그 한 장에 RYW TTL·온콜 순서·페일오버 시 풀 동작까지 한 줄씩만 덧붙여도, 나중에 온콜 로테이션이 훨씬 편해집니다.