Published on

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

Authors

서버 모니터링을 보면 트래픽은 그대로인데 디스크 사용량이 하루에 몇십 GB씩 늘고, 쿼리는 점점 느려지며, 인덱스 스캔이 비효율적으로 변합니다. PostgreSQL에서 이런 증상은 대부분 VACUUM이 제때 동작하지 않아 dead tuple이 쌓이고, 테이블과 인덱스가 비대해지는 bloat로 이어진 결과입니다.

이 글은 "VACUUM이 안 돌아서 테이블이 폭증"하는 상황을 빠르게 재현 가능한 지표로 확인하고, 원인별로 복구하는 방법을 단계적으로 정리합니다. 특히 운영 환경에서 자주 겪는 autovacuum 미동작, 장기 트랜잭션, 잘못된 튜닝, 파티션/대량 배치 패턴까지 함께 다룹니다.

관련해서 VACUUM이 끝나지 않거나 락/대기 때문에 멈춘 것처럼 보일 때는 아래 글의 진단 흐름도 같이 참고하면 좋습니다.

1) 왜 VACUUM이 없으면 테이블이 "폭증"하나

PostgreSQL은 MVCC 구조라서 UPDATE는 제자리 수정이 아니라 "새 버전의 row"를 만들고, 기존 row는 dead tuple이 됩니다. DELETE도 즉시 물리 삭제가 아니라 dead tuple 마킹입니다.

  • dead tuple은 쿼리 결과에서는 사라졌지만, 디스크 페이지에는 남아 있음
  • 인덱스 엔트리도 함께 누적되어 인덱스 bloat까지 발생
  • 통계가 오래되면 플래너가 잘못된 실행 계획을 선택

여기서 VACUUM의 역할은 크게 두 가지입니다.

  1. dead tuple을 "재사용 가능한 공간"으로 표시해서 테이블 내부에서 재활용되게 함
  2. autovacuum은 추가로 통계 갱신(ANALYZE)을 통해 실행 계획 품질을 유지

중요: 일반 VACUUM은 파일 크기를 OS에 반환하지 않습니다. 즉, "폭증한 파일을 줄이는" 목적이라면 VACUUM FULL 또는 pg_repack 같은 재작성 계열이 필요합니다. 하지만 대부분의 폭증은 우선 "계속 커지는 상태"를 멈추는 것이 1순위이고, 그 다음에 축소를 고민하는 편이 안전합니다.

2) 지금 진짜 bloat인지 5분 내 확인하는 쿼리

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

SELECT
  n.nspname AS schema,
  c.relname AS table,
  pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
  pg_size_pretty(pg_relation_size(c.oid)) AS table_size,
  pg_size_pretty(pg_total_relation_size(c.oid) - pg_relation_size(c.oid)) AS index_size
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY pg_total_relation_size(c.oid) DESC
LIMIT 20;

2-2. dead tuple 비율과 마지막 vacuum 시간

SELECT
  schemaname,
  relname,
  n_live_tup,
  n_dead_tup,
  CASE WHEN (n_live_tup + n_dead_tup) = 0 THEN 0
       ELSE round(100.0 * n_dead_tup / (n_live_tup + n_dead_tup), 2)
  END 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 문제 가능성이 큽니다.
  • 반대로 last_autovacuum은 최근인데도 dead tuple이 줄지 않으면 장기 트랜잭션 또는 vacuum이 충분히 "전진"하지 못하는 상황을 의심합니다.

2-3. 장기 트랜잭션 확인

장기 트랜잭션은 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_start ASC
LIMIT 20;

여기서 xact_age가 비정상적으로 길면(예: 수십 분~수시간) vacuum이 막힐 수 있습니다. 특히 커넥션 풀에서 idle in transaction이 길게 남는 케이스가 흔합니다.

3) VACUUM이 "안 도는" 대표 원인 6가지

3-1. autovacuum이 꺼져 있음

테이블 단위로 꺼둔 경우가 생각보다 많습니다.

SELECT
  n.nspname AS schema,
  c.relname AS table,
  c.reloptions
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
  AND array_to_string(c.reloptions, ',') LIKE '%autovacuum_enabled=false%'
ORDER BY 1, 2;

3-2. autovacuum이 돌아도 임계치가 너무 큼

