Published on

PostgreSQL VACUUM 안 먹을 때 - bloat·autovacuum 튜닝

Authors

서버 운영을 하다 보면 VACUUM (ANALYZE)를 돌렸는데도 디스크 사용량이 그대로이거나, 쿼리 성능이 계속 나빠지는 상황을 겪습니다. 이때 흔히 “VACUUM이 안 먹는다”라고 표현하지만, 실제로는 VACUUM이 할 수 있는 일과 할 수 없는 일이 명확히 나뉘고, 그 사이에 bloat(테이블/인덱스 팽창), autovacuum 트리거 조건, 오래 열린 트랜잭션, 잠금/IO 병목, freeze 지연 같은 변수가 얽혀 있습니다.

이 글은 “왜 VACUUM이 효과가 없는 것처럼 보이는지”를 원인별로 분해하고, 진단 SQLautovacuum 튜닝 체크리스트까지 실무 관점에서 정리합니다.

> 참고로 트랜잭션이 길게 열려 VACUUM이 막히는 이슈는 애플리케이션 레벨의 트랜잭션 관리 문제와도 연결됩니다. 특히 Spring을 쓰는 경우 Spring Boot 3에서 @Transactional 무시되는 7가지 같은 케이스로 “의도치 않게 커넥션이 오래 물리는” 상황이 생길 수 있습니다.

1) 먼저 결론: VACUUM이 디스크를 줄이지 않는 이유

1-1. VACUUM은 “공간을 OS에 반환”하지 않는다(대부분)

일반 VACUUMdead tuple을 재사용 가능 상태로 표시할 뿐, 파일을 줄여 OS에 반환하지 않습니다. 그래서 du -sh 같은 디스크 관점에서는 변화가 없어도 정상입니다.

  • 디스크를 실제로 줄이려면 보통 다음 중 하나가 필요합니다.
    • VACUUM FULL (테이블 재작성, 강한 잠금)
    • CLUSTER (인덱스 기준 재작성)
    • pg_repack (온라인에 가깝게 재작성)
    • 파티셔닝 후 오래된 파티션 DROP (가장 확실한 “공간 반환”)

1-2. VACUUM은 인덱스 bloat를 충분히 해결하지 못할 수 있다

테이블의 dead tuple은 VACUUM으로 정리되지만, 인덱스 페이지의 단편화/팽창은 단순 VACUUM만으로 원상 복구가 제한적입니다. 인덱스가 비대해지면 캐시 효율이 떨어지고 IO가 증가해 “VACUUM 했는데도 느림”이 됩니다.

1-3. 오래 열린 트랜잭션이 있으면 dead tuple을 치울 수 없다

PostgreSQL MVCC에서는 어떤 트랜잭션이 과거 스냅샷을 붙잡고 있으면, 그 시점 이후 생긴 dead tuple을 “아직 누군가 볼 수 있다”고 판단하여 정리하지 못합니다.

2) 증상별로 원인 좁히기: 체크리스트

아래 질문에 답하면 방향이 빨리 잡힙니다.

  1. 디스크가 안 줄어드는가? → 정상일 가능성 큼(일반 VACUUM 특성). bloat 자체는 남아 있을 수 있음.
  2. 쿼리가 계속 느린가? → 인덱스 bloat/통계 부정확/캐시 미스/IO 병목 가능.
  3. autovacuum이 도는 흔적이 없나? → 트리거 조건(threshold/scale_factor) 또는 worker/비용 제한 문제.
  4. VACUUM이 오래 걸리거나 멈춘 듯 보이나? → 잠금 대기, IO 포화, 오래 열린 트랜잭션, vacuum freeze backlog.

3) 지금 당장 보는 진단 SQL

3-1. “오래 열린 트랜잭션”부터 확인

이게 있으면 VACUUM이 기대만큼 청소를 못 합니다.

-- 오래 열린 트랜잭션/세션 확인
SELECT
  pid,
  usename,
  state,
  backend_xmin,
  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;
  • backend_xmin이 오래 유지되는 세션은 VACUUM의 “지울 수 있는 범위”를 제한합니다.
  • 배치/리포트/ETL/유휴 트랜잭션(idle in transaction)이 주범인 경우가 많습니다.

3-2. 테이블별 dead tuple 규모와 autovacuum 상태

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 30;
  • n_dead_tup가 큰데 last_autovacuum이 오래됐으면 autovacuum 트리거/리소스 문제가 의심됩니다.

