Published on

PostgreSQL VACUUM 안 될 때 bloat 진단·해결

Authors

서버 운영 중 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_typeIO 이거나 특정 락 대기라면, 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_workers
  • autovacuum_naptime
  • autovacuum_vacuum_scale_factor, autovacuum_vacuum_threshold
  • autovacuum_analyze_scale_factor, autovacuum_analyze_threshold
  • autovacuum_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) 조치 우선순위

  1. 긴 트랜잭션 제거(효과 즉시)
  2. autovacuum 튜닝(재발 방지)
  3. 인덱스 재구성(성능 회복)
  4. 테이블 재작성(VACUUM (FULL) 또는 pg_repack)은 마지막 카드

실전 예시: “VACUUM 했는데 용량이 그대로” 상황을 끝내는 절차

아래는 운영에서 자주 쓰는 최소 절차입니다.

  1. 상위 점유 테이블 확인
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;
  1. 해당 테이블 dead tuple 및 최근 vacuum 확인
SELECT
  relname,
  n_live_tup,
  n_dead_tup,
  last_autovacuum,
  last_autoanalyze
FROM pg_stat_user_tables
WHERE relname = 'events';
  1. 긴 트랜잭션 확인 후 종료 또는 수정
SELECT pid, now() - xact_start AS age, query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY age DESC
LIMIT 10;
  1. 인덱스가 과대하면 REINDEX ... CONCURRENTLY
REINDEX TABLE CONCURRENTLY public.events;
  1. 디스크 반환이 반드시 필요하면 점검 시간에 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)이 가장 안전한지 상황 맞춤으로 제안해드릴게요.