
DELETE 쿼리를 수십만 건 실행했는데 df -h로 확인한 디스크 사용량이 줄지 않아서 검색하셨다면, 결론은 간단합니다. PostgreSQL은 행을 지워도 그 공간을 바로 운영체제에 돌려주지 않습니다. 삭제된 행은 “데드 튜플”로 테이블 파일 안에 그대로 남아 있고, 이걸 치우는 오토배큠(autovacuum)이 제대로 돌지 않으면 테이블 파일 크기는 줄어들지 않습니다. 이 글은 PostgreSQL 13 이상, 단일 테이블 기준 autovacuum 파라미터 조정을 전제로 설명합니다.
행을 지웠는데 디스크가 그대로인 이유
PostgreSQL은 여러 트랜잭션이 동시에 같은 행을 읽고 쓰도록 하기 위해 다중버전동시성제어 방식을 씁니다. DELETE를 실행해도 행이 즉시 사라지는 게 아니라 “삭제됨” 표시만 붙은 채로 테이블 파일에 남습니다. 이 방식을 MVCC라고 부르는데, 다른 트랜잭션이 삭제 전 시점의 데이터를 봐야 할 수도 있기 때문에 즉시 삭제하면 일관성이 깨집니다.
이렇게 남은 행을 데드 튜플이라고 합니다. 데드 튜플은 디스크 공간을 계속 점유하고 있고, 이걸 정리해서 재사용 가능하게 만드는 작업이 VACUUM입니다. 문제는 일반 VACUUM이 공간을 테이블 내부에서 재사용 가능하게만 만들고, 파일 끝에 빈 페이지가 남지 않는 한 파일 자체를 줄이지는 않는다는 점입니다. 그래서 DELETE 직후 df -h를 봐도 변화가 없는 게 정상 동작입니다.
데드 튜플이 쌓였는지 확인하는 방법
가장 먼저 할 일은 짐작 대신 숫자로 확인하는 것입니다. pg_stat_user_tables 뷰에는 테이블별 살아있는 행(n_live_tup)과 죽은 행(n_dead_tup) 수가 기록됩니다.
SELECT relname, n_live_tup, n_dead_tup,
round(n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup, 0) * 100, 1) AS dead_ratio
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;
dead_ratio가 20~30%를 넘어가는 테이블이 있다면 오토배큠이 제때 돌지 않고 있다는 신호입니다. 같은 뷰의 last_autovacuum, autovacuum_count 컬럼도 함께 보면 마지막으로 언제 돌았는지, 몇 번 돌았는지 알 수 있습니다. 대량 DELETE나 UPDATE가 반복되는 배치 테이블인데 last_autovacuum이 며칠 전이라면, 다음 섹션에서 다루는 파라미터 문제일 가능성이 높습니다.

