Published on

PostgreSQL VACUUM·AUTOVACUUM 튜닝 - bloat로 느려질 때

Authors

서버가 갑자기 느려졌는데 CPU는 애매하게 바쁘고, 디스크 I/O가 꾸준히 튀며, EXPLAIN을 보면 계획은 그럴듯한데 실행 시간이 길어지는 경우가 있습니다. PostgreSQL에서는 이때 bloat(테이블/인덱스 팽창)VACUUM 지연을 의심해야 합니다. 특히 UPDATE/DELETE가 잦은 서비스(주문 상태 변경, 세션/토큰 테이블, 이벤트 로그 정정 등)는 MVCC 특성상 죽은 튜플(dead tuple)이 자연스럽게 쌓이고, 이를 제때 청소하지 못하면 쿼리 성능이 “서서히”가 아니라 “어느 순간” 무너집니다.

이 글은 VACUUM/AUTOVACUUM의 핵심 원리 → bloat 진단 → 파라미터 튜닝 → 운영에서의 안전한 실행 순서까지, 실무에서 바로 적용 가능한 형태로 정리합니다.

> 운영 환경에서 튜닝을 하다 보면 DB만의 문제가 아니라 인프라/오케스트레이션 이슈(예: OOMKilled로 autovacuum worker가 죽는다거나, 노드 리소스 제약으로 I/O가 막히는 상황)와 엮이기도 합니다. 쿠버네티스 환경에서 메모리 부족으로 장애가 반복된다면 EKS CrashLoopBackOff - OOMKilled·Exit 137 원인과 해결도 함께 점검하는 것이 좋습니다.

왜 bloat가 쿼리를 느리게 만드는가 (MVCC 관점)

PostgreSQL은 MVCC를 사용합니다. UPDATE는 “제자리 수정”이 아니라 새 버전의 튜플을 추가하고, 기존 버전은 더 이상 보이지 않게(marked dead) 됩니다. DELETE도 마찬가지로 “삭제 표시”만 남깁니다.

  • 테이블에 dead tuple이 쌓이면
    • 같은 결과를 얻기 위해 더 많은 페이지(블록)를 읽어야 합니다.
    • 힙(테이블) 접근이 늘고, 캐시 효율이 떨어집니다.
  • 인덱스도 bloat가 생기면
    • 인덱스 페이지가 커져서 탐색 단계 자체가 늘어납니다.
    • 인덱스 스캔 후 힙 접근(랜덤 I/O)이 증가합니다.

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

  1. dead tuple을 “재사용 가능” 상태로 만들기 (공간 회수는 즉시 OS로 반환되지 않을 수 있음)
  2. frozen XID/멀티XID 관리로 트랜잭션 wraparound 방지

즉, VACUUM이 밀리면 성능 저하뿐 아니라 wraparound로 인한 강제 VACUUM(사실상 장애) 위험도 커집니다.

증상 체크: bloat/VACUUM 지연을 의심할 신호

다음이 동시에 보이면 가능성이 높습니다.

  • 특정 테이블/인덱스만 유독 느려짐 (핫 테이블)
  • SELECT도 느려지지만, 특히 UPDATE/DELETE가 더 느려짐
  • pg_stat_user_tables에서 n_dead_tup가 계속 증가
  • autovacuum이 돌긴 도는데 “항상 뭔가를 하고 있음”
  • 디스크 사용량이 꾸준히 증가 (특히 인덱스가 커짐)

빠른 진단 SQL: 지금 어디가 부었는지 확인

1) 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_pct,
  last_autovacuum,
  last_vacuum,
  vacuum_count,
  autovacuum_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 30;
  • dead_pct가 높고 last_autovacuum가 오래됐다면 “청소가 못 따라가는” 상태일 확률이 큽니다.

2) 테이블/인덱스 크기 상위

SELECT
  n.nspname AS schema,
  c.relname,
  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 30;
  • 인덱스 비중이 과도하게 크면 인덱스 bloat 가능성을 의심합니다.

3) autovacuum이 지금 뭘 하고 있나

SELECT
  pid,
  datname,
  relid::regclass AS relation,
  phase,
  heap_blks_total,
  heap_blks_scanned,
  heap_blks_vacuumed,
  index_vacuum_count,
  max_dead_tuples,
  num_dead_tuples
FROM pg_stat_progress_vacuum;
  • phase가 오래 멈춰 있거나, 스캔은 하는데 vacuumed가 잘 안 늘면 I/O나 락/경합을 확인해야 합니다.

4) freeze 위험(트랜잭션 age) 점검

