cat ~/notes/posts/cs-comic-02-mvcc-vacuum.md
[CS 당직일지 2화] DELETE 했는데 왜 디스크가 그대로예요?
로그 행 절반을 지웠는데 디스크는 82% 그대로. PostgreSQL의 MVCC, dead tuple, VACUUM과 XID wraparound를 치즈 선배와 봄의 8컷 만화로 정리했다.
결론 PostgreSQL의 DELETE와 UPDATE는 행을 바로 지우지 않고 옛 버전에 xmax 표시만 남긴다. VACUUM은 그 자리를 재사용 가능하게 할 뿐 보통 OS에 돌려주지 않는다.
적용 PostgreSQL 18 (autovacuum 기본값은 버전에 따라 다름)
확인일
치즈 선배의 CS 당직일지 2화. 1화에서 BBM이 왜 autovacuum을 지켜봤는지, 이번 화에서 답한다. 주제는 PostgreSQL의 MVCC와 VACUUM이다.
이번 화에서 배운 것
- UPDATE는 덮어쓰기가 아니라 새 행 버전 추가다. 옛 버전에는 끝낸 트랜잭션 번호(xmax)가 찍히고, DELETE는 xmax만 찍는다.
- 문장마다 자기 시점의 스냅샷을 보기 때문에 읽기는 쓰기를 막지 않고, 쓰기도 읽기를 막지 않는다. 대신 아무도 안 보는 옛 버전(dead tuple)이 쌓인다.
- VACUUM은 dead tuple 자리를 재사용 가능으로 표시할 뿐 보통 파일 크기는 그대로다. VACUUM FULL은 ACCESS EXCLUSIVE 잠금을 잡고 통째로 다시 쓰니 운영 중엔 드물게.
- 오래 열린 트랜잭션(
idle in transaction)은 VACUUM의 천적이다. 트랜잭션 ID는 32비트라 오래된 행은 freeze 해야 하고, 나이가 2억을 넘으면 autovacuum이 꺼져 있어도 강제로 돈다.
대사 옮겨 적기
| 컷 | 상황 | 대사 |
|---|---|---|
| 1 | 월요일 아침, 디스크 경보 (디스크 82% → 82%) | 봄: 선배! 오래된 로그 행을 절반이나 DELETE 했는데 디스크가 그대로예요! 치즈 선배: Postgres는 바로 안 지워. ‘지웠음’ 표시만 하지. |
| 2 | MVCC: UPDATE는 덮어쓰기가 아니라 ‘새 행 버전 추가’ | 치즈 선배: UPDATE는 새 버전을 쓰고, 옛 버전엔 ‘xid 205가 끝냄’ 도장(xmax)만 찍어. 봄: 그럼 DELETE는요? 치즈 선배: DELETE = xmax 도장만. 끝. |
| 3 | 스냅샷: 각 문장(또는 트랜잭션)은 자기 시점의 버전만 본다 | (보이는 버전 = xmin이 내 스냅샷 전에 커밋됨, 그리고 xmax가 없거나 내 스냅샷엔 아직 미커밋) 봄: 제 쿼리는 옛 사진을 보고, 저쪽은 안 기다리고 바로 쓰네요! 치즈 선배: 읽기는 쓰기를 안 막고, 쓰기도 읽기를 안 막아. |
| 4 | dead tuple이 쌓이면 bloat — 디스크도, 스캔도 무거워진다 | 봄: 죽은 버전도 디스크를 차지하고, 스캔할 때 같이 읽힌다고요? 치즈 선배: 그게 bloat. 그래서 청소부가 필요해. |
| 5 | VACUUM은 빈자리를 재사용할 뿐, OS 반환은 끝쪽 빈 페이지만 | (VACUUM · 청소: 죽은 칸 → ‘재사용 가능’, SELECT·INSERT·UPDATE·DELETE 계속, 파일 크기는 보통 그대로 / VACUUM FULL · 이사: 새 파일로 통째로 다시 씀, ACCESS EXCLUSIVE로 아무도 못 씀, 그동안 테이블만큼 디스크 더 필요) 봄: 디스크 줄이려면 FULL 돌리면 되겠네요! 치즈 선배: 운영 중엔 함부로 못 해. 평소엔 청소를 자주. |
| 6 | 오래 열린 트랜잭션 = VACUUM의 천적 | (BEGIN; …3일째 대기, idle in transaction)치즈 선배: 청소기가 와도 못 치워. 저 세션이 아직 옛 사진을 들고 있거든. 봄: BEGIN 해 놓고 퇴근한 게 누구죠…? (버려진 replication slot, hot_standby_feedback도 같은 효과) |
| 7 | 트랜잭션 ID는 32비트 — 오래된 행은 얼려야(freeze) 한다 | (XID: 32비트 · 원형 · 약 43억 칸, 과거 21억 / 미래 21억. autovacuum_freeze_max_age = 2억. 방치하면 남은 XID 300만 미만일 때 새 쓰기 거부, 읽기만 가능)봄: 21억 넘게 지나면 옛 행이 ‘미래’로 보여서 사라져요?! 치즈 선배: 그래서 미리 얼려 둬. 2억 넘으면 autovacuum이 꺼져 있어도 강제 출동. |
| 8 | 한 장 요약 (치트 시트) | (아래 요약과 SQL 참고) 봄: 1화 BBM이 왜 autovacuum을 지켜봤는지 알겠어요! 치즈 선배: 청소는 매일, 이사는 드물게. |
8컷 치트 시트의 SQL은 그대로 옮겨 둔다.
-- dead tuple 많은 테이블
SELECT relname, n_dead_tup, last_autovacuum FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC LIMIT 5;
-- 오래 열린 트랜잭션
SELECT pid, state, xact_start FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL ORDER BY age(backend_xmin) DESC;
SELECT datname, age(datfrozenxid) FROM pg_database; -- XID 나이
autovacuum은 dead tuple 수가 min(50 + 0.2 × 행 수, 1억)을 넘으면 출동한다(PG18 기본값. 1억 상한인 autovacuum_vacuum_max_threshold는 18에서 새로 생겼다). 예방으로는 idle_in_transaction_session_timeout을 설정한다. 기본값 0은 꺼짐이다.
컷에는 없는 운영 팁
- 테이블 전체를 주기적으로 비운다면
DELETE+VACUUM대신TRUNCATE를 고려한다. 공간을 바로 돌려주지만 엄격한 MVCC 의미는 깨진다. - VACUUM FULL 대신
CLUSTER나 테이블을 다시 쓰는ALTER TABLE도 같은 효과다. 모두 ACCESS EXCLUSIVE 잠금을 잡는다. - wraparound 직전 복구에서는 VACUUM FULL과 VACUUM FREEZE를 쓰지 말라고 문서가 못 박아 둔다.
일부러 단순하게 그린 부분
- 1컷의 82%와 6컷의 “3일째”는 그림용 숫자다.
- 3컷의 가시성 규칙은 개념용 요약이다. 실제 판정에는 같은 트랜잭션 안의 변경(cmin/cmax), 롤백된 xmax, 진행 중인 트랜잭션 목록 같은 경우가 더 있다.
- 2컷은 새 버전이 같은 페이지에 들어가는 것처럼 그렸다. 실제로는 자리가 있으면 HOT 업데이트, 없으면 다른 페이지로 간다. HOT은 3화(B-tree 인덱스)에서 다룬다.
- 7컷은 2³¹을 “21억”, 2³²를 “약 43억”으로 반올림했다. MultiXact ID wraparound는 다루지 않았다.
- 8컷 공식은 update/delete 기준 조건만 적었다. insert 기준 autovacuum(
autovacuum_vacuum_insert_threshold등)은 뺐다.
출처
모두 PostgreSQL 18 공식 문서(current 판)다.
| # | 출처 | 확인일 |
|---|---|---|
| S1 | 13.1 Introduction (MVCC) | 2026-11-03 |
| S2 | 13.2 Transaction Isolation | 2026-11-03 |
| S3 | 5.6 System Columns | 2026-11-03 |
| S4 | 24.1 Routine Vacuuming | 2026-11-03 |
| S5 | 19.10.1 Automatic Vacuuming | 2026-11-03 |
| S6 | 19.11 Client Connection Defaults | 2026-11-03 |
| S7 | 27.2 The Cumulative Statistics System | 2026-11-03 |
| S8 | 19.6 Replication (hot_standby_feedback) | 2026-11-03 |
| S9 | 66.6 Database Page Layout | 2026-11-03 |
댓글