Published on

PostgreSQL VACUUM 안 도는 이유 7가지와 해법

Authors

서버가 느려지고 디스크가 계속 불어나며, pg_stat_user_tables에는 dead tuple이 쌓이는데 VACUUM이 “안 도는 것 같다”는 상황은 운영에서 흔합니다. 하지만 대부분은 VACUUM이 실제로는 돌고 있는데 관측이 안 되거나, autovacuum이 트리거 조건을 못 맞추거나, 락/장기 트랜잭션 때문에 진도가 안 나가거나, I/O·리소스 제약으로 사실상 멈춘 것처럼 보이는 케이스입니다.

이 글은 “VACUUM이 안 돈다”를 7가지 원인으로 분해해 확인 방법과 해결책을 제시합니다. (참고로 원인 추적 방식은 다른 장애 디버깅 글—예: GitHub Actions 캐시가 안 먹을 때 - key·dir 충돌 디버깅—처럼 관측 지표 → 가설 → 재현/검증 → 조치 순서로 접근하는 게 가장 빠릅니다.)

먼저: VACUUM이 “안 돈다”의 의미 정리

VACUUM 관련 증상은 크게 3종류로 나뉩니다.

  1. autovacuum worker가 아예 안 뜸 (스케줄러가 일을 시작하지 않음)
  2. worker는 뜨지만 특정 테이블에서 진전이 없음 (락/트랜잭션/비용 제한)
  3. 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_activityidle 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_typeLock 또는 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(가장 확실한 공간 회수)

운영에서 바로 쓰는 체크리스트(요약)

  1. pg_stat_user_tables에서 last_autovacuum, n_dead_tup 확인
  2. pg_stat_activity에서 autovacuum worker 존재/대기 이벤트 확인
  3. autovacuum=on 및 테이블 reloptions로 비활성화 여부 점검
  4. 트리거 임계치(scale factor/threshold) 계산 후 핫 테이블만 낮추기
  5. worker 수/naptime 조정으로 backlog 해소
  6. 장기 트랜잭션(idle in transaction) 제거 + timeout 설정
  7. “디스크 반환”이 목표면 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가지를 순서대로 체크하면, 설정을 무작정 바꾸기 전에 원인을 정확히 특정하고 안전하게 해결할 수 있습니다.