VACUUM을 돌렸는데 디스크가 그대로라는 이야기를 종종 듣는다. 정상 동작이다. VACUUM은 공간을 운영체제에 돌려주는 명령이 아니다.
여기서부터 시작해서 VACUUM이 실제로 무엇을 하는지, bloat가 왜 쌓이고 어떻게 재는지까지 정리한다.
죽은 튜플은 어떻게 생기는가
PostgreSQL은 MVCC를 쓰기 때문에 UPDATE가 제자리 수정이 아니다. 기존 행을 고치는 게 아니라 새 버전을 하나 더 쓰고, 예전 버전에 “이 시점 이후로는 무효"라는 표시를 남긴다. DELETE도 마찬가지로 즉시 지우지 않고 무효 표시만 한다.
예전 버전을 바로 못 지우는 이유는 아직 그걸 봐야 하는 트랜잭션이 있을 수 있어서다. 먼저 시작된 트랜잭션에게는 여전히 그 버전이 유효한 데이터다.
그래서 어떤 튜플이 정말로 필요 없어지는 시점은 “삭제된 순간"이 아니라 **“그걸 볼 수 있는 트랜잭션이 하나도 남지 않은 순간”**이다. 그 판정선을 넘은 튜플이 죽은 튜플(dead tuple)이고, 이걸 정리하는 게 VACUUM이다.
한 줄로 정리하면 이렇다.
UPDATE 한 번은 지운 자리 하나와 새로 쓴 자리 하나를 남긴다. 갱신이 잦은 테이블일수록 실제 데이터보다 파일이 훨씬 커진다.
VACUUM이 실제로 하는 일
일반 VACUUM은 대략 이런 작업을 한다.
- 죽은 튜플이 차지하던 자리를 그 페이지 안에서 재사용 가능한 공간으로 표시한다
- 해당 튜플을 가리키던 인덱스 엔트리를 제거한다
- Free Space Map(FSM)을 갱신해 “이 페이지에 이만큼 빈자리가 있다"를 기록한다
- Visibility Map(VM)을 갱신한다. 페이지 전체가 모든 트랜잭션에게 보이면 표시해두고, 이게 Index-Only Scan과 이후 VACUUM 생략의 근거가 된다
- 오래된 튜플을 freeze 처리한다 (이 부분은 트랜잭션 ID 글에서 따로 다뤘다)
여기 파일 크기를 줄인다는 항목이 없다. 확보한 공간은 이후 그 테이블의 INSERT와 UPDATE가 다시 쓴다. 즉 VACUUM의 목적은 파일을 줄이는 게 아니라 파일이 더 커지지 않게 하는 것이다.
예외가 하나 있다. 파일 끝부분이 통째로 비었다면 VACUUM이 그 뒤를 잘라낼 수 있다. 다만 이건 잠깐 ACCESS EXCLUSIVE 잠금이 필요하고, 잠금을 못 잡으면 그냥 건너뛴다. 그리고 현실에서 빈 공간은 대개 파일 중간에 흩어져 있어서 잘라낼 조건이 잘 안 맞는다. 디스크가 안 줄어드는 이유의 대부분이 이것이다.
VACUUM FULL은 다른 명령이다
이름이 비슷해서 강한 VACUUM처럼 보이지만 동작 방식 자체가 다르다.
VACUUM FULL은 살아 있는 튜플만 골라 새 파일에 다시 쓰고 예전 파일을 버린다. 그래서 디스크가 실제로 줄어든다. 대신 대가가 있다.
| VACUUM | VACUUM FULL | |
|---|---|---|
| 동작 | 제자리에서 정리 | 테이블 전체를 새로 씀 |
| 잠금 | 읽기·쓰기 모두 허용 | ACCESS EXCLUSIVE — 조회도 막힘 |
| 디스크 | 반환 안 함 | 반환함 |
| 추가 공간 | 거의 없음 | 테이블 크기만큼 더 필요 |
| 인덱스 | 엔트리 정리 | 전부 재구축 |
운영 중인 대형 테이블에 VACUUM FULL을 거는 건 대개 나쁜 선택이다. 조회까지 막히고, 작업 시간이 테이블 크기에 비례하며, 하필 공간이 부족해서 실행하는 상황인데 원본만큼의 여유 공간을 추가로 요구한다.
서비스를 멈출 수 없다면 pg_repack 같은 도구를 쓴다. 새 테이블을 만들면서 그동안의 변경을 트리거로 따라잡고 마지막에 짧게 교체하는 방식이라, 긴 배타 잠금 없이 같은 효과를 낸다. 인덱스만 문제라면 REINDEX INDEX CONCURRENTLY(PostgreSQL 12 이상)로 개별 재구축할 수도 있다.
bloat를 재는 방법
“많이 부풀었다"는 감이 아니라 숫자로 봐야 한다. 방법이 두 가지다.
1. 통계 기반 (가볍다, 추정치)
SELECT relname,
n_live_tup,
n_dead_tup,
round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_pct,
last_vacuum, last_autovacuum,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
n_dead_tup은 통계 수집기가 관리하는 추정치다. 정확한 값은 아니지만 테이블을 읽지 않으므로 부담이 없고, 추이를 보는 용도로는 이게 맞다. 대시보드에 올릴 값은 이쪽이다.
주의할 점이 하나 있다. 통계는 리셋될 수 있다(pg_stat_reset(), 그리고 서버 크래시 후). 값이 갑자기 0이 됐다면 정리된 게 아니라 통계가 초기화된 것일 수 있다.
2. 실측 (정확하다, 무겁다)
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT * FROM pgstattuple('public.대상테이블');
-- table_len | tuple_count | tuple_percent | dead_tuple_count
-- | dead_tuple_percent | free_space | free_percent
pgstattuple은 테이블 전체를 실제로 읽어서 정확한 값을 준다. 대신 큰 테이블에서는 그 자체가 부하다. 운영 시간대에 수백 GB 테이블에 걸면 안 된다.
큰 테이블은 표본 기반을 쓴다.
SELECT * FROM pgstattuple_approx('public.대상테이블');
인덱스는 따로 본다.
SELECT * FROM pgstatindex('public.대상인덱스');
-- avg_leaf_density 가 낮으면 인덱스가 성기게 퍼져 있다는 뜻
어느 정도면 손을 대야 하나
절대 기준은 없고, 판단 기준을 이렇게 잡는 편이다.
죽은 튜플 비율이 높은데 계속 올라가고 있다면 — 정리가 생성 속도를 못 따라간다는 뜻이다. 이건 bloat 자체보다 autovacuum 설정 문제일 가능성이 크다. 다음 편에서 다룬다.
비율이 높지만 안정적이라면 — 그 테이블의 정상 상태일 수 있다. 갱신이 잦은 테이블은 항상 일정 수준의 여유 공간을 갖고 도는 게 오히려 효율적이다. 그 공간을 재사용하니까. 무리해서 줄이면 다음 갱신 때 페이지를 새로 할당한다.
한 번 크게 부풀고 그 뒤로 안 줄어든다면 — 과거에 대량 삭제나 대량 갱신이 있었고, 그때 확보된 공간을 지금의 트래픽이 다 못 쓰고 있는 상태다. 이럴 때가 pg_repack을 고려할 만한 경우다.
여기서 중요한 건 한 번 부푼 파일은 저절로 줄지 않는다는 점이다. 그러니 대량 삭제를 계획할 때는 그 뒤에 공간을 어떻게 할지도 같이 정해두는 게 좋다.
정리
- UPDATE와 DELETE는 예전 버전을 남기고, 이건 볼 수 있는 트랜잭션이 사라져야 정리 대상이 된다
- VACUUM은 그 공간을 재사용 가능하게 만들 뿐 파일을 줄이지 않는다
- 파일을 줄이려면 VACUUM FULL 또는
pg_repack이 필요하고, 각각 대가가 다르다 - 추이는
pg_stat_user_tables로, 정확한 값은pgstattuple로 본다 - 비율보다 방향이 중요하다. 계속 올라가고 있다면 설정을 봐야 한다
다음 편에서는 그 설정, 즉 autovacuum이 왜 안 도는가를 다룬다.
자주 묻는 질문
Q. VACUUM을 실행했는데 왜 디스크 사용량이 그대로인가요?
VACUUM은 죽은 튜플이 차지하던 공간을 해당 페이지 안에서 재사용 가능한 상태로 표시할 뿐, 파일 크기를 줄이지 않기 때문입니다. 확보된 공간은 이후 그 테이블의 INSERT와 UPDATE가 다시 사용합니다. 파일 끝부분이 통째로 비어 있는 경우에 한해 VACUUM이 잘라내기를 시도하지만, 빈 공간이 중간에 흩어져 있으면 파일 크기는 변하지 않습니다.
Q. VACUUM과 VACUUM FULL은 무엇이 다른가요?
VACUUM은 테이블을 제자리에 두고 죽은 튜플을 재사용 가능하게 표시하며, 일반 읽기와 쓰기를 막지 않습니다. VACUUM FULL은 테이블 전체를 새 파일로 다시 쓴 뒤 예전 파일을 버리므로 디스크를 실제로 반환하지만, 작업 내내 ACCESS EXCLUSIVE 잠금을 잡아 조회조차 막히고 원본만큼의 여유 디스크가 추가로 필요합니다.
Q. 테이블 bloat는 어떻게 측정하나요?
pg_stat_user_tables의 n_dead_tup은 통계 수집기가 관리하는 추정치라 추이 감시에 적합하고, 정확한 값이 필요하면 pgstattuple 확장의 pgstattuple 함수가 실제 페이지를 읽어 dead_tuple_percent와 free_percent를 알려줍니다. 다만 이 함수는 테이블 전체를 읽으므로 큰 테이블에서는 표본 기반인 pgstattuple_approx를 쓰는 편이 안전합니다.