autovacuum은 기본적으로 "변경된 튜플 수"가 임계치를 넘으면 동작합니다.

  • autovacuum_vacuum_threshold
  • autovacuum_vacuum_scale_factor

큰 테이블에서는 scale_factor가 너무 크면 vacuum이 늦게 시작되어 dead tuple이 폭증합니다.

3-3. autovacuum worker 수가 부족함

동시에 많은 테이블이 폭증하면 기본 worker 수로는 따라가지 못합니다.

  • autovacuum_max_workers
  • autovacuum_naptime

3-4. vacuum이 너무 느림(스롯틀링)

IO를 아끼려고 vacuum이 지나치게 느리면 "돌긴 도는데 못 따라감" 상태가 됩니다.

  • autovacuum_vacuum_cost_limit
  • autovacuum_vacuum_cost_delay

3-5. 장기 트랜잭션과 xmin 고정

앞서 확인한 장기 트랜잭션이 있으면 VACUUM이 공간 회수를 못합니다. 특히 논리 복제 슬롯, 오래된 스냅샷도 xmin을 고정할 수 있습니다.

3-6. 대량 배치 패턴이 vacuum을 압도

예를 들어 "매일 대량 UPDATE" 또는 "소프트 딜리트 플래그 업데이트"는 MVCC 상 row 버전을 계속 쌓습니다. 이때는 스키마/패턴 자체를 바꿔야 근본 해결이 됩니다.

4) 응급 처치: 폭증을 멈추는 실전 절차

4-1. 가장 위험한 것부터: 장기 트랜잭션 정리

  • 애플리케이션에서 idle in transaction이 생기지 않게 타임아웃을 겁니다.
  • 운영 중 즉시 조치가 필요하면 원인 세션을 종료합니다.
-- 신중히 사용: 실제 영향 확인 후 실행
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'idle in transaction'
  AND now() - xact_start > interval '10 minutes';

또는 애플리케이션 레벨에서 다음 설정을 권장합니다.

  • idle_in_transaction_session_timeout
  • statement_timeout
ALTER SYSTEM SET idle_in_transaction_session_timeout = '5min';
ALTER SYSTEM SET statement_timeout = '2min';
SELECT pg_reload_conf();

4-2. 타깃 테이블에 수동 VACUUM 수행

폭증 상위 테이블부터 "공간 재사용"을 확보합니다.

VACUUM (VERBOSE, ANALYZE) public.big_table;
  • ANALYZE까지 같이 해서 플래너 통계를 즉시 회복시키는 게 중요합니다.
  • vacuum 중 락은 보통 DML을 막지 않지만, 매우 바쁜 테이블에서는 IO 경합으로 지연이 생길 수 있습니다.

4-3. autovacuum 튜닝: 큰 테이블은 테이블 단위로

전역 파라미터를 무작정 올리기보다, 폭증하는 "특정 대형 테이블"에만 aggressive하게 적용하는 편이 안전합니다.

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도 자주 되어야 실행 계획이 안정적입니다.

4-4. worker와 비용 파라미터를 현실화

서버가 감당 가능한 범위에서 autovacuum이 "따라잡을 수 있게" 만듭니다.

예시(환경에 따라 다름):

ALTER SYSTEM SET autovacuum_max_workers = 6;
ALTER SYSTEM SET autovacuum_naptime = '10s';
ALTER SYSTEM SET autovacuum_vacuum_cost_limit = 2000;
ALTER SYSTEM SET autovacuum_vacuum_cost_delay = '5ms';
SELECT pg_reload_conf();
  • worker를 늘리면 동시에 더 많은 테이블을 처리합니다.
  • cost 제한을 완화하면 vacuum이 더 빨리 진행되지만, IO 사용량이 늘 수 있습니다.

5) "이미 커진" 테이블 크기 줄이기: VACUUM FULL vs pg_repack

앞서 말했듯 일반 VACUUM은 파일 크기를 줄이지 않습니다. 디스크를 실제로 돌려받아야 하면 선택지는 다음과 같습니다.

5-1. VACUUM FULL

  • 테이블을 재작성해서 물리 크기를 줄입니다.
  • 단점: 강한 락이 필요합니다(운영 트래픽에 치명적일 수 있음).
VACUUM (FULL, VERBOSE, ANALYZE) public.big_table;

