Published on

PostgreSQL VACUUM 안 돌면 테이블 폭증 해결법

Authors

운영 중인 PostgreSQL에서 어느 날 디스크가 갑자기 꽉 차고, 쿼리는 느려지고, pg_stat_user_tables를 보면 업데이트나 삭제는 많았는데 테이블 용량은 줄지 않는 상황이 발생합니다. 이때 가장 흔한 원인이 바로 VACUUM 미동작 또는 autovacuum이 제때 따라오지 못해 생기는 테이블 bloat(팽창) 입니다.

PostgreSQL은 MVCC 구조라서 UPDATEDELETE가 곧바로 물리 삭제로 이어지지 않습니다. 기존 튜플은 죽은 튜플(dead tuple)로 남고, 새 튜플이 추가됩니다. 이 죽은 튜플을 회수해 재사용 가능 상태로 만들고 통계를 갱신하는 역할이 VACUUM입니다. 이 과정이 막히면 테이블과 인덱스는 계속 커지기만 하고, 결국 디스크 폭증과 성능 장애로 이어집니다.

관련 장애 패턴은 아래 글에서도 더 자세히 다뤘습니다.

왜 VACUUM이 안 돌면 테이블이 폭증하나

핵심은 3가지입니다.

1) MVCC로 인해 죽은 튜플이 계속 쌓임

  • UPDATE는 사실상 DELETE + INSERT
  • DELETE는 마킹만 하고 실제 공간은 남음
  • VACUUM이 죽은 튜플을 정리하지 않으면 페이지 내부의 빈 공간이 재사용되지 못하고, 새로운 페이지가 계속 할당되어 파일이 커짐

2) 인덱스 bloat는 테이블보다 더 치명적

테이블이 커지면 느려지는 것은 당연하지만, 많은 경우 인덱스가 더 빨리/더 크게 팽창합니다.

  • 인덱스 엔트리는 DELETE되더라도 곧바로 물리 제거되지 않음
  • btree 페이지 분할이 누적되면 랜덤 I/O 증가
  • 결과적으로 인덱스 스캔이 느려지고 캐시 효율이 급락

3) 트랜잭션이 오래 열려 있으면 VACUUM이 못 치움

VACUUM은 모든 죽은 튜플을 무조건 지우는 작업이 아닙니다. 아직 다른 트랜잭션에서 볼 수 있는 버전이라면 제거하지 못합니다.

  • 장기 트랜잭션
  • idle in transaction
  • 오래된 replication slot, 오래된 스냅샷

이 경우 autovacuum은 돌아도 효과가 거의 없고, n_dead_tup가 줄지 않는 현상이 생깁니다.

증상 체크리스트: 지금 bloat가 의심되는가

다음 증상이 겹치면 bloat 가능성이 큽니다.

  • 디스크 사용량이 꾸준히 증가하고 줄지 않음
  • DELETE를 많이 했는데 테이블 용량이 그대로임
  • VACUUM 로그가 거의 없거나, 항상 특정 테이블에서 오래 걸림
  • 쿼리 플랜이 인덱스를 타는데도 느림
  • pg_stat_user_tables.n_dead_tup가 높고 잘 안 줄어듦

즉시 진단: 어떤 테이블이 폭증 중인지 찾기

아래 쿼리는 사용자 테이블의 크기와 dead tuple을 함께 봅니다.

SELECT
  schemaname,
  relname,
  n_live_tup,
  n_dead_tup,
  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_toast_size,
  last_vacuum,
  last_autovacuum,
  last_analyze,
  last_autoanalyze
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 30;

n_dead_tup가 높고 last_autovacuum이 오래됐거나, autovacuum이 돌아도 dead tuple이 줄지 않는 테이블이 1차 타겟입니다.

인덱스가 비정상적으로 큰지 확인

SELECT
  ns.nspname AS schema,
  t.relname AS table_name,
  i.relname AS index_name,
  pg_size_pretty(pg_relation_size(i.oid)) AS index_size
FROM pg_class t
JOIN pg_namespace ns ON ns.oid = t.relnamespace
JOIN pg_index ix ON ix.indrelid = t.oid
JOIN pg_class i ON i.oid = ix.indexrelid
WHERE ns.nspname NOT IN ('pg_catalog','information_schema')
ORDER BY pg_relation_size(i.oid) DESC
LIMIT 30;

