Published on

PostgreSQL VACUUM 안됨? bloat·wraparound 7분 해결

Authors

서버 운영 중에 "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_ageautovacuum_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_timeout
    • statement_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_workers
  • autovacuum_naptime
  • autovacuum_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는 “용량”이 아니라 “데이터 일관성과 쓰기 가능 여부” 문제입니다. 임박하면 다음을 우선합니다.

  1. 위험 테이블에 강제 freeze vacuum
VACUUM (FREEZE, VERBOSE, ANALYZE) public.big_table;
  1. DB 전체가 위험하면(운영 영향 고려) 데이터베이스 단위 vacuum
VACUUM (FREEZE, VERBOSE) ;
  1. autovacuum이 freeze를 더 공격적으로 하도록(상황에 따라) 설정 검토
  • autovacuum_freeze_max_age
  • vacuum_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 transaction
  • pg_replication_slots 유무
  • 문제 테이블의 n_dead_tup, autovacuum timestamps