Published on

PostgreSQL VACUUM 안 돌 때 bloat 진단·해결

Authors

운영 중인 PostgreSQL에서 VACUUM이 “안 도는 것 같다”는 말은 대개 두 가지 증상으로 나타납니다.

  • 테이블/인덱스 용량이 계속 증가한다(디스크 압박)
  • 쿼리 성능이 점점 나빠진다(특히 UPDATE/DELETE 많은 테이블)

이때 핵심은 VACUUM이 실제로 못 도는지, 아니면 돌고는 있지만 bloat를 줄이지 못하는 상황인지를 구분하는 것입니다. PostgreSQL의 일반 VACUUM은 “공간을 OS에 즉시 반환”하지 않고, 내부 재사용 가능 공간으로만 정리하는 경우가 많습니다. 반면 bloat가 심해지면 캐시 효율이 떨어지고, 인덱스 탐색 비용이 커지며, 체크포인트/복제/백업에도 악영향이 생깁니다.

이 글에서는 다음 순서로 접근합니다.

  • bloat(테이블/인덱스 팽창) 징후를 수치로 진단
  • VACUUM 또는 autovacuum이 막히는 대표 원인(긴 트랜잭션, 락, 설정, I/O)
  • 안전한 해결책(우선순위와 실행 절차)
  • 재발 방지 체크리스트

autovacuum 자체 튜닝은 아래 글에서 더 깊게 다뤘습니다.

1) 먼저 확인: 정말 VACUUM이 안 도는가

1-1. autovacuum 워커가 돌고 있는지

아래 쿼리는 현재 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
ORDER BY heap_blks_total DESC;
  • 결과가 비어 있다면 “현재 진행 중인 VACUUM이 없다”는 뜻입니다.
  • 결과가 있는데 heap_blks_scanned가 느리게 증가한다면 I/O 병목, 락 대기, 너무 낮은 비용 한도(cost limit) 등을 의심합니다.

1-2. 최근 VACUUM 기록과 dead tuple 추세

pg_stat_user_tables는 테이블별 vacuum 통계와 dead tuple(죽은 튜플) 추정치를 제공합니다.

SELECT
  relname,
  n_live_tup,
  n_dead_tup,
  last_vacuum,
  last_autovacuum,
  vacuum_count,
  autovacuum_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 30;
  • n_dead_tup가 큰데 last_autovacuum이 오래되었다면 autovacuum이 못 돌고 있거나, 임계치 계산이 너무 느슨할 수 있습니다.
  • 반대로 last_autovacuum이 최신인데도 n_dead_tup가 계속 큰 경우는 “autovacuum이 따라잡지 못함” 또는 “긴 트랜잭션 때문에 dead tuple을 제거하지 못함”일 가능성이 큽니다.

2) bloat 진단: 테이블과 인덱스를 분리해서 본다

bloat은 크게 두 종류가 있습니다.

  • 테이블 bloat: heap 파일에 죽은 튜플/빈 공간이 많이 남아 페이지 수가 불필요하게 커짐
  • 인덱스 bloat: 인덱스 페이지가 분열/팽창하여 탐색 비용이 증가

운영에서 체감 성능 저하는 인덱스 bloat이 더 크게 만드는 경우도 많습니다.

2-1. 테이블 크기와 인덱스 크기 상위

가장 먼저 “어디가 큰지”를 잡아야 합니다.

SELECT
  schemaname,
  relname,
  pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
  pg_size_pretty(pg_relation_size(relid)) AS table_size,
  pg_size_pretty(pg_total_relation_size(relid) - pg_relation_size(relid)) AS index_size
FROM pg_catalog.pg_statio_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 30;

여기서 상위 몇 개 테이블이 전체 디스크를 먹는다면, 그 테이블을 중심으로 bloat 원인을 파고드는 게 효율적입니다.

2-2. dead tuple 비율로 “위험 테이블” 선별

절대값만 보면 큰 테이블이 항상 상위에 오르므로, 비율도 함께 봅니다.

SELECT
  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
FROM pg_stat_user_tables
WHERE n_live_tup + n_dead_tup > 0
ORDER BY dead_pct DESC, n_dead_tup DESC
LIMIT 30;
  • dead_pct가 높고, 애플리케이션에서 해당 테이블에 대한 UPDATE/DELETE가 잦다면 bloat 가능성이 큽니다.

2-3. 인덱스 스캔량 대비 크기(비효율 인덱스 후보)

인덱스 bloat은 정교한 추정이 필요하지만, 운영에서 빠르게 후보를 뽑는 데는 “크기 대비 사용량”이 유용합니다.

SELECT
  s.schemaname,
  s.relname AS table_name,
  s.indexrelname AS index_name,
  pg_size_pretty(pg_relation_size(s.indexrelid)) AS index_size,
  s.idx_scan,
  s.idx_tup_read,
  s.idx_tup_fetch
FROM pg_stat_user_indexes s
ORDER BY pg_relation_size(s.indexrelid) DESC
LIMIT 50;
  • 크기는 큰데 idx_scan이 낮으면 “불필요 인덱스”거나 “쿼리가 인덱스를 못 타는 구조”일 수 있습니다.
  • 인덱스 재구성(REINDEX)이나 제거 검토 후보가 됩니다.

