PostgreSQL Vacuum Part 2 — Autovacuum 튜닝과 XID Wraparound 재난 방지
Autovacuum이 켜져 있다고 운영 부담이 사라지는 것이 아니다. Launcher·Worker 구조와 세 가지 트리거 조건, Cost-Based Throttling의 실제 처리 속도 한계, 대형 테이블의 scale_factor 위험성과 테이블별 오버라이드 전략, XID Wraparound 4단계 방어선과 긴급 대응 절차, PostgreSQL 13–17 버전별 Vacuum 개선 이력까지 — Autovacuum을 운영 관점에서 이해하고 조정하는 방법을 체계적으로 정리한다.
시리즈 구성
- Part 1 — VACUUM의 존재 이유: MVCC와 Dead Tuple
- Part 2 — Autovacuum 튜닝과 XID Wraparound 재난 방지 (현재 편)
- Part 3 — 실전 모니터링, Bloat 제거, 운영 전략
목차
- Autovacuum 아키텍처: Launcher와 Worker
- Autovacuum 트리거 조건 — 언제 작동하는가?
- Cost-Based Throttling — 왜 Autovacuum은 느린가?
- 핵심 파라미터 완전 해부
- 테이블별 파라미터 오버라이드 — 핫 테이블 집중 관리
- XID Wraparound — PostgreSQL 최악의 재난 시나리오
- 버전별 Vacuum 개선 이력 (PG 13 → PG 17)
- 긴급 대응 플레이북
- Part 2 핵심 정리 및 Part 3 예고
1. Autovacuum 아키텍처: Launcher와 Worker
Autovacuum은 단일 프로세스가 아니다. Launcher와 Worker 두 종류의 프로세스가 협력하는 구조다.
Autovacuum Launcher는 autovacuum_naptime(기본 1분) 간격으로 깨어나 클러스터 내 모든 데이터베이스를 순회하며 Vacuum이 필요한 테이블을 탐색한다. 대상 테이블을 찾으면 Autovacuum Worker 프로세스를 fork한다.
Worker 수의 상한은 autovacuum_max_workers(기본 3)로 제한된다. 동시에 최대 3개 테이블만 Autovacuum이 가능하다는 뜻이다. 대규모 데이터베이스에서 이 기본값이 병목이 되는 경우가 많다.
-- 현재 실행 중인 Autovacuum Worker 확인
SELECT pid, datname, query, now() - query_start AS duration
FROM pg_stat_activity
WHERE query LIKE 'autovacuum:%'
ORDER BY duration DESC;
2. Autovacuum 트리거 조건 — 언제 작동하는가?
Autovacuum Worker는 다음 세 가지 조건 중 하나를 충족하는 테이블에 Vacuum을 수행한다.
Dead Tuple 기반 트리거 (일반 VACUUM)
트리거 조건:
n_dead_tup > autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × n_live_tup
기본값으로 계산하면 테이블 크기가 클수록 임계값이 폭발적으로 증가한다.
| 테이블 크기 | 기본 트리거 임계값 | 소요 Dead Tuple |
|---|---|---|
| 10,000행 | 50 + 0.2 × 10,000 | 2,050개 |
| 100만 행 | 50 + 0.2 × 1,000,000 | 200,050개 |
| 1억 행 | 50 + 0.2 × 100,000,000 | 2,000만 개 |
1억 행 테이블에서는 2,000만 개의 Dead Tuple이 쌓여야 Autovacuum이 발동한다. 이 기본값이 대형 테이블에 얼마나 위험한지 직관적으로 드러난다.
INSERT 기반 트리거 (PostgreSQL 13+, Freeze 목적)
트리거 조건:
n_ins_since_vacuum > autovacuum_vacuum_insert_threshold + autovacuum_vacuum_insert_scale_factor × n_live_tup
PostgreSQL 13에서 추가된 기능으로, INSERT만 발생하는 Append-Only 테이블도 XID Freeze 목적으로 주기적으로 Vacuum이 수행된다.
XID Age 기반 트리거 (Anti-Wraparound VACUUM — 강제 실행)
트리거 조건:
age(relfrozenxid) > autovacuum_freeze_max_age
이 조건에 해당하면 autovacuum = off로 설정해 두어도 강제로 Autovacuum이 실행된다. Dead Tuple이 하나도 없어도 테이블 전체를 스캔하는 Aggressive Vacuum이 발동하므로, 예상치 못한 I/O 급등의 원인이 된다.
-- 각 테이블의 Autovacuum 트리거까지 남은 여유 확인
SELECT
schemaname || '.' || relname AS table_name,
n_live_tup,
n_dead_tup,
(50 + 0.2 * n_live_tup)::bigint AS vacuum_threshold,
CASE
WHEN n_dead_tup > (50 + 0.2 * n_live_tup)
THEN '즉시 Vacuum 필요'
ELSE ROUND(100.0 * n_dead_tup / NULLIF(50 + 0.2 * n_live_tup, 0), 1) || '% 도달'
END AS status
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
실무 팁: 이 쿼리의 임계값은 기본
scale_factor = 0.2를 가정한다. 테이블별 오버라이드가 설정된 경우에는pg_class.reloptions를 추가로 참조해야 정확한 임계값을 확인할 수 있다.
3. Cost-Based Throttling — 왜 Autovacuum은 느린가?
Autovacuum이 예상보다 훨씬 느리게 돌아간다면 Cost-Based Throttling 때문이다. PostgreSQL은 Autovacuum이 일반 사용자 쿼리에 미치는 I/O 영향을 최소화하기 위해 의도적으로 Vacuum 속도를 제한한다.
비용 계산 기준
| 작업 유형 | 기본 비용 | 파라미터 |
|---|---|---|
| 버퍼 캐시에서 읽기 (Hit) | 1 | vacuum_cost_page_hit |
| 디스크에서 읽기 (Miss) | 2 | vacuum_cost_page_miss |
| 더티 페이지 쓰기 | 20 | vacuum_cost_page_dirty |
누적 비용이 autovacuum_vacuum_cost_limit(기본 200)에 달하면, autovacuum_vacuum_cost_delay(기본 2ms)만큼 슬립한다.
실질적인 Vacuum 처리 속도 계산
초당 처리 가능 비용 = (1000ms / cost_delay_ms) × cost_limit
= (1000 / 2) × 200 = 100,000 비용/초
페이지 크기 = 8KB, 페이지당 비용 ≈ 2 (캐시 미스 가정)
초당 처리 가능 페이지 = 100,000 / 2 = 50,000 페이지/초
초당 처리 가능 데이터 = 50,000 × 8KB ≈ 400MB/초
-> NVMe SSD 기준 실제 처리 가능 속도의 5-10% 수준만 사용!
NVMe 스토리지를 사용한다면 cost_delay = 0으로 설정해 풀 스피드로 Vacuum을 실행하는 것이 합리적이다.
4. 핵심 파라미터 완전 해부
postgresql.conf 핵심 파라미터 총람
# === 기본 활성화 ===
autovacuum = on # 절대 off 하지 말 것
track_counts = on # autovacuum 작동의 전제 조건
# === Worker 수 및 주기 ===
autovacuum_max_workers = 3 # 기본값; 대형 DB는 5-10 권장
autovacuum_naptime = 1min # 소형 DB는 줄여도 됨 (30s)
# === VACUUM 트리거 임계값 ===
autovacuum_vacuum_threshold = 50 # 최소 Dead Tuple 수
autovacuum_vacuum_scale_factor = 0.2 # 대형 테이블은 0.01-0.05로 낮춰야 함
autovacuum_analyze_threshold = 50
autovacuum_analyze_scale_factor = 0.1 # 통계 갱신 주기
# === Cost Throttling ===
autovacuum_vacuum_cost_delay = 2ms # NVMe면 0 또는 1ms 권장
autovacuum_vacuum_cost_limit = 200 # 높일수록 빠름; 1000-2000 권장
# === Freeze & Wraparound 방지 ===
autovacuum_freeze_max_age = 200000000 # 2억 트랜잭션마다 Aggressive Vacuum
vacuum_freeze_min_age = 50000000 # 이 나이 이상 Tuple을 Freeze 대상으로
vacuum_failsafe_age = 1600000000 # PG14+: 16억 초과 시 긴급 Failsafe 발동
# === 메모리 ===
autovacuum_work_mem = -1 # -1이면 maintenance_work_mem 따름
maintenance_work_mem = 64MB # 대형 테이블 Vacuum 시 256MB~1GB 권장
스토리지 유형별 권장 Cost 설정
| 스토리지 | cost_delay | cost_limit | 이유 |
|---|---|---|---|
| HDD | 20ms | 200 | I/O 경합 최소화 |
| SATA SSD | 2ms | 400 | 기본값 + α |
| NVMe SSD | 0-1ms | 1000-2000 | 고속 스토리지 최대 활용 |
5. 테이블별 파라미터 오버라이드 — 핫 테이블 집중 관리
전역 파라미터를 바꾸지 않고, 자주 업데이트되는 특정 테이블에만 공격적인 설정을 적용하는 것이 모범 사례다.
-- 예: 초당 수백 건 업데이트가 발생하는 주문 테이블
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.01, -- 1%만 쌓여도 Vacuum
autovacuum_vacuum_threshold = 100,
autovacuum_analyze_scale_factor = 0.005,
autovacuum_vacuum_cost_delay = 0, -- 지연 없이 최대 속도
autovacuum_vacuum_cost_limit = 2000,
autovacuum_freeze_max_age = 100000000
);
-- 설정 확인
SELECT relname, reloptions
FROM pg_class
WHERE relname = 'orders';
테이블 유형별 튜닝 전략
[유형별 권장 scale_factor]
Append-Only 로그 테이블 (INSERT only)
-> vacuum_scale_factor: 0.05-0.1 (상대적으로 여유)
-> insert_scale_factor: 0.1 (PG13+, Freeze 목적)
일반 OLTP 테이블 (UPDATE/DELETE 빈번)
-> vacuum_scale_factor: 0.05-0.1
-> cost_delay: 1-2ms
초고빈도 업데이트 테이블 (세션/카운터 등)
-> vacuum_scale_factor: 0.01
-> cost_delay: 0
-> freeze_max_age: 1억 이하
대형 히스토리/아카이브 테이블 (거의 변경 없음)
-> vacuum_scale_factor: 0.2 (기본값 OK)
-> freeze_max_age: 조정 필요 (XID Age 모니터링 필수)
6. XID Wraparound — PostgreSQL 최악의 재난 시나리오
이 섹션이 Part 2 전체에서 가장 중요하다.
왜 위험한가?
PostgreSQL의 트랜잭션 ID(XID)는 32비트 정수다. 약 42억(2³²)개를 소진하면 0으로 되돌아간다. PostgreSQL은 과거/미래 판단에 모듈러 비교를 사용하므로 실질적인 안전 한계는 약 21억(2³¹)이다.
XID Wraparound 발생 시 무슨 일이 일어나는가?
XID 1,000,000 (과거 데이터)
-> XID 카운터가 약 21억을 넘어 순환
-> XID 1,000,000이 이제 "미래 트랜잭션"으로 인식됨
-> 해당 행들이 모든 트랜잭션에서 보이지 않게 됨 (데이터 소실!)
-> PostgreSQL이 이를 감지하고 DB를 읽기 전용으로 전환
ERROR: database is not accepting commands to avoid
wraparound data loss in database "mydb"
Wraparound 방어선 — 4단계
XID Age (단위: 트랜잭션 수)
0 200M 1.5B 1.6B 2B
|--------------|-----------------|------|--------|
정상 운영 구간 1차: Aggressive 경보 Failsafe PANIC
VACUUM 발동 (PG14+) DB 쓰기 거부
| 방어선 | XID Age | 파라미터 | 조치 |
|---|---|---|---|
| 1차 | ~200M | autovacuum_freeze_max_age | 일반 Autovacuum Freeze |
| 2차 | ~1.5B | 모니터링 알람 권장 구간 | 수동 VACUUM FREEZE 검토 |
| 3차 | 1.6B | vacuum_failsafe_age (PG14+) | 긴급 Failsafe VACUUM 발동 |
| 4차 | ~2B | PostgreSQL 내부 | DB 쓰기 거부 / 강제 종료 |
XID Age 모니터링 쿼리
-- 데이터베이스 단위 XID Age
SELECT
datname,
age(datfrozenxid) AS xid_age,
2000000000 - age(datfrozenxid) AS xid_remaining,
ROUND(age(datfrozenxid) / 20000000.0, 1) AS danger_pct
FROM pg_database
ORDER BY xid_age DESC;
-- 테이블 단위 XID Age (위험 테이블 상위 20개)
SELECT
n.nspname || '.' || c.relname AS table_name,
age(c.relfrozenxid) AS xid_age,
pg_size_pretty(pg_total_relation_size(c.oid)) AS size,
CASE
WHEN age(c.relfrozenxid) > 1500000000 THEN 'CRITICAL'
WHEN age(c.relfrozenxid) > 1000000000 THEN 'WARNING'
WHEN age(c.relfrozenxid) > 500000000 THEN 'CAUTION'
ELSE 'OK'
END AS status
FROM pg_class c
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE c.relkind = 'r'
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY xid_age DESC
LIMIT 20;
-- 장기 실행 트랜잭션 확인 (Vacuum의 최대 적)
SELECT
pid,
usename,
state,
now() - xact_start AS tx_duration,
LEFT(query, 80) AS query_snippet
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
AND state != 'idle'
ORDER BY xact_start
LIMIT 10;
실무 경고: 장기 실행 트랜잭션은 Vacuum의 Dead Tuple 정리를 완전히 막는다.
idle_in_transaction_session_timeout = '30min'설정으로 방치된 트랜잭션을 자동 종료시키는 것이 좋다. 단, 이 설정은 배치 작업처럼 오래 실행되는 연결도 함께 종료시킬 수 있으므로, 배치 전용 연결에는SET idle_in_transaction_session_timeout = 0으로 개별 비활성화하거나 별도 롤을 사용하는 것이 좋다.
7. 버전별 Vacuum 개선 이력 (PG 13 → PG 17)
PostgreSQL은 매 버전마다 Vacuum을 개선해 왔다. 현재 사용하는 버전에 따라 사용 가능한 기능과 성능이 크게 달라진다.
PostgreSQL 13 (2020)
- 인덱스 병렬 Vacuum 도입(
VACUUM (PARALLEL n)옵션): 단일 테이블 Vacuum에서 인덱스 클리닝을 병렬 Worker에게 분산 - B-tree 인덱스 중복 제거(Deduplication) → 인덱스 Bloat 감소
- INSERT 기반 Autovacuum 트리거 추가: Append-Only 테이블 Freeze 지원
PostgreSQL 14 (2021)
vacuum_failsafe_age파라미터 신설: XID Age가 기본 16억을 넘으면 Cost Throttling을 비활성화하고 인덱스 Vacuum도 건너뛰며 최대한 빠르게 Freeze 수행- Autovacuum 중 인덱스 클리닝 지연(Defer) 기능 강화
- 대형 테이블 Vacuum 성능 일반 개선
PostgreSQL 15 (2022)
- VACUUM 통계 내부 처리 개선
autovacuum_vacuum_max_threshold파라미터 추가: Dead Tuple 임계값에 절대 상한 설정 가능
PostgreSQL 16 (2023)
- Vacuum Freezing 성능 향상: 비-Freeze 작업 중에도 적합한 페이지를 미리 Freeze 처리 → Full-Table Freeze Vacuum 빈도 감소
BUFFER_USAGE_LIMIT옵션 추가: Vacuum이 Shared Buffer를 얼마나 사용할지 제한 → 캐시 오염 방지SKIP_DATABASE_STATS/ONLY_DATABASE_STATS옵션 추가 →vacuumdb성능 향상pg_vacuum_all_tables/pg_analyze_all_tables사전 정의 롤 추가
PostgreSQL 17 (2024) — 가장 큰 Vacuum 변화
- TidStore 새 메모리 구조 도입: Dead Tuple ID 저장에 최적화된 자료구조를 사용해 메모리 사용량 최대 20배 감소(벤치마크 기준) → 대형 테이블 Vacuum 속도 향상 + Shared Buffer 경합 감소
pg_stat_progress_vacuum에 인덱스 Vacuum 진행률 추가pg_maintain롤 및MAINTAIN권한 도입: 비-슈퍼유저에게 Vacuum 권한 위임 가능- Incremental VACUUM: 이전 실행 이후 변경된 페이지만 선택적으로 처리 → 대형 테이블에서 불필요한 전체 스캔 감소
-- PG17: 인덱스 Vacuum 진행 상황 실시간 모니터링
SELECT
p.pid,
p.relid::regclass AS table_name,
p.phase,
p.heap_blks_scanned,
p.heap_blks_total,
ROUND(100.0 * p.heap_blks_scanned / NULLIF(p.heap_blks_total, 0), 1) AS heap_pct,
p.index_vacuum_count,
p.num_dead_item_ids
FROM pg_stat_progress_vacuum p;
8. 긴급 대응 플레이북
시나리오 A: "Autovacuum이 도는데도 Dead Tuple이 계속 쌓인다"
- 장기 실행 트랜잭션 확인 →
pg_stat_activity에서 오래된xact_start탐색 - Replication Slot이 오래된 XID를 붙잡고 있는지 확인 — 비활성 Slot은 XID를 무한정 보존해 Autovacuum이 Dead Tuple을 정리하지 못하게 막는다. Vacuum Worker가 돌고 있어도 Dead Tuple이 계속 쌓인다면 이 원인을 먼저 의심해야 한다.
-- Replication Slot이 Vacuum을 막고 있는가?
SELECT slot_name,
catalog_xmin,
age(catalog_xmin) AS catalog_xmin_age,
xmin,
age(xmin) AS xmin_age
FROM pg_replication_slots
WHERE NOT active
ORDER BY age(xmin) DESC;
- 불필요한 비활성 Replication Slot 삭제
SELECT pg_drop_replication_slot('slot_name'); -- 반드시 확인 후 실행
시나리오 B: "XID Age가 10억을 넘겼다"
-- 즉시 수동 VACUUM FREEZE 실행
VACUUM (FREEZE, ANALYZE, VERBOSE) target_table;
-- DB 전체에 대해 실행 (maintenance_work_mem 사전 조정 필수)
SET maintenance_work_mem = '1GB';
VACUUM FREEZE;
시나리오 C: "ERROR: database is not accepting commands..." 발생
PostgreSQL이 Wraparound 방지를 위해 DB를 읽기 전용으로 전환한 최악의 상황이다.
# 1. DB를 단일 사용자 모드로 시작 (서비스 완전 중단 전제)
postgres --single -D /var/lib/postgresql/data mydb
# 2. 단일 사용자 모드에서 VACUUM 수행
VACUUM;
# 3. 정상 재시작
또는 PostgreSQL 정상 기동 후 vacuumdb로 직접 강제 실행한다.
# vacuumdb로 강제 실행
vacuumdb --all --freeze --jobs=4 -U postgres
이 상황이 되기 전에 반드시 모니터링으로 예방해야 한다. XID Age 10억(1B) 초과 시 즉각 알림을 받을 수 있도록 모니터링 알람을 설정해 두는 것이 좋다.
시나리오 D: "VACUUM이 너무 느려서 피크 시간에 I/O를 잡아먹는다"
-- 실행 중인 Autovacuum을 일시 취소 (해당 Worker만 종료, 나중에 재실행)
SELECT pg_cancel_backend(pid)
FROM pg_stat_activity
WHERE query LIKE 'autovacuum:%'
AND query LIKE '%my_hot_table%';
-- 피크 타임 회피: cron으로 수동 VACUUM 예약 + autovacuum 임시 비활성화
ALTER TABLE big_table SET (autovacuum_enabled = false);
-- 새벽 배치에서 실행 후 즉시 재활성화
VACUUM ANALYZE big_table;
ALTER TABLE big_table SET (autovacuum_enabled = true);
9. Part 2 핵심 정리 및 Part 3 예고
Part 2 핵심 요약
| 주제 | 핵심 내용 |
|---|---|
| Autovacuum 구조 | Launcher(감시) + Worker(실행), 최대 동시 실행 수 = max_workers |
| 트리거 조건 | Dead Tuple 비율 / INSERT 수 / XID Age 세 가지 |
| Cost Throttling | 기본값은 매우 보수적; NVMe 환경에서 cost_delay=0 고려 |
| 대형 테이블 전략 | scale_factor=0.01-0.05 + 테이블별 오버라이드 |
| XID Wraparound | 32비트 한계 → Freeze 필수 → 모니터링으로 사전 예방 |
| PG17 최대 변화 | TidStore로 메모리 20배 절감 + Incremental VACUUM |
| 긴급 대응 | Replication Slot / 장기 트랜잭션 확인 → 수동 VACUUM FREEZE |
Part 3 예고: 실전 모니터링, Bloat 제거, 운영 전략
마지막 편에서는 현업에서 바로 가져다 쓸 수 있는 실전 도구와 쿼리 모음을 제공한다.
- 프로덕션 Bloat 측정:
pgstattuple활용법 pg_repack/pg_squeeze: 서비스 무중단으로 테이블/인덱스 재작성- VACUUM 진행 상황 실시간 대시보드 (
pg_stat_progress_vacuum) - OLTP + OLAP 혼합 환경에서의 Vacuum 전략
- 자주 묻는 질문: "VACUUM FULL을 쓰면 안 되는가?"
- 운영 체크리스트 — 월간/분기별 Vacuum 점검 항목
참고 자료
- PostgreSQL 17 공식 문서 — Autovacuum Parameters
- PostgreSQL 17 공식 문서 — Routine Vacuuming
- InfoQ — PostgreSQL 17: Improved Vacuum Process
- Percona — Importance of PostgreSQL Vacuum Tuning
- CYBERTEC — Autovacuum Wraparound Protection