왜 단일 서버만으로는 부족한가
PostgreSQL 서버 한 대가 모든 읽기와 쓰기를 처리하는 구조는 단순하다. 학습, 내부 도구, 작은 서비스에는 이 구조로도 충분하다. 문제는 장애가 났을 때다. 서버가 멈추거나 디스크가 고장 나거나 운영자가 잘못된 설정을 반영하면 서비스 전체가 데이터베이스에 접속하지 못한다.
중요한 서비스에서는 "장애가 나지 않게 한다"보다 "장애가 나도 버틴다"가 더 현실적인 목표다. 이를 위해 보통 다음 구조를 만든다.
쓰기를 담당하는 primary 서버
primary의 변경 사항을 따라가는 standby 서버
장애 시 standby를 새 primary로 승격하는 failover 절차
읽기 부하를 일부 standby로 보내는 read scaling 구조
대용량 테이블을 작은 단위로 나누는 partitioning 구조
여기서 먼저 구분해야 할 것이 복제와 백업이다. 복제는 현재 변경 사항을 다른 서버에 빠르게 전달하는 장치다. primary에서 잘못 삭제한 데이터가 standby에도 복제될 수 있다. 백업은 과거 특정 시점으로 돌아가기 위한 장치다. 운영 환경에서는 둘 중 하나가 아니라 둘 다 필요하다.
PostgreSQL의 고가용성 구조는 여러 서버가 함께 동작하다가 primary 장애 시 다른 서버가 이어받는 방식을 기본으로 한다. standby는 설정에 따라 접속할 수 없는 warm standby가 될 수도 있고, 읽기 전용 쿼리를 처리하는 hot standby가 될 수도 있다.1
AI 생성 이미지
물리 복제와 논리 복제의 차이
PostgreSQL 복제는 크게 물리 복제와 논리 복제로 나누어 생각하면 된다.
물리 복제는 WAL 기록을 기반으로 primary의 저장 상태를 standby에 거의 그대로 따라가게 만든다. WAL은 데이터 변경 내역을 순서대로 기록한 로그다. 물리 복제는 개별 테이블이 아니라 PostgreSQL 데이터 디렉터리, 즉 데이터베이스 클러스터 단위를 대상으로 한다. 그래서 특정 테이블만 골라 복제하는 방식은 아니지만, 장애 조치와 hot standby 구성에는 잘 맞는다.2
논리 복제는 테이블의 INSERT, UPDATE, DELETE 같은 논리적 변경을 발행자와 구독자 모델로 전달한다. publication은 "어떤 테이블의 변경을 내보낼 것인가"를 정하고, subscription은 "어느 서버의 publication을 받아올 것인가"를 정한다. 처음에는 대상 테이블의 스냅샷을 복사하고, 이후 변경 사항을 계속 적용한다.3
두 방식을 실무 사례로 비교해 보자.
| 목적 | 더 적합한 방식 | 이유 |
|---|---|---|
| primary 장애 시 빠르게 대체 | 물리 복제 | 클러스터 전체를 같은 상태로 따라가므로 standby 승격에 적합하다. |
| 읽기 전용 조회 분산 | 물리 복제 | hot standby에서 무거운 조회를 분리할 수 있다. 단, 복제 지연을 고려해야 한다. |
| 일부 테이블만 다른 시스템에 전달 | 논리 복제 | publication 단위로 필요한 테이블을 고를 수 있다. |
| PostgreSQL 메이저 버전 이전 | 논리 복제 | 서로 다른 버전 사이의 데이터 동기화 시나리오에 활용할 수 있다. |
| 분석용 DB에 주문·상품 데이터만 복사 | 논리 복제 | 운영 전체가 아니라 필요한 데이터만 보낼 수 있다. |
예를 들어 쇼핑몰 운영 DB가 있고, 관리자 통계 화면이 주문 데이터를 많이 읽는다고 하자. 같은 데이터베이스에서 통계를 계속 계산하면 primary의 CPU와 I/O를 같이 사용한다. 이때 hot standby를 두고 통계 조회를 standby로 보내면 쓰기 부하와 읽기 부하를 어느 정도 분리할 수 있다.
반면 고객 개인정보를 제외한 주문 요약 데이터만 정산 시스템으로 보내야 한다면 물리 복제보다 논리 복제가 맞다. 서버 전체를 복제하면 불필요한 테이블과 권한까지 함께 이동하기 때문이다.
스트리밍 복제의 기본 구성 요소
물리 복제에서 가장 자주 쓰는 방식은 스트리밍 복제다. primary에서 생성되는 WAL을 standby가 네트워크로 받아 적용한다.
구성 요소는 다음과 같다.
primary: 쓰기를 받는 원본 서버다.
standby: primary의 WAL을 받아 적용하는 서버다. hot standby 설정 시 읽기 전용 쿼리를 처리할 수 있다.
WAL sender: primary에서 standby로 WAL을 보내는 프로세스다.
WAL receiver: standby에서 WAL을 받아 저장하고 적용하는 프로세스다.
replication slot: standby가 아직 받지 못한 WAL을 primary가 지우지 않도록 붙잡아 두는 장치다.
replication slot은 유용하지만 위험도 있다. standby가 오래 멈춰 있으면 primary는 slot 때문에 필요한 WAL 파일을 계속 보관한다. 디스크가 가득 차면 primary 자체가 위험해진다. 따라서 slot을 만들었다면 복제 지연과 WAL 보관량을 함께 감시해야 한다.
운영 중 복제 상태는 primary에서 다음처럼 확인한다.
SELECT
application_name,
client_addr,
state,
sync_state,
sent_lsn,
write_lsn,
flush_lsn,
replay_lsn,
pg_wal_lsn_diff(sent_lsn, replay_lsn) AS replay_lag_bytes
FROM pg_stat_replication;여기서 replay_lag_bytes가 계속 커진다면 standby가 WAL을 따라가지 못하고 있다는 뜻이다. 원인은 네트워크, standby 디스크 I/O, 무거운 읽기 쿼리, primary의 쓰기 폭증 등으로 나뉜다.
standby 쪽에서는 마지막으로 WAL을 받은 시점과 재생한 시점을 확인한다.
SELECT
pg_is_in_recovery() AS is_standby,
pg_last_wal_receive_lsn() AS receive_lsn,
pg_last_wal_replay_lsn() AS replay_lsn,
now() - pg_last_xact_replay_timestamp() AS replay_delay;pg_is_in_recovery()가 true이면 현재 서버는 standby로 동작 중이다. receive_lsn과 replay_lsn이 계속 벌어지면 standby가 WAL을 받았지만 아직 적용하지 못하고 있다는 뜻이다. replay_delay는 마지막으로 재생한 트랜잭션 시각과 현재 시각의 차이를 보여 주므로 참고 지표로는 유용하다. 다만 primary에 쓰기가 한동안 없었거나 아직 재생한 트랜잭션이 없다면 값이 크게 보이거나 NULL이 될 수 있으므로, 이 값 하나만 보고 장애라고 판단하지는 않아야 한다.
장애 조치와 데이터 유실 가능성
장애 조치에서 가장 중요한 질문은 하나다.
"primary에서 COMMIT 성공으로 알려 준 트랜잭션이 새 primary에도 남아 있는가?"
비동기 복제에서는 primary가 COMMIT을 사용자에게 성공으로 응답한 뒤, 해당 WAL이 standby에 도착하기 전에 장애가 날 수 있다. 이 경우 standby를 승격하면 마지막 몇 건의 트랜잭션이 사라질 수 있다. 예를 들어 결제 승인 결과를 저장한 직후 primary가 멈췄고, 그 WAL이 standby에 아직 도착하지 않았다면 새 primary에는 결제 기록이 없을 수 있다.
동기 복제는 이 위험을 줄인다. primary는 지정된 standby가 WAL을 일정 수준까지 확인할 때까지 COMMIT 응답을 기다린다. 어느 시점까지 기다릴지는 설정에 따라 달라질 수 있지만, 핵심은 standby 확인을 쓰기 성공 조건에 포함한다는 점이다. 대신 standby나 네트워크가 느려지면 쓰기 지연이 primary 사용자에게 직접 나타난다. 공식 문서도 동기 방식은 데이터 유실을 줄이는 대신 성능 영향을 줄 수 있고, 비동기 방식은 성능 부담이 작지만 장애 조치 시 최근 변경을 잃을 수 있다고 설명한다.1
정리하면 다음과 같다.
| 방식 | 장점 | 단점 | 적합한 경우 |
|---|---|---|---|
| 비동기 복제 | 쓰기 지연이 작다. 구성이 비교적 단순하다. | 장애 직전 데이터 유실 가능성이 있다. | 일반 웹 서비스, 읽기 분산, 약간의 지연 허용 가능 업무 |
| 동기 복제 | COMMIT된 데이터의 유실 가능성을 크게 줄인다. | standby 지연이 primary 쓰기 지연으로 이어진다. | 결제, 정산, 재고처럼 유실 비용이 큰 업무 |
주의할 점은 동기 복제가 모든 장애를 해결하지 않는다는 것이다. 애플리케이션이 잘못된 UPDATE를 실행하면 그 결과도 복제된다. 두 서버가 같은 데이터센터에 있고 전원 장애가 동시에 발생하면 둘 다 멈출 수 있다. 고가용성은 복제 설정 하나가 아니라 네트워크, 디스크, 백업, 모니터링, 장애 조치 절차까지 포함한 운영 구조다.
장애 조치 절차도 미리 정해야 한다.
primary가 정말 장애인지 확인한다.
standby 중 가장 최신 WAL을 적용한 서버를 고른다.
해당 standby를 primary로 승격한다.
애플리케이션 접속 주소를 새 primary로 바꾼다.
기존 primary가 살아 돌아오면 자동으로 쓰기를 받지 못하게 격리한다.
복구 후 새 primary의 standby로 다시 붙인다.
마지막 항목이 중요하다. 예전 primary와 새 primary가 동시에 쓰기를 받으면 split-brain 상태가 된다. 이 상태에서는 어느 쪽 데이터가 진짜인지 판단하기 어려워진다. 그래서 장애 조치에서는 단순히 standby를 승격하는 것뿐 아니라, 예전 primary를 확실히 격리하는 절차도 함께 필요하다. 운영 환경에서는 Patroni, repmgr, pg_auto_failover 같은 도구나 클라우드 관리형 PostgreSQL의 장애 조치 기능을 검토할 수 있지만, 도구를 쓰더라도 위 흐름을 이해하고 있어야 한다.
논리 복제로 부분 동기화와 이전 준비하기
논리 복제는 publication과 subscription으로 구성한다. 다음은 운영 DB의 주문 요약 테이블을 분석 DB로 보내는 단순 예다.
원본 서버에서 publication을 만든다.
CREATE PUBLICATION pub_order_report
FOR TABLE order_daily_summaries, products;대상 서버에는 같은 구조의 테이블이 있어야 한다. 그런 다음 subscription을 만든다.
CREATE SUBSCRIPTION sub_order_report
CONNECTION 'host=primary.example.com port=5432 dbname=shop user=repl password=secret'
PUBLICATION pub_order_report;논리 복제는 시작 시 기존 데이터를 복사한 뒤 이후 변경 사항을 계속 적용한다. 그래서 새 서버를 준비해 두고 데이터를 따라잡게 만든 다음, 짧은 중단 시간만 두고 애플리케이션 접속을 새 서버로 바꾸는 이전 전략에 활용할 수 있다.
다만 논리 복제는 마법이 아니다. 테이블 구조 변경은 별도로 관리해야 한다. publication과 subscription을 만들었다고 해서 ALTER TABLE 같은 DDL이 자동으로 같은 순서로 반영되는 것은 아니다. UPDATE와 DELETE를 안정적으로 복제하려면 보통 기본 키가 필요하며, 기본 키가 없다면 REPLICA IDENTITY 설정까지 검토해야 한다. 구독자 테이블에 직접 쓰기를 하면 원본에서 들어오는 변경과 충돌할 수 있다. 따라서 "구독자는 읽기 전용으로 쓴다", "스키마 변경은 양쪽에 같은 순서로 반영한다", "전환 전 행 수와 핵심 집계를 비교한다" 같은 운영 규칙이 필요하다.
검증 쿼리는 단순해야 한다.
-- 원본과 대상에서 각각 실행해 비교한다.
SELECT count(*) FROM order_daily_summaries;
SELECT report_date, sum(order_count), sum(total_amount)
FROM order_daily_summaries
GROUP BY report_date
ORDER BY report_date DESC
LIMIT 7;완벽한 검증 쿼리 하나보다 업무상 중요한 집계 여러 개를 반복 비교하는 편이 실수 발견에 도움이 된다.
파티셔닝으로 큰 테이블을 관리하기
복제는 서버 장애와 읽기 부하 문제를 줄인다. 하지만 테이블 하나가 너무 커져서 조회, 삭제, 인덱스 관리가 느려지는 문제는 별도로 봐야 한다. 이때 먼저 검토할 수 있는 기능이 파티셔닝이다.
파티셔닝은 논리적으로는 하나의 테이블처럼 사용하되, 내부적으로는 여러 하위 테이블로 나누어 저장하는 방식이다. PostgreSQL의 선언적 파티셔닝은 RANGE, LIST, HASH 방식을 지원한다. 날짜 기준 로그나 주문 데이터에는 보통 RANGE 파티셔닝을 많이 쓴다. RANGE 파티션의 하한은 포함되고 상한은 제외된다.4
예를 들어 주문 이벤트 로그를 월별로 나누어 보자.
CREATE TABLE order_events (
event_id bigint GENERATED ALWAYS AS IDENTITY,
order_id bigint NOT NULL,
event_type text NOT NULL,
event_time timestamptz NOT NULL,
payload jsonb NOT NULL,
PRIMARY KEY (event_id, event_time)
) PARTITION BY RANGE (event_time);
CREATE TABLE order_events_2026_05
PARTITION OF order_events
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE order_events_2026_06
PARTITION OF order_events
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');파티션 테이블에서 기본 키나 UNIQUE 제약 조건을 만들 때는 파티션 키를 포함해야 한다. 위 예제에서 기본 키를 event_id 하나만 두지 않고 (event_id, event_time)으로 둔 이유도 event_time이 파티션 키이기 때문이다.
조회 조건에 파티션 키가 들어가면 PostgreSQL은 관련 없는 파티션을 읽지 않을 수 있다. 이를 partition pruning이라고 한다.
SELECT order_id, event_type, event_time
FROM order_events
WHERE event_time >= '2026-05-10'
AND event_time < '2026-05-11'
ORDER BY event_time DESC;이 쿼리는 2026년 5월 파티션만 읽으면 된다. 반대로 event_time 조건 없이 order_id만 검색하면 모든 월별 파티션을 확인해야 할 수 있다. 그래서 파티셔닝은 "큰 테이블을 나누면 무조건 빨라진다"가 아니라 "자주 쓰는 조회 조건이 파티션 키와 맞을 때 효과가 있다"로 이해해야 한다.
파티셔닝의 큰 장점은 오래된 데이터 정리에서도 나타난다. 1년 전 로그를 삭제하려고 수천만 행에 DELETE를 실행하면 시간이 오래 걸리고 VACUUM 부담도 커진다. 월별 파티션이면 오래된 파티션을 분리하거나 삭제하는 방식으로 처리할 수 있다.
ALTER TABLE order_events
DETACH PARTITION order_events_2025_05;
-- 보관이 끝났다면 별도 백업 후 삭제
DROP TABLE order_events_2025_05;운영에서는 다음을 미리 정해야 한다.
새 달 파티션을 언제 만들 것인가
오래된 파티션을 언제 분리하거나 삭제할 것인가
각 파티션에 필요한 인덱스가 생성되는가
쿼리 조건에 파티션 키가 포함되는가
너무 많은 파티션으로 계획 시간이 늘어나지 않는가
파티션 생성은 배치 작업으로 자동화하는 편이 좋다. 다음 달 파티션을 만들지 않은 상태에서 새 데이터가 들어오면 INSERT가 실패할 수 있다. 기본 파티션을 두어 임시로 받는 방법도 있지만, 그것만 믿고 파티션 생성을 미루면 나중에 데이터를 다시 옮기는 일이 생긴다.
샤딩 전에 먼저 확인할 것들
샤딩은 데이터를 여러 PostgreSQL 서버에 나누어 저장하는 방식이다. 사용자 ID, tenant ID, 지역, 날짜 같은 기준으로 데이터를 분산한다. 쓰기 부하와 저장 공간을 여러 서버로 나눌 수 있지만, 운영 난도는 크게 올라간다.
샤딩을 시작하면 다음 문제가 생긴다.
여러 샤드에 걸친 조인이 어렵다.
전역 UNIQUE 제약 조건을 단순하게 보장하기 어렵다.
트랜잭션 경계가 복잡해진다.
백업과 복구 절차가 샤드 수만큼 늘어난다.
장애 조치와 재분배 작업이 어려워진다.
애플리케이션이 데이터 위치를 알아야 할 수 있다.
그래서 외부 분산 솔루션이나 샤딩을 검토하기 전에 단일 PostgreSQL에서 다음을 먼저 확인해야 한다.
느린 쿼리를
pg_stat_statements와EXPLAIN (ANALYZE, BUFFERS)로 확인했는가?주요 조회 조건에 맞는 인덱스를 설계했는가?
autovacuum, ANALYZE, bloat 상태가 관리되고 있는가?
커넥션 폭증을 PgBouncer 같은 풀링으로 완화했는가?
읽기 부하를 hot standby로 분산할 수 있는가?
대용량 테이블을 날짜나 범위 기준 파티셔닝으로 줄일 수 있는가?
오래된 데이터를 아카이빙하거나 요약 테이블로 분리할 수 있는가?
하드웨어의 CPU, 메모리, 디스크 I/O 한계를 실제 지표로 확인했는가?
이 점검을 거치지 않은 샤딩은 문제를 해결하기보다 문제를 여러 서버로 복제할 가능성이 크다. PostgreSQL은 단일 서버 안에서도 인덱스, 실행 계획, 파티셔닝, 복제, 커넥션 풀링으로 상당한 범위의 성능 문제를 다룰 수 있다. 샤딩은 마지막 수단에 가깝게 판단하는 편이 안전하다.
운영 판단 기준 정리
복제와 확장 전략은 기능 이름이 아니라 장애 시나리오에서 출발해야 한다.
primary 한 대가 멈춰도 서비스를 이어 가야 한다면 물리 스트리밍 복제와 장애 조치 절차가 필요하다.
읽기 쿼리가 primary를 압박한다면 hot standby로 읽기를 분산할 수 있다.
최근 몇 초 데이터 유실도 허용할 수 없다면 동기 복제를 검토하되 쓰기 지연을 함께 측정해야 한다.
일부 테이블만 다른 시스템으로 보내거나 버전 이전을 준비한다면 논리 복제가 적합할 수 있다.
테이블이 너무 커져 관리가 어렵다면 날짜나 범위 기준 파티셔닝을 먼저 검토한다.
샤딩은 단일 PostgreSQL의 쿼리, 인덱스, 운영 설정, 파티셔닝 한계를 확인한 뒤 선택한다.
좋은 고가용성 구조는 장애가 없을 때는 조용히 동작하고, 장애가 났을 때는 정해진 순서대로 움직인다. 복제 지연을 보고, failover를 연습하고, 복구 후 검증 쿼리를 실행하는 절차까지 갖춰야 실제 운영에서 사용할 수 있다.
참고
1PostgreSQL 18 Documentation: High Availability, Load Balancing, and Replication
2PostgreSQL 18 Documentation: Comparison of Different Solutions
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.