Published on

PostgreSQL VACUUM 안 돌면 디스크 폭증 해결법

Authors

운영 중인 PostgreSQL에서 어느 날 디스크 사용량이 급격히 치솟는 경우가 있습니다. 애플리케이션 로그를 보면 대량 배치도 없고, 테이블 row 수가 폭발적으로 늘어난 것도 아닌데 스토리지가 차는 상황이죠. 이때 가장 흔한 원인은 VACUUM(특히 autovacuum)이 제때 동작하지 않아 dead tuple이 회수되지 못하고, 그 결과 테이블과 인덱스가 bloat(팽창)되는 것입니다.

이 글에서는 “VACUUM이 안 돌아서 디스크가 폭증”하는 전형적인 케이스를 기준으로,

  • 왜 디스크가 늘어나는지(원리)
  • 현재 상태를 어떻게 진단하는지(쿼리)
  • 즉시 디스크 압박을 완화하는 응급 조치
  • autovacuum을 어떻게 튜닝해 재발을 막는지

를 단계별로 정리합니다.

관련해서 VACUUM 자체가 먹히지 않거나 bloat가 이미 심각한 경우는 아래 글도 함께 참고하면 좋습니다.

왜 VACUUM이 안 돌면 디스크가 폭증하나

PostgreSQL은 MVCC 구조라서 UPDATE/DELETE가 “제자리 수정”이 아니라 “새 버전 생성”으로 처리됩니다. 즉,

  • 기존 튜플은 dead tuple이 되고
  • 새 튜플이 다른 위치에 기록되며
  • 트랜잭션 가시성 규칙에 따라 조회 시점에 맞는 버전을 읽습니다.

문제는 dead tuple이 자동으로 디스크에서 사라지지 않는다는 점입니다. 이 공간을 재사용 가능 상태로 표시해 주는 작업이 VACUUM이며, 파일 자체 크기를 OS에 반환하려면 보통 VACUUM FULL 또는 테이블 재작성(CLUSTER, pg_repack) 같은 더 강한 작업이 필요합니다.

따라서 VACUUM이 장기간 제대로 수행되지 않으면:

  • dead tuple 누적
  • 테이블 스캔 시 불필요한 페이지 읽기 증가(쿼리 느려짐)
  • 인덱스도 함께 팽창(인덱스 bloat)
  • 결국 데이터 파일이 계속 커져 디스크 폭증

으로 이어집니다.

1단계: 지금 “진짜로” VACUUM이 안 도는지 확인

먼저 감으로 접근하면 시간을 버립니다. 아래 쿼리로 객관적으로 상태를 확인합니다.

최근 VACUUM/ANALYZE 이력 확인

pg_stat_user_tables에서 테이블별 vacuum/analyze 이력을 볼 수 있습니다.

SELECT
  schemaname,
  relname,
  n_live_tup,
  n_dead_tup,
  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은 최근인데도 n_dead_tup이 계속 크면 vacuum이 “완료는 했지만 부족하게” 돌았거나, 장기 트랜잭션 때문에 정리가 제한된 상황일 수 있습니다.

autovacuum이 막혀 있는지 확인(설정/테이블 옵션)

테이블 단위로 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 c.reloptions IS NOT NULL
  AND array_to_string(c.reloptions, ',') LIKE '%autovacuum%'
ORDER BY 1, 2;

reloptionsautovacuum_enabled=false가 보이면 해당 테이블은 자동 청소가 아예 안 돕니다.

장기 트랜잭션이 VACUUM을 방해하는지 확인

VACUUM은 “더 이상 누구도 참조하지 않는” dead tuple만 회수할 수 있습니다. 오래 열린 트랜잭션이 있으면 xmin 경계가 뒤로 밀려 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;

xact_age가 비정상적으로 길면(예: 수십 분~수시간) 그 세션이 vacuum 효율을 크게 떨어뜨릴 수 있습니다.

2단계: 디스크 폭증의 주범 테이블부터 찾기

“DB 전체가 커졌다”는 말은 사실상 “특정 몇 개 객체가 커졌다”인 경우가 많습니다.

테이블/인덱스 크기 상위 확인

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_indexes_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 30;

여기서 상위 몇 개 테이블이 디스크 증가분 대부분을 차지하는지 확인합니다.

dead tuple이 많은 테이블과 “크기 큰 테이블” 교차 확인

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_ratio_pct
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 30;