인덱스가 테이블 대비 과도하게 큰 경우, 단순 VACUUM만으로는 회복이 안 되고 REINDEXpg_repack 같은 재작성 작업이 필요할 수 있습니다.

VACUUM이 “못 도는” 대표 원인 6가지

1) autovacuum이 꺼져 있음

가끔 운영에서 실수로 꺼져 있거나, 특정 테이블에만 꺼져 있습니다.

SHOW autovacuum;

테이블 단위 설정도 확인합니다.

SELECT
  n.nspname,
  c.relname,
  c.reloptions
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
  AND c.reloptions IS NOT NULL
ORDER BY 1, 2;

reloptionsautovacuum_enabled=false가 있으면 해당 테이블은 자동 청소가 멈춥니다.

2) autovacuum이 너무 늦게 발동되도록 설정됨

기본값은 큰 테이블에서 지나치게 보수적일 수 있습니다.

  • autovacuum_vacuum_threshold
  • autovacuum_vacuum_scale_factor

예를 들어 1억 행 테이블에서 scale factor가 0.2면 2천만 dead tuple이 쌓여야 발동합니다. 그 사이에 디스크는 이미 폭증할 수 있습니다.

3) autovacuum worker/비용 제한으로 따라잡지 못함

  • autovacuum_max_workers가 너무 작음
  • autovacuum_vacuum_cost_limit이 너무 낮음
  • autovacuum_vacuum_cost_delay가 너무 큼

이 경우 “돌긴 도는데 항상 밀리는” 상태가 됩니다.

4) 장기 트랜잭션 또는 idle in transaction

이게 가장 흔한 실전 함정입니다. 오래 열린 트랜잭션이 있으면 VACUUM이 제거 가능한 범위가 줄어듭니다.

SELECT
  pid,
  usename,
  state,
  xact_start,
  now() - xact_start AS xact_age,
  query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start ASC
LIMIT 20;

stateidle in transaction이고 xact_age가 길면 매우 위험합니다.

5) replication slot이 오래돼서 xmin이 고정됨

논리 복제나 CDC 도구가 죽어 있으면 슬롯이 WAL을 붙잡고, vacuum도 간접적으로 악화됩니다.

SELECT
  slot_name,
  slot_type,
  active,
  restart_lsn,
  confirmed_flush_lsn
FROM pg_replication_slots;

6) vacuum 자체가 락/IO에 막혀 진행이 느림

  • 테이블이 너무 커서 vacuum 시간이 길다
  • IO 병목으로 vacuum이 사실상 기어간다
  • 동시에 많은 테이블이 터져서 worker가 분산된다

응급 처치: 지금 당장 폭증을 멈추는 순서

상황에 따라 “정석”보다 “멈추게 하는 것”이 먼저입니다.

1) 가장 위험한 장기 트랜잭션부터 정리

  • 애플리케이션 커넥션 풀에서 트랜잭션 누수 여부 확인
  • 배치 작업이 트랜잭션을 오래 잡고 있지 않은지 확인
  • 필요 시 해당 세션 종료

세션 종료는 신중해야 하지만, 디스크가 꽉 차기 직전이면 선택지가 없습니다.

SELECT pg_terminate_backend(`pid`)
FROM pg_stat_activity
WHERE state = 'idle in transaction'
  AND now() - xact_start `>` interval '10 minutes';

본문에 >가 노출되면 MDX에서 문제가 될 수 있으니 위 쿼리처럼 >는 인라인 코드로 감쌌습니다.

2) 타겟 테이블에 수동 VACUUM 실행

일단 공간 재사용과 통계 갱신을 위해 실행합니다.

VACUUM (ANALYZE, VERBOSE) public.big_table;
  • VACUUM은 파일 크기를 즉시 줄이지는 않습니다. 다만 내부 공간을 재사용 가능하게 만들어 “더 커지는 것”을 막습니다.
  • 통계가 오래돼 플래너가 삽질 중이면 ANALYZE가 즉효가 있습니다.

3) 파일 크기 자체를 줄여야 하면 VACUUM FULL 또는 재작성 도구

