Published on

PostgreSQL VACUUM 안 하면 폭증하는 3가지 증상

Authors

PostgreSQL은 UPDATE/DELETE가 발생해도 레코드를 즉시 덮어쓰지 않고, MVCC 방식으로 “이전 버전 튜플(죽은 튜플, dead tuple)”을 남깁니다. 이 구조 덕분에 읽기 일관성이 좋아지지만, 그 대가로 정리 작업이 필수입니다. 그 정리의 핵심이 VACUUM(그리고 통계 갱신을 포함하는 VACUUM ANALYZE)입니다.

autovacuum이 기본으로 켜져 있으니 괜찮다고 생각하기 쉽지만, 트래픽 패턴/테이블 크기/설정값이 맞지 않으면 autovacuum이 따라가지 못합니다. 그 결과는 “서서히”가 아니라 어느 순간부터 폭증하는 형태로 나타납니다.

아래는 VACUUM을 제대로 못 돌릴 때 현장에서 가장 자주 터지는 3가지 증상과, 바로 확인할 수 있는 SQL/운영 체크리스트입니다.

증상 1) 디스크 사용량이 비정상적으로 증가한다 (bloat 폭증)

왜 생기나

UPDATE는 새 버전을 만들고, 기존 버전은 더 이상 보이지 않는(dead) 튜플이 됩니다. DELETE도 마찬가지로 “삭제 표시된 튜플”이 남습니다. 이 dead 튜플이 VACUUM으로 회수되지 않으면 테이블/인덱스가 점점 부풀어 오르는(bloat) 상태가 됩니다.

특히 다음 조건에서 빨리 악화됩니다.

  • 특정 테이블에 UPDATE가 매우 잦다(예: 상태 컬럼 토글, last_seen 갱신)
  • 핫 테이블에 대한 배치 업데이트가 있다
  • 대용량 테이블에서 autovacuum 임계값이 너무 높다
  • VACUUM이 락/IO 때문에 자주 중단되거나 지연된다

바로 확인할 수 있는 신호

  1. 테이블별 dead tuple 추정치 확인
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가 오래 전이면 위험 신호입니다.
  1. 테이블 크기/인덱스 크기 확인
SELECT
  nspname AS schema,
  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 indexes
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;
  • heap 대비 total이 과도하게 커지면 인덱스 bloat도 의심해야 합니다.

대응 요령

  • 단기: VACUUM (ANALYZE)로 dead 튜플 회수 + 통계 갱신
  • 중기: autovacuum 튜닝(테이블별 스토리지 파라미터 포함)
  • 장기: 이미 bloat가 심하면 VACUUM FULL 또는 pg_repack 같은 재작성 전략 검토

주의: VACUUM FULL은 테이블을 재작성하며 강한 락이 필요합니다. 운영 중에는 영향 범위를 반드시 계산하세요.

증상 2) 쿼리가 점점 느려지고, 인덱스를 타도 체감이 안 난다

왜 생기나

VACUUM은 단순히 공간만 회수하는 게 아니라, **가시성 맵(visibility map)**을 갱신해 인덱스 온리 스캔(index-only scan)이 효율적으로 동작하도록 돕습니다. 또한 ANALYZE가 함께 이루어지지 않으면 플래너 통계가 낡아져 잘못된 실행 계획을 선택할 확률이 커집니다.

VACUUM이 부족하면 다음이 동시에 발생합니다.

  • 테이블/인덱스 페이지에 dead 튜플이 섞여 실제 읽기 페이지 수가 증가
  • 인덱스 온리 스캔이 힙 방문을 자주 하게 되어 이점이 감소
  • 통계가 오래되어 조인 순서/인덱스 선택이 틀어짐

바로 확인할 수 있는 신호

  1. 인덱스 온리 스캔인데 heap fetches가 높다
SELECT
  relname,
  idx_scan,
  idx_tup_read,
  idx_tup_fetch
FROM pg_stat_user_tables
ORDER BY idx_tup_fetch DESC
LIMIT 20;
  • idx_tup_fetch가 과도하면 인덱스만으로 못 끝나고 힙을 많이 뒤진다는 뜻일 수 있습니다.
  1. EXPLAIN (ANALYZE, BUFFERS)로 실제 읽기량 확인
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM orders
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 50;
  • Buffers: shared hit=... read=...에서 read가 커지는 추세면 캐시 미스/IO 증가 가능성이 큽니다.
  1. 통계가 오래됨
SELECT
  schemaname,
  relname,
  last_analyze,
  last_autoanalyze
FROM pg_stat_user_tables
ORDER BY COALESCE(last_autoanalyze, last_analyze) NULLS FIRST
LIMIT 30;

대응 요령

  • VACUUM (ANALYZE)를 우선 적용해 dead 튜플 회수와 통계를 동시에 갱신
  • 슬로우 쿼리 자체도 함께 점검(인덱스 미적중/옵티마이저 이슈). 다른 DB지만 점검 관점은 유사하니 MySQL·MongoDB 느린쿼리, 인덱스 미적중 7가지도 같이 참고하면 원인 분류에 도움이 됩니다.

증상 3) 갑자기 쓰기가 막히거나, 최악엔 트랜잭션 ID 랩어라운드 경고가 터진다

왜 생기나

PostgreSQL은 각 행 버전이 어떤 트랜잭션에서 만들어졌는지 XID(transaction ID)를 기반으로 가시성을 판단합니다. VACUUM은 dead 튜플을 치우는 것뿐 아니라, 오래된 XID를 정리해 **랩어라운드(wraparound)**를 방지합니다.