dead_ratio_pct가 높고 테이블 크기까지 큰 테이블이 1차 타깃입니다.

3단계: 응급 조치(디스크 압박 완화) 우선순위

디스크가 거의 찼다면 “근본 해결”보다 “서비스 지속”이 먼저입니다. 다만 PostgreSQL에서는 조치마다 락/부하/효과가 다르므로 우선순위를 지키는 게 중요합니다.

3-1. 즉시 할 수 있는 가장 안전한 조치: VACUUM (ANALYZE)

일반 VACUUM은 테이블에 강한 배타 락을 오래 잡지 않습니다(일부 짧은 락은 발생). 먼저 이걸로 “재사용 가능 공간”을 최대한 확보합니다.

VACUUM (ANALYZE, VERBOSE) public.big_table;

주의:

  • 이 작업은 OS에 디스크를 바로 반환하지 않을 수 있습니다. 하지만 내부적으로는 공간 재사용이 가능해져 “추가 폭증”을 막는 데 효과적입니다.
  • 트래픽이 큰 시간대에 돌리면 IO가 튈 수 있으니, 가능하면 부하가 낮을 때 실행합니다.

3-2. 장기 트랜잭션 정리(가능하면)

위에서 찾은 오래 열린 트랜잭션이 있다면 원인 파악 후 종료를 검토합니다. 예를 들어 배치 작업이 BEGIN 후 커밋을 안 하고 대기하거나, 커넥션 풀에서 트랜잭션이 열린 채로 놀고 있는 경우가 있습니다.

운영 정책에 따라 신중히 판단해야 하지만, vacuum이 계속 막히는 상황이면 해당 세션 종료가 가장 큰 효과를 내기도 합니다.

3-3. 정말 급할 때만: VACUUM FULL

VACUUM FULL은 테이블을 재작성하며 OS에 디스크를 반환할 수 있습니다. 하지만 강한 락을 오래 잡습니다.

VACUUM (FULL, VERBOSE, ANALYZE) public.big_table;

권장 시나리오:

  • 야간 점검 시간처럼 쓰기 트래픽을 멈출 수 있을 때
  • 특정 테이블이 디스크를 거의 다 먹고 있고, 지금 당장 OS 레벨에서 공간이 필요할 때

대안:

  • 운영 중 락을 줄이려면 pg_repack 같은 도구를 검토합니다(별도 설치/권한/리소스 고려 필요).

4단계: autovacuum이 “안 도는” 대표 원인과 해결

여기부터가 재발 방지의 핵심입니다.

원인 A. autovacuum 임계치가 현재 쓰기 패턴에 비해 너무 큼

autovacuum은 “테이블 변경량이 특정 기준을 넘으면” 실행됩니다. 기본값은 대규모 테이블에서 너무 늦게 동작할 수 있습니다.

테이블별로 더 공격적으로 돌리려면 테이블 스토리지 파라미터를 조정합니다.

ALTER TABLE public.big_table SET (
  autovacuum_vacuum_scale_factor = 0.02,
  autovacuum_vacuum_threshold = 2000,
  autovacuum_analyze_scale_factor = 0.01,
  autovacuum_analyze_threshold = 1000
);

해석:

  • scale_factor는 테이블 크기에 비례하는 트리거 비율입니다.
  • 대형 테이블일수록 scale_factor를 낮춰야 “더 자주” vacuum이 돕니다.

원인 B. autovacuum 워커 수/리소스가 부족

동시에 vacuum이 필요한 테이블이 많으면 워커 수가 적어 밀릴 수 있습니다.

대표 파라미터:

  • autovacuum_max_workers
  • autovacuum_naptime
  • autovacuum_vacuum_cost_limit
  • autovacuum_vacuum_cost_delay

현재 설정 확인:

SHOW autovacuum;
SHOW autovacuum_max_workers;
SHOW autovacuum_naptime;
SHOW autovacuum_vacuum_cost_limit;
SHOW autovacuum_vacuum_cost_delay;

튜닝 방향(일반적 가이드):

  • 워커 수를 늘리면 동시 처리량이 오르지만, IO 경쟁도 커집니다.
  • cost 관련 설정을 완화하면 vacuum이 더 빨리 돌지만, 서비스 쿼리의 IO를 더 잡아먹을 수 있습니다.