디스크가 정말 부족하면 “파일 크기 감소”가 필요합니다.

  • VACUUM FULL은 테이블을 새로 써서 공간을 줄입니다.
  • 단, 강한 락이 걸려 운영 서비스에 영향이 큽니다.
VACUUM (FULL, VERBOSE, ANALYZE) public.big_table;

운영에서 락이 부담되면 pg_repack 같은 온라인 재작성 도구를 고려합니다.

4) 인덱스 bloat가 심하면 REINDEX

REINDEX INDEX CONCURRENTLY public.big_table_some_idx;
  • CONCURRENTLY는 시간이 더 걸리지만 서비스 영향이 적습니다.
  • 버전에 따라 제약이 있으니 사전에 공식 문서를 확인하세요.

근본 해결: autovacuum 튜닝을 테이블별로 하라

전역 파라미터만으로는 한계가 있습니다. 쓰기량이 높은 테이블은 테이블 단위로 기준을 낮춰야 합니다.

아래는 “큰 테이블에서 더 자주 vacuum”하게 만드는 예시입니다.

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를 낮춰 테이블 크기가 커도 비교적 빨리 발동
  • threshold로 최소 발동 기준을 보완

autovacuum이 밀릴 때 자주 만지는 전역 파라미터

아래는 “방향성”입니다. 값은 워크로드와 디스크 성능에 따라 다릅니다.

  • autovacuum_max_workers 증가: 동시에 처리할 테이블 수 확장
  • autovacuum_naptime 감소: 주기적으로 더 자주 점검
  • autovacuum_vacuum_cost_limit 증가: vacuum이 더 공격적으로 IO 사용
  • autovacuum_vacuum_cost_delay 감소: vacuum의 쉬는 시간 감소

다만 공격적으로 올리면 서비스 쿼리와 IO 경합이 생길 수 있으니, 모니터링하면서 단계적으로 조정하는 게 안전합니다.

bloat를 반복시키는 애플리케이션 패턴

DB 설정만으로 해결이 안 되는 경우가 많습니다.

1) 대량 UPDATE를 자주 하는 테이블 설계

예: 상태 컬럼을 계속 갱신하는 이벤트 테이블

  • append-only로 바꾸거나
  • hot update가 잘 되도록 컬럼 배치/인덱스 설계를 점검하거나
  • 파티셔닝으로 “최근 데이터만 쓰기”로 제한하는 방식이 효과적입니다.

2) 대량 DELETE 배치

대량 삭제는 vacuum 부담을 폭발시킵니다.

  • 가능하면 soft delete 후 파티션 드랍으로 정리
  • 삭제를 작은 배치로 쪼개기

3) JSONB 인덱스 남발로 인덱스 팽창 가속

JSONB는 편하지만 인덱스 설계가 잘못되면 비용이 큽니다. 인덱스가 안 타거나, 불필요한 인덱스가 많으면 bloat와 유지비가 증가합니다.

운영에서 자주 쓰는 모니터링 포인트

vacuum 진행 상황 확인

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

dead tuple이 계속 늘기만 하는지 추세 확인

  • n_dead_tup 추세
  • last_autovacuum 갱신 여부
  • 테이블별 DML 빈도(n_tup_ins, n_tup_upd, n_tup_del)

이 지표들을 대시보드로 올려두면 “디스크 꽉 차기 직전”이 아니라 훨씬 앞에서 감지할 수 있습니다.

정리: 폭증을 멈추는 실전 체크 순서

  1. pg_stat_activity에서 장기 트랜잭션과 idle in transaction 제거
  2. 상위 용량 테이블 기준으로 n_dead_tup 확인, 타겟 선정
  3. VACUUM (ANALYZE)로 우선 진화
  4. 디스크가 급하면 VACUUM FULL 또는 온라인 재작성 도구 검토
  5. 인덱스가 비대하면 REINDEX CONCURRENTLY
  6. 재발 방지로 테이블별 autovacuum 파라미터 튜닝

VACUUM은 “가끔 돌리는 작업”이 아니라 PostgreSQL이 정상적으로 숨 쉬기 위한 상시 메커니즘입니다. 테이블 폭증이 한 번이라도 있었다면, 그 순간의 응급 처치만 기록하지 말고 왜 autovacuum이 따라오지 못했는지까지 원인을 고정해두는 것이 다음 장애를 막는 가장 확실한 방법입니다.