오토배큠이 도는데도 테이블이 줄지 않는 이유
autovacuum이 실행되고 있어도 테이블 파일 크기가 그대로인 경우가 흔합니다. 이유는 VACUUM의 공간 회수 방식 자체에 있습니다. 일반 VACUUM은 데드 튜플이 있던 자리를 빈 공간으로 표시해서 이후 INSERT나 UPDATE가 그 자리를 재사용하게 만들 뿐, 파일 중간에 뚫린 구멍을 채우려고 파일을 앞으로 당겨오지는 않습니다.
파일 맨 끝쪽 페이지들이 전부 비어 있을 때만 autovacuum이 그 꼬리 부분을 잘라내서(truncate) 파일 크기를 줄입니다. 그런데 테이블에 INSERT가 꾸준히 섞여 들어오면 빈 페이지가 파일 끝까지 몰리기 전에 새 데이터가 그 자리를 채워버려서 truncate가 일어나지 않습니다. 결국 “데드 튜플은 정리됐지만 파일 크기는 그대로”인 상태가 계속 유지됩니다. 참고로 PostgreSQL 12부터는 테이블별로 vacuum_truncate 저장 파라미터를 꺼서 이 truncate 과정에서 발생하는 락 경쟁 자체를 피하는 선택도 가능합니다.
autovacuum 파라미터 튜닝, 이렇게 시작하세요
기본값은 대부분의 워크로드에 너무 느슨합니다. autovacuum이 돌아가는 기준은 autovacuum_vacuum_threshold(기본 50) + autovacuum_vacuum_scale_factor(기본 0.2) × 테이블 행 수로 계산됩니다. 행이 100만 건인 테이블이면 20만 건 이상 죽어야 vacuum이 트리거된다는 뜻입니다. DELETE와 UPDATE가 자주 일어나는 대형 테이블에는 이 기준이 너무 늦습니다.
테이블 단위로 다음처럼 조정하면 더 자주, 더 가볍게 vacuum이 돌도록 만들 수 있습니다.
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_vacuum_threshold = 1000,
autovacuum_vacuum_cost_delay = 2
);
scale_factor를 낮추면 vacuum이 더 자주 돌아서 데드 튜플이 쌓이기 전에 정리되지만, 그만큼 I/O 부하가 늘어납니다. autovacuum_vacuum_cost_delay를 낮추면 vacuum이 더 빠르게 끝나지만 디스크 경쟁이 심한 시간대에는 다른 쿼리에 영향을 줄 수 있습니다. 운영 중인 시스템이라면 한 번에 모든 테이블을 바꾸지 말고, dead_ratio가 높은 테이블 한두 개부터 적용해 보고 pg_stat_user_tables로 효과를 확인하는 식으로 접근하는 편이 안전합니다. 파라미터 전체 목록과 기본값은 VACUUM 공식 레퍼런스에 정리돼 있습니다.
VACUUM FULL과 pg_repack 중 무엇을 선택해야 할까요
튜닝만으로 데드 튜플 누적은 막을 수 있지만, 이미 부풀어 있는 기존 테이블 파일 자체를 줄이려면 다른 조치가 필요합니다. 가장 확실한 방법은 VACUUM FULL입니다.
VACUUM (FULL, VERBOSE, ANALYZE) orders;
VACUUM FULL은 테이블을 처음부터 다시 써서 데드 튜플을 완전히 제거하고 파일 크기를 실제로 줄여줍니다. 다만 작업 중에는 ACCESS EXCLUSIVE 락이 걸려서 해당 테이블에 대한 읽기·쓰기가 모두 막히고, 재작성 과정에서 원본과 비슷한 크기의 여유 디스크 공간이 추가로 필요합니다. 수십 GB가 넘는 테이블을 서비스 중에 VACUUM FULL로 돌리면 그 시간 동안 서비스 장애가 나는 셈이라서, 운영 중인 대형 테이블에는 거의 쓰기 어렵습니다.
이럴 때 대안으로 쓰는 게 pg_repack 확장입니다. 원본 테이블을 복제한 뒤 인덱스를 다시 만들고 짧은 락만으로 테이블을 교체하는 방식이라 서비스 중단 없이 테이블을 재작성할 수 있습니다. 단, 재작성 중에는 원본 테이블만큼의 여유 공간이 추가로 필요하고, 기본키가 없는 테이블에는 적용이 까다롭습니다. 관리형 서비스를 쓰고 있다면 해당 서비스에서 pg_repack 확장 설치를 허용하는지 먼저 확인해야 합니다.
pg_repack -d mydb -t orders
지금 바로 실행할 수 있는 점검 항목
행을 지워도 디스크가 안 줄어드는 현상은 버그가 아니라 MVCC 구조상 당연한 동작입니다. 해결 순서는 명확합니다. 먼저 pg_stat_user_tables로 어느 테이블에 데드 튜플이 몰려 있는지 숫자로 확인하고, 그 테이블의 autovacuum 파라미터를 더 적극적으로 조정해서 다음부터는 쌓이지 않게 막습니다. 이미 부풀어 있는 파일은 서비스 영향이 허용되면 VACUUM FULL로, 중단이 어려우면 pg_repack으로 줄입니다.
오늘 당장 할 수 있는 작업은 위의 dead_ratio 조회 쿼리를 운영 DB에 돌려보는 것입니다. 상위 테이블 몇 개만 추려서 autovacuum_vacuum_scale_factor를 낮춰보고, 일주일 정도 지켜본 뒤 다시 같은 쿼리로 비율이 낮아졌는지 확인해 보시기 바랍니다.