SELECT
  c.oid::regclass AS table,
  age(c.relfrozenxid) AS xid_age,
  current_setting('autovacuum_freeze_max_age') AS freeze_max_age
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 age(c.relfrozenxid) DESC
LIMIT 30;
  • xid_age가 상위권인 테이블은 성능보다 안정성 관점에서 우선 처리 대상입니다.

VACUUM 종류를 정확히 구분하기

운영에서 자주 혼동하는 포인트입니다.

  • VACUUM (기본)

    • dead tuple을 재사용 가능하게 표시
    • 통계 갱신은 별도 옵션(또는 autovacuum analyze)
    • 파일을 줄여 OS로 즉시 반환하지는 않음
  • VACUUM (ANALYZE)

    • vacuum + 통계 갱신
    • 플랜이 흔들리는 구간에서 효과가 큼
  • VACUUM FULL

    • 테이블을 새로 써서 물리적으로 압축(실제 공간 반환)
    • 강한 락(ACCESS EXCLUSIVE) 필요 → 운영에서 매우 신중
  • REINDEX

    • 인덱스 bloat가 심할 때 필요
    • REINDEX CONCURRENTLY로 락을 줄일 수 있으나 시간이 더 걸리고 리소스를 사용

정리하면, 성능 이슈의 80%는 autovacuum이 제때 돌게 만드는 튜닝 + 필요 시 부분적 REINDEX로 해결하고, VACUUM FULL은 “마지막 수단”으로 두는 편이 안전합니다.

AUTOVACUUM이 ‘제때’ 못 도는 대표 원인

1) 임계치(threshold)가 테이블 변화량을 못 따라감

autovacuum 트리거는 대략 다음으로 결정됩니다.

  • vacuum 트리거: autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * reltuples
  • analyze 트리거: autovacuum_analyze_threshold + autovacuum_analyze_scale_factor * reltuples

테이블이 크고 변경이 잦으면 scale factor가 커서 “너무 늦게” 발동합니다.

2) 작업자(worker) 수가 부족하거나, 한 테이블이 너무 오래 점유

  • autovacuum_max_workers가 낮으면 대기열이 밀립니다.
  • 한 번 vacuum이 시작되면 큰 테이블은 오래 걸려서 다른 테이블이 방치됩니다.

3) 비용 기반 throttling 때문에 너무 느리게 진행

  • autovacuum_vacuum_cost_limit, autovacuum_vacuum_cost_delay
  • I/O를 아끼는 대신 vacuum이 밀려 bloat가 커지는 전형적인 트레이드오프가 발생합니다.

4) long transaction(긴 트랜잭션)으로 dead tuple 제거가 불가

VACUUM은 “아무도 볼 수 없는” 튜플만 정리합니다. 오래 열린 트랜잭션이 있으면 dead tuple이 dead가 아니게 됩니다.

긴 트랜잭션 확인:

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

실전 튜닝 전략: “전역 + 테이블별”로 나눠 접근

1) 전역 설정: worker/리소스/속도 균형 잡기

아래는 방향성입니다(정답 값은 워크로드/스토리지/코어 수에 따라 달라집니다).

  • autovacuum_max_workers

    • 기본값이 낮으면(버전에 따라 3) 대형 서비스에서 부족합니다.
    • CPU/디스크 여유가 있다면 늘려 병렬성을 확보합니다.
  • autovacuum_naptime

    • 너무 길면(기본 1min) burst 트래픽에서 따라잡기 어렵습니다.
    • 짧게 하면 더 자주 점검하지만 오버헤드가 늘 수 있습니다.
  • autovacuum_vacuum_cost_limit / autovacuum_vacuum_cost_delay

    • vacuum이 밀려 bloat가 커졌다면 “조금 더 공격적으로” 진행하도록 조정합니다.
    • 단, OLTP 피크 시간대에 무작정 올리면 쿼리 I/O와 경쟁할 수 있습니다.

전역 설정 예시(상황에 맞게 조정):

ALTER SYSTEM SET autovacuum_max_workers = 6;
ALTER SYSTEM SET autovacuum_naptime = '30s';
ALTER SYSTEM SET autovacuum_vacuum_cost_limit = 4000;
ALTER SYSTEM SET autovacuum_vacuum_cost_delay = '5ms';

SELECT pg_reload_conf();

2) 테이블별 설정: “핫 테이블”은 scale factor를 낮춘다

bloat는 대체로 일부 테이블에서 집중적으로 발생합니다. 해당 테이블만 별도 정책을 주는 것이 가장 비용 효율적입니다.

예: 업데이트가 매우 잦은 orders 테이블

