- Published on
PostgreSQL autovacuum 튜닝으로 테이블 폭증 막기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 디스크가 갑자기 꽉 차고, 쿼리는 점점 느려지고, pg_stat_user_tables를 보면 n_dead_tup가 계속 늘어나는 상황은 대개 autovacuum이 workload를 따라가지 못해 bloat가 누적된 결과입니다. PostgreSQL은 MVCC 구조라서 UPDATE/DELETE가 곧바로 공간을 반환하지 않고, 죽은 튜플(dead tuple)을 VACUUM이 정리해줘야 합니다. 문제는 기본 autovacuum 설정이 모든 테이블에 “무난한” 값이라서, 특정 테이블(핫 테이블, 대량 배치 테이블, 파티션 테이블 등)에서는 쉽게 한계를 드러낸다는 점입니다.
이 글은 autovacuum이 동작하는 원리를 짚고, 테이블 폭증(테이블/인덱스 bloat) 예방을 목표로 어떤 파라미터를 어떻게 조정해야 하는지, 그리고 실제로 효과가 나는지 관측하는 방법까지 한 번에 정리합니다.
관련해서 VACUUM 자체가 안 도는 상황(권한/설정/장기 트랜잭션 등)부터 점검하려면 아래 글도 같이 보면 좋습니다.
왜 autovacuum이 느리면 테이블이 “폭증”하나
PostgreSQL에서 UPDATE는 “제자리 수정”이 아니라 새 버전을 추가하고 기존 버전을 죽은 튜플로 남기는 방식입니다. DELETE도 마찬가지로 튜플을 즉시 제거하지 않고 “죽었다”는 표시만 남깁니다. 이 dead tuple이 쌓이면 다음 문제가 연쇄적으로 발생합니다.
- 테이블 파일이 계속 커짐(공간 반환 지연)
- 인덱스에도 죽은 엔트리가 쌓여 인덱스가 커짐
- 쿼리가 더 많은 페이지를 읽고, 캐시 효율이 떨어짐
- autovacuum이 더 많은 일을 해야 해서 더 느려짐(악순환)
즉, autovacuum은 “성능 최적화 기능”이 아니라 운영 안정성을 위한 필수 메커니즘입니다.
autovacuum이 실제로 하는 일 2가지: VACUUM과 ANALYZE
autovacuum worker는 테이블별로 다음을 수행합니다.
- VACUUM: dead tuple을 재사용 가능 상태로 표시, visibility map 갱신, 인덱스 청소 등
- ANALYZE: 통계 갱신으로 플래너가 올바른 실행 계획을 선택하도록 도움
운영에서 흔한 오해는 “VACUUM만 잘 돌면 된다”인데, 통계가 낡으면 실행 계획이 틀어지고, 그 결과 쿼리가 느려져 트랜잭션이 길어지며, 그게 다시 vacuum을 방해하는 케이스가 많습니다. 그래서 autovacuum 튜닝은 VACUUM과 ANALYZE를 함께 보아야 합니다.
먼저 현상부터 계량하기: bloat 후보 테이블 찾기
튜닝은 감이 아니라 데이터로 해야 합니다. 우선 dead tuple이 쌓이는 테이블을 찾습니다.
SELECT
schemaname,
relname,
n_live_tup,
n_dead_tup,
ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
last_autovacuum,
last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 30;
다음으로 autovacuum이 “돌긴 도는데 따라가지 못하는지” 확인하려면 진행 상황 뷰가 유용합니다.
SELECT
pid,
datname,
relid::regclass AS table_name,
phase,
heap_blks_scanned,
heap_blks_vacuumed,
index_vacuum_count,
max_dead_tuples
FROM pg_stat_progress_vacuum;
그리고 autovacuum 로그를 켜면 원인 파악이 빨라집니다.
-- postgresql.conf
log_autovacuum_min_duration = '5s'
log_autovacuum_min_duration를 너무 낮추면 로그가 과도해질 수 있으니, 문제 분석 때는 0 또는 수 초로 낮추고, 안정화 후 다시 올리는 식으로 운영합니다.
autovacuum 트리거(임계치) 이해하기
autovacuum이 특정 테이블에서 VACUUM을 시작하는 대표 조건은 다음과 같은 형태입니다.
- vacuum 임계치
= autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * reltuples - analyze 임계치
= autovacuum_analyze_threshold + autovacuum_analyze_scale_factor * reltuples
여기서 reltuples는 대략적인 행 수 추정치입니다.
문제는 기본값이 대략 다음과 같다는 점입니다(버전에 따라 조금 다를 수 있음).
autovacuum_vacuum_threshold = 50autovacuum_vacuum_scale_factor = 0.2
행이 1천만 건인 테이블이면 vacuum 트리거가 대략 50 + 0.2 * 10,000,000 = 2,000,050 변경 이후입니다. 즉, UPDATE/DELETE가 많은 대형 테이블에서는 dead tuple이 200만 개 쌓여야 시작할 수 있습니다. 이 사이에 테이블은 충분히 비대해질 수 있습니다.
핵심 전략: 전역(global)보다 테이블별(per-table) 튜닝
전역 설정을 공격적으로 바꾸면 모든 테이블에서 autovacuum이 과도하게 돌 수 있고, 그 결과 I/O 경합이나 CPU 스파이크가 생길 수 있습니다. 운영에서는 보통 다음 순서가 안전합니다.
- 전역은 “기본 안전값”으로 유지
- 폭증하는 상위 몇 개 테이블에만 per-table 옵션을 걸어 빠르게 개선
- workload를 관측하며 전역 파라미터를 미세 조정
테이블별 autovacuum 설정 예시
핫 테이블(업데이트가 잦고, 크며, 지연이 곧 장애로 이어지는 테이블)에 아래처럼 적용합니다.
ALTER TABLE public.orders SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_threshold = 2000,
autovacuum_analyze_scale_factor = 0.01,
autovacuum_analyze_threshold = 1000,
autovacuum_vacuum_cost_limit = 4000,
autovacuum_vacuum_cost_delay = 5
);
해석하면:
- scale factor를 낮춰 더 자주 vacuum/analyze가 트리거되게 함
- threshold를 약간 올려 “너무 자주”는 방지
- cost limit을 올려 worker가 더 많은 작업을 하도록 허용
- cost delay로 I/O를 완전히 무제한으로 풀지 않고 적당히 쉬게 함
cost_*는 스토리지 성능과 동시 부하에 따라 최적점이 달라집니다. NVMe 기반이라면 더 공격적으로, 네트워크 스토리지나 공유 디스크라면 더 보수적으로 잡는 편이 안전합니다.
autovacuum worker가 부족한 경우: 동시성부터 늘리기
bloat가 누적되는 흔한 이유는 “한 테이블이 느려서”가 아니라, vacuum해야 할 테이블이 많은데 worker 수가 부족한 경우입니다.
대표 파라미터는 다음입니다.
autovacuum_max_workers: 동시 실행 worker 수autovacuum_naptime: 다음 라운드까지 쉬는 시간
예시:
# postgresql.conf
autovacuum_max_workers = 6
autovacuum_naptime = '10s'
autovacuum_max_workers를 늘리면 vacuum이 빨라질 수 있지만, 동시에 디스크 I/O가 튈 수 있습니다. 따라서 아래처럼 “관측 지표”를 같이 봐야 합니다.
- 디스크 read/write latency
- buffer cache hit ratio
- checkpoint 빈도 및
bgwriter통계 pg_stat_activity에서 autovacuum이 쿼리 지연을 유발하는지
Kubernetes 같은 환경에서 노드 메모리 압박이 심하면 DB가 OOM 위험에 노출될 수 있으니, 시스템 레벨 메모리 이슈도 함께 점검하세요.
vacuum이 “막히는” 가장 흔한 원인: 오래 열린 트랜잭션
VACUUM은 어떤 튜플이 “모든 트랜잭션에서 보이지 않게 됐는지”를 판단해야 합니다. 그런데 오래 열린 트랜잭션이 있으면, dead tuple이 실제로는 아직 누군가에게 보일 수 있다고 간주되어 정리가 지연됩니다.
아래 쿼리로 장기 트랜잭션/idle in transaction을 찾습니다.
SELECT
pid,
usename,
state,
now() - xact_start AS xact_age,
now() - query_start AS query_age,
wait_event_type,
wait_event,
LEFT(query, 120) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_age DESC
LIMIT 30;
운영 정책으로는 다음이 자주 쓰입니다.
- 애플리케이션에서 트랜잭션을 짧게 유지(특히 커넥션 풀 사용 시)
idle_in_transaction_session_timeout설정- 배치 작업에서 커서/긴 트랜잭션을 피하고 커밋을 잘게 나누기
bloat를 “예방”하는 튜닝 포인트 6가지
1) scale factor를 과감히 낮추되, 테이블별로 적용
대형 테이블일수록 autovacuum_vacuum_scale_factor 기본값 0.2는 너무 큽니다. 핫 테이블은 0.01에서 0.05 사이가 출발점이 되는 경우가 많습니다.
- 변경량이 매우 큰 테이블:
0.01근처 - 중간 정도:
0.03근처 - 거의 변경 없는 대형 테이블: 기본 유지
2) analyze도 같이 자주 돌리기
UPDATE가 많은 테이블은 데이터 분포가 자주 변합니다. 통계가 낡으면 실행 계획이 틀어져 쿼리 시간이 늘고, 그게 다시 vacuum 지연으로 이어집니다. autovacuum_analyze_scale_factor도 함께 낮추는 편이 효과적입니다.
3) cost limit과 delay로 “속도 vs 경합” 균형 맞추기
vacuum이 너무 얌전하면 따라가지 못하고, 너무 공격적이면 서비스 쿼리를 압박합니다.
- 따라가지 못한다:
autovacuum_vacuum_cost_limit을 올리거나autovacuum_vacuum_cost_delay를 줄이기 - 경합이 심하다: cost limit을 내리거나 delay를 늘리기
테이블별로만 조정해도 체감 효과가 큽니다.
4) autovacuum worker 수 확장
테이블이 많고 변경이 분산돼 있으면 worker 수가 병목이 됩니다. 다만 worker를 늘리면 메모리 사용량도 늘 수 있습니다(각 worker가 유지하는 상태, 인덱스 처리 등). 늘린 뒤에는 메모리와 I/O를 함께 모니터링하세요.
5) 파티셔닝 환경에서는 “파티션별 설정”을 고려
파티션 테이블은 파티션마다 변경량이 다르기 때문에, 부모 테이블에만 설정해도 기대한 대로 동작하지 않는 경우가 있습니다. hot 파티션에만 더 공격적인 설정을 주는 식이 비용 대비 효과가 좋습니다.
ALTER TABLE public.events_2026_02 SET (
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_analyze_scale_factor = 0.005
);
6) fillfactor로 UPDATE 여유 공간 확보
UPDATE가 많은 테이블은 페이지에 여유 공간이 없으면 새 버전이 다른 페이지로 밀려나면서 페이지 split과 bloat가 빨리 진행됩니다. 이런 테이블은 fillfactor를 낮춰 “미리 빈 공간”을 남기는 전략이 유효합니다.
ALTER TABLE public.orders SET (fillfactor = 80);
-- 적용을 위해서는 보통 VACUUM FULL 또는 재작성(rewrite)이 필요할 수 있음
주의: fillfactor는 공간을 더 쓰는 대신 bloat와 페이지 분할을 줄이는 트레이드오프입니다.
튜닝 후 검증: 무엇이 좋아져야 성공인가
튜닝의 목표는 단순히 vacuum 로그가 많이 찍히는 게 아니라, 아래 지표가 개선되는 것입니다.
n_dead_tup가 일정 수준 이상으로 계속 상승하지 않고 톱니 형태로 내려감last_autovacuum이 적절한 주기로 갱신됨- 쿼리 latency가 안정화(특히 인덱스 스캔/조인 구간)
- 디스크 사용량 증가율이 둔화
검증 쿼리 예시입니다.
SELECT
relname,
n_dead_tup,
n_live_tup,
last_autovacuum,
vacuum_count,
autovacuum_count,
analyze_count,
autoanalyze_count
FROM pg_stat_user_tables
WHERE relname IN ('orders', 'order_items')
ORDER BY relname;
운영 팁: 응급 처치와 근본 처치를 분리하기
이미 bloat가 심하면 autovacuum 튜닝만으로 “즉시” 디스크가 줄어들지 않습니다. VACUUM은 공간을 OS에 반환하지 않고 재사용 가능 상태로 만드는 것이 기본이기 때문입니다. 디스크를 당장 회수해야 한다면 VACUUM FULL 또는 테이블 재작성(예: pg_repack) 같은 강한 처치가 필요할 수 있습니다. 다만 이는 락/부하가 크므로 반드시 점검 창을 잡고 수행해야 합니다.
- 응급 처치:
VACUUM (VERBOSE, ANALYZE)로 dead tuple 감소 유도, 장기 트랜잭션 제거 - 근본 처치: autovacuum 임계치/worker/cost 튜닝, 트랜잭션 관리 개선, fillfactor/스키마 설계 개선
응급 처치 중심의 내용은 아래 글이 더 직접적입니다.
체크리스트: 폭증하는 테이블을 만났을 때의 권장 순서
pg_stat_user_tables로 dead tuple 상위 테이블 식별pg_stat_activity로 장기 트랜잭션/idle in transaction 제거- autovacuum 로그 활성화(
log_autovacuum_min_duration)로 원인 가시화 - 문제 테이블에 per-table로 scale factor/analyze factor 우선 조정
- 따라가지 못하면 worker 수와 cost limit/delay 조정
- 이미 커진 파일은 별도 작업으로 회수(필요 시)
마무리
autovacuum 튜닝의 본질은 “VACUUM을 더 자주, 더 적절한 강도로, 필요한 테이블에 집중”시키는 것입니다. 기본 설정은 대부분의 환경에서 무난하지만, UPDATE/DELETE가 많은 대형 테이블이 있는 순간부터는 테이블별 정책이 필요합니다.
특히 scale factor를 낮추고 analyze를 함께 자주 돌리는 것만으로도, 테이블 폭증과 성능 저하의 악순환을 끊는 경우가 많습니다. 이후에는 worker 동시성과 cost 파라미터로 I/O 경합을 관리하면서, 장기 트랜잭션을 제거하는 운영 습관까지 갖추면 bloat는 “가끔 터지는 장애”가 아니라 “관리 가능한 비용”이 됩니다.