8장. VACUUM과 Autovacuum — 청소부 없는 MVCC는 없다
UPDATE와 DELETE가 남긴 dead tuple은 저절로 사라지지 않습니다. Bloat 폭증, 쿼리 지연, 그리고 최악의 장애 XID Wraparound까지 — PostgreSQL 운영 사고의 절반은 여기서 시작됩니다.
① dead tuple이 쌓이는 이유와 VACUUM이 실제로 하는 일 ② Autovacuum이 자동으로 도는 조건(트리거 공식)과 튜닝 ③ XID Wraparound의 원리와 Freeze ④ VACUUM을 막는 4대 요인과 Bloat 대응
8.1 왜 VACUUM이 필요한가 — 지워도 지워지지 않는 데이터
3장에서 배웠듯이 PostgreSQL의 UPDATE는 "기존 튜플에 xmax 표시 + 새 버전 삽입"이고, DELETE는 xmax만 표시할 뿐 물리적으로 지우지 않습니다. 마치 아파트에서 이사를 나가도 이전 집 짐을 그대로 두고 새 집으로 옮기는 것과 같습니다. 누군가 이전 집을 치워주지 않으면 아파트는 빈 짐으로 가득 차게 되죠.
이 "빈 짐"이 바로 dead tuple이고, 치워주는 청소부가 VACUUM입니다. 청소를 안 하면 네 가지 문제가 생깁니다.
- Bloat: 테이블·인덱스가 실제 데이터보다 몇 배로 부풀어 스캔 I/O 증가, 캐시 효율 저하
- XID Wraparound: 32비트 트랜잭션 ID가 순환하며 "과거 커밋이 미래처럼 보이는" 재앙
- Index-Only Scan 불가: Visibility Map이 갱신되지 않아 항상 heap 접근
- 통계 부정확:
pg_class.reltuples오류로 플래너가 잘못된 계획을 세움
처음에는 heap 페이지에 살아있는(live) 튜플만 있습니다. 모든 것이 깨끗한 상태.
UPDATE가 실행되면 기존 튜플은 dead tuple이 되고, 새 버전이 페이지에 추가됩니다. 공간은 두 배로!
UPDATE가 반복될수록 dead tuple이 쌓입니다. 살아있는 데이터는 그대로인데 페이지는 죽은 버전으로 가득 — 이것이 Bloat입니다.
VACUUM이 페이지를 돌며 어떤 스냅샷에서도 보이지 않는 dead tuple을 식별하고, 그 자리를 재사용 가능한 빈 공간으로 표시합니다.
빈 공간 정보는 Free Space Map(.fsm)에 기록되고, 이후 INSERT가 그 자리를 재사용합니다. 단, 일반 VACUUM은 파일 크기를 줄이지 않는다는 점을 기억하세요!
8.2 VACUUM이 실제로 하는 일 — 그리고 하지 않는 일
일반 VACUUM(lazy vacuum)은 생각보다 많은 일을 합니다. dead tuple 회수는 그중 하나일 뿐입니다.
- Heap 스캔 — 페이지를 읽어 dead tuple 식별
- 인덱스 정리 — dead tuple을 가리키는 인덱스 엔트리 제거
- 공간 회수 — dead tuple의 line pointer를 재사용 가능 상태로 표시
- FSM/VM 갱신 — 빈 공간 지도와 Visibility Map(
all-visible/all-frozen) 갱신 - 통계 갱신 —
pg_class.reltuples,relpages - Freeze — 오래된 튜플의 xmin을 FrozenXid로 교체 (8.4에서 자세히)
페이지 안의 공간을 재사용 가능하게 만들 뿐, 물리 파일은 테이블 맨 끝이 완전히 비었을 때만 조건부로 잘립니다. "VACUUM 했는데 디스크가 안 줄어요"는 정상 동작입니다. 파일 축소가 필요하면 pg_repack을 쓰세요.
| 도구 | 공간 회수 | Lock | 특징 |
|---|---|---|---|
VACUUM | 페이지 내 재사용 | 비차단 (SUE) | 기본. 파일 크기 안 줄어듦 |
VACUUM FULL | 완전 회수 + 파일 축소 | AccessExclusive | 읽기까지 차단 — 서비스 중 금지 |
CLUSTER | 완전 회수 + 물리 정렬 | AccessExclusive | 인덱스 순으로 재작성 |
pg_repack | 완전 회수 | 순간의 AE만 | 확장 설치 필요, 사실상 무중단 — 운영 표준 |
-- 자주 쓰는 VACUUM 옵션
VACUUM (VERBOSE, ANALYZE) orders; -- 상세 로그 + 통계 갱신
VACUUM (FREEZE) orders; -- 가능한 모든 튜플을 즉시 frozen
VACUUM (PARALLEL 4) orders; -- 인덱스 병렬 정리 (13+)
-- 진행 중인 VACUUM 실시간 확인 (9.6+)
SELECT pid, relid::regclass, phase,
heap_blks_total, heap_blks_scanned, num_dead_tuples
FROM pg_stat_progress_vacuum;
8.3 Autovacuum — 청소부는 언제 자동으로 출근하는가
매번 수동으로 VACUUM을 돌릴 수는 없으니, PostgreSQL은 Autovacuum Launcher라는 관리자를 둡니다. Launcher는 autovacuum_naptime(기본 1분)마다 깨어나 통계를 살펴보고, 청소가 필요한 테이블에 워커를 파견합니다. 파견 기준은 명확한 공식입니다.
-- VACUUM 트리거 공식 (기본값 기준)
n_dead_tup > autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × reltuples
= 50 + 0.2 × 행수
-- 예: 100만 행 테이블 → dead tuple이 200,050개를 넘어야 autovacuum이 돈다
-- ANALYZE 트리거는 별도: 50 + 0.1 × 행수
Launcher가 autovacuum_naptime(기본 1분)마다 깨어나 각 테이블의 pg_stat_all_tables 통계를 확인합니다.
테이블마다 공식을 대입합니다. users는 dead 5만 < 임계 200,050이라 통과, orders는 25만 > 200,050이라 VACUUM 대상!
Launcher가 워커를 파견해 orders를 청소합니다. 워커는 동시에 최대 autovacuum_max_workers(기본 3)개까지만.
워커는 전력 질주하지 않습니다. 페이지를 읽고 쓸 때마다 비용을 합산해 cost_limit에 도달하면 cost_delay만큼 쉽니다. 기본값은 너무 느려서(약 8MB/s 수준) 대형 테이블에서는 cost_limit 상향이 현대적 권장입니다.
기본값의 함정: scale_factor = 0.2는 10억 행 테이블에서 2억 개의 dead tuple이 쌓여야 청소가 시작된다는 뜻입니다. 큰 테이블일수록 개별 설정으로 문턱을 낮춰야 합니다.
-- 핫 테이블은 개별 storage parameter가 정답
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.02, -- 20% → 2%로 낮춤
autovacuum_vacuum_threshold = 10000,
autovacuum_vacuum_cost_limit = 2000, -- 더 빠르게 청소
fillfactor = 85 -- HOT 업데이트용 여유 공간
);
autovacuum_vacuum_insert_threshold(기본 1000) 덕분에 append-only 로그 테이블도 autovacuum이 돌아 Visibility Map을 갱신합니다. 12 이하에서는 이런 테이블이 wraparound 직전에야 emergency vacuum을 맞는 문제가 있었습니다.
8.4 XID Wraparound — 가장 위험한 장애와 Freeze
PostgreSQL의 트랜잭션 ID(XID)는 32비트, 약 40억 개입니다. 다 쓰면 0부터 다시 시작하는 순환 구조죠. 문제는 MVCC의 가시성 판정이 "내 XID보다 과거인가/미래인가" 비교에 의존한다는 것입니다. XID가 한 바퀴 돌면 아주 오래된 과거 커밋이 갑자기 미래처럼 보이는 — 즉 데이터가 사라져 보이는 재앙이 일어납니다.
방지책이 Freeze입니다. 충분히 오래된 튜플의 xmin을 특수값 FrozenXid로 바꿔 "누가 봐도 무조건 과거"로 만들어 XID를 재활용할 수 있게 합니다.
XID는 시계처럼 순환합니다. 튜플의 xmin=100과 현재 XID가 가까울 때(age 작음)는 아무 문제가 없습니다.
트랜잭션이 계속 발생하며 현재 XID가 전진합니다. 튜플의 age(나이)가 점점 커집니다.
age가 autovacuum_freeze_max_age(기본 2억)를 넘으면 anti-wraparound VACUUM이 강제 실행됩니다. 이 VACUUM은 autovacuum을 꺼둔 테이블에도 옵니다.
VACUUM이 오래된 튜플의 xmin을 FrozenXid로 교체(Freeze)합니다. 이제 이 튜플은 순환과 무관하게 "무조건 과거"로 판정되어 안전합니다.
만약 Freeze가 계속 실패해 age가 20억에 근접하면 DB가 쓰기를 거부합니다. 단일 사용자 모드로 수동 VACUUM해야 하는 최악의 장애 — 조기 경보 모니터링이 필수인 이유입니다.
조기 경보는 간단한 쿼리 하나면 됩니다. 일간 모니터링에 반드시 포함하세요.
-- 테이블별 frozenxid 나이 — 5억 넘으면 WARN, 15억 넘으면 CRITICAL
SELECT c.relname,
age(c.relfrozenxid) AS xid_age,
pg_size_pretty(pg_table_size(c.oid)) AS size
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r','m')
AND n.nspname NOT IN ('pg_catalog','information_schema')
ORDER BY age(c.relfrozenxid) DESC LIMIT 20;
8.5 VACUUM을 막는 4대 요인
VACUUM은 "현재 활성인 가장 오래된 스냅샷보다 더 오래된" dead tuple만 회수할 수 있습니다. 누군가 오래된 스냅샷을 붙들고 있으면, VACUUM이 아무리 부지런히 돌아도 헛돌게 됩니다.
| 원인 | 증상 | 해결 |
|---|---|---|
| 긴 트랜잭션 | pg_stat_activity.backend_xmin이 오래된 XID를 유지 | idle_in_transaction_session_timeout, statement_timeout |
| 2PC prepared transaction | pg_prepared_xacts에 잔류 거래 | ROLLBACK PREPARED로 정리 |
| Replication slot | 소비자 없는 슬롯이 xmin·WAL 홀드 | 불필요 슬롯 pg_drop_replication_slot |
| 장기 커서 / hot_standby_feedback | 세션 스냅샷이 오래 유지 | 커서 스트리밍 쿼리 재검토 |
-- 범인 색출: 가장 오래된 xmin을 붙들고 있는 세션
SELECT pid, usename, state,
now() - xact_start AS tx_age,
age(backend_xmin) AS xmin_age,
left(query, 100) AS q
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY age(backend_xmin) DESC LIMIT 10;
① Autovacuum 끄기 — 재앙의 지름길. 꺼도 anti-wraparound VACUUM은 반드시 옵니다. ② 운영 중 VACUUM FULL — 읽기까지 차단됩니다. ③ 긴 ETL 트랜잭션 방치 — 한 세션이 전체 클러스터의 VACUUM을 마비시킵니다. 배치는 작게 쪼개 커밋하세요.
✍️ 이해도 체크
VACUUM(FULL 아님)을 실행한 뒤 테이블에 일어나는 일로 옳은 것은?threshold(50) + scale_factor(0.2) × reltuples입니다. 100만 행이면 200,050개. 테이블이 클수록 문턱이 비례해서 높아지므로, 대형·핫 테이블은 ALTER TABLE ... SET (autovacuum_vacuum_scale_factor = 0.02)처럼 개별 설정으로 낮추는 것이 운영 표준입니다.이 장의 원문 문서: chapters/ch08_vacuum_autovacuum.md — cost 계산 상세, pgstattuple로 Bloat 정밀 측정, 커뮤니티 bloat 쿼리, 운영 체크리스트 전체가 담겨 있습니다.