Published on

PostgreSQL VACUUM 안됨? bloat·wraparound 10분 진단

Authors

서버가 느려지고 디스크가 계속 차는데 VACUUM을 돌려도 효과가 없을 때가 있습니다. 이때 많은 팀이 VACUUM FULL부터 시도하거나, 재시작으로 "운 좋게" 해결되길 기대합니다. 하지만 PostgreSQL의 VACUUM역할이 명확히 분리되어 있어요.

  • 일반 VACUUMdead tuple(죽은 튜플) 을 표시/정리해 재사용 가능하게 만들고, 가시성 맵을 갱신하며, 트랜잭션 ID 관련 위험(특히 wraparound)을 낮춥니다.
  • 반면 파일 크기(디스크 사용량)를 즉시 줄이는 것은 보통 VACUUM FULL(테이블 재작성) 또는 pg_repack 같은 재작성 도구의 영역입니다.

이 글은 "VACUUM이 안 되는 것 같다"는 상황을 bloat, autovacuum 정체, wraparound 위험으로 나눠서 10분 내에 진단하는 체크리스트를 제공합니다.

관련해서 인덱스/테이블 bloat 진단을 더 깊게 보고 싶다면 PostgreSQL 인덱스가 느릴 때 - Bloat·VACUUM·REINDEX도 함께 보세요.

0. 먼저 결론부터: "안됨"의 4가지 패턴

현장에서 자주 보는 패턴은 보통 아래 중 하나입니다.

  1. VACUUM은 성공하지만 디스크가 줄지 않음
    • 정상일 수 있습니다. 일반 VACUUM은 OS에 공간을 반환하지 않는 경우가 흔합니다.
  2. autovacuum이 계속 밀리거나, 특정 테이블만 계속 bloat
    • 장기 트랜잭션, 너무 낮은 autovacuum 자원, 테이블별 설정 불균형이 원인인 경우가 많습니다.
  3. "database is not accepting commands to avoid wraparound" 경고/장애
    • wraparound 임계치 근접. 즉시 대응이 필요합니다.
  4. VACUUM이 너무 오래 걸리거나 I/O로 서비스가 흔들림
    • vacuum cost, index cleanup, freeze, I/O 병목 등 자원/설정 이슈.

이제부터는 위 패턴을 빠르게 분류하기 위한 SQL만 추려서 진행합니다.

1) 2분: 지금 VACUUM/autovacuum이 실제로 돌고 있나

실행 중인 vacuum 확인

SELECT
  pid,
  usename,
  state,
  wait_event_type,
  wait_event,
  now() - xact_start AS xact_age,
  now() - query_start AS query_age,
  query
FROM pg_stat_activity
WHERE query ILIKE '%vacuum%'
ORDER BY query_start;
  • wait_event_typeLock이면 다른 세션이 락을 잡고 있거나 vacuum이 락을 기다리는 중일 수 있습니다.
  • xact_age가 비정상적으로 길면(예: 수십 분~수시간) vacuum이 아니라 장기 트랜잭션 때문에 정리가 막히는 상황일 수 있습니다.

autovacuum 작업 내역/상태

SELECT
  relname,
  phase,
  heap_blks_total,
  heap_blks_scanned,
  heap_blks_vacuumed,
  index_vacuum_count,
  max_dead_tuples,
  num_dead_tuples
FROM pg_stat_progress_vacuum
JOIN pg_class ON pg_class.oid = pg_stat_progress_vacuum.relid
ORDER BY num_dead_tuples DESC;
  • phasevacuuming indexes에서 오래 멈춰 있으면 인덱스 정리 비용이 크거나 I/O가 병목일 수 있습니다.

2) 3분: "디스크가 안 줄어"가 정상인지, bloat인지 구분

일반 VACUUM이 디스크를 줄이지 않는 이유

PostgreSQL은 일반 VACUUM으로 dead tuple을 치워도 파일 내부에 빈 공간만 생기고, OS로 공간을 돌려주지 않는 경우가 많습니다. 즉, du로 보는 디스크 사용량이 그대로여도 내부적으로는 재사용 가능한 빈 공간이 늘었을 수 있습니다.

이때 중요한 건 "파일 크기"가 아니라 "테이블/인덱스의 bloat"와 "dead tuple이 계속 쌓이는지"입니다.

테이블별 dead tuple과 autovacuum 상태

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_vacuum,
  last_autovacuum,
  vacuum_count,
  autovacuum_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 30;
  • dead_pct가 높고 last_autovacuum이 오래됐으면 autovacuum이 밀렸을 확률이 큽니다.
  • 반대로 last_autovacuum이 최근인데도 n_dead_tup가 계속 크면, 장기 트랜잭션 또는 업데이트 폭주(UPDATE/DELETE 패턴) 를 의심합니다.