VACUUM이 제때 수행되지 않으면 다음 상황이 발생할 수 있습니다.

  • 오래된 테이블이 freeze 되지 않아 datfrozenxid가 뒤처짐
  • 임계치에 가까워지면 PostgreSQL이 보호를 위해 강제적으로 vacuum을 유도하거나, 더 심하면 쓰기 트랜잭션을 제한하는 상황으로 이어질 수 있음

이 문제는 “서서히 느려짐”이 아니라, 어느 순간 운영 중단급 이벤트로 나타날 수 있어 가장 위험합니다.

바로 확인할 수 있는 신호

  1. 데이터베이스별 XID 나이 확인
SELECT
  datname,
  age(datfrozenxid) AS xid_age
FROM pg_database
ORDER BY xid_age DESC;
  • xid_age가 비정상적으로 큰 DB가 있으면 즉시 원인 테이블을 찾아야 합니다.
  1. 테이블별 relfrozenxid 나이 확인
SELECT
  n.nspname AS schema,
  c.relname AS table,
  age(c.relfrozenxid) AS xid_age,
  pg_size_pretty(pg_total_relation_size(c.oid)) AS total
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
ORDER BY xid_age DESC
LIMIT 50;
  • 특정 대용량 테이블이 상위에 있으면, 그 테이블의 vacuum/freeze가 지연되고 있을 가능성이 큽니다.

대응 요령

  • 즉시: 해당 테이블에 VACUUM (FREEZE, ANALYZE)를 검토
  • 원인 제거: 장시간 트랜잭션(오래 열린 세션)이 vacuum을 방해하는지 확인

장시간 트랜잭션은 vacuum이 “치워도 되는 튜플”을 판단하지 못하게 만들기 때문에, autovacuum이 돌아도 효과가 크게 떨어집니다.

autovacuum이 있는데도 터지는 대표 패턴

1) 임계값이 테이블 크기에 비해 너무 크다

autovacuum_vacuum_scale_factor는 “테이블 크기 대비 일정 비율 이상 변경되면 vacuum”이라는 규칙입니다. 대용량 테이블에서 비율이 높으면 vacuum이 너무 늦게 시작합니다.

테이블별로 스토리지 파라미터를 조정할 수 있습니다.

ALTER TABLE orders
SET (autovacuum_vacuum_scale_factor = 0.02,
     autovacuum_vacuum_threshold = 5000,
     autovacuum_analyze_scale_factor = 0.01,
     autovacuum_analyze_threshold = 5000);
  • 핫 테이블은 비율을 낮추고(threshold도 적절히), 분석도 더 자주 돌게 만드는 게 일반적입니다.

2) vacuum가 따라가려면 IO/작업자 수가 부족하다

  • autovacuum_max_workers가 너무 낮으면 동시에 처리할 테이블 수가 제한됩니다.
  • autovacuum_vacuum_cost_limit/autovacuum_vacuum_cost_delay가 보수적이면 vacuum이 너무 “천천히” 진행됩니다.

설정 변경은 시스템 전체에 영향을 주니, 먼저 어떤 테이블이 병목인지부터 좁히는 게 안전합니다.

3) 락/경합으로 vacuum가 자주 밀린다

vacuum 자체는 보통 강한 락을 잡지 않지만, 스키마 변경이나 특정 작업과 겹치면 지연될 수 있습니다. 또한 과도한 경합은 다른 장애(데드락 등)로도 이어질 수 있으니, DB 동시성 이슈가 의심되면 PostgreSQL RDS deadlock_detected(40P01) 원인·해결도 함께 점검해보는 게 좋습니다.

운영에서 바로 쓰는 점검 체크리스트

1) vacuum 지연 테이블 Top N

SELECT
  schemaname,
  relname,
  n_dead_tup,
  last_autovacuum,
  last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
  • dead 튜플이 많은데 last_autovacuum가 오래 전이면 우선순위 높음

2) 장시간 트랜잭션/idle in transaction 확인

SELECT
  pid,
  usename,
  state,
  now() - xact_start AS xact_age,
  wait_event_type,
  wait_event,
  query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_age DESC
LIMIT 20;
  • stateidle in transaction이고 xact_age가 길면 vacuum 방해 가능성이 큼

3) 정말로 급하면 수동 vacuum

VACUUM (ANALYZE) orders;
  • 단, 대형 테이블은 IO를 크게 쓸 수 있으니 피크 타임에는 주의하세요.

결론: VACUUM은 “청소”가 아니라 성능/안정성 장치다

VACUUM을 안 하면 PostgreSQL은 다음 3가지를 거의 확실하게 겪습니다.

  1. 디스크 사용량 폭증: dead 튜플이 쌓여 bloat가 커짐
  2. 쿼리 지연 증가: 불필요 페이지 읽기 + 통계 노후화로 실행 계획 악화
  3. 랩어라운드 리스크 증가: freeze 지연으로 운영 중단급 이벤트 가능

운영 환경에서는 “autovacuum이 켜져 있다”가 끝이 아니라, 핫 테이블의 변경량과 autovacuum 설정이 맞는지, 그리고 장시간 트랜잭션이 없는지를 주기적으로 확인해야 합니다. 이 3가지만 체계적으로 봐도 VACUUM 관련 장애의 대부분은 사전에 차단할 수 있습니다.