- Published on
PostgreSQL VACUUM 안 도는 이유 7가지와 해법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 느려지고 디스크가 계속 불어나며, pg_stat_user_tables에는 dead tuple이 쌓이는데 VACUUM이 “안 도는 것 같다”는 상황은 운영에서 흔합니다. 하지만 대부분은 VACUUM이 실제로는 돌고 있는데 관측이 안 되거나, autovacuum이 트리거 조건을 못 맞추거나, 락/장기 트랜잭션 때문에 진도가 안 나가거나, I/O·리소스 제약으로 사실상 멈춘 것처럼 보이는 케이스입니다.
이 글은 “VACUUM이 안 돈다”를 7가지 원인으로 분해해 확인 방법과 해결책을 제시합니다. (참고로 원인 추적 방식은 다른 장애 디버깅 글—예: GitHub Actions 캐시가 안 먹을 때 - key·dir 충돌 디버깅—처럼 관측 지표 → 가설 → 재현/검증 → 조치 순서로 접근하는 게 가장 빠릅니다.)
먼저: VACUUM이 “안 돈다”의 의미 정리
VACUUM 관련 증상은 크게 3종류로 나뉩니다.
- autovacuum worker가 아예 안 뜸 (스케줄러가 일을 시작하지 않음)
- worker는 뜨지만 특정 테이블에서 진전이 없음 (락/트랜잭션/비용 제한)
- VACUUM은 실행되지만 기대 효과(디스크 반환/성능 개선)가 없음 (FREEZE 미흡, bloat, 인덱스/TOAST, FULL 필요 등)
우선 아래 쿼리로 “최근 vacuum이 있었는지/지금 돌고 있는지”부터 확인합니다.
-- 1) 테이블별 vacuum/autovacuum 실행 이력
SELECT
schemaname,
relname,
n_live_tup,
n_dead_tup,
last_vacuum,
last_autovacuum,
vacuum_count,
autovacuum_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 30;
-- 2) 현재 진행 중인 vacuum 확인(autovacuum 포함)
SELECT
pid,
usename,
state,
wait_event_type,
wait_event,
query_start,
now() - query_start AS running_for,
query
FROM pg_stat_activity
WHERE query ILIKE '%vacuum%'
ORDER BY query_start;
추가로 정말 중요한 단서는 로그입니다. 운영에서는 아래 두 설정을 켜두면 “왜 vacuum이 안 도는지”가 로그로 드러납니다.
# postgresql.conf
log_autovacuum_min_duration = 0 # 모든 autovacuum 로깅(부담되면 1s~5s로)
log_min_duration_statement = 1000 # 느린 쿼리 추적(선택)
1) autovacuum 자체가 꺼져 있거나, 테이블 단위로 비활성화됨
증상
pg_stat_activity에 autovacuum worker가 거의/전혀 보이지 않음last_autovacuum가 계속 NULL 또는 매우 오래됨
확인
SHOW autovacuum;
-- 테이블 옵션으로 autovacuum이 꺼진 경우
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
AND array_to_string(c.reloptions, ',') ILIKE '%autovacuum%'
ORDER BY 1,2;
해법
- 전역 설정 복구:
autovacuum = on
- 특정 테이블에서 꺼져 있었다면 다시 켭니다.
ALTER TABLE your_table SET (autovacuum_enabled = true);
2) autovacuum 트리거 조건이 너무 느슨해서 “아직 때가 아님”
autovacuum은 기본적으로 “dead tuple이 일정 비율/개수 이상”일 때 동작합니다.
증상
n_dead_tup는 늘지만 autovacuum이 잘 안 옴- 큰 테이블에서 특히 심함(비율 기준 때문에)
확인
테이블별 임계치를 실제 숫자로 계산해보면 빠릅니다.
-- autovacuum 트리거 임계치 추정
-- threshold ≈ autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * reltuples
SELECT
n.nspname AS schema,
c.relname AS table,
st.n_dead_tup,
c.reltuples::bigint AS reltuples_est,
current_setting('autovacuum_vacuum_threshold')::int AS base_threshold,
current_setting('autovacuum_vacuum_scale_factor')::float AS scale_factor,
(current_setting('autovacuum_vacuum_threshold')::int
+ current_setting('autovacuum_vacuum_scale_factor')::float * c.reltuples)::bigint AS vacuum_trigger_est
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_stat_user_tables st ON st.relid = c.oid
WHERE c.relkind='r'
ORDER BY (st.n_dead_tup - (current_setting('autovacuum_vacuum_threshold')::int
+ current_setting('autovacuum_vacuum_scale_factor')::float * c.reltuples)) DESC
LIMIT 30;
해법
핫 테이블은 테이블 단위로 scale factor를 낮추는 것이 정석입니다.
-- 예: 대형/고변경 테이블은 더 자주 vacuum
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_vacuum_threshold = 5000
);
-- 분석(ANALYZE)도 같이 자주
ALTER TABLE orders SET (
autovacuum_analyze_scale_factor = 0.02,
autovacuum_analyze_threshold = 3000
);
전역으로 무작정 낮추면 I/O가 증가하니, 상위 몇 개 테이블만 타겟팅하세요.
3) autovacuum worker가 부족하거나, 다른 테이블에 “밀려서” 못 옴
autovacuum은 동시 실행 worker 수 제한이 있고, backlog가 쌓이면 특정 테이블이 오래 기다립니다.
증상
- autovacuum은 보이지만 항상 다른 테이블만 처리
- dead tuple이 많은 테이블이 대기열에서 밀림
확인
SHOW autovacuum_max_workers;
SHOW autovacuum_naptime;
-- 데이터베이스별 autovacuum 작업자/런처 상태
SELECT
backend_type,
count(*)
FROM pg_stat_activity
WHERE backend_type IN ('autovacuum launcher', 'autovacuum worker')
GROUP BY 1;
해법
- worker 수를 늘리고, naptime을 줄여 반응성을 높입니다.
autovacuum_max_workers = 6 # CPU/IO 여유에 따라 3~10+ 조정
autovacuum_naptime = 10s # 기본 1min은 느릴 수 있음
- 단, worker를 늘리면 디스크 I/O 경쟁이 생깁니다. 이때는 아래 5번(비용 제한)과 함께 튜닝해야 합니다.
4) 장기 트랜잭션/idle in transaction이 VACUUM을 “막고” 있음
VACUUM은 dead tuple을 치우더라도, 아주 오래된 트랜잭션 스냅샷이 살아 있으면 실제로 공간 재사용이 제한되고, xmin이 고정되어 freeze도 지연됩니다.
증상
- dead tuple이 줄지 않거나, vacuum이 오래 걸림
pg_stat_activity에idle in transaction세션이 오래 유지age(datfrozenxid)가 증가(트랜잭션 ID wraparound 위험)
확인
-- 오래 열린 트랜잭션 찾기
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 20;
-- DB별 freeze 진행 상태(랩어라운드 위험 관측)
SELECT
datname,
age(datfrozenxid) AS xid_age
FROM pg_database
ORDER BY xid_age DESC;
해법
- 애플리케이션에서 트랜잭션을 짧게: 커넥션 풀에서 “요청 끝나면 즉시 commit/rollback”이 기본.
- 운영 차단 장치:
idle_in_transaction_session_timeout = 60s
statement_timeout = 30s # 워크로드에 맞게
- 이미 문제가 된 세션은 종료(신중히):
SELECT pg_terminate_backend(<pid>);
이 유형은 원인-결과가 명확해서, 쿠버네티스에서 네트워크 타임아웃을 로그/지표로 쪼개듯(Kubernetes apiserver i/o timeout 원인과 해결) “오래 열린 것”을 먼저 제거하는 게 핵심입니다.
5) autovacuum cost limit/throttle 때문에 너무 느려 “안 도는 것처럼” 보임
autovacuum은 기본적으로 I/O를 세게 쓰지 않도록 스로틀이 걸려 있습니다. 디스크가 느리거나 테이블이 크면, vacuum이 며칠씩 돌며 효과가 없는 것처럼 보일 수 있습니다.
증상
- vacuum 쿼리가 장시간 실행
log_autovacuum_min_duration로그에 “cost-based delay”가 자주 보임
확인
SHOW autovacuum_vacuum_cost_limit;
SHOW autovacuum_vacuum_cost_delay;
SHOW vacuum_cost_limit;
SHOW vacuum_cost_delay;
해법
- 전역 또는 특정 테이블에 대해 autovacuum 비용 제한을 완화합니다.
# 예시(서버 여유에 맞게)
autovacuum_vacuum_cost_limit = 4000
autovacuum_vacuum_cost_delay = 2ms
테이블 단위도 가능합니다.
ALTER TABLE events SET (
autovacuum_vacuum_cost_limit = 8000,
autovacuum_vacuum_cost_delay = 0
);
주의: 비용 제한을 풀면 **쿼리 지연(읽기/쓰기 latency)**이 튈 수 있습니다. 피크 시간대에는 보수적으로, 야간에는 공격적으로(스케줄링) 운용하세요.
6) VACUUM이 락/대기 이벤트에 걸려 진행이 멈춤
일반 VACUUM은 강한 락을 오래 잡지 않지만, 특정 상황에서는 락 대기/IO 대기처럼 보이며 진행이 멈출 수 있습니다. 특히 DDL(ALTER TABLE), 장시간 유지되는 관계 확장/수정, 또는 다른 작업과 충돌할 때 문제가 됩니다.
증상
pg_stat_activity.wait_event_type가Lock또는IO로 오래 유지- vacuum 프로세스가 실행 중인데 dead tuple이 안 줄고 시간만 감
확인
-- vacuum이 무엇을 기다리는지
SELECT
pid,
wait_event_type,
wait_event,
now() - query_start AS running_for,
query
FROM pg_stat_activity
WHERE query ILIKE '%vacuum%';
-- 락 홀더/대기자 확인
SELECT
a.pid,
a.usename,
a.state,
now() - a.query_start AS age,
l.locktype,
l.mode,
l.granted,
left(a.query, 120) AS query
FROM pg_locks l
JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.relation IS NOT NULL
ORDER BY l.granted, age DESC;
해법
- 충돌하는 DDL/배치 작업 시간을 조정(maintenance window).
- vacuum 대상 테이블을 분리: 파티셔닝/작업 단위 축소.
- 필요 시 수동 vacuum을 “짧게”:
VACUUM (VERBOSE, ANALYZE) your_table;
7) “VACUUM 했는데 디스크가 안 줄어요”: 기대 효과 착각(또는 VACUUM FULL 필요)
가장 흔한 오해: 일반 VACUUM은 OS에 파일 공간을 반환하지 않습니다.
- 일반 VACUUM: 테이블 파일 내부에서 재사용 가능 공간을 표시(프리 스페이스 맵)
VACUUM FULL: 테이블을 재작성하여 파일 크기 자체를 줄임(강한 락 필요)
또한 bloat는 테이블뿐 아니라 인덱스 bloat, TOAST 테이블에서도 발생합니다.
증상
n_dead_tup는 줄어도 디스크 사용량은 그대로- 인덱스가 비대해져 조회 성능이 나쁨
확인
테이블/인덱스 크기부터 봅니다.
SELECT
n.nspname AS schema,
c.relname AS table,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
pg_size_pretty(pg_relation_size(c.oid)) AS table_size,
pg_size_pretty(pg_indexes_size(c.oid)) AS index_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;
해법
- 디스크를 실제로 줄여야 한다면(유지보수 창 확보 가능):
VACUUM (FULL, ANALYZE) your_table;
- 락이 부담이면 대안:
- 인덱스만 비대한 경우:
REINDEX (CONCURRENTLY)(버전에 따라 제약 있음) - 테이블이 계속 커지는 구조라면: 파티셔닝 + 오래된 파티션 DROP(가장 확실한 공간 회수)
- 인덱스만 비대한 경우:
운영에서 바로 쓰는 체크리스트(요약)
pg_stat_user_tables에서last_autovacuum,n_dead_tup확인pg_stat_activity에서 autovacuum worker 존재/대기 이벤트 확인autovacuum=on및 테이블 reloptions로 비활성화 여부 점검- 트리거 임계치(scale factor/threshold) 계산 후 핫 테이블만 낮추기
- worker 수/naptime 조정으로 backlog 해소
- 장기 트랜잭션(
idle in transaction) 제거 + timeout 설정 - “디스크 반환”이 목표면
VACUUM FULL/파티셔닝/REINDEX 등 올바른 수단 선택
부록: 문제 재현 없이도 원인 좁히는 최소 SQL 세트
아래 쿼리 3개만 정기적으로 뽑아도 VACUUM 이슈의 80%는 방향이 잡힙니다.
-- A) dead tuple 상위 테이블
SELECT schemaname, relname, n_live_tup, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
-- B) 오래 열린 트랜잭션
SELECT pid, usename, state, now() - xact_start AS xact_age, left(query,120)
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_age DESC
LIMIT 20;
-- C) vacuum 관련 세션의 대기 원인
SELECT pid, wait_event_type, wait_event, now() - query_start AS running_for, left(query,120)
FROM pg_stat_activity
WHERE query ILIKE '%vacuum%'
ORDER BY query_start;
VACUUM이 “안 돈다”는 말은 대개 스케줄링(트리거/worker) 문제이거나 막는 주체(장기 트랜잭션/락) 문제입니다. 위 7가지를 순서대로 체크하면, 설정을 무작정 바꾸기 전에 원인을 정확히 특정하고 안전하게 해결할 수 있습니다.