3) VACUUM이 막히는 대표 원인 5가지

3-1. 긴 트랜잭션(또는 idle in transaction)

PostgreSQL은 MVCC 특성상 가장 오래된 스냅샷이 유지되는 동안 죽은 튜플을 완전히 치우지 못합니다. 즉, VACUUM이 돌아도 “제거할 수 없는 dead tuple”이 남아 bloat가 축적됩니다.

아래로 오래 열린 세션을 찾습니다.

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_age DESC
LIMIT 20;

특히 stateidle in transaction이면 애플리케이션 버그(커넥션 풀, 트랜잭션 종료 누락)일 확률이 큽니다.

해결은 보통 다음 우선순위입니다.

  • 애플리케이션에서 트랜잭션 범위를 줄이고, 커밋/롤백 누락 제거
  • 필요 시 강제 종료(운영 정책에 따라)
-- 위험: 실제 트랜잭션을 끊습니다. 영향 범위를 확인 후 사용.
SELECT pg_terminate_backend(12345);

3-2. VACUUM이 락 때문에 대기하는 경우

일반 VACUUM은 강한 락이 필요하지 않지만, 특정 상황(예: VACUUM FULL, 일부 DDL, 확장 기능)에서는 락 경합이 문제가 됩니다.

락 대기를 확인합니다.

SELECT
  a.pid,
  a.state,
  a.wait_event_type,
  a.wait_event,
  a.query,
  l.locktype,
  l.mode,
  l.granted,
  l.relation::regclass AS relation
FROM pg_stat_activity a
JOIN pg_locks l ON l.pid = a.pid
WHERE a.query ILIKE '%vacuum%'
ORDER BY a.pid;
  • grantedfalse인 락이 보이면 대기 중입니다.
  • 이 경우 “누가 막고 있는지”를 역으로 찾아야 합니다(동일 relation에 강한 락을 잡은 세션).

3-3. autovacuum이 너무 보수적으로 설정됨

임계치가 커서 vacuum이 늦게 시작되거나, 워커 수가 부족해 밀리는 경우가 흔합니다.

SHOW autovacuum;
SHOW autovacuum_max_workers;
SHOW autovacuum_vacuum_scale_factor;
SHOW autovacuum_vacuum_threshold;
SHOW autovacuum_naptime;
SHOW autovacuum_vacuum_cost_limit;
SHOW autovacuum_vacuum_cost_delay;
  • 대규모 테이블에 autovacuum_vacuum_scale_factor가 기본값(예: 0.2)이면 “테이블의 20%가 바뀔 때까지” vacuum이 안 도는 셈이라, bloat가 커질 수 있습니다.
  • 워커 수가 적으면 핫 테이블이 여러 개일 때 backlog가 생깁니다.

튜닝의 구체적인 접근은 위 내부 링크 글을 참고하세요.

3-4. I/O 병목과 체크포인트 압박

VACUUM은 디스크를 읽고(스캔) 쓰는(프리즈/클린업) 작업입니다. 스토리지가 느리거나, 동시에 큰 배치가 돌아가면 진행이 더뎌져 “안 도는 것처럼” 보일 수 있습니다.

  • pg_stat_progress_vacuum에서 스캔이 매우 느림
  • 시스템 레벨에서 디스크 대기 증가

이 경우 해결책은 “vacuum을 더 세게”가 아니라, 작업 시간대 분리, 배치 쿼리 최적화, 스토리지 성능 개선, 비용 파라미터 조정 같은 운영적 접근이 필요합니다.

3-5. 파티션/핫 테이블에서 업데이트 폭주

시간 파티셔닝 테이블에서 현재 파티션에 업데이트가 몰리면 dead tuple이 폭증합니다. 또 UPDATE가 사실상 DELETE + INSERT로 동작하는 패턴(폭 넓은 업데이트, TOAST 컬럼 포함)은 bloat를 가속합니다.

  • 가능하면 UPDATE를 줄이고 append-only 모델로 변경
  • 파티션 교체 전략(롤오버)로 “낡은 파티션은 freeze 후 읽기 전용”으로 전환

4) 해결 전략: 위험도와 효과를 구분하자

4-1. 1단계: 긴 트랜잭션부터 제거

bloat가 심한데 vacuum이 효과가 없다면, 대부분 “가장 오래된 트랜잭션”이 발목을 잡습니다.

  • xact_age가 비정상적으로 큰 세션 제거
  • 애플리케이션 코드/풀 설정 수정
  • 필요하면 idle_in_transaction_session_timeout 도입 검토
-- 예: 트랜잭션을 물고 놀면 자동 종료(운영 정책에 맞게 초 단위 조정)
ALTER SYSTEM SET idle_in_transaction_session_timeout = '60s';
SELECT pg_reload_conf();

4-2. 2단계: 표준 VACUUM(ANALYZE)로 회복 가능한지 확인

대부분의 경우 VACUUM (ANALYZE)로 통계 갱신까지 같이 해 주면 플래너가 정상화되어 체감 성능이 돌아옵니다.

