- Published on
PostgreSQL VACUUM·AUTOVACUUM 튜닝 - bloat로 느려질 때
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 갑자기 느려졌는데 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의 역할은 크게 두 가지입니다.
- dead tuple을 “재사용 가능” 상태로 만들기 (공간 회수는 즉시 OS로 반환되지 않을 수 있음)
- 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같은 온라인 재작성 도구(운영 정책/보안에 따라 선택)
운영에서의 추천 절차(체크리스트)
- 긴 트랜잭션부터 제거/개선(애플리케이션 커넥션 풀, 배치 트랜잭션 범위)
pg_stat_user_tables로 dead tuple 상위 테이블 식별- 해당 테이블에 테이블별 autovacuum 파라미터 적용(scale factor 낮추기)
- 피크 시간대가 아니라면
VACUUM (ANALYZE)로 즉시 회복 시도 - 인덱스가 과도하게 커졌다면 REINDEX CONCURRENTLY로 단계적 정비
- freeze 위험 테이블은 우선순위 최상(성능 이슈보다 더 위험)
자주 하는 실수
VACUUM을 “디스크 용량을 줄이는 명령”로 오해- 일반 VACUUM은 내부 재사용 공간을 만들 뿐, OS로 즉시 반환하지 않을 수 있습니다.
autovacuum을 꺼버림
- 단기적으로 조용해 보이지만 장기적으로 bloat/wraparound 리스크가 폭발합니다.
scale factor를 전 테이블에 일괄로 낮춤
- 불필요한 vacuum 폭주로 오히려 성능이 흔들릴 수 있습니다. 핫 테이블 중심으로 조정하세요.
마무리
PostgreSQL에서 bloat로 인한 성능 저하는 “쿼리 튜닝”만으로 해결되지 않는 경우가 많고, 결국 VACUUM이 얼마나 제때, 얼마나 적절한 강도로 돌고 있는가의 문제로 귀결됩니다.
핵심은 두 가지입니다.
- autovacuum이 밀리는 구조적 원인(긴 트랜잭션, worker 부족, 비용 제한, 늦은 트리거)을 제거한다.
- 전체를 건드리기보다, 문제가 집중되는 테이블에 테이블별 정책을 적용한다.
이 두 축만 제대로 잡아도 “bloat로 쿼리 느려질 때”의 대부분은 재발 빈도를 크게 낮출 수 있습니다.
추가로, 애플리케이션 레벨에서 커넥션 풀 고갈/지연이 DB 문제로 오인되는 케이스도 많습니다. 스레드/커넥션 모델 변경 이후 지연이 생겼다면 Spring Boot 3 가상스레드에서 HikariCP 고갈 해결처럼 앱-DB 경계도 함께 점검해보면 원인 분리가 빨라집니다.