- Published on
PostgreSQL VACUUM 안됨? bloat·wraparound 7분 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 운영 중에 "VACUUM 돌렸는데 왜 디스크가 안 줄지?", "autovacuum이 있는데도 테이블이 계속 부풀지?", "갑자기 wraparound 경고가 뜨는데 뭐부터 해야 하지?" 같은 상황이 자주 나옵니다.
이 글은 원인 분리(12분) → 즉시 응급조치(34분) → 재발 방지(1~2분) 순서로, 현장에서 바로 써먹을 수 있는 체크리스트와 명령어를 제공합니다.
> 핵심 요약 > - VACUUM은 “공간을 OS에 반환”하는 명령이 아닙니다. 대부분은 내부 재사용 가능 공간만 늘립니다. > - 디스크를 실제로 줄이려면 보통 VACUUM (FULL) 또는 pg_repack(확장) 같은 rewrite 계열이 필요합니다. > - wraparound는 디스크 문제가 아니라 “서비스 정지 위험” 입니다. 우선순위가 훨씬 높습니다.
1) 7분 진단 플로우: 지금 bloat? wraparound?
1-1. 우선 wraparound 위험부터 확인 (30초)
wraparound는 방치하면 강제 autovacuum / 쓰기 중단까지 갈 수 있습니다. 다음 쿼리로 위험 테이블을 바로 뽑습니다.
-- 현재 DB의 트랜잭션 ID 사용량(대략적인 위험도)
SELECT datname,
age(datfrozenxid) AS xid_age,
current_setting('autovacuum_freeze_max_age')::int AS freeze_max_age
FROM pg_database
ORDER BY xid_age DESC;
-- 테이블별로 가장 위험한 순서
SELECT n.nspname AS schema,
c.relname AS table,
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 xid_age DESC
LIMIT 20;
xid_age가autovacuum_freeze_max_age에 근접하거나, 경고 로그가 이미 있다면 wraparound 대응이 최우선입니다.
1-2. bloat(팽창)인지 확인 (1~2분)
VACUUM을 했는데도 테이블/인덱스가 커 보이면, 보통 아래 중 하나입니다.
- dead tuple이 많다 → autovacuum이 못 따라감
- UPDATE가 많은 테이블 → HOT 업데이트가 깨지고 인덱스 bloat 증가
- long transaction/replication slot 때문에 vacuum이 청소를 못함
빠른 지표는 pg_stat_user_tables입니다.
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,
last_analyze, last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
dead_pct가 높고last_autovacuum가 오래전이면 autovacuum이 밀린 상태입니다.
2) “VACUUM 했는데 용량이 안 줄어요”의 정답
PostgreSQL에서 일반 VACUUM은 테이블 파일을 줄이지 않습니다.
- dead tuple을 "비워서" 같은 파일 내에서 재사용 가능하게 만들 뿐
- OS에 실제 공간 반환은 거의 없음(일부 truncation 상황 제외)
따라서 “디스크 용량이 줄어야 한다”가 목표면, 다음 중 하나를 선택해야 합니다.
- 테이블 잠금 허용 + 가장 확실:
VACUUM (FULL) - 온라인에 가깝게(추가 확장 필요):
pg_repack - 테이블/파티션 재작성 전략: 파티션 교체, CTAS, swap
3) VACUUM이 ‘안 되는’ 대표 원인 5가지와 즉시 조치
3-1. Long transaction 때문에 dead tuple이 못 지워짐
가장 흔한 원인입니다. 오래 열린 트랜잭션이 있으면 vacuum은 “아직 누가 볼지 모르는 버전”을 지울 수 없습니다.
-- 오래 열린 트랜잭션/쿼리 확인
SELECT pid, usename, state,
now() - xact_start AS xact_age,
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_age DESC
LIMIT 20;
조치:
- 업무 영향이 허용되면 해당 세션 종료
SELECT pg_terminate_backend(<pid>);
- 애플리케이션에서 idle in transaction이 생기지 않도록 타임아웃 설정
idle_in_transaction_session_timeoutstatement_timeout
3-2. replication slot / logical decoding이 xmin을 붙잡음
논리 복제/CDC(Kafka Connect, Debezium 등)가 슬롯을 만들고 소비가 멈추면 vacuum이 밀립니다.
SELECT slot_name, slot_type, active,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal,
restart_lsn
FROM pg_replication_slots
ORDER BY retained_wal DESC;
조치:
- 불필요한 슬롯 제거(주의: 소비자 재동기화 필요)
SELECT pg_drop_replication_slot('slot_name');
3-3. autovacuum이 너무 약하거나 테이블 특성이 안 맞음
기본 설정이 “대부분의 워크로드에 무난”할 뿐, UPDATE/DELETE가 많은 테이블에는 부족합니다.
테이블별 vacuum 튜닝(핫 테이블에만 적용):
ALTER TABLE public.orders SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_threshold = 1000,
autovacuum_analyze_scale_factor = 0.01,
autovacuum_analyze_threshold = 1000
);
그리고 autovacuum worker가 부족하면 전역 설정도 점검합니다.
autovacuum_max_workersautovacuum_naptimeautovacuum_vacuum_cost_limit,autovacuum_vacuum_cost_delay
3-4. 인덱스 bloat가 본체일 수 있음
테이블은 괜찮은데 인덱스가 커져서 디스크를 잡아먹는 경우도 많습니다. 특히 UPDATE가 잦고 인덱스가 많은 테이블에서요.
즉시 조치(온라인/오프라인 선택):
- 온라인:
REINDEX CONCURRENTLY - 오프라인(락 큼):
REINDEX
-- 인덱스만 온라인 재구성
REINDEX INDEX CONCURRENTLY public.orders_created_at_idx;
-- 테이블의 모든 인덱스 온라인 재구성
REINDEX TABLE CONCURRENTLY public.orders;
참고로 인덱스가 “안 타서” 쿼리가 느려지고 그 결과 트랜잭션이 길어져 vacuum까지 방해하는 연쇄도 흔합니다. JSONB 쿼리에서 인덱스가 안 타는 전형적인 원인은 따로 정리해둔 글을 같이 보면 진단이 빨라집니다: PostgreSQL JSONB 인덱스가 안타는 이유 7가지
3-5. wraparound 임박인데 vacuum이 따라가지 못함
wraparound는 “용량”이 아니라 “데이터 일관성과 쓰기 가능 여부” 문제입니다. 임박하면 다음을 우선합니다.
- 위험 테이블에 강제 freeze vacuum
VACUUM (FREEZE, VERBOSE, ANALYZE) public.big_table;
- DB 전체가 위험하면(운영 영향 고려) 데이터베이스 단위 vacuum
VACUUM (FREEZE, VERBOSE) ;
- autovacuum이 freeze를 더 공격적으로 하도록(상황에 따라) 설정 검토
autovacuum_freeze_max_agevacuum_freeze_min_age,vacuum_freeze_table_age
4) “디스크를 줄여야” 할 때 선택지: FULL vs REPACK
4-1. VACUUM (FULL): 가장 확실하지만 큰 락
- 테이블을 새로 작성(rewrite)해서 파일을 줄입니다.
- ACCESS EXCLUSIVE 락이 걸려 읽기/쓰기가 막힐 수 있습니다.
VACUUM (FULL, VERBOSE, ANALYZE) public.orders;
사용 기준:
- 새벽 점검 시간 등 다운타임/락 허용
- 단일 테이블이 디스크를 대부분 차지
4-2. pg_repack: 온라인에 가깝게(확장 필요)
- 확장 설치가 필요하지만, 운영 중에도 비교적 안전하게 재작성 가능
- 부하/추가 디스크(임시 공간) 고려 필요
예시(서버에서 실행):
# 확장 설치(1회)
psql -d mydb -c "CREATE EXTENSION IF NOT EXISTS pg_repack;"
# 테이블 리팩
pg_repack -d mydb -t public.orders
# 인덱스만 리팩
pg_repack -d mydb -i public.orders_created_at_idx
5) 운영에서 바로 쓰는 “7분 해결” 체크리스트
5-1. 1분: 위험도 분류
age(datfrozenxid)확인 → 높으면 wraparound 대응부터n_dead_tup상위 테이블 확인 → bloat/vacuum backlog 파악
5-2. 2분: vacuum 방해범 제거
- long transaction / idle in transaction 종료
- replication slot 적체 확인
5-3. 2분: 응급 vacuum 실행
- wraparound 위험 테이블:
VACUUM (FREEZE) - bloat 테이블:
VACUUM (ANALYZE)+ autovacuum 파라미터 조정
5-4. 2분: “디스크 반환”이 목표면 rewrite 계획
- 락 허용:
VACUUM (FULL) - 락 최소화:
pg_repack - 인덱스가 크면
REINDEX CONCURRENTLY
6) 재발 방지: 관측/알림 포인트
운영에서 VACUUM 문제는 “한 번 해결”로 끝나지 않고, 워크로드 변화나 배포(쿼리 변경)로 재발합니다. 다음 지표는 최소로 걸어두는 편이 좋습니다.
age(datfrozenxid)(DB별)- 상위 테이블의
n_dead_tup,dead_pct pg_replication_slots의 retained WAL- autovacuum 로그(느린 vacuum, freeze 경고)
그리고 디스크가 꽉 차서 장애로 이어지는 패턴은 DB만의 문제가 아닙니다. 예를 들어 리눅스에서 “삭제했는데 용량이 안 줄어드는” 케이스(삭제된 파일을 프로세스가 잡고 있는 상황)도 종종 같이 터집니다. DB 서버 디스크가 이상하게 줄지 않는다면 이 글도 같이 체크하면 좋습니다: 리눅스 디스크 100%인데 용량이 안 줄 때 - deleted-but-open(lsof)
7) 결론: VACUUM은 만능이 아니라 ‘규칙’이 있다
- VACUUM이 안 되는 것처럼 보일 때는 먼저 “정말 안 도는지”가 아니라, **무엇이 청소를 막는지(트랜잭션/슬롯)**와 **무엇을 목표로 하는지(재사용 vs 디스크 반환)**를 분리해야 합니다.
- wraparound는 최우선으로 다루고, bloat는 테이블/인덱스/쿼리 패턴까지 함께 봐야 재발이 줄어듭니다.
필요하면 다음 정보를 주면, 상황에 맞춰 (1) 어떤 테이블부터 (2) 어떤 명령을 (3) 어떤 설정으로 조정할지 더 구체적으로 제안할 수 있습니다.
- PostgreSQL 버전, 테이블 크기/증가 추이
pg_stat_activity상위 long transactionpg_replication_slots유무- 문제 테이블의
n_dead_tup, autovacuum timestamps