- Published on
PostgreSQL VACUUM 안됨? bloat·wraparound 10분 진단
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 느려지고 디스크가 계속 차는데 VACUUM을 돌려도 효과가 없을 때가 있습니다. 이때 많은 팀이 VACUUM FULL부터 시도하거나, 재시작으로 "운 좋게" 해결되길 기대합니다. 하지만 PostgreSQL의 VACUUM은 역할이 명확히 분리되어 있어요.
- 일반
VACUUM은 dead tuple(죽은 튜플) 을 표시/정리해 재사용 가능하게 만들고, 가시성 맵을 갱신하며, 트랜잭션 ID 관련 위험(특히 wraparound)을 낮춥니다. - 반면 파일 크기(디스크 사용량)를 즉시 줄이는 것은 보통
VACUUM FULL(테이블 재작성) 또는pg_repack같은 재작성 도구의 영역입니다.
이 글은 "VACUUM이 안 되는 것 같다"는 상황을 bloat, autovacuum 정체, wraparound 위험으로 나눠서 10분 내에 진단하는 체크리스트를 제공합니다.
관련해서 인덱스/테이블 bloat 진단을 더 깊게 보고 싶다면 PostgreSQL 인덱스가 느릴 때 - Bloat·VACUUM·REINDEX도 함께 보세요.
0. 먼저 결론부터: "안됨"의 4가지 패턴
현장에서 자주 보는 패턴은 보통 아래 중 하나입니다.
- VACUUM은 성공하지만 디스크가 줄지 않음
- 정상일 수 있습니다. 일반
VACUUM은 OS에 공간을 반환하지 않는 경우가 흔합니다.
- 정상일 수 있습니다. 일반
- autovacuum이 계속 밀리거나, 특정 테이블만 계속 bloat
- 장기 트랜잭션, 너무 낮은 autovacuum 자원, 테이블별 설정 불균형이 원인인 경우가 많습니다.
- "database is not accepting commands to avoid wraparound" 경고/장애
- wraparound 임계치 근접. 즉시 대응이 필요합니다.
- 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_type이Lock이면 다른 세션이 락을 잡고 있거나 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;
phase가vacuuming 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;
state가idle 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 위험으로 이어질 수 있습니다.
장기 트랜잭션이 원인이라면, 우선순위는 아래입니다.
- 문제 세션을 만든 애플리케이션/배치 식별
- 트랜잭션 범위를 줄이거나, 커밋 주기를 촘촘히
- 정말 필요하면
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_age가autovacuum_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분 체크리스트(복붙용)
아래 순서대로 실행하면 원인 범주를 빠르게 좁힐 수 있습니다.
- 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;
- 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;
- 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;
- 장기 트랜잭션
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;
- 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 시도와 운영 리스크를 크게 줄일 수 있습니다.