3-3. autovacuum이 실제로 돌고 있는지(진행 상황)

SELECT
  pid,
  datname,
  relid::regclass AS relation,
  phase,
  heap_blks_total,
  heap_blks_scanned,
  heap_blks_vacuumed,
  index_vacuum_count,
  max_dead_tuples,
  num_dead_tuples
FROM pg_stat_progress_vacuum;

3-4. 테이블/인덱스 크기 상위 확인

SELECT
  n.nspname AS schema,
  c.relname AS name,
  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 indexes_toast
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;

인덱스가 비정상적으로 큰지(테이블 대비 과도) 감을 잡는 데 유용합니다.

4) “bloat”를 제대로 이해하기: 테이블 bloat vs 인덱스 bloat

4-1. 테이블 bloat

  • 원인: UPDATE/DELETE가 많고, HOT update가 깨지거나(인덱스 컬럼 변경), 페이지에 빈 공간이 여기저기 생김.
  • 영향: seq scan/heap fetch 증가, 캐시 효율 저하.
  • VACUUM 효과: dead tuple 재사용 가능하게 만들지만, 파일 자체는 유지.

4-2. 인덱스 bloat

  • 원인: 인덱스 엔트리 churn, 페이지 split, 랜덤 업데이트.
  • 영향: 인덱스 높이 증가, 랜덤 IO 증가, planner가 인덱스를 꺼리거나(비용 증가) 반대로 잘못 쓰는 경우.
  • VACUUM 효과: 일부 정리(특히 B-tree의 cleanup) 가능하지만 “원상 복구”는 제한적.
  • 해결: REINDEX / pg_repack / CLUSTER 등 재작성 계열이 실효성이 큼.

5) autovacuum이 “안 도는 것처럼” 보이는 대표 원인 6가지

5-1. scale_factor/threshold가 커서 트리거가 늦다

기본값은 대략 “테이블의 일정 비율이 변경되어야 vacuum”입니다. 대형 테이블에서 이 비율이 크면 dead tuple이 쌓이기 쉽습니다.

  • 핵심 파라미터
    • autovacuum_vacuum_scale_factor
    • autovacuum_vacuum_threshold
    • (analyze도 동일한 구조)

대형 테이블은 scale_factor를 낮추고 threshold를 조정하는 방식이 흔합니다.

5-2. autovacuum worker 수가 부족하다

테이블이 많거나 쓰기 부하가 높으면 vacuum backlog가 생깁니다.

  • 관련 파라미터
    • autovacuum_max_workers
    • autovacuum_naptime

5-3. 비용 제한(cost limit) 때문에 너무 “착하게” 돈다

autovacuum은 기본적으로 IO를 과도하게 쓰지 않도록 스스로 속도 제한을 겁니다. 그런데 이 제한이 너무 강하면 따라잡지 못합니다.

  • 관련 파라미터
    • autovacuum_vacuum_cost_limit
    • autovacuum_vacuum_cost_delay

5-4. 오래 열린 트랜잭션/replication slot이 xmin을 붙잡는다

  • idle in transaction 세션
  • 논리 복제/CDC의 replication slot이 소비를 못 해서 xmin을 유지

이 경우 vacuum이 돌아도 “버릴 수 있는 튜플”이 줄어 효과가 미미합니다.

5-5. vacuum freeze backlog(특히 오래 운영한 DB)

freeze가 밀리면 wraparound 위험 때문에 “emergency autovacuum”이 뜨거나, 특정 테이블 vacuum이 비정상적으로 오래 걸립니다.

5-6. IO/CPU 포화로 vacuum이 실질적으로 진행이 느리다

EBS/네트워크 스토리지, 컨테이너 환경에서 IO 크레딧 고갈 등으로 vacuum이 느리게 진행될 수 있습니다. 이런 상황은 애플리케이션에서는 “DB가 간헐적으로 멈춤”처럼 보이며, 쿠버네티스라면 readiness/liveness에도 영향을 줍니다. (유사한 운영 관점 트러블슈팅은 EKS에서 Readiness 실패인데 로그는 정상일 때 같은 접근이 도움이 됩니다.)

6) 실전 autovacuum 튜닝: ‘전체’보다 ‘핫 테이블’에 집중

전역 파라미터를 무작정 올리면 다른 워크로드를 망칠 수 있습니다. 보통은 문제 테이블에 per-table 설정을 먼저 적용합니다.

6-1. 핫 테이블에 per-table autovacuum 설정

