- Published on
PostgreSQL autovacuum 안 도는 원인과 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 점점 느려지고, 테이블이 비정상적으로 커지고, pg_stat_user_tables의 n_dead_tup가 계속 늘어나는데도 autovacuum이 “안 도는 것 같다”는 상황은 운영에서 꽤 자주 만납니다. 문제는 autovacuum이 정말로 꺼져 있는 경우도 있지만, 대부분은 동작은 하고 있으나 효과가 안 나거나(청소가 못 따라감), 특정 테이블에서만 반복적으로 실패하는 형태로 나타난다는 점입니다.
이 글은 “왜 autovacuum이 안 도는지”를 감으로 추측하지 않고, 관측 가능한 지표로 원인을 좁히고 해결하는 체크리스트로 구성했습니다.
autovacuum이 하는 일과 “안 도는 것처럼 보이는” 착시
autovacuum은 크게 두 가지를 자동으로 수행합니다.
VACUUM: dead tuple(삭제/업데이트로 생긴 쓰레기 행)을 정리해 테이블/인덱스의 부풀음(bloat)을 완화ANALYZE: 통계를 갱신해 플래너가 올바른 실행 계획을 선택하도록 도움
여기서 흔한 착시는 다음과 같습니다.
- VACUUM은 파일을 즉시 줄이지 않습니다. 테이블 파일 크기는 보통 유지되고, 내부 공간을 재사용 가능한 상태로 만들 뿐입니다. 파일을 줄이려면
VACUUM FULL또는pg_repack같은 별도 작업이 필요합니다. - dead tuple이 줄지 않는 이유가 autovacuum 미동작이 아닐 수 있습니다. 장기 트랜잭션이
xmin을 붙잡고 있으면 vacuum이 정리할 수 없습니다. - autovacuum이 떠도 비용 제한(cost limit) 때문에 매우 느리게 진행되어 “안 도는 것처럼” 보일 수 있습니다.
1단계: 정말 autovacuum이 꺼져 있는지 확인
가장 먼저 전역 설정과 테이블별 설정을 확인합니다.
SHOW autovacuum;
SHOW track_counts;
SHOW log_autovacuum_min_duration;
SELECT
n.nspname AS schema,
c.relname AS table,
c.reloptions
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
AND (c.reloptions IS NOT NULL);
autovacuum = off면 전역적으로 동작하지 않습니다.track_counts = off면 autovacuum 트리거에 필요한 통계가 제대로 쌓이지 않습니다(운영에서는 보통 켜야 합니다).- 테이블
reloptions에autovacuum_enabled=false가 있으면 해당 테이블만 autovacuum이 비활성화됩니다.
해결:
- 전역 활성화
ALTER SYSTEM SET autovacuum = 'on';
ALTER SYSTEM SET track_counts = 'on';
SELECT pg_reload_conf();
- 특정 테이블에서만 켜기
ALTER TABLE your_table RESET (autovacuum_enabled);
2단계: autovacuum 워커가 부족하거나 막혀 있는지 확인
autovacuum은 백그라운드 워커 수에 제한이 있습니다. 워커가 부족하면 대기열이 길어지고 특정 테이블은 계속 밀립니다.
SHOW autovacuum_max_workers;
SHOW autovacuum_naptime;
SELECT
pid, usename, state, query,
backend_type,
xact_start, query_start
FROM pg_stat_activity
WHERE backend_type ILIKE '%autovacuum%'
ORDER BY query_start;
autovacuum_max_workers가 작으면(예: 3) 대형 DB에서 병목이 됩니다.autovacuum_naptime이 크면(예: 1min 이상) 반응성이 떨어집니다.
해결 가이드(예시):
- 워커 수 증가(리소스 여유가 있을 때)
ALTER SYSTEM SET autovacuum_max_workers = '6';
ALTER SYSTEM SET autovacuum_naptime = '15s';
SELECT pg_reload_conf();
주의:
- 워커를 늘리면 I/O와 CPU를 더 사용합니다. 스토리지 성능이 낮은 환경에서는 오히려 쿼리 지연이 늘 수 있으니, 아래의 비용 파라미터도 함께 조정해야 합니다.
3단계: 비용 제한(cost) 때문에 너무 느린지 확인
autovacuum은 기본적으로 “너무 세게 돌지 않도록” throttling이 걸립니다. 그 결과 쓰기량이 큰 테이블에서는 vacuum이 영원히 따라잡지 못하는 상태가 됩니다.
SHOW autovacuum_vacuum_cost_limit;
SHOW autovacuum_vacuum_cost_delay;
SHOW vacuum_cost_limit;
SHOW vacuum_cost_delay;
일반적인 조정 방향:
- 지연을 줄이거나(
autovacuum_vacuum_cost_delay를 낮춤) - 한 번에 더 많이 처리하도록(
autovacuum_vacuum_cost_limit을 높임)
예시:
ALTER SYSTEM SET autovacuum_vacuum_cost_delay = '2ms';
ALTER SYSTEM SET autovacuum_vacuum_cost_limit = '2000';
SELECT pg_reload_conf();
운영 팁:
- 스토리지가 느리거나 동시 트래픽이 높으면 cost를 무작정 올리기보다, 문제 테이블에만 공격적으로 적용하는 편이 안전합니다(아래 “테이블별 튜닝”).
4단계: 장기 트랜잭션이 vacuum을 막는지 확인(가장 흔한 원인)
vacuum은 모든 dead tuple을 지울 수 있는 게 아닙니다. 다른 세션이 오래된 스냅샷을 들고 있으면, 해당 dead tuple은 아직 “보일 수 있는 행”으로 간주되어 제거할 수 없습니다.
다음 쿼리로 오래 열린 트랜잭션/쿼리를 찾습니다.
SELECT
pid, usename, application_name,
state,
now() - xact_start AS xact_age,
now() - query_start AS query_age,
wait_event_type, wait_event,
left(query, 200) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start ASC
LIMIT 30;
특히 다음 패턴이 위험합니다.
- 커넥션 풀/배치 작업이 트랜잭션을 열어둔 채로 대기
idle in transaction상태로 장시간 유지- 리포팅 쿼리/ETL이
REPEATABLE READ로 오래 실행
해결:
- 애플리케이션에서 트랜잭션 범위를 줄이고, 커넥션 풀 설정에서
idle in transaction을 강하게 제한 - DB에서 강제 종료(최후 수단)
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND now() - xact_start > interval '10 minutes';
이 이슈는 “DB가 느려졌다”로 시작해 결국 시스템 전반 장애로 번지기도 합니다. 장애 상황에서 원인 추적을 체계화하는 관점은 Linux OOM Killer 로그 추적과 메모리 누수 진단처럼, 증상에서 관측 지표로 내려가는 방식이 특히 유효합니다.
5단계: 락 때문에 autovacuum이 진행을 못 하는지 확인
일반 VACUUM은 강한 락을 오래 잡지 않지만, 특정 상황에서는 락 경합으로 진행이 막힐 수 있습니다.
VACUUM은 테이블에 대해ShareUpdateExclusiveLock를 잡습니다(대부분의 DML과 공존 가능)- 하지만
ALTER TABLE,VACUUM FULL, 일부 DDL은 더 강한 락을 요구하고, 그 반대도 성립합니다
락 대기 확인:
SELECT
a.pid,
a.backend_type,
a.wait_event_type,
a.wait_event,
now() - a.query_start AS wait_age,
left(a.query, 200) AS query
FROM pg_stat_activity a
WHERE a.wait_event_type = 'Lock'
ORDER BY a.query_start;
블로킹 관계 확인:
SELECT
blocked.pid AS blocked_pid,
blocker.pid AS blocker_pid,
now() - blocked.query_start AS blocked_for,
left(blocked.query, 120) AS blocked_query,
left(blocker.query, 120) AS blocker_query
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocker
ON blocker.pid = ANY(pg_blocking_pids(blocked.pid))
ORDER BY blocked_for DESC;
해결:
- 대형 DDL(컬럼 타입 변경, 테이블 리라이트 등)을 트래픽 낮은 시간으로 이동
lock_timeout을 설정해 “락을 무한정 기다리다 전체가 밀리는” 상황을 방지
ALTER SYSTEM SET lock_timeout = '5s';
SELECT pg_reload_conf();
6단계: 트리거 임계치가 너무 높아 vacuum 자체가 안 걸리는지 확인
autovacuum은 “변경량이 임계치를 넘으면” 실행됩니다. 기본값은 대형 테이블에 불리할 수 있습니다.
핵심 파라미터:
autovacuum_vacuum_thresholdautovacuum_vacuum_scale_factorautovacuum_analyze_thresholdautovacuum_analyze_scale_factor
대형 테이블에서 scale_factor가 크면, vacuum이 걸리기까지 필요한 변경량이 너무 커집니다.
현재 테이블의 상태를 요약:
SELECT
schemaname,
relname,
n_live_tup,
n_dead_tup,
last_autovacuum,
last_autoanalyze,
vacuum_count,
autovacuum_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 30;
해결(테이블별 튜닝 권장):
ALTER TABLE public.big_table SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_threshold = 5000,
autovacuum_analyze_scale_factor = 0.01,
autovacuum_analyze_threshold = 3000
);
- 업데이트/삭제가 잦은 테이블은 scale factor를 낮춰 “자주 조금씩” 청소하게 만드는 것이 효과적입니다.
7단계: freeze(트랜잭션 ID) 문제로 사실상 멈춘 것처럼 보이는지 확인
autovacuum에는 “bloat 정리” 말고도 중요한 역할이 하나 더 있습니다. 트랜잭션 ID wraparound 방지를 위한 freeze입니다.
다음으로 위험 테이블을 찾습니다.
SELECT
n.nspname AS schema,
c.relname AS table,
age(c.relfrozenxid) AS xid_age,
c.reltuples::bigint AS est_rows
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
ORDER BY age(c.relfrozenxid) DESC
LIMIT 30;
xid_age가 계속 증가하면 freeze vacuum이 충분히 돌지 않는 것입니다.
해결:
- 워커/비용/임계치 조정으로 vacuum이 따라잡게 만들고
- 필요 시 수동 vacuum으로 밀어줍니다.
VACUUM (VERBOSE, ANALYZE) public.big_table;
주의:
- wraparound가 임계치에 근접하면 PostgreSQL이 보호를 위해 쓰기 작업을 막을 수 있습니다. 이 단계까지 오면 “autovacuum이 안 돈다”가 아니라 “DB 생존 문제”입니다.
8단계: toast 테이블이 비대해졌는데 본 테이블만 보고 있는지 확인
큰 컬럼(text, jsonb, bytea)이 자주 업데이트되면 TOAST 쪽 bloat가 커질 수 있습니다. 본 테이블만 vacuum해도 체감이 없을 수 있습니다.
TOAST 포함 크기 확인:
SELECT
n.nspname,
c.relname,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
pg_size_pretty(pg_relation_size(c.oid)) AS heap_size,
pg_size_pretty(pg_total_relation_size(c.reltoastrelid)) AS toast_total
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
ORDER BY pg_total_relation_size(c.oid) DESC
LIMIT 20;
해결:
- 업데이트 패턴 개선(전체 JSON을 매번 덮어쓰지 않기)
- 큰 값은 별도 테이블로 분리
- 테이블별 autovacuum 튜닝으로 TOAST 변경이 많은 테이블을 더 자주 vacuum
9단계: 통계가 안 맞아 autovacuum이 “필요 없어 보이는” 상태인지 확인
autovacuum 트리거는 통계에 의존합니다. 통계가 부정확하면 실행 타이밍이 어긋납니다.
- 대량 적재 후
ANALYZE가 안 됨 - 파티션 교체/스왑 후 통계가 stale
해결:
ANALYZE VERBOSE;
-- 또는 특정 테이블만
ANALYZE VERBOSE public.big_table;
또한 아래처럼 autovacuum 로그를 켜두면 “돌았는지/얼마나 걸렸는지/몇 튜플을 처리했는지”가 명확해집니다.
ALTER SYSTEM SET log_autovacuum_min_duration = '1s';
SELECT pg_reload_conf();
로그 기반으로 병목을 쪼개는 접근은 분산 시스템 장애 분석에도 그대로 통합니다. 예를 들어 gRPC 마이크로서비스 503·데드라인 초과 디버깅처럼, “타임아웃이 났다”를 “어디에서 시간이 소비됐는가”로 분해하는 방식이 autovacuum에도 유효합니다.
10단계: 파티션/대량 삭제 워크로드에서 autovacuum 전략 재설계
다음 패턴에서는 autovacuum만으로는 비용이 너무 큽니다.
- 대량 삭제를 주기적으로 수행(예:
DELETE WHERE created_at) - 시계열 데이터가 계속 쌓이고 오래된 데이터를 지움
권장:
- 가능하면
DELETE대신 파티션 드롭(메타데이터 작업)으로 제거 - 파티션별 autovacuum 설정을 다르게 적용
예시(월 파티션에 더 공격적으로):
ALTER TABLE public.events_2026_01 SET (
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_analyze_scale_factor = 0.005
);
운영에서 바로 쓰는 점검 순서 요약
SHOW autovacuum, 테이블reloptions로 “꺼짐” 여부 확인pg_stat_activity에서 autovacuum 워커가 실제로 떠 있는지 확인- 비용 제한 파라미터로 “너무 느림” 여부 확인
pg_stat_activity에서 장기 트랜잭션(idle in transaction포함) 제거- 락 대기/블로킹 확인 후 DDL/배치 조정
- 문제 테이블에 한해 scale factor/threshold를 낮춰 자주 vacuum
age(relfrozenxid)로 wraparound 위험 점검- TOAST 크기/패턴 확인
log_autovacuum_min_duration로 로그 관측 체계화
부록: 문제 테이블을 빠르게 찾는 “상위 N” 쿼리
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 20;
용량 상위:
SELECT
n.nspname,
c.relname,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
ORDER BY pg_total_relation_size(c.oid) DESC
LIMIT 20;
결론
autovacuum이 “안 도는” 문제는 대개 다음 셋 중 하나로 귀결됩니다.
- 꺼져 있거나(전역/테이블 옵션)
- 돌긴 도는데 너무 느리거나(cost/워커/임계치)
- 돌고 싶어도 못 도는 상태(장기 트랜잭션, 락, wraparound 압박)
핵심은 한 번에 설정을 크게 바꾸기보다, 문제 테이블을 특정하고 관측(로그/통계)하면서 테이블별로 튜닝하는 것입니다. 그렇게 하면 autovacuum을 “믿고 맡기는” 수준이 아니라, 워크로드에 맞게 “관리 가능한 자동화”로 만들 수 있습니다.