5-2. pg_repack

  • 확장 모듈 기반으로 온라인에 가깝게 재구성합니다.
  • 운영 중 적용 가능성이 높지만, 여전히 부하와 잠깐의 락 타이밍은 고려해야 합니다.

환경마다 설치/권한/버전 이슈가 있어 여기서는 원리만 언급합니다. "지금 당장 디스크가 터진다"면 VACUUM FULL이 가장 단순하지만, 서비스 중단 가능성을 반드시 따져야 합니다.

6) 폭증을 반복하지 않게 만드는 설계 체크리스트

6-1. 소프트 딜리트 남발 줄이기

deleted_at 업데이트는 결국 UPDATE라서 row 버전이 쌓입니다. 가능하면:

  • 파티셔닝 후 오래된 파티션 DROP
  • 아카이브 테이블로 이관 후 원본에서 DELETE

6-2. 대량 UPDATE 대신 INSERT-ONLY + 교체 패턴 고려

집계 테이블을 매번 UPDATE로 덮어쓰면 bloat가 빠르게 쌓입니다. 배치라면:

  • 새 테이블에 적재
  • 스왑(예: ALTER TABLE ... RENAME)으로 교체

단, 스왑은 의존성/락/권한을 정교하게 설계해야 합니다.

6-3. 커넥션 풀에서 트랜잭션 경계 강제

폭증의 숨은 원인은 DB가 아니라 애플리케이션인 경우가 많습니다.

  • 요청 단위로 트랜잭션을 짧게
  • 예외 경로에서 COMMIT/ROLLBACK 누락 방지
  • ORM의 자동 트랜잭션 범위 점검

운영 자동화 관점에서는 장애 원인 추적을 위해 파이프라인에서 진단 정보를 모으는 습관도 도움이 됩니다. 예를 들어 실패를 한 번에 수집하는 방식은 인프라/DB 튜닝에도 유용합니다.

7) 운영에서 자주 쓰는 "점검용" 쿼리 모음

7-1. autovacuum이 현재 무엇을 하는지

SELECT
  a.pid,
  a.usename,
  a.state,
  a.wait_event_type,
  a.wait_event,
  now() - a.query_start AS running_for,
  left(a.query, 200) AS query
FROM pg_stat_activity a
WHERE a.query ILIKE '%autovacuum%'
ORDER BY a.query_start ASC;

7-2. 테이블별 vacuum 진행 상황(가능한 버전에서)

버전에 따라 pg_stat_progress_vacuum 뷰를 사용할 수 있습니다.

SELECT
  pid,
  datname,
  relid::regclass AS table,
  phase,
  heap_blks_total,
  heap_blks_scanned,
  heap_blks_vacuumed,
  index_vacuum_count,
  num_dead_tuples
FROM pg_stat_progress_vacuum
ORDER BY heap_blks_total DESC;

7-3. 통계가 오래되어 플래너가 흔들리는지

SELECT
  schemaname,
  relname,
  n_mod_since_analyze,
  last_analyze,
  last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_mod_since_analyze DESC
LIMIT 30;

8) 결론: "폭증"은 VACUUM 문제가 아니라 "진행 불가" 문제일 때가 많다

테이블이 커지는 현상 자체는 MVCC에서는 자연스럽습니다. 문제는 다음 중 하나로 vacuum이 제 역할을 못할 때 폭증이 "가속"된다는 점입니다.

  • autovacuum이 비활성화되었거나 임계치가 커서 늦게 시작
  • worker/비용 설정이 보수적이라 쓰기량을 따라가지 못함
  • 장기 트랜잭션이 vacuum을 막아 공간 회수가 불가능
  • 대량 업데이트/소프트 딜리트 같은 패턴이 구조적으로 bloat를 유발

우선순위는 항상 동일합니다.

  1. 장기 트랜잭션과 idle in transaction 제거
  2. 폭증 상위 테이블에 VACUUM (ANALYZE)로 재사용 공간 확보
  3. 해당 테이블 중심으로 autovacuum 임계치 튜닝
  4. 필요할 때만 VACUUM FULL 또는 pg_repack으로 물리 크기 축소

이 순서대로 진행하면 "디스크가 계속 늘어나는 상태"를 멈추고, 성능과 안정성을 함께 회복할 수 있습니다.