DB Study

8장. VACUUM과 Autovacuum — 청소부 없는 MVCC는 없다

UPDATE와 DELETE가 남긴 dead tuple은 저절로 사라지지 않습니다. Bloat 폭증, 쿼리 지연, 그리고 최악의 장애 XID Wraparound까지 — PostgreSQL 운영 사고의 절반은 여기서 시작됩니다.

중급 ⏱ 약 30분 🎬 애니메이션 3개 선수 지식: 3장 MVCC
🧭
이 장에서 배우는 것

① dead tuple이 쌓이는 이유와 VACUUM이 실제로 하는 일 ② Autovacuum이 자동으로 도는 조건(트리거 공식)과 튜닝 ③ XID Wraparound의 원리와 Freeze ④ VACUUM을 막는 4대 요인과 Bloat 대응

8.1 왜 VACUUM이 필요한가 — 지워도 지워지지 않는 데이터

3장에서 배웠듯이 PostgreSQL의 UPDATE는 "기존 튜플에 xmax 표시 + 새 버전 삽입"이고, DELETE는 xmax만 표시할 뿐 물리적으로 지우지 않습니다. 마치 아파트에서 이사를 나가도 이전 집 짐을 그대로 두고 새 집으로 옮기는 것과 같습니다. 누군가 이전 집을 치워주지 않으면 아파트는 빈 짐으로 가득 차게 되죠.

이 "빈 짐"이 바로 dead tuple이고, 치워주는 청소부가 VACUUM입니다. 청소를 안 하면 네 가지 문제가 생깁니다.

heap 페이지 (8KB) id=1 "Alice" (live) id=2 "Bob" (live) id=1 "Alice" ☠ dead id=1 "Alicia" (live) id=2 v2 ☠ dead id=1 v2 ☠ dead id=2 "Bobby" (live) 🎈 Bloat! 페이지의 절반이 죽은 데이터 🧹 VACUUM 회수됨 (재사용 가능) 회수됨 회수됨 Free Space Map "이 페이지에 빈 공간 있음" id=3 "Carol" ✨ 새 INSERT ⚠️ 단, 파일 크기 자체는 줄지 않습니다 — 페이지 안에서 재사용될 뿐!

8.2 VACUUM이 실제로 하는 일 — 그리고 하지 않는 일

일반 VACUUM(lazy vacuum)은 생각보다 많은 일을 합니다. dead tuple 회수는 그중 하나일 뿐입니다.

  1. Heap 스캔 — 페이지를 읽어 dead tuple 식별
  2. 인덱스 정리 — dead tuple을 가리키는 인덱스 엔트리 제거
  3. 공간 회수 — dead tuple의 line pointer를 재사용 가능 상태로 표시
  4. FSM/VM 갱신 — 빈 공간 지도와 Visibility Map(all-visible/all-frozen) 갱신
  5. 통계 갱신pg_class.reltuples, relpages
  6. Freeze — 오래된 튜플의 xmin을 FrozenXid로 교체 (8.4에서 자세히)
⚠️
일반 VACUUM은 파일 크기를 줄이지 않는다

페이지 안의 공간을 재사용 가능하게 만들 뿐, 물리 파일은 테이블 맨 끝이 완전히 비었을 때만 조건부로 잘립니다. "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 × 행수
Autovacuum Launcher naptime(1min)마다 기상 ⏰ users (100만 행) n_dead_tup = 5만 orders (100만 행) n_dead_tup = 25만 5만 < 50 + 0.2×100만 기준 미달 → 통과 ✅ 25만 > 200,050 기준 초과 → VACUUM! 🚨 🧹 Autovacuum Worker 최대 autovacuum_max_workers(3) orders 청소 시작 브레이크: cost 기반 스로틀링 작업 sleep 작업 sleep cost_limit(200) 도달 → cost_delay(2ms) 휴식

기본값의 함정: 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 업데이트용 여유 공간
);
💡
PG 13+: INSERT만 하는 테이블도 청소 대상

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 순환 시계 (32bit ≈ 40억) 비교 가능 범위 = 약 20억 (반원) 튜플 xmin=100 현재 XID (age 작음) 시간이 흘러 age 증가… age > 2억! 🚨 anti-wraparound VACUUM 강제 autovacuum_freeze_max_age = 2억 🧊 Freeze xmin=100 → FrozenXid "누가 봐도 과거" — 순환과 무관해짐 최후의 선: age 20억 database is not accepting commands

조기 경보는 간단한 쿼리 하나면 됩니다. 일간 모니터링에 반드시 포함하세요.

-- 테이블별 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 transactionpg_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;
🚨
자주 하는 실수 Top 3

Autovacuum 끄기 — 재앙의 지름길. 꺼도 anti-wraparound VACUUM은 반드시 옵니다. ② 운영 중 VACUUM FULL — 읽기까지 차단됩니다. ③ 긴 ETL 트랜잭션 방치 — 한 세션이 전체 클러스터의 VACUUM을 마비시킵니다. 배치는 작게 쪼개 커밋하세요.

✍️ 이해도 체크

일반 VACUUM(FULL 아님)을 실행한 뒤 테이블에 일어나는 일로 옳은 것은?
✅ 일반 VACUUM은 페이지 안의 공간을 회수해 재사용 가능하게 만들 뿐 파일을 줄이지 않습니다. 파일 축소가 필요하면 pg_repack(무중단) 또는 VACUUM FULL(서비스 차단)이 필요합니다. 읽기 차단은 VACUUM FULL의 이야기이고, 전체 freeze는 VACUUM (FREEZE) 옵션의 동작입니다.
100만 행 테이블(기본 설정)에서 autovacuum의 VACUUM이 촉발되려면 dead tuple이 대략 몇 개 쌓여야 할까요?
✅ 공식은 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 쿼리, 운영 체크리스트 전체가 담겨 있습니다.