- Published on
PostgreSQL VACUUM 안 돌아가 디스크 폭증 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 중인 PostgreSQL에서 어느 날부터 디스크 사용량이 급격히 증가하는데, 애플리케이션 트래픽은 평소와 비슷한 경우가 있습니다. 이런 상황에서 가장 흔한 원인 중 하나가 VACUUM(autovacuum 포함)이 제 역할을 못 하면서 죽은 튜플(dead tuple) 이 쌓여 테이블과 인덱스가 비대해지는 것입니다.
이 글은 “VACUUM이 안 돌아간다”는 증상을 정확히 어떤 상태로 정의하고, 원인을 좁혀가며, 디스크 폭증을 멈추고, 재발을 방지하는 순서로 정리합니다.
관련 장애 대응 관점에서, 데이터베이스가 비대해지면 커넥션 대기나 타임아웃 같은 2차 장애로 번지는 경우가 많습니다. 필요하면 커넥션 병목도 함께 점검하세요: Spring Boot HikariCP 커넥션 고갈 원인·해결 9가지
VACUUM이 “안 돈다”의 정확한 의미
먼저 상태를 나눠야 합니다.
- autovacuum 프로세스는 떠 있는데 효과가 없다
- 예:
n_dead_tup가 계속 증가 - 예: 테이블/인덱스 파일 크기는 계속 커지는데 줄지 않음
- 예:
- autovacuum 자체가 거의 실행되지 않는다
- 예:
last_autovacuum이 오래됨
- 예:
- VACUUM이 실행되다가 멈추거나 너무 오래 걸린다
- 예: 락 대기, I/O 병목, freeze 관련 작업 폭증
PostgreSQL에서 일반 VACUUM은 OS 디스크를 즉시 줄이지 않는 것이 정상입니다. 파일 내부의 빈 공간을 재사용 가능하게 만들 뿐이며, OS에 반납하려면 보통 VACUUM FULL 또는 pg_repack 같은 재작성(rewrite)이 필요합니다. 따라서 “디스크가 줄지 않는다”는 것만으로 VACUUM 실패로 단정하면 안 됩니다.
1단계: 지금 디스크를 누가 먹고 있는지부터 확인
테이블/인덱스 크기 상위 찾기
SELECT
schemaname,
relname,
pg_size_pretty(pg_total_relation_size(relid)) AS total,
pg_size_pretty(pg_relation_size(relid)) AS heap,
pg_size_pretty(pg_total_relation_size(relid) - pg_relation_size(relid)) AS index_and_toast
FROM pg_catalog.pg_statio_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 20;
heap은 테이블 본체index_and_toast가 크면 인덱스 비대 또는 TOAST(큰 값 저장) 비대 가능
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_vacuum,
last_autovacuum,
last_analyze,
last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
n_dead_tup가 큰데last_autovacuum이 비어 있거나 오래되면 autovacuum이 충분히 못 돈 신호입니다.
2단계: autovacuum이 실제로 돌고 있는지 확인
autovacuum 워커가 실행 중인지
SELECT
pid,
usename,
datname,
state,
wait_event_type,
wait_event,
query
FROM pg_stat_activity
WHERE query ILIKE '%autovacuum%'
ORDER BY pid;
wait_event_type이Lock이면 락에 막힌 것입니다.IO또는 빈번한BufferPin류 대기면 I/O 또는 핫페이지 경합을 의심합니다.
설정값 확인
SHOW autovacuum;
SHOW autovacuum_max_workers;
SHOW autovacuum_naptime;
SHOW autovacuum_vacuum_threshold;
SHOW autovacuum_vacuum_scale_factor;
SHOW autovacuum_analyze_threshold;
SHOW autovacuum_analyze_scale_factor;
SHOW autovacuum_vacuum_cost_limit;
SHOW autovacuum_vacuum_cost_delay;
여기서 흔한 실수는 다음입니다.
autovacuum = off로 꺼져 있음autovacuum_max_workers가 너무 작아 큰 테이블이 많은 환경에서 처리량 부족autovacuum_vacuum_cost_limit이 너무 낮거나cost_delay가 너무 커서 지나치게 천천히 수행
3단계: VACUUM이 막히는 대표 원인 6가지
원인 1) 장기 트랜잭션(롱 트랜잭션) 때문에 청소를 못 함
PostgreSQL은 MVCC 특성상 오래 열린 트랜잭션이 있으면, 그 트랜잭션이 볼 수 있어야 하는 과거 버전 튜플을 지우지 못합니다. 결과적으로 dead tuple이 계속 쌓입니다.
확인:
SELECT
pid,
usename,
datname,
now() - xact_start AS xact_age,
state,
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;
대응:
- 애플리케이션에서 트랜잭션을 오래 잡는 코드(대량 처리, 외부 API 호출 포함)를 분리
- 배치 작업은 커밋 단위를 쪼개기
- 읽기 전용이라도 긴 트랜잭션은 vacuum을 막을 수 있음
- 필요 시 문제 세션을 종료(신중히)
SELECT pg_terminate_backend(`pid`);
주의: 위 쿼의 pid는 실제 정수 값으로 치환해 실행하세요.
원인 2) 복제 슬롯(logical replication slot)로 인해 WAL이 쌓임
디스크 폭증이 테이블이 아니라 pg_wal일 수도 있습니다. VACUUM 문제처럼 보이지만, 실제로는 WAL이 지워지지 않는 상황입니다.
확인:
SELECT
slot_name,
plugin,
slot_type,
active,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots
ORDER BY pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) DESC;
대응:
- 사용하지 않는 슬롯 제거
- 소비자(예: Debezium, logical subscriber) 장애 복구
- 물리 복제라면
pg_stat_replication의 지연도 확인
원인 3) autovacuum이 락에 막힘
VACUUM은 일반적으로 강한 락을 오래 잡지 않지만, 특정 DDL이나 인덱스 작업과 충돌하면 대기할 수 있습니다.
확인(락 대기 관계):
SELECT
a.pid AS waiting_pid,
a.query AS waiting_query,
b.pid AS blocking_pid,
b.query AS blocking_query
FROM pg_stat_activity a
JOIN pg_locks l1 ON l1.pid = a.pid AND NOT l1.granted
JOIN pg_locks l2 ON l2.locktype = l1.locktype
AND l2.database IS NOT DISTINCT FROM l1.database
AND l2.relation IS NOT DISTINCT FROM l1.relation
AND l2.page IS NOT DISTINCT FROM l1.page
AND l2.tuple IS NOT DISTINCT FROM l1.tuple
AND l2.virtualxid IS NOT DISTINCT FROM l1.virtualxid
AND l2.transactionid IS NOT DISTINCT FROM l1.transactionid
AND l2.classid IS NOT DISTINCT FROM l1.classid
AND l2.objid IS NOT DISTINCT FROM l1.objid
AND l2.objsubid IS NOT DISTINCT FROM l1.objsubid
AND l2.pid != l1.pid
JOIN pg_stat_activity b ON b.pid = l2.pid
WHERE a.query ILIKE '%vacuum%';
대응:
- 배포 시간에 대형 DDL을 피하고,
CREATE INDEX CONCURRENTLY사용 - 장시간 트랜잭션과 DDL을 동시에 돌리지 않기
원인 4) vacuum freeze 폭탄(트랜잭션 ID wraparound 위험)
VACUUM이 “안 도는 것처럼” 보이다가 어느 순간 엄청 느려지거나, 시스템이 wraparound 방지를 위해 강제 vacuum에 들어가면서 장애가 나기도 합니다.
확인:
SELECT
datname,
age(datfrozenxid) AS xid_age,
current_setting('autovacuum_freeze_max_age') AS freeze_max_age
FROM pg_database
ORDER BY age(datfrozenxid) DESC;
특정 테이블도 확인:
SELECT
n.nspname,
c.relname,
age(c.relfrozenxid) AS rel_xid_age
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r','m')
ORDER BY age(c.relfrozenxid) DESC
LIMIT 20;
대응:
- 해당 테이블에 대해 우선
VACUUM (FREEZE, VERBOSE, ANALYZE)수행 - 장기 트랜잭션을 제거하지 않으면 freeze도 진행이 막힐 수 있음
원인 5) 테이블별 autovacuum 설정이 비현실적
특정 테이블에만 dead tuple이 폭증한다면, 기본 scale factor가 너무 커서 vacuum 트리거가 늦게 걸리는 경우가 많습니다.
예: autovacuum_vacuum_scale_factor = 0.2면, 1억 행 테이블은 2천만 dead tuple이 쌓여야 vacuum이 적극적으로 돌 수 있습니다.
테이블별로 더 공격적으로 설정:
ALTER TABLE public.big_table SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_threshold = 10000,
autovacuum_analyze_scale_factor = 0.01,
autovacuum_analyze_threshold = 5000
);
주의: 무조건 낮추면 autovacuum이 과도하게 자주 돌아 I/O를 잡아먹을 수 있으니, 상위 몇 개 대형 테이블부터 점진 적용하세요.
원인 6) 인덱스 비대가 주범인데 VACUUM만 돌리고 있음
업데이트가 많은 테이블은 인덱스가 특히 비대해질 수 있습니다. 일반 VACUUM은 인덱스 공간을 일부 정리하지만, 디스크를 눈에 띄게 줄이긴 어렵습니다.
진단(확장 사용 가능 시):
pgstattuple확장으로 bloat 추정 가능
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT * FROM pgstattuple('public.big_table');
대응 옵션:
- 온라인에 가깝게 하려면
REINDEX CONCURRENTLY
REINDEX INDEX CONCURRENTLY public.big_table_some_idx;
- 테이블 자체가 심각하면
pg_repack고려(별도 도구) - 다운타임 허용 시 최후 수단으로
VACUUM FULL(강한 락)
VACUUM (FULL, VERBOSE, ANALYZE) public.big_table;
4단계: “지금 당장” 디스크 폭증을 멈추는 응급 처치 플랜
상황별로 우선순위를 나누는 게 안전합니다.
A. pg_wal이 폭증한 경우(복제/슬롯)
pg_replication_slots에서retained_wal상위 확인- 사용하지 않는 슬롯 제거 또는 소비자 복구
- 아카이빙/백업 파이프라인 장애 여부 점검
테이블 vacuum을 아무리 해도 WAL 문제는 해결되지 않습니다.
B. 테이블/인덱스 비대로 폭증한 경우(MVCC bloat)
- 장기 트랜잭션 제거(가장 중요)
- 상위 1~3개 대형 테이블에 대해 수동 vacuum 가속
VACUUM (VERBOSE, ANALYZE) public.big_table;
- 인덱스 비대가 크면
REINDEX CONCURRENTLY로 순차 재작성 - 디스크가 이미 임계치면, 다운타임을 잡고
VACUUM FULL또는pg_repack으로 공간 회수
C. autovacuum 처리량이 부족한 경우
- 워커 수와 비용 파라미터를 조정해 “조금 더 자주, 조금 더 빠르게”
예시(환경에 맞게 조정 필요):
ALTER SYSTEM SET autovacuum_max_workers = '6';
ALTER SYSTEM SET autovacuum_vacuum_cost_limit = '2000';
ALTER SYSTEM SET autovacuum_vacuum_cost_delay = '5ms';
SELECT pg_reload_conf();
주의: 이는 I/O를 더 쓰는 방향입니다. 스토리지 여력이 없으면 오히려 쿼리 지연이 커질 수 있어, 트래픽 낮은 시간부터 적용하고 모니터링하세요.
5단계: 재발 방지 체크리스트(운영 관점)
1) 장기 트랜잭션을 “장애”로 취급하기
- 애플리케이션 레벨에서 트랜잭션 시간 상한 설정
- 배치 작업은 페이징 처리 및 주기적 커밋
- 커넥션 풀에서 대기/고갈이 생기면 DB에서 트랜잭션이 길어지는 악순환이 생깁니다. 커넥션 이슈까지 같이 보면 해결이 빨라집니다: Spring Boot 3 가상스레드로 DB 커넥션 고갈 막기
2) 모니터링 지표를 “크기”가 아니라 “누적”으로 잡기
권장 관찰 항목:
pg_stat_user_tables.n_dead_tup상위last_autovacuum지연age(datfrozenxid)및 상위 테이블age(relfrozenxid)pg_wal디렉터리 크기,pg_replication_slots의 retained WAL
3) 테이블별 autovacuum 튜닝을 기본으로
대형 테이블, 업데이트가 많은 테이블은 전역 기본값으로는 늦습니다.
- 핵심 테이블에만 scale factor를 낮추고
- 나머지는 기본값 유지
- 릴리즈마다 데이터 증가에 맞춰 재조정
4) 파티셔닝과 데이터 수명주기(보관/삭제) 설계
대량 삭제는 dead tuple을 폭증시킵니다. 시계열 데이터라면:
- 파티션 drop 방식으로 삭제를 대체(메타데이터 작업)
- 오래된 데이터는 아카이빙 후 파티션 단위 제거
이는 vacuum 부담을 구조적으로 줄이는 가장 강력한 방법입니다.
실전 예시: “삭제 배치 후 디스크가 계속 증가” 케이스
증상:
- 매일 밤 대량
DELETE - 다음날 디스크 사용량 증가
- 쿼리는 점점 느려짐
진단 흐름:
pg_total_relation_size상위에서 특정 테이블/인덱스가 급증pg_stat_user_tables에서 해당 테이블n_dead_tup폭증pg_stat_activity에서 장기 트랜잭션(리포팅 쿼리)이 밤새 열려 있음 확인
조치:
- 리포팅 쿼리 세션 종료 또는 타임아웃 적용
- 삭제 배치를
DELETE에서 파티션 drop으로 전환(가능하다면) - 단기적으로
VACUUM (ANALYZE)+ 인덱스REINDEX CONCURRENTLY - 장기적으로 테이블별 autovacuum 파라미터 조정
마무리
PostgreSQL에서 VACUUM은 “한 번 돌리면 끝”이 아니라, 트랜잭션 패턴, autovacuum 처리량, 테이블 설계, 복제/WAL 구조가 함께 맞물리는 운영 시스템입니다.
디스크 폭증을 봤을 때는 먼저 “테이블 비대”인지 “pg_wal 적체”인지 갈라내고, 테이블 비대라면 장기 트랜잭션부터 제거한 뒤, 상위 테이블에 대해 vacuum과 인덱스 재작성을 단계적으로 적용하세요. 이 순서만 지켜도 불필요한 다운타임과 위험한 VACUUM FULL 남발을 크게 줄일 수 있습니다.