- Published on
PostgreSQL VACUUM 안 될 때 bloat 진단·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 운영 중 VACUUM 을 돌렸는데도 디스크가 줄지 않거나, 쿼리 성능이 계속 나쁘고, 심지어 autovacuum 로그가 찍히는데도 체감이 없는 상황을 자주 만납니다. 이런 경우 대부분은 “VACUUM이 안 된다”기보다, VACUUM이 할 수 있는 일과 할 수 없는 일이 섞여 있고, 그 사이에 bloat(팽창) 가 쌓여 있기 때문입니다.
이 글에서는 다음을 목표로 합니다.
- 테이블 bloat, 인덱스 bloat, TOAST bloat를 구분해서 진단
autovacuum이 제 역할을 못 하는 대표 원인(긴 트랜잭션, 설정, I/O 병목)을 찾기- 다운타임 최소화 관점에서
VACUUM (FULL)과REINDEX를 포함한 해결책을 선택하기
운영 중 디스크가 100%에 가까워졌는데 원인이 잘 안 보인다면, OS 레벨에서 삭제된 파일 점유 같은 케이스도 함께 의심해야 합니다. 이 경우는 PostgreSQL 이슈가 아니라 리눅스 파일 핸들 문제일 수 있으니 아래 글도 같이 참고하세요.
VACUUM이 “줄여주는 것”과 “못 줄이는 것”
먼저 오해를 정리해야 진단이 빨라집니다.
VACUUM(일반 vacuum)- 죽은 튜플(dead tuple)을 재사용 가능 상태로 표시
- 통계 갱신(옵션에 따라) 및 visibility map 업데이트
- 디스크 파일 자체 크기를 보통 줄이지 못함
VACUUM (FULL)- 테이블을 새로 써서 물리 파일을 재작성
- 디스크를 실제로 반환할 수 있음
- 대신 강한 락(접근 차단) 과 큰 I/O가 발생
- 인덱스
- 테이블 vacuum만으로는 인덱스 bloat가 크게 줄지 않을 수 있음
- 많은 경우
REINDEX또는pg_repack같은 온라인 재작성 도구가 필요
즉 “VACUUM을 했는데도 디스크가 안 줄어듦”은 정상일 수 있습니다. 중요한 건 현재 공간이 재사용 가능한지, 그리고 성능 저하가 bloat 때문인지를 확인하는 것입니다.
1단계: autovacuum이 실제로 돌고 있는지 확인
autovacuum 작업 상태 확인
SELECT
pid,
datname,
usename,
state,
wait_event_type,
wait_event,
query_start,
now() - query_start AS runtime,
query
FROM pg_stat_activity
WHERE query ILIKE '%autovacuum%'
ORDER BY runtime DESC;
여기서 wait_event_type 이 IO 이거나 특정 락 대기라면, vacuum이 느린 이유가 I/O 또는 락 경합일 수 있습니다.
테이블별 vacuum 통계
SELECT
schemaname,
relname,
n_live_tup,
n_dead_tup,
last_vacuum,
last_autovacuum,
vacuum_count,
autovacuum_count,
last_analyze,
last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 30;
n_dead_tup이 계속 큰데last_autovacuum이 오래됐다면 autovacuum이 못 따라가는 상황입니다.- 반대로 autovacuum은 자주 도는데도 성능이 나쁘면 인덱스 bloat 또는 긴 트랜잭션으로 인한 정리 불가를 의심합니다.
2단계: “VACUUM이 못 치우는” 대표 원인 1순위, 긴 트랜잭션
PostgreSQL은 MVCC 때문에, 오래 열린 트랜잭션이 있으면 오래된 버전을 지울 수 없습니다. 이때 vacuum은 돌고 있어도 실제로 dead tuple 제거가 지연됩니다.
오래 열린 트랜잭션 찾기
SELECT
pid,
usename,
datname,
state,
xact_start,
now() - xact_start AS xact_age,
query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_age DESC
LIMIT 20;
특히 다음이 자주 원인입니다.
- 애플리케이션 커넥션 풀에서 트랜잭션을 열어둔 채로 idle
- 배치 작업이 큰 트랜잭션을 오래 유지
- 논리 복제 슬롯, 오래된 스냅샷
replication slot로 인한 vacuum 정체 확인
SELECT
slot_name,
plugin,
slot_type,
active,
restart_lsn,
confirmed_flush_lsn
FROM pg_replication_slots;
논리 복제 슬롯이 쌓이면 WAL이 안 지워져 디스크가 증가하기도 합니다. 이건 vacuum만으로 해결되지 않습니다.
3단계: bloat 종류를 나눠서 진단하기
bloat는 크게 3가지로 나눠 보는 게 실전에서 유용합니다.
- 테이블 bloat: heap 파일에 빈 공간이 많음
- 인덱스 bloat: 인덱스 페이지가 비대해짐
- TOAST bloat: 큰 컬럼(text, jsonb 등)의 외부 저장 영역이 팽창
빠른 체감 지표: dead tuple 비율
SELECT
schemaname,
relname,
n_live_tup,
n_dead_tup,
CASE WHEN n_live_tup = 0 THEN 0
ELSE round(100.0 * n_dead_tup / n_live_tup, 2)
END AS dead_pct
FROM pg_stat_user_tables
ORDER BY dead_pct DESC
LIMIT 30;
dead_pct 가 지속적으로 높다면 vacuum이 부족하거나, 업데이트 패턴이 bloat를 유발하고 있을 가능성이 큽니다.
실제 디스크 점유 상위 테이블 확인
SELECT
n.nspname AS schema,
c.relname AS table,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total,
pg_size_pretty(pg_relation_size(c.oid)) AS heap,
pg_size_pretty(pg_total_relation_size(c.oid) - pg_relation_size(c.oid)) AS other
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;
other 가 크면 인덱스/TOAST 비중이 큰 테이블입니다. 이 경우 단순 vacuum보다 REINDEX 또는 TOAST 정리가 핵심일 수 있습니다.
4단계: “VACUUM을 했는데도 느린” 경우의 흔한 정답, 인덱스 bloat
테이블은 vacuum으로 재사용 공간이 생겨도, 인덱스는 업데이트/삭제가 많으면 내부적으로 빈 페이지가 늘고 트리 구조가 비대해져 성능이 떨어질 수 있습니다.
인덱스 크기 상위 확인
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;
인덱스 bloat를 정밀 추정하려면 확장 모듈이 필요할 수 있지만, 운영에서는 일단 크기와 쿼리 플랜 악화를 함께 보고 의사결정하는 경우가 많습니다.
해결책 선택 가이드: 상황별로 무엇을 할 것인가
1) autovacuum 튜닝으로 “밀린 청소” 따라잡기
트래픽 대비 autovacuum이 약하면 dead tuple이 계속 누적됩니다. 다음 파라미터를 점검합니다.
autovacuum_max_workersautovacuum_naptimeautovacuum_vacuum_scale_factor,autovacuum_vacuum_thresholdautovacuum_analyze_scale_factor,autovacuum_analyze_thresholdautovacuum_vacuum_cost_limit,autovacuum_vacuum_cost_delay
특정 테이블만 더 공격적으로 vacuum
대형 핫 테이블은 전역 설정보다 테이블 단위로 낮은 scale factor를 주는 게 효과적입니다.
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
);
핵심은 “자주 조금씩” 청소하게 만드는 것입니다. bloat가 심해진 뒤에 한 번에 치우려 하면 I/O 스파이크가 더 큽니다.
2) 긴 트랜잭션 제거: vacuum 정체를 푸는 가장 확실한 방법
- 애플리케이션에서 트랜잭션 범위를 줄이고, idle in transaction을 없애기
- 배치 작업은 커밋을 더 자주 하도록 쪼개기
- 필요 시 운영 정책으로
idle_in_transaction_session_timeout적용
ALTER SYSTEM SET idle_in_transaction_session_timeout = '5min';
SELECT pg_reload_conf();
이 설정은 서비스 특성에 따라 부작용이 있을 수 있으니, 특정 DB 또는 롤 단위로 먼저 적용하는 방식을 권장합니다.
3) 디스크를 실제로 줄여야 한다면: VACUUM (FULL)
VACUUM (FULL) 은 테이블을 재작성하므로 디스크를 반환할 수 있습니다. 다만 강한 락 때문에 운영에서는 신중해야 합니다.
VACUUM (FULL, VERBOSE, ANALYZE) public.events;
권장 시나리오
- 새벽 점검 시간 등 쓰기 트래픽이 거의 없고, 잠깐의 접근 차단이 허용될 때
- 디스크가 임계치라서 물리 파일 축소가 반드시 필요할 때
대안
- 온라인 재작성 도구인
pg_repack을 검토(별도 설치 필요)
4) 인덱스 bloat가 핵심이면: REINDEX
인덱스가 과도하게 커졌거나, 인덱스 스캔 성능이 악화되면 재구성이 직접적인 해결입니다.
단일 인덱스 재구성
REINDEX INDEX CONCURRENTLY public.events_created_at_idx;
테이블의 모든 인덱스 재구성
REINDEX TABLE CONCURRENTLY public.events;
CONCURRENTLY 는 락을 줄여 운영 친화적이지만 더 오래 걸리고 추가 작업 공간이 필요합니다.
5) TOAST bloat 의심 시 체크 포인트
jsonb, text, 큰 bytea 컬럼을 자주 업데이트하면 TOAST 테이블이 커질 수 있습니다.
- 업데이트 패턴을 바꿀 수 있으면 가장 좋습니다. 예: 큰 json 전체를 매번 갱신하지 말고 분리 테이블로 쪼개기
- 불필요한 컬럼 갱신을 피하기
- 주기적으로
VACUUM (ANALYZE)가 잘 돌도록 autovacuum 튜닝
TOAST 자체를 강제로 줄이는 건 결국 테이블 재작성 계열(VACUUM (FULL) 또는 pg_repack)로 가는 경우가 많습니다.
운영에서 안전하게 진행하는 “진단에서 조치까지” 체크리스트
1) 지금 당장 급한가: 디스크 임계치와 증가 속도
- 디스크가 임계치라면, DB 내부 bloat만 보지 말고 OS 레벨 점유도 같이 확인
- WAL, 아카이브, 로그, 삭제된 파일 점유 여부를 함께 점검
2) 원인 분류
- dead tuple이 많은데 autovacuum이 뜸하다: 설정/리소스 부족
- autovacuum이 도는데 dead tuple이 줄지 않는다: 긴 트랜잭션/스냅샷/슬롯
- 테이블보다 인덱스가 비정상적으로 크다:
REINDEX후보 - 큰 컬럼 갱신이 잦고 TOAST 비중이 크다: 모델링/업데이트 패턴 개선 + 재작성 고려
3) 조치 우선순위
- 긴 트랜잭션 제거(효과 즉시)
- autovacuum 튜닝(재발 방지)
- 인덱스 재구성(성능 회복)
- 테이블 재작성(
VACUUM (FULL)또는pg_repack)은 마지막 카드
실전 예시: “VACUUM 했는데 용량이 그대로” 상황을 끝내는 절차
아래는 운영에서 자주 쓰는 최소 절차입니다.
- 상위 점유 테이블 확인
SELECT
c.oid::regclass AS rel,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total
FROM pg_class c
WHERE c.relkind = 'r'
ORDER BY pg_total_relation_size(c.oid) DESC
LIMIT 10;
- 해당 테이블 dead tuple 및 최근 vacuum 확인
SELECT
relname,
n_live_tup,
n_dead_tup,
last_autovacuum,
last_autoanalyze
FROM pg_stat_user_tables
WHERE relname = 'events';
- 긴 트랜잭션 확인 후 종료 또는 수정
SELECT pid, now() - xact_start AS age, query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY age DESC
LIMIT 10;
- 인덱스가 과대하면
REINDEX ... CONCURRENTLY
REINDEX TABLE CONCURRENTLY public.events;
- 디스크 반환이 반드시 필요하면 점검 시간에
VACUUM (FULL)또는pg_repack
VACUUM (FULL, ANALYZE) public.events;
마무리: “VACUUM이 안 된다”는 증상은 대개 구조적 신호다
정리하면, VACUUM 은 만능 디스크 청소기가 아닙니다. 파일 크기를 줄이는 게 목적이라면 애초에 VACUUM (FULL) 계열이 필요하고, 성능 문제라면 인덱스 bloat와 긴 트랜잭션이 더 흔한 범인입니다.
운영에서 가장 효과적인 접근은 다음 한 줄로 요약됩니다.
- 긴 트랜잭션 제거로 vacuum 정체를 풀고, autovacuum을 테이블 특성에 맞게 튜닝한 뒤, 필요한 경우에만 재작성(리빌드)한다.
원하시면 사용 중인 PostgreSQL 버전, 문제 테이블의 대략적인 크기, pg_stat_user_tables 상위 5개 결과를 주시면, 어떤 조합(REINDEX, autovacuum 튜닝, VACUUM (FULL) 또는 pg_repack)이 가장 안전한지 상황 맞춤으로 제안해드릴게요.