테이블 크기 vs 인덱스 크기 빠른 확인

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 heap_size,
  pg_size_pretty(pg_total_relation_size(c.oid) - pg_relation_size(c.oid)) AS index_toast_size
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY pg_total_relation_size(c.oid) DESC
LIMIT 20;
  • index_toast_size 비중이 과도하면 인덱스 bloat 또는 TOAST 팽창 가능성이 있습니다.
  • 이 경우 단순 VACUUM만으로는 체감 개선이 작고, REINDEX나 재작성(VACUUM FULL/pg_repack)이 필요할 수 있습니다.

3) 3분: VACUUM을 "막는" 1순위, 장기 트랜잭션 찾기

VACUUM은 dead tuple을 제거할 때 아직 어떤 트랜잭션에서 보일 수 있는 행은 지울 수 없습니다. 그래서 장기 트랜잭션이 하나만 있어도 정리가 정체됩니다.

오래 열린 트랜잭션/세션 찾기

SELECT
  pid,
  usename,
  application_name,
  client_addr,
  state,
  now() - xact_start AS xact_age,
  now() - query_start AS query_age,
  query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start
LIMIT 20;
  • stateidle in transaction이면 특히 위험합니다. 애플리케이션이 트랜잭션을 열어둔 채 커넥션을 반환하지 않는 패턴이 흔합니다.

현재 DB에서 가장 오래된 xmin(정리 한계) 확인

SELECT
  datname,
  age(datfrozenxid) AS frozen_xid_age
FROM pg_database
ORDER BY frozen_xid_age DESC;
  • age(datfrozenxid)가 계속 증가하는데 vacuum이 따라가지 못하면 wraparound 위험으로 이어질 수 있습니다.

장기 트랜잭션이 원인이라면, 우선순위는 아래입니다.

  1. 문제 세션을 만든 애플리케이션/배치 식별
  2. 트랜잭션 범위를 줄이거나, 커밋 주기를 촘촘히
  3. 정말 필요하면 pg_terminate_backend(pid)로 종료(서비스 영향 평가 필수)

커넥션이 과도해져서 autovacuum이나 관리 작업이 밀리는 경우도 많습니다. 연결 폭증 이슈가 의심되면 RDS PostgreSQL too many connections 원인·해결도 같이 점검하세요.

4) 2분: wraparound 위험(진짜로 급한 케이스) 판별

wraparound는 "성능" 문제가 아니라 "가용성" 문제입니다. 임계치에 도달하면 PostgreSQL이 쓰기를 막거나, 최악의 경우 장애로 이어집니다.

DB 단위 wraparound 위험도

SELECT
  datname,
  age(datfrozenxid) AS xid_age,
  current_setting('autovacuum_freeze_max_age') AS freeze_max_age
FROM pg_database
ORDER BY xid_age DESC;
  • xid_ageautovacuum_freeze_max_age에 근접하면 위험합니다.

테이블 단위로 freeze가 밀린 곳 찾기

SELECT
  n.nspname AS schema,
  c.relname AS table,
  age(c.relfrozenxid) AS table_xid_age,
  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'
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY table_xid_age DESC
LIMIT 30;

여기서 상위에 뜨는 큰 테이블이 wraparound의 "주범"인 경우가 많습니다.

5) "VACUUM이 안됨" 상황별 처방전

5.1 디스크가 안 줄어드는 게 문제라면

  • 일반 VACUUM으로는 OS에 공간이 반환되지 않는 경우가 흔합니다.
  • 정말로 디스크를 회수해야 한다면 선택지는 다음입니다.

선택지 A: VACUUM FULL (락 큼, 재작성)

VACUUM (FULL, VERBOSE, ANALYZE) public.big_table;
  • 강한 락이 걸려 동시 쓰기가 어려울 수 있습니다.
  • 운영에서는 윈도우를 잡고 수행하거나, 대체 수단을 고려합니다.

선택지 B: pg_repack (대체로 온라인에 가깝게 재작성)

  • 확장 설치/권한/추가 디스크 등 제약이 있지만 운영 친화적입니다.
  • 글 범위를 넘지만, "서비스를 멈추지 않고 bloat를 줄이기"에 자주 쓰입니다.

5.2 autovacuum이 밀리는 경우(설정/자원)

우선 현재 설정을 확인합니다.

SHOW autovacuum;
SHOW autovacuum_max_workers;
SHOW autovacuum_naptime;
SHOW autovacuum_vacuum_cost_limit;
SHOW autovacuum_vacuum_cost_delay;
SHOW maintenance_work_mem;