VACUUM (ANALYZE, VERBOSE) public.big_table;
  • 표준 VACUUM은 테이블 파일 자체를 줄이지는 않을 수 있습니다.
  • 하지만 재사용 가능한 공간 확보인덱스 클린업, 통계 갱신으로 성능이 개선될 수 있습니다.

4-3. 3단계: 인덱스 bloat는 REINDEX를 우선 고려

테이블 bloat보다 인덱스 bloat가 더 큰 문제라면 REINDEX가 효과적입니다. 운영에서는 동시성 때문에 “컨커런트” 옵션이 중요합니다.

-- PostgreSQL 버전에 따라 지원 범위가 다릅니다.
-- 가능하면 컨커런트 재구성을 사용해 잠금을 줄입니다.
REINDEX INDEX CONCURRENTLY public.big_table_some_idx;

-- 또는 테이블 단위
REINDEX TABLE CONCURRENTLY public.big_table;

주의점

  • CONCURRENTLY는 더 오래 걸리고 추가 공간이 필요합니다.
  • 실패 시 재시도가 필요할 수 있습니다.

4-4. 4단계: 테이블 파일을 실제로 줄여야 하면 VACUUM FULL 또는 재작성

디스크를 OS에 반환해야 하거나 bloat가 극단적이면 VACUUM FULL을 고려합니다. 다만 이는 강한 락을 잡고 테이블을 재작성하므로, 트래픽이 있는 테이블에는 위험합니다.

-- 강한 락 필요. 유지보수 창에서만 권장.
VACUUM (FULL, VERBOSE, ANALYZE) public.big_table;

운영 대안

  • 유지보수 창이 어렵다면 “새 테이블로 재작성 후 스왑” 패턴을 씁니다.
  • 예: CREATE TABLE ... AS SELECT ... 후 인덱스 생성, 트리거/권한/제약 복구, 짧은 락으로 rename 스왑

이 방식은 설계와 테스트가 필요하지만, 대규모 테이블에서 다운타임을 줄이는 데 유용합니다.

4-5. 5단계: autovacuum을 테이블별로 핀포인트 튜닝

전역 파라미터를 건드리기 전에, 문제 테이블에만 임계치를 낮춰 “자주, 조금씩” 치우게 만드는 게 안전합니다.

ALTER TABLE public.big_table 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도 같이 따라가야 플랜이 망가지지 않습니다.

5) 운영에서 자주 놓치는 포인트

5-1. VACUUM이 공간을 “줄여주지” 않는 게 정상일 수 있다

표준 VACUUM은 파일을 축소하지 않고 내부 free space를 재사용하도록 만듭니다.

  • 디스크 사용량이 줄어야 한다면 VACUUM FULL 또는 테이블 재작성 계열이 필요
  • 다만 성능 문제는 표준 VACUUM (ANALYZE)만으로도 상당수 해결

5-2. 통계가 오래되면 “VACUUM 했는데도 느린” 상태가 된다

ANALYZE가 안 돌면 플래너가 잘못된 카디널리티로 비효율 플랜을 선택합니다. vacuum만 돌리고 analyze를 안 하면 체감이 안 좋아질 수 있습니다.

ANALYZE VERBOSE public.big_table;

5-3. 배치성 대량 삭제는 bloat 폭탄이 된다

대량 DELETE는 dead tuple을 대량으로 만들고, vacuum이 따라잡지 못하면 급격히 팽창합니다.

  • 가능하면 배치 삭제를 작은 청크로 쪼갬
  • 파티션이면 DROP PARTITION 같은 메타데이터 작업으로 대체

6) 재발 방지 체크리스트

  • 긴 트랜잭션 감시: pg_stat_activity에서 xact_age 상시 모니터링
  • 문제 테이블 식별: 크기 상위 테이블과 dead_pct 상위 테이블을 정기 점검
  • 인덱스 관리: 크기만 크고 idx_scan 낮은 인덱스 정리
  • 테이블별 autovacuum 튜닝: 전역보다 “핫 테이블만 먼저”
  • 유지보수 전략: 디스크 반환이 필요하면 VACUUM FULL 대신 재작성/스왑 전략 검토

7) 빠른 트러블슈팅 플로우(요약)

  1. pg_stat_progress_vacuum 확인: 진행 중인가, 멈췄는가

  2. pg_stat_activity로 긴 트랜잭션 확인: 있다면 먼저 해결

  3. pg_stat_user_tablesn_dead_tup, last_autovacuum 확인

  4. 인덱스가 큰데 느리면 REINDEX ... CONCURRENTLY 우선 검토

  5. 디스크 반환이 목표면 유지보수 창에서 VACUUM FULL 또는 재작성/스왑

  6. 마지막으로 autovacuum 설정을 테이블별로 조정

운영 환경에서 VACUUM 문제는 단순히 “명령을 더 자주 돌리자”로 해결되지 않는 경우가 많습니다. bloat의 형태(테이블 vs 인덱스), 긴 트랜잭션 여부, 락/IO 병목을 분리해 진단하면, 다운타임과 위험을 최소화하면서도 확실하게 회복시킬 수 있습니다.