본문으로 건너뛰기
Previous
Next
PostgreSQL 완벽 가이드 2026 | MVCC·VACUUM·인덱스부터 운영까지

PostgreSQL 완벽 가이드 2026 | MVCC·VACUUM·인덱스부터 운영까지

PostgreSQL 완벽 가이드 2026 | MVCC·VACUUM·인덱스부터 운영까지

이 글의 핵심

PostgreSQL의 MVCC·VACUUM·쿼리 플래너 동작을 엔진 관점에서 이해하고, B-tree·GiST·GIN 인덱스 선택과 EXPLAIN·통계 튜닝, 파티셔닝·복제·백업·프로덕션 패턴까지 한 번에 다룹니다.

솔직히 말하면

이 글 제목은 “완전 가이드”인데, 요즘 제가 문서 볼 때 표로 갈라놓은 비교전부 다 나열한 목차는 잘 안 봅니다. 그래서 여기서는 MVCC·VACUUM·플래너·인덱스를 짧게 박고, 그다음 프로덕션에서 실제로 겪은 것 같은 이야기 하나로 묶었어요. 표는 없습니다. 의견은 많습니다.

PostgreSQL을 쓸 때 난 “갱신하면 옛 행이 바로 사라진다”는 환상부터 버리는 게 좋다고 봅니다. 갱신은 힙에 새 버전을 쌓는 쪽에 가깝고, 그게 쌓이면 VACUUM이 못 따라가서 테이블이 부풀고(bloat), 그때부터는 인덱스가 있어도 읽기가 묵직해집니다. 반대로 말하면, 장시간 질질 끄는 트랜잭션 하나가 autovacuum이 정리할 수 있는 범위를 뒤로 밀어서 팀 전체를 괴롭히는 경우가 꽤 있습니다.

MVCC랑 VACUUM, 한 방에

MVCC는 “읽기는 대체로 스냅샷으로 가고, 쓰기끼리는 행 잠금으로 부딪힌다” 정도로 기억하면 됩니다. 읽기가 쓰기를 안 막는다고 해서 락이 없는 건 아님 — 갱신·삭제는 그대로 경합합니다.

VACUUM은 디스크 청소만이 아니라, 가시성 맵 갱신 같은 읽기 경로에도 연결돼 있습니다. 제 스타일은 autovacuum을 기본 신뢰하되, 이벤트처럼 갱신 미친 테이블은 autovacuum_vacuum_scale_factor를 낮추거나 파티션으로 쪼개는 쪽입니다. VACUUM FULL은 마이그레이션 창구 아니면 거의 안 씁니다. 잠금 크고 멈추는 거 싫으면요.

-- 갱신 많은 테이블에서 흔히 쓰는 쪽 (수치는 모니터링 보면서)
ALTER TABLE events SET (
  autovacuum_vacuum_scale_factor = 0.05,
  autovacuum_analyze_scale_factor = 0.02
);

플래너: 통계가 틀리면 답이 없다

플래너는 “비용 모델 + 통계”입니다. random_page_cost를 SSD에 맞게 깎는 건 많이들 하지만, 진짜 망하는 건 ANALYZE 안 돼 있거나 분포가 바뀐 뒤 통계가 썩은 경우예요. 저는 의심 가면 바로 EXPLAIN (ANALYZE, BUFFERS) 붙이고, 예상 행 수가 실제랑 10배만 어긋나도 확장 통계나 쿼리 쪽을 의심합니다. 힌트로 플랜 고정하는 건 증거가 쌓였을 때 마지막 카드로 두는 편입니다.

인덱스: B-tree 먼저, GIN은 값이 있을 때만

솔직히 대부분은 B-tree면 끝납니다. 복합 인덱스는 선행 컬럼이 조건에 안 걸리면 헛수고인 경우가 많고요.

JSONB·배열·전문 검색 쪽은 GIN이 강하지만, 쓰기 비용이 커질 수 있어서 “검색 한 번 더 빠르게”만 보고 달면 나중에 후회하는 경우 봤습니다. GiST는 기하·범위·겹침 같은 데 가서 쓰고, 둘 중 뭐냐만 오래 고민할 바엔 EXPLAIN으로 찍어보는 게 빠릅니다.

CREATE INDEX idx_orders_user_date ON orders(user_id, created_at);
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE user_id = 1 AND created_at > now() - interval '7 days';

프로덕션 스토리 (합성이지만 이런 식으로 터짐)

어떤 팀이 이벤트 로그 테이블 하나에 거의 모든 트래픽을 쌓고 있었어요. 처음엔 잘 돌아갔는데, 프로모션 날 쓰기는 버티는데 조회가 갑자기 2~3초로 가버렸습니다. 앱 로그엔 느린 쿼리가 딱 찍혔는데, EXPLAIN을 보면 인덱스는 타는데 실제 읽은 행이 너무 많았어요. 뜯어보니 테이블·인덱스 bloat가 심했고, 동시에 오래 열린 배치 트랜잭션 하나가 autovacuum이 정리할 xmin을 밀고 있었습니다.

당시 조치는 대략 이랬습니다. 배치는 짧은 트랜잭션으로 쪼개거나 읽기 전용 스냅샷 정책을 정리하고, 이벤트 테이블은 월 단위 파티션으로 나눠서 autovacuum이 한 덩어리씩만 잡게 만들었습니다. 앱 앞단은 PgBouncer로 커넥션 폭주를 막았고요. 복제본에 읽기 돌릴 땐 lag 보고 “방금 쓴 거 바로 읽기” 같은 요구는 UI/캐시 설계에서 깎아냈습니다. 백업은 이야기 길어지니 베이스 + WAL만 기억하세요. pg_dump만 믿고 PITR 안 해두면 진짜 사고 날 때 울어요.

이런 건 표로 “장단점” 정리하는 것보다 한 번 겪으면 머리에 남습니다.

내가 실서비스에서 우선순위 두는 것

느리면 순서는 대추 이렇습니다. pg_stat_statements로 상위 쿼리 잡기EXPLAIN (ANALYZE, BUFFERS) → 통계·인덱스·쿼리 수정 → 그다음에야 shared_buffers 같은 건 손대는 쪽이 낫다고 봅니다. 설정만 만지다가 하루 보내는 경우, 제 경험상 원인의 절반은 쿼리와 통계예요.

마지막으로: 이 글에서 안 쓴 것도 많습니다. 파티션 전부 문법 나열, 논리 복제 스크립트 풀세트, 로그 테이블 DO $$ ... $$로 월 파티션 자동 생성 같은 건 필요할 때 공식 문서랑 같이 보는 게 정신 건강에 이롭습니다. 여기서는 엔진이 왜 이렇게 굴지운영에서 한 번쯤은 터지는 줄기만 짚었습니다.


같이 보면 좋은 글: 성능만 더 파고들 거면 postgresql-performance-tuning-guide, 트랜잭션 격리 쪽은 database-transaction-guide 쪽이 이어집니다.