자주 쓰는 접근은 아래입니다.

  • autovacuum_max_workers를 늘려 병렬 처리 여지를 확보
  • autovacuum_vacuum_cost_limit 상향 또는 cost_delay 하향으로 vacuum이 더 적극적으로 일하도록 조정
  • 큰 테이블은 테이블 단위로 autovacuum 파라미터를 별도 튜닝

테이블 단위 autovacuum 튜닝 예시

ALTER TABLE public.events SET (
  autovacuum_vacuum_scale_factor = 0.02,
  autovacuum_analyze_scale_factor = 0.01,
  autovacuum_vacuum_threshold = 10000,
  autovacuum_analyze_threshold = 5000
);
  • 업데이트/삭제가 많은 테이블은 scale_factor를 낮춰 더 자주 청소하게 만듭니다.
  • 단, 너무 공격적으로 하면 I/O가 늘어 서비스에 영향이 갈 수 있어 모니터링이 필요합니다.

5.3 장기 트랜잭션이 원인인 경우(가장 흔함)

  • idle in transaction을 줄이는 것이 1순위입니다.
  • 애플리케이션에서 트랜잭션 범위를 축소하고, 커넥션 풀 설정/타임아웃을 강화합니다.

예: 세션 타임아웃을 통해 "열린 채 방치"를 줄입니다.

ALTER ROLE app_user SET idle_in_transaction_session_timeout = '60s';
ALTER ROLE app_user SET statement_timeout = '30s';
  • 너무 짧게 잡으면 정상 쿼리도 끊길 수 있으니 서비스 특성에 맞춰 조정하세요.

5.4 wraparound가 임박한 경우(즉시 대응)

이 경우 목표는 "공간 회수"가 아니라 freeze를 끝내는 것입니다.

응급: DB 전체 vacuum freeze

VACUUM (FREEZE, VERBOSE) ;
  • 실제로는 테이블별로 나눠 실행하거나, 가장 위험한 테이블부터 처리하는 것이 안전합니다.

위험 테이블부터 우선 처리

VACUUM (FREEZE, VERBOSE, ANALYZE) public.big_table;

그리고 반드시 병행해야 합니다.

  • 장기 트랜잭션 제거(위 3번 섹션)
  • autovacuum이 일할 수 있는 자원 확보(작업 시간대 조정, I/O 여유 확보)

6) 10분 체크리스트(복붙용)

아래 순서대로 실행하면 원인 범주를 빠르게 좁힐 수 있습니다.

  1. vacuum 실행 여부
SELECT pid, state, wait_event_type, wait_event, now()-query_start AS age, query
FROM pg_stat_activity
WHERE query ILIKE '%vacuum%'
ORDER BY query_start;
  1. vacuum 진행 상태
SELECT relid::regclass AS rel, phase, heap_blks_total, heap_blks_scanned, heap_blks_vacuumed,
       num_dead_tuples, index_vacuum_count
FROM pg_stat_progress_vacuum
ORDER BY num_dead_tuples DESC;
  1. 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
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
  1. 장기 트랜잭션
SELECT pid, usename, state, now()-xact_start AS xact_age, query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start
LIMIT 20;
  1. wraparound 위험
SELECT datname, age(datfrozenxid) AS xid_age,
       current_setting('autovacuum_freeze_max_age') AS freeze_max_age
FROM pg_database
ORDER BY xid_age DESC;

7) 운영 팁: "VACUUM을 돌렸는데 더 느려졌어요"를 줄이려면

  • vacuum은 I/O를 사용합니다. 특히 인덱스가 많은 테이블은 비용이 큽니다.
  • ANALYZE가 적절히 수행되지 않으면 플래너가 잘못된 계획을 선택해 "vacuum 후 느려짐"처럼 보이기도 합니다.

필요 시 수동으로 통계를 갱신하세요.

ANALYZE VERBOSE public.big_table;

또한 인덱스 자체가 bloat라면 vacuum만으로는 한계가 있습니다. 인덱스 재구성이 필요한지 판단하는 흐름은 위 내부 링크 글(PostgreSQL 인덱스가 느릴 때 - Bloat·VACUUM·REINDEX)에서 더 자세히 다뤘습니다.

마무리

VACUUM이 "안 된다"는 말은 대부분 다음 중 하나로 번역됩니다.

  • 일반 VACUUM으로는 디스크가 안 줄어드는 게 정상(공간 회수는 재작성 필요)
  • 장기 트랜잭션 때문에 dead tuple 정리가 막힘
  • autovacuum 자원/설정이 워크로드를 못 따라감
  • wraparound 임계치 접근으로 freeze가 급함

위 SQL 체크리스트로 현재 상태를 수치로 확인한 뒤, 목적(공간 회수 vs 성능 안정화 vs wraparound 회피)에 맞는 처방을 선택하면 불필요한 VACUUM FULL 시도와 운영 리스크를 크게 줄일 수 있습니다.