ALTER TABLE public.orders SET (
  autovacuum_vacuum_scale_factor = 0.02,
  autovacuum_vacuum_threshold = 2000,
  autovacuum_analyze_scale_factor = 0.01,
  autovacuum_analyze_threshold = 1000
);
  • scale factor를 낮추면 더 자주 vacuum/analyze가 트리거됩니다.
  • threshold는 “작은 테이블에서 너무 자주 도는 것”을 막는 완충재 역할을 합니다.

3) vacuum이 너무 오래 걸리는 테이블: parallel vacuum 고려

PostgreSQL은 버전에 따라 vacuum에서 병렬 처리를 지원합니다(주로 인덱스 vacuum 단계). 다음 설정을 검토합니다.

  • max_parallel_maintenance_workers
ALTER SYSTEM SET max_parallel_maintenance_workers = 4;
SELECT pg_reload_conf();

4) 통계가 낡아 플랜이 망가질 때: analyze 우선

bloat 자체보다 통계 부정확 때문에 느려지는 경우도 많습니다.

VACUUM (ANALYZE) public.orders;
  • 대량 변경 직후, 배치 작업 직후에 특히 효과적입니다.

인덱스 bloat 대응: REINDEX를 “안전하게”

테이블 vacuum만으로 인덱스 bloat가 충분히 줄지 않는 경우가 있습니다. 이때는 REINDEX CONCURRENTLY를 고려합니다.

REINDEX INDEX CONCURRENTLY public.idx_orders_created_at;
  • 동시성 락을 줄여 운영 영향도를 낮춥니다.
  • 대신 시간이 더 걸리고, 작업 중 추가 디스크 공간이 필요할 수 있습니다.

여러 인덱스를 한 번에 재구성해야 한다면(버전에 따라) 테이블 단위도 가능하지만, 운영 영향과 디스크 여유를 먼저 계산하세요.

VACUUM FULL은 언제 쓰나 (그리고 대안)

VACUUM FULL은 실제로 파일을 재작성해 공간을 반환하지만, 강한 락 때문에 OLTP 서비스에는 위험합니다.

  • 정말 필요한 상황
    • 대량 삭제 후 디스크를 반드시 회수해야 함
    • 다운타임/점검 창이 확보됨
VACUUM FULL public.event_logs;

대안으로는 다음이 자주 쓰입니다.

  • 파티셔닝 후 오래된 파티션 DROP (가장 깔끔한 공간 회수)
  • pg_repack 같은 온라인 재작성 도구(운영 정책/보안에 따라 선택)

운영에서의 추천 절차(체크리스트)

  1. 긴 트랜잭션부터 제거/개선(애플리케이션 커넥션 풀, 배치 트랜잭션 범위)
  2. pg_stat_user_tablesdead tuple 상위 테이블 식별
  3. 해당 테이블에 테이블별 autovacuum 파라미터 적용(scale factor 낮추기)
  4. 피크 시간대가 아니라면 VACUUM (ANALYZE)로 즉시 회복 시도
  5. 인덱스가 과도하게 커졌다면 REINDEX CONCURRENTLY로 단계적 정비
  6. freeze 위험 테이블은 우선순위 최상(성능 이슈보다 더 위험)

자주 하는 실수

  • VACUUM을 “디스크 용량을 줄이는 명령”로 오해

    • 일반 VACUUM은 내부 재사용 공간을 만들 뿐, OS로 즉시 반환하지 않을 수 있습니다.
  • autovacuum을 꺼버림

    • 단기적으로 조용해 보이지만 장기적으로 bloat/wraparound 리스크가 폭발합니다.
  • scale factor를 전 테이블에 일괄로 낮춤

    • 불필요한 vacuum 폭주로 오히려 성능이 흔들릴 수 있습니다. 핫 테이블 중심으로 조정하세요.

마무리

PostgreSQL에서 bloat로 인한 성능 저하는 “쿼리 튜닝”만으로 해결되지 않는 경우가 많고, 결국 VACUUM이 얼마나 제때, 얼마나 적절한 강도로 돌고 있는가의 문제로 귀결됩니다.

핵심은 두 가지입니다.

  • autovacuum이 밀리는 구조적 원인(긴 트랜잭션, worker 부족, 비용 제한, 늦은 트리거)을 제거한다.
  • 전체를 건드리기보다, 문제가 집중되는 테이블에 테이블별 정책을 적용한다.

이 두 축만 제대로 잡아도 “bloat로 쿼리 느려질 때”의 대부분은 재발 빈도를 크게 낮출 수 있습니다.

추가로, 애플리케이션 레벨에서 커넥션 풀 고갈/지연이 DB 문제로 오인되는 케이스도 많습니다. 스레드/커넥션 모델 변경 이후 지연이 생겼다면 Spring Boot 3 가상스레드에서 HikariCP 고갈 해결처럼 앱-DB 경계도 함께 점검해보면 원인 분리가 빨라집니다.