정답은 “DB가 감당 가능한 IO 한도 내에서 vacuum을 더 자주, 더 빨리”입니다. 운영에서는 관측(디스크 증가율, 쿼리 지연, 체크포인트/IO)과 함께 단계적으로 조정하세요.

원인 C. 장기 트랜잭션/복제 슬롯이 xmin을 붙잡음

앞서 말한 것처럼 오래 열린 트랜잭션이 있으면 vacuum이 치울 수 있는 범위가 줄어듭니다. 또한 논리 복제/CDC에서 replication slot이 오래 쌓이면 xmin 또는 WAL 정리에 영향을 줄 수 있습니다.

복제 슬롯 확인:

SELECT
  slot_name,
  slot_type,
  active,
  restart_lsn,
  confirmed_flush_lsn
FROM pg_replication_slots
ORDER BY active DESC, slot_name;

슬롯이 쌓이거나 비활성인데 유지되는 경우는 CDC/커넥터 상태를 점검해야 합니다.

원인 D. 특정 테이블의 인덱스 bloat가 심각

VACUUM은 인덱스 공간을 “완전히” 줄여주지 못하는 경우가 많습니다. 업데이트가 많은 테이블은 인덱스가 계속 커질 수 있습니다.

대응 옵션:

  • 자주 갱신되는 컬럼에 과도한 인덱스가 없는지 점검
  • 불필요한 인덱스 제거
  • 필요한 경우 인덱스 REINDEX
REINDEX INDEX CONCURRENTLY public.big_table_some_idx;

CONCURRENTLY는 락 영향을 줄이지만 시간이 더 걸리고 리소스를 더 사용할 수 있습니다.

5단계: 운영에서 바로 쓰는 “디스크 폭증” 대응 런북

아래 순서로 진행하면 실수 확률이 줄어듭니다.

1) 상위 용량 객체 찾기

  • pg_total_relation_size로 상위 테이블/인덱스 확인

2) dead tuple 및 vacuum 이력 확인

  • pg_stat_user_tables에서 n_dead_tup, last_autovacuum 확인

3) 장기 트랜잭션 확인

  • pg_stat_activity에서 xact_age 긴 세션 확인

4) 응급 vacuum 실행

  • 우선 VACUUM (ANALYZE)
  • OS 디스크가 정말 필요하면 점검 시간에 VACUUM FULL 또는 pg_repack 검토

5) autovacuum 튜닝(테이블 단위부터)

  • 큰 테이블에 autovacuum_vacuum_scale_factor 낮추기
  • 워커/비용 파라미터는 전체 부하를 보며 조정

6단계: 재발 방지를 위한 모니터링 체크리스트

다음 지표를 대시보드로 상시 관측하면 “디스크 폭증”이 오기 전에 잡을 수 있습니다.

  • 테이블별 n_dead_tup 상위
  • 테이블별 last_autovacuum 지연 시간
  • DB 디스크 사용량 증가율(절대값보다 기울기)
  • 장기 트랜잭션 수 및 최장 xact_age
  • autovacuum 워커 활동량(작업 큐가 밀리는지)

추가로, 애플리케이션 레벨에서 불필요한 대량 UPDATE를 줄이는 것도 중요합니다. 예를 들어 ORM에서 변경 감지로 인해 의미 없는 업데이트가 많이 나가면 dead tuple이 급증합니다. JPA를 쓰는 환경이라면 N+1이나 배치 처리 최적화가 쓰기 패턴에도 영향을 주므로 함께 점검해볼 만합니다.

마무리

PostgreSQL에서 VACUUM이 제때 돌지 않으면 디스크 폭증은 시간문제입니다. 핵심은 “디스크가 찼으니 vacuum 한 번 돌리자”가 아니라,

  • 어떤 테이블이 커지는지 정확히 식별하고
  • dead tuple과 장기 트랜잭션 같은 방해 요인을 제거하며
  • autovacuum이 현재 쓰기 패턴을 따라갈 수 있도록 테이블 단위로 튜닝

하는 것입니다.

만약 VACUUM을 돌려도 dead tuple이 줄지 않거나, bloat가 이미 심각해 운영 중 조치가 어려운 상태라면 아래 글에서 더 깊은 원인(autovacuum이 “먹히지 않는” 케이스)과 튜닝 포인트를 추가로 확인해보세요.