- Published on
PostgreSQL VACUUM 안먹을 때 - bloat 원인·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 VACUUM을 돌렸는데도 디스크가 안 줄고, 쿼리는 점점 느려지고, pg_stat_user_tables를 보면 dead tuple이 쌓여 있는 상황을 흔히 “VACUUM이 안 먹는다”라고 표현합니다. 하지만 대부분은 VACUUM이 실패한 게 아니라, VACUUM이 할 수 있는 일과 할 수 없는 일이 명확히 나뉘어 있기 때문에 기대한 효과가 안 나오는 경우가 많습니다.
이 글은 다음을 목표로 합니다.
- bloat가 생기는 대표 원인을 원인별로 분류
- 지금 상황이 “정상적인 VACUUM 한계”인지 “설정/운영 문제”인지 판별
VACUUM (FULL)같은 고위험 처방을 쓰기 전, 안전한 해결 루트 제시
운영 환경이 RDS라면 autovacuum이 폭주하며 CPU를 태우는 케이스도 자주 겹칩니다. 그 상황은 별도 글인 PostgreSQL RDS autovacuum 폭주로 CPU 100% 해결도 함께 참고하면 좋습니다.
VACUUM이 “안 먹는” 것처럼 보이는 대표 증상
아래 중 하나라도 해당하면 bloat 진단을 시작할 타이밍입니다.
VACUUM이후에도 테이블 파일 크기(pg_relation_size)가 거의 그대로n_dead_tup가 계속 증가하거나, 줄어도 금방 다시 증가- 인덱스 스캔이 느려지고,
Index Only Scan이 잘 안 뜨거나 힙 fetch가 많음 - 업데이트가 많은 테이블에서 IOPS, WAL, 체크포인트가 증가
autovacuum이 돌고 있는데도 특정 테이블만 계속 느림
여기서 중요한 포인트는 하나입니다.
- 일반
VACUUM은 “파일 크기를 줄이지 않습니다.”
일반 VACUUM은 dead tuple을 “재사용 가능 공간”으로 표시하고 통계(visibility map 등)를 정리하지만, OS에 반납되는 용량 감소는 거의 기대하기 어렵습니다. 파일을 줄이려면 보통 VACUUM (FULL) 또는 테이블 재작성(rewrite)이 필요합니다.
bloat가 생기는 구조: dead tuple과 MVCC
PostgreSQL은 MVCC 특성상 UPDATE가 실제로는 “새 버전 INSERT + 구버전 죽음 처리”에 가깝습니다. 즉,
- UPDATE/DELETE가 많을수록 dead tuple이 늘고
- 그 dead tuple이 정리되기 전까지는 테이블이 팽창하며
- 인덱스도 함께 팽창합니다(인덱스 엔트리는 자동으로 줄지 않음)
따라서 “VACUUM을 했는데도 느리다”는 말은 보통 다음 중 하나입니다.
- VACUUM이 충분히 자주/충분히 깊게 못 돌고 있다
- VACUUM이 돌긴 도는데, dead tuple이 회수되지 못하는 이유가 있다
- 테이블은 정리됐지만 인덱스 bloat가 남아 있다
- 애초에 파일 크기 감소를 기대하고 있다(일반 VACUUM의 한계)
이제 원인별로 진단과 해결을 정리하겠습니다.
1) 가장 흔한 원인: 오래 열린 트랜잭션
VACUUM은 “아무도 참조하지 않는” dead tuple만 회수할 수 있습니다. 그런데 오래 열린 트랜잭션이 있으면, 그 트랜잭션 시점에서는 아직 필요한 레코드일 수 있어 회수가 막힙니다.
진단 쿼리: 오래 열린 트랜잭션 찾기
SELECT
pid,
usename,
application_name,
client_addr,
state,
xact_start,
now() - xact_start AS xact_age,
query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start ASC
LIMIT 20;
xact_age가 수분 이상이면 의심- 배치 작업, 커넥션 풀, 워커가 트랜잭션을 열어둔 채 idle 상태가 흔한 패턴
추가로 replication slot을 쓰는 경우, WAL 정리가 막혀 디스크가 계속 차는 문제가 같이 발생할 수 있습니다.
SELECT
slot_name,
active,
restart_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots;
해결
- 애플리케이션에서 트랜잭션 범위 축소(특히 “읽기 트랜잭션” 장시간 유지 금지)
- 커넥션 풀에서 idle in transaction 타임아웃 적용
- 필요 시 문제 세션 종료
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE pid = 12345;
운영에서는 “무조건 종료”가 아니라, 해당 세션이 어떤 작업인지 먼저 확인하고 종료 기준을 합의해 두는 것이 안전합니다.
2) autovacuum이 테이블을 따라가지 못함
쓰기량이 많은 테이블에서 autovacuum이 늦으면 dead tuple이 계속 쌓입니다. 특히 기본 설정은 대형 테이블에 보수적으로 동작해, 폭증하는 쓰기를 못 따라가는 경우가 많습니다.
빠른 상태 확인
SELECT
relname,
n_live_tup,
n_dead_tup,
last_autovacuum,
last_vacuum,
vacuum_count,
autovacuum_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
n_dead_tup상위 테이블이 “핫스팟”last_autovacuum이 오래됐거나 null이면 autovacuum이 못 돌고 있는 것
현재 autovacuum 작업이 진행 중인지도 봅니다.
SELECT
pid,
datname,
relid::regclass AS relation,
phase,
heap_blks_scanned,
heap_blks_vacuumed,
index_vacuum_count,
max_dead_tuples
FROM pg_stat_progress_vacuum;
해결: 테이블 단위로 autovacuum 튜닝
전역 파라미터를 건드리기 전에, 문제 테이블에만 옵션을 주는 게 안전합니다.
ALTER TABLE public.events
SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_threshold = 5000,
autovacuum_analyze_scale_factor = 0.01,
autovacuum_analyze_threshold = 5000
);
scale_factor를 낮추면 더 자주 vacuum/analyze가 트리거됨- 대형 테이블일수록 scale factor 기반 트리거가 너무 늦게 걸리기 쉬움
또한 vacuum이 너무 오래 걸려 중간에 취소되거나 따라가지 못하면 autovacuum_vacuum_cost_limit, autovacuum_max_workers 같은 자원 파라미터 조정이 필요할 수 있습니다. 다만 이 조정은 시스템 전체에 영향을 주므로, CPU 100% 같은 부작용을 피하려면 앞서 링크한 글의 접근(관측 후 단계적 조정)이 안전합니다.
3) VACUUM은 했는데 “디스크가 안 줄어”서 안 먹는 것처럼 보임
앞서 말했듯 일반 VACUUM은 파일 크기를 줄이지 않습니다. 따라서 목표가 “디스크 회수”라면 선택지가 달라집니다.
선택지 A: VACUUM (FULL)
VACUUM (FULL, VERBOSE, ANALYZE) public.events;
- 테이블을 재작성하여 파일 크기를 줄임
- 대신 강한 잠금이 걸려(사실상 쓰기/읽기 영향) 운영에서 위험
- 대형 테이블이면 실행 시간도 길고, 추가 디스크(임시 공간)도 필요할 수 있음
선택지 B: pg_repack 같은 온라인 재구성
운영 중 잠금을 최소화하며 bloat를 줄이려면 pg_repack이 자주 쓰입니다.
- 테이블/인덱스를 새로 만들어 스왑하는 방식
- 짧은 잠금 구간만 발생
- 확장 설치/권한/추가 부하 고려 필요
환경 제약으로 pg_repack을 못 쓰면, 트래픽 저점에 VACUUM (FULL)을 제한적으로 적용하거나, 파티셔닝으로 “큰 테이블을 작게 쪼개” 관리 난이도를 낮추는 전략도 고려할 수 있습니다.
4) 인덱스 bloat: 테이블은 괜찮은데 인덱스가 느리다
PostgreSQL에서 인덱스는 dead tuple이 정리되어도 크기가 자동으로 줄지 않는 경우가 많습니다. 그래서 “VACUUM 했는데도 인덱스가 커서 느린” 상황이 발생합니다.
진단: 인덱스 크기 상위 목록
SELECT
schemaname,
relname AS table_name,
indexrelname AS index_name,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 20;
해결 1: REINDEX (가능하면 CONCURRENTLY)
REINDEX INDEX CONCURRENTLY public.events_created_at_idx;
CONCURRENTLY는 잠금을 줄이지만 시간이 더 걸릴 수 있음- 버전/환경에 따라 제약이 있으니 사전에 테스트 권장
테이블 전체를 재인덱싱하려면 다음도 가능하지만 영향 범위를 잘 보셔야 합니다.
REINDEX TABLE CONCURRENTLY public.events;
해결 2: 인덱스 설계 재검토
- 업데이트가 잦은 컬럼에 불필요한 보조 인덱스가 많으면 bloat가 가속
- 실제 쿼리 패턴에 맞춰 복합 인덱스/부분 인덱스로 줄이는 게 장기적으로 유리
예: 최근 데이터만 조회한다면 부분 인덱스.
CREATE INDEX CONCURRENTLY events_recent_idx
ON public.events (created_at)
WHERE created_at >= now() - interval '30 days';
5) HOT 업데이트가 깨져서 bloat가 빨리 커지는 경우
HOT 업데이트는 같은 페이지 내에서 업데이트가 처리되어 인덱스 변경을 최소화하는 최적화입니다. 하지만 다음이 있으면 HOT이 잘 안 됩니다.
- 업데이트되는 컬럼이 인덱스에 포함됨
- 테이블의 fillfactor가 너무 높아 같은 페이지에 여유 공간이 없음
대응: fillfactor 조정
쓰기 많은 테이블은 여유 공간을 남겨 HOT 가능성을 높이는 전략이 있습니다.
ALTER TABLE public.events SET (fillfactor = 80);
VACUUM (ANALYZE) public.events;
주의할 점은, fillfactor 변경은 “앞으로 쌓이는 페이지”에 유리하며 이미 커진 bloat를 즉시 줄이진 않습니다. 이미 커졌다면 pg_repack 또는 VACUUM (FULL) 같은 재작성 계열이 필요합니다.
6) 통계가 낡아서 플래너가 잘못된 계획을 고르는 경우
bloat 자체와 별개로, ANALYZE가 늦으면 플래너가 잘못된 카디널리티를 추정해 느려질 수 있습니다. 이때 “VACUUM 했는데도 느림”으로 체감됩니다.
VACUUM (ANALYZE) public.events;
또는 autovacuum analyze 트리거를 더 공격적으로 조정합니다(앞 절의 테이블 옵션 참고).
실전 해결 플로우: 위험도 낮은 순서
운영에서 바로 VACUUM (FULL)로 가면 잠금 이슈로 장애가 나기 쉽습니다. 아래 순서를 추천합니다.
- 오래 열린 트랜잭션/슬롯부터 제거
- dead tuple 회수 자체가 막혀 있으면 어떤 튜닝도 효과가 제한적
- 핫스팟 테이블 식별
pg_stat_user_tables에서n_dead_tup상위 테이블을 뽑고 집중
- 테이블 단위 autovacuum 파라미터 조정
- scale factor 낮추고 threshold 보정
- 인덱스 bloat는 REINDEX로 별도 처리
- 가능하면
REINDEX ... CONCURRENTLY
- 가능하면
- 디스크 회수가 목표면 재작성 계열 선택
- 가능하면
pg_repack, 아니면 점검된 윈도우에VACUUM (FULL)
- 가능하면
이 과정에서 관측(메트릭/로그)이 중요합니다. 애플리케이션 레이턴시가 같이 튀는 경우, DB만 보지 말고 프론트/백엔드 전체 병목을 함께 보는 게 유리합니다. 성능 이슈를 단계적으로 좁혀가는 접근은 Chrome INP 급락 - Long Task 잡는 8단계처럼 “원인 분해 후 하나씩 제거”하는 방식과 결이 같습니다.
체크리스트: “VACUUM 안 먹음” 상황에서 바로 확인할 것
pg_stat_activity에 오래 열린 트랜잭션이 있는가pg_stat_user_tables에서 dead tuple이 많은 테이블은 무엇인가pg_stat_progress_vacuum에서 vacuum이 실제로 진행 중인가, 멈췄는가- 디스크를 줄이고 싶은가, 쿼리 성능을 회복하고 싶은가(목표 분리)
- 인덱스가 과도하게 크지 않은가(인덱스 크기 상위 확인)
마무리
정리하면, VACUUM은 만능 청소기가 아니라 MVCC 하에서 “회수 가능한 것만 회수하는” 유지보수 작업입니다. “안 먹는다”는 체감은 보통
- 오래 열린 트랜잭션으로 회수가 막혔거나
- autovacuum이 쓰기량을 못 따라가거나
- 인덱스 bloat/통계 문제로 성능이 안 돌아오거나
- 일반 vacuum으로는 파일 크기가 줄지 않는 한계를 기대했거나
중 하나(혹은 복합)입니다.
운영에서 가장 안전한 접근은 원인 분류 후, 테이블 단위로 조정하고, 인덱스와 파일 크기 문제를 분리하는 것입니다. 이 루틴을 갖춰두면 “VACUUM 안 먹음” 이슈는 재발해도 훨씬 빠르게 정상화할 수 있습니다.