2026년 6월 4일 목요일
글 목록
Lv.3 중급PostgreSQL
25분 읽기Lv.3 중급
시리즈PostgreSQL Vacuum 완전 정복 · 파트 2시리즈 허브 보기

PostgreSQL Vacuum Part 2 — Autovacuum 튜닝과 XID Wraparound 재난 방지

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 제거, 운영 전략

목차

  1. Autovacuum 아키텍처: Launcher와 Worker
  2. Autovacuum 트리거 조건 — 언제 작동하는가?
  3. Cost-Based Throttling — 왜 Autovacuum은 느린가?
  4. 핵심 파라미터 완전 해부
  5. 테이블별 파라미터 오버라이드 — 핫 테이블 집중 관리
  6. XID Wraparound — PostgreSQL 최악의 재난 시나리오
  7. 버전별 Vacuum 개선 이력 (PG 13 → PG 17)
  8. 긴급 대응 플레이북
  9. Part 2 핵심 정리 및 Part 3 예고

1. Autovacuum 아키텍처: Launcher와 Worker

Autovacuum은 단일 프로세스가 아니다. LauncherWorker 두 종류의 프로세스가 협력하는 구조다.

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,0002,050개
100만 행50 + 0.2 × 1,000,000200,050개
1억 행50 + 0.2 × 100,000,0002,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)1vacuum_cost_page_hit
디스크에서 읽기 (Miss)2vacuum_cost_page_miss
더티 페이지 쓰기20vacuum_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_delaycost_limit이유
HDD20ms200I/O 경합 최소화
SATA SSD2ms400기본값 + α
NVMe SSD0-1ms1000-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차~200Mautovacuum_freeze_max_age일반 Autovacuum Freeze
2차~1.5B모니터링 알람 권장 구간수동 VACUUM FREEZE 검토
3차1.6Bvacuum_failsafe_age (PG14+)긴급 Failsafe VACUUM 발동
4차~2BPostgreSQL 내부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이 계속 쌓인다"

  1. 장기 실행 트랜잭션 확인 → pg_stat_activity에서 오래된 xact_start 탐색
  2. 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;
  1. 불필요한 비활성 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 Wraparound32비트 한계 → 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

이 글 공유하기

시리즈 내비게이션

PostgreSQL Vacuum 완전 정복

현재 글 2 · 3 편 공개

같은 주제 더 보기·대표 시리즈로 시작

English

최신 글을 RSS로 받아보세요

RSS로 새 글과 시리즈 업데이트를 바로 받아볼 수 있습니다.

RSS 구독 안내 보기