- Published on
PostgreSQL VACUUM 미작동? bloat 진단·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 중인 PostgreSQL에서 VACUUM이 “분명히 실행되고 있는데” 디스크 사용량이 줄지 않거나, 쿼리가 점점 느려지고, pg_stat_user_tables의 dead tuple이 줄지 않는 경험은 흔합니다. 이 현상은 대개 VACUUM 자체가 고장 난 것이 아니라, VACUUM이 회수할 수 없는 상태(정체된 트랜잭션, replica slot, 오래 열린 세션) 이거나 회수는 했지만 파일이 줄지 않는 구조적 특성(공간 재사용 vs 파일 축소), 혹은 인덱스 bloat 때문입니다.
이 글에서는 “VACUUM 미작동처럼 보이는” 상황을 bloat 관점에서 진단하고, 원인별로 가장 안전한 해결책부터 강한 처방까지 단계적으로 정리합니다.
참고로, 장애가 애플리케이션 레벨로 번져서 Pod 재시작이나 OOM으로 이어질 때는 인프라 관점 점검도 병행하세요. 예: K8s CrashLoopBackOff 진단 - OOMKilled·Probe
1) 먼저 오해부터: VACUUM은 파일을 줄이지 않는다
PostgreSQL의 일반 VACUUM은 dead tuple을 “삭제”하는 게 아니라 재사용 가능 표시를 합니다. 즉, 테이블 파일 크기(pg_relation_size)는 그대로일 수 있습니다.
- 파일 크기를 줄이려면
VACUUM FULL또는CLUSTER또는pg_repack같은 재작성(rewrite)이 필요합니다. - 하지만 대부분의 운영 환경에서 목표는 “파일 축소”가 아니라 “성능 회복과 공간 재사용”입니다.
따라서 “용량이 줄지 않는다”만으로 VACUUM 미작동을 단정하지 말고, 아래 지표로 실제 bloat와 vacuum 진행 상태를 확인해야 합니다.
2) bloat 진단 1단계: dead tuple과 vacuum 상태 보기
가장 먼저 테이블별 dead tuple과 vacuum 시점을 확인합니다.
SELECT
schemaname,
relname,
n_live_tup,
n_dead_tup,
last_vacuum,
last_autovacuum,
last_analyze,
last_autoanalyze,
vacuum_count,
autovacuum_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 30;
여기서 체크 포인트:
n_dead_tup가 계속 증가하는데last_autovacuum이 갱신되지 않는다- autovacuum이 아예 못 돌거나(설정/리소스) 대상 선정이 안 되는 상황
last_autovacuum은 갱신되는데n_dead_tup가 잘 안 줄어든다- vacuum이 “끝까지 못 가는” 방해 요인이 있거나, 너무 느리거나, dead tuple이 생성 속도를 못 따라감
추가로, 테이블이 autovacuum 대상이 되는 임계치를 이해해야 합니다.
- 기본 임계치(개념):
autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * reltuples - 큰 테이블일수록 scale factor 때문에 vacuum이 늦게 시작될 수 있습니다.
3) bloat 진단 2단계: 실제 디스크 점유와 인덱스 포함 크기
테이블이 얼마나 비대해졌는지 “크기”로 확인합니다.
SELECT
n.nspname AS schema,
c.relname AS table,
pg_size_pretty(pg_relation_size(c.oid)) AS table_size,
pg_size_pretty(pg_indexes_size(c.oid)) AS index_size,
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;
관찰 포인트:
index_size가 비정상적으로 큰 경우: 인덱스 bloat 가능성이 큼table_size자체가 큰데 업데이트/삭제가 많았다면: heap bloat 가능성
4) “VACUUM이 못 치우는” 대표 원인: 오래 열린 트랜잭션
VACUUM이 dead tuple을 치우려면, 해당 튜플을 볼 가능성이 있는 가장 오래된 트랜잭션(OldestXmin) 이 앞으로 전진해야 합니다. 즉, 오래 열린 트랜잭션이 있으면 vacuum이 정리할 수 있는 범위가 제한됩니다.
4-1) 오래 열린 세션 찾기
SELECT
pid,
usename,
datname,
state,
xact_start,
now() - xact_start AS xact_age,
query_start,
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;
해결 방향:
- 애플리케이션에서 불필요한
BEGIN후 방치 제거 - 커넥션 풀에서 idle in transaction 방지
- 배치 작업이 긴 트랜잭션으로 뭉치지 않게 chunk 처리
운영 중 긴 트랜잭션을 강제 종료해야 한다면(주의 필요):
SELECT pg_terminate_backend(`pid`);
위 쿼리의 pid는 실제 숫자로 치환하세요. (MDX 렌더링을 고려해 pid는 인라인 코드로 표기)
4-2) replica slot이 xmin을 붙잡는 경우
논리 복제나 CDC 도구가 replica slot을 만들면, 소비가 느릴 때 vacuum이 정리하지 못해 bloat가 커질 수 있습니다.
SELECT
slot_name,
plugin,
slot_type,
active,
xmin,
catalog_xmin,
restart_lsn
FROM pg_replication_slots
ORDER BY active DESC, slot_name;
active = false인데 slot이 남아 있고xmin이 오래되면 위험 신호- 원인 도구(예: Debezium, 자체 CDC)가 멈췄는데 slot만 남아있는 상황을 자주 봅니다.
불필요한 slot 제거(매우 신중):
SELECT pg_drop_replication_slot('slot_name');
5) autovacuum이 “안 도는 것처럼” 보이는 설정/리소스 문제
5-1) autovacuum이 켜져 있는지 확인
SHOW autovacuum;
SHOW autovacuum_max_workers;
SHOW autovacuum_naptime;
SHOW autovacuum_vacuum_cost_limit;
SHOW autovacuum_vacuum_cost_delay;
자주 발생하는 문제 패턴:
autovacuum_max_workers가 너무 작아 큰 테이블을 못 따라감- cost 기반 제한이 너무 보수적이라 vacuum이 지나치게 느림
- 디스크 I/O가 바쁜 시간대에 vacuum이 사실상 진척이 없음
5-2) 테이블별로 autovacuum 튜닝하기
특정 대형 테이블(이벤트 로그, 세션 테이블 등)은 기본 scale factor로는 vacuum이 너무 늦습니다. 테이블 단위로 더 공격적으로 설정합니다.
ALTER TABLE public.events SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_threshold = 10000,
autovacuum_analyze_scale_factor = 0.01,
autovacuum_analyze_threshold = 5000
);
- scale factor를 낮추면 더 자주 vacuum 대상이 됩니다.
- threshold는 너무 낮추면 자주 깨워 오버헤드가 커질 수 있으니, 쓰기량에 맞춰 조정합니다.
6) 인덱스 bloat: VACUUM만으로는 해결이 안 된다
일반 VACUUM은 인덱스 페이지를 “정리”하긴 하지만, 커진 인덱스 파일을 의미 있게 줄이진 못합니다. 특히 업데이트가 잦고 키 분포가 나쁜 인덱스는 bloat가 심해집니다.
6-1) 인덱스 크기 상위 확인
SELECT
schemaname,
tablename,
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 30;
6-2) 안전한 처방: REINDEX CONCURRENTLY
운영 중 락 영향을 줄이려면 동시 재인덱싱을 고려합니다.
REINDEX INDEX CONCURRENTLY public.idx_events_created_at;
주의:
- 동시 옵션은 시간이 더 걸리고, 추가 디스크가 필요할 수 있습니다.
- 트랜잭션 블록 안에서 실행할 수 없습니다.
7) heap bloat를 실제로 줄여야 할 때: 선택지 비교
7-1) VACUUM FULL (강력하지만 락 큼)
- 테이블을 재작성하여 파일을 줄입니다.
- 긴 ACCESS EXCLUSIVE 락이 걸릴 수 있어 운영 트래픽에 치명적일 수 있습니다.
VACUUM (FULL, VERBOSE, ANALYZE) public.events;
7-2) CLUSTER (인덱스 기준 재정렬)
- 테이블을 특정 인덱스 순서로 재작성합니다.
- 쿼리 locality 개선도 가능하지만, 역시 큰 락과 시간이 필요합니다.
CLUSTER VERBOSE public.events USING idx_events_created_at;
ANALYZE public.events;
7-3) pg_repack (운영 친화적 대안)
- 확장 도구로 온라인에 가깝게 재작성하여 bloat를 줄입니다.
- 설치/권한/버전 호환을 검토해야 합니다.
운영 환경에서는 보통 우선순위가 다음과 같습니다.
- 오래 열린 트랜잭션/slot 등 “vacuum 방해물” 제거
- autovacuum 설정 조정으로 재발 방지
- 인덱스는
REINDEX CONCURRENTLY - heap은
pg_repack또는 점검 윈도우에VACUUM FULL
8) freeze 지연과 VACUUM (FREEZE) 체크
bloat와 별개로, vacuum이 제때 freeze를 못 하면 트랜잭션 ID wraparound 위험이 커집니다. 이 경우 PostgreSQL이 강제 vacuum을 유도하거나, 최악에는 쓰기 중단에 가까운 상황으로 번질 수 있습니다.
테이블별 freeze 관련 힌트:
SELECT
n.nspname,
c.relname,
age(c.relfrozenxid) AS 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'
ORDER BY age(c.relfrozenxid) DESC
LIMIT 30;
xid_age가 큰 테이블은 autovacuum이 freeze를 못 따라가고 있을 수 있습니다.
필요 시(부하 고려):
VACUUM (FREEZE, VERBOSE, ANALYZE) public.events;
9) “VACUUM이 도는데 느리다”를 체감 성능으로 연결해 보기
VACUUM이 느린 이유는 대개 I/O 병목, cost delay, 동시성 경쟁입니다. 이때 DB 튜닝만 보지 말고, 서비스 전체 지연으로 번지는지도 함께 봐야 합니다. 예를 들어 DB 지연이 웹 TTFB에 영향을 준다면, 서버 렌더링 경로도 같이 점검해야 합니다. 관련 글: Next.js 14 App Router TTFB 폭증 잡는 RSC 튜닝
10) 운영 체크리스트: 재발 방지까지 포함
10-1) 즉시 점검(오늘 해야 할 것)
pg_stat_activity에서 오래 열린 트랜잭션 제거pg_replication_slots에서 불필요/정체 slot 정리- dead tuple 상위 테이블 확인 후 테이블별 autovacuum 튜닝
- 인덱스 크기 상위에
REINDEX CONCURRENTLY계획
10-2) 구조 개선(이번 스프린트에 해야 할 것)
- 업데이트 폭탄 테이블의 스키마/인덱스 재검토
- 불필요한 인덱스 제거(쓰기 비용과 bloat 감소)
- 핫 테이블은 파티셔닝 고려
- 배치 작업을 짧은 트랜잭션으로 쪼개기
- 커넥션 풀 설정에서 idle in transaction 방지
10-3) 모니터링(상시)
n_dead_tup,last_autovacuum, 인덱스 크기 상위- replica slot lag 및
xmin고착 age(relfrozenxid)상위 테이블
마무리
“VACUUM이 미작동한다”는 느낌은 대부분 (1) vacuum이 치울 수 없게 만드는 정체 요인 또는 (2) 치워도 파일이 줄지 않는 특성 또는 (3) 인덱스 bloat에서 시작합니다.
먼저 dead tuple과 트랜잭션 정체를 확인하고, 테이블별 autovacuum 임계치를 현실화한 뒤, 인덱스는 REINDEX CONCURRENTLY, heap은 필요할 때만 재작성 도구(pg_repack 등)를 선택하는 흐름이 운영에서 가장 안전합니다.