ALTER TABLE public.orders SET (
  autovacuum_vacuum_scale_factor = 0.02,
  autovacuum_vacuum_threshold    = 5000,
  autovacuum_analyze_scale_factor = 0.01,
  autovacuum_analyze_threshold    = 3000,
  autovacuum_vacuum_cost_limit    = 2000
);
  • 대형 테이블일수록 scale_factor를 낮춰 “더 자주” 돌게 합니다.
  • 분석 통계(ANALYZE)도 같이 조정해야 planner가 정상적으로 비용을 계산합니다.

6-2. 전역(worker/비용) 튜닝의 방향성

환경마다 다르지만, 튜닝은 보통 아래 순서가 안전합니다.

  1. backlog가 명확하면 autovacuum_max_workers를 늘림
  2. autovacuum_vacuum_cost_limit 상향 또는 cost_delay 완화
  3. maintenance_work_mem을 적절히(인덱스 vacuum/빌드에 영향)

적용은 ALTER SYSTEM 또는 파라미터 그룹(RDS 등)로 관리합니다.

-- 예시: 전역 설정(적용 전 부하 테스트 권장)
ALTER SYSTEM SET autovacuum_max_workers = 6;
ALTER SYSTEM SET autovacuum_vacuum_cost_limit = 3000;
ALTER SYSTEM SET autovacuum_vacuum_cost_delay = '5ms';
SELECT pg_reload_conf();

7) VACUUM이 “막히는” 진짜 이유: 잠금과 DDL

일반 VACUUM은 강한 잠금을 오래 잡지 않지만, 아래 상황에서는 대기/지연이 생길 수 있습니다.

  • 테이블에 ACCESS EXCLUSIVE를 잡는 DDL(ALTER TABLE 등)이 대기 중 → VACUUM도 뒤에서 줄을 섬
  • 반대로 VACUUM FULL은 강한 잠금이 필요 → 실서비스에서 사실상 창구가 제한

잠금 확인:

SELECT
  a.pid,
  a.usename,
  a.state,
  l.locktype,
  l.mode,
  l.granted,
  l.relation::regclass AS relation,
  now() - a.query_start AS query_age,
  left(a.query, 200) AS query
FROM pg_locks l
JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.relation IS NOT NULL
ORDER BY l.granted ASC, query_age DESC;

8) 인덱스 bloat 대응: REINDEX vs pg_repack vs 파티셔닝

8-1. REINDEX

  • 장점: 간단
  • 단점: 큰 인덱스는 시간이 오래 걸리고 락/부하 고려 필요(버전에 따라 동시성 옵션 차이)
REINDEX INDEX CONCURRENTLY public.orders_created_at_idx;

8-2. pg_repack

  • 장점: 온라인에 가깝게 테이블/인덱스 재작성 가능, bloat 회복에 효과적
  • 단점: 확장 설치/운영 절차 필요

8-3. 파티셔닝 + 오래된 파티션 DROP

시간축 데이터라면 가장 강력합니다. “VACUUM으로 치우기”가 아니라 “파일 자체를 제거”합니다.

9) 운영 팁: vacuum/analyze를 관측 가능하게 만들기

  • log_autovacuum_min_duration을 설정해 “얼마나 자주/얼마나 오래” 도는지 로그로 남기세요.
ALTER SYSTEM SET log_autovacuum_min_duration = '1s';
SELECT pg_reload_conf();

10) 정리: “VACUUM 안 먹음”을 끝내는 우선순위

  1. 오래 열린 트랜잭션/replication slot부터 제거(가장 흔한 근본 원인)
  2. pg_stat_user_tablesdead tuple이 쌓이는 핫 테이블을 특정
  3. 핫 테이블에 per-table autovacuum scale_factor/threshold 적용
  4. backlog가 크면 worker 수/비용 제한을 조정해 따라잡기
  5. 느림이 지속되면 인덱스 bloat을 의심하고 REINDEX/pg_repack 검토
  6. 디스크 반환이 목적이면 VACUUM FULL 대신 재작성/파티션 DROP 전략으로 전환

VACUUM은 만능 청소기가 아니라, MVCC 설계 안에서 “재사용 가능한 공간을 확보하고 통계를 최신화하는 도구”에 가깝습니다. 그래서 효과가 없을 때는 VACUUM 자체보다 (1) 지울 수 없는 상태(xmin 보유), (2) autovacuum 트리거/리소스, (3) 인덱스 재작성 필요성을 함께 봐야 실제로 문제가 해결됩니다.