DB Study

3장. MVCC — PostgreSQL 동시성의 비밀

읽기가 쓰기를 막지 않고, 쓰기가 읽기를 막지 않는 마법. 그 대가로 생기는 Bloat와 VACUUM까지 — PostgreSQL 운영의 심장을 그림으로 이해합니다.

중급 ⏱ 약 25분 🎬 애니메이션 3개 선수 지식: 2장 아키텍처
🧭
이 장에서 배우는 것

① 왜 MVCC인가 ② 튜플 헤더(xmin/xmax)로 버전을 기록하는 방법 ③ 스냅샷으로 "내게 보이는 버전"을 고르는 방법 ④ UPDATE가 남기는 dead tuple과 Bloat의 탄생

3.1 왜 MVCC인가 — "읽기는 쓰기를 막지 않는다"

전통적인 Two-Phase Locking(2PL) 방식에서는 읽기(shared lock)와 쓰기(exclusive lock)가 서로를 막습니다. 한 트랜잭션이 행을 읽는 동안 다른 트랜잭션의 UPDATE는 줄을 서서 기다려야 합니다.

PostgreSQL의 선택은 다릅니다. 데이터를 덮어쓰는 대신, 새 버전(version)을 만듭니다. 각 트랜잭션은 자신만의 일관된 스냅샷(snapshot)을 보기 때문에 락 경합 없이 동시에 일할 수 있습니다.

🔒 락 방식 (2PL) row: Alice 읽기 TX 쓰기 TX ⛔ 대기! 🌀 MVCC (PostgreSQL) v1: Alice xmin=100 읽기 TX v1을 읽음 쓰기 TX v2: Alicia ✨ xmin=101 새 버전 생성
💡
공식 문서 한 문장 요약

"In MVCC ... reading never blocks writing and writing never blocks reading." — postgresql.org/docs/current/mvcc-intro.html

그 결과:

3.2 튜플 헤더 — 버전은 어디에 기록되는가

MVCC를 떠받치는 것은 모든 heap 튜플 앞에 붙는 23바이트 헤더입니다. 이 중 버전 판단의 핵심은 네 가지입니다.

필드크기의미
t_xmin4 B이 행을 INSERT한 트랜잭션의 XID
t_xmax4 B이 행을 DELETE/UPDATE한 XID (없으면 0)
t_cid4 B같은 트랜잭션 안에서의 명령 순번(Command ID)
t_ctid6 B현재 물리 주소, UPDATE 후에는 다음 버전을 가리키는 포인터

실제로 눈으로 확인해 봅시다. 숨겨진 시스템 컬럼을 SELECT하면 됩니다.

-- xmin, xmax는 모든 테이블에 숨어있는 시스템 컬럼이다
SELECT xmin, xmax, ctid, * FROM users;

 xmin | xmax | ctid  | id | name
------+------+-------+----+-------
  100 |    0 | (0,1) |  1 | Alice   ← xmax=0: 아무도 안 지움 (살아있음)

이제 INSERT → UPDATE → DELETE가 헤더를 어떻게 바꾸는지 애니메이션으로 봅시다. PostgreSQL은 절대 제자리에서 덮어쓰지 않는다는 것이 포인트입니다.

heap 페이지 (block 0) ctid=(0,1) xmin=100 xmax=0 데이터: "Alice" ← TX 100이 INSERT xmax=200 ✍️ ← TX 200이 UPDATE 시작 ctid=(0,2) xmin=200 xmax=0 데이터: "Alicia" ✨ (새 버전) t_ctid가 새 버전을 가리킴 ← dead tuple (아무에게도 안 보임) 🧹 VACUUM: dead tuple 공간을 회수 → 재사용 가능 공간으로
⚠️
DELETE는 지우지 않는다

DELETE도 마찬가지로 xmax만 기록합니다. PostgreSQL에서 즉시 지워지는 데이터는 없습니다. "지웠는데 디스크가 안 줄어요"라는 단골 질문의 답이 바로 여기에 있습니다.

3.3 스냅샷 — "내게 보이는 버전"을 고르는 법

버전이 여러 개라면, 각 트랜잭션은 어떤 버전을 봐야 할까요? 트랜잭션은 시작 시점(READ COMMITTED에서는 쿼리 시작 시점)에 스냅샷을 찍습니다. 스냅샷은 세 가지 정보로 구성됩니다.

-- 현재 스냅샷 보기: xmin:xmax:xip_list 형식
SELECT pg_current_snapshot();

 pg_current_snapshot
---------------------
 100:105:101,103      ← xmin=100, xmax=105, 진행 중=101,103
내 스냅샷: 100:105:101,103 "이 순간의 세상만 보겠다" 📸 xmin=99 (커밋됨) "Alice" ✅ 보임 xmin=101 (진행 중) "Bob" ❌ 안 보임 xip_list에 있음 = 아직 커밋 안 됨 xmin=107 (미래) "Carol" ❌ 안 보임 xmax(105) 이후 시작된 미래 TX xmin=98, xmax=99 (둘 다 커밋됨) "Dave" ❌ 안 보임 (dead) 지운 TX 99도 내 과거 → 삭제 확정 판정 규칙: xmin이 "내 과거의 커밋"이고, xmax가 없거나 아직 확정되지 않았을 때만 보인다

3.4 직접 실험 — 두 세션으로 MVCC 관찰하기

터미널 두 개를 열고 직접 눈으로 확인해 보세요. MVCC는 실험으로 배우는 것이 가장 빠릅니다.

-- [세션 A]
CREATE TABLE t (id int, name text);
INSERT INTO t VALUES (1, 'Alice');
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT xmin, xmax, name FROM t;   -- Alice가 보인다. 스냅샷 고정!

-- [세션 B] : A가 트랜잭션을 연 채로 실행
UPDATE t SET name = 'Alicia' WHERE id = 1;   -- 블로킹 없이 즉시 성공!
SELECT xmin, xmax, name FROM t;   -- B에게는 Alicia (새 버전)

-- [세션 A] : 다시 조회
SELECT xmin, xmax, name FROM t;   -- 여전히 Alice! (내 스냅샷의 세상)
COMMIT;
SELECT name FROM t;               -- 이제 Alicia가 보인다
🔬
더 깊이 보고 싶다면

CREATE EXTENSION pageinspect;SELECT * FROM heap_page_items(get_raw_page('t', 0));로 페이지 안의 모든 버전(dead tuple 포함)을 볼 수 있습니다. 13장 Extension에서 자세히 다룹니다.

3.5 MVCC의 청구서 — Bloat와 XID Wraparound

공짜 점심은 없습니다. MVCC의 대가는 두 가지이며, 둘 다 PostgreSQL 운영 사고의 단골 원인입니다.

🎈 Table Bloat

UPDATE/DELETE가 남긴 dead tuple이 회수되지 못하고 쌓이면 테이블이 실제 데이터보다 몇 배로 부풀어 오릅니다. 스캔이 느려지고 캐시 효율이 떨어집니다. → 8장 VACUUM

🔄 XID Wraparound

XID는 32비트(약 40억, 비교 가능 범위는 약 20억)입니다. 소진되기 전에 오래된 튜플을 "얼려서(freeze)" XID를 재활용해야 합니다. 실패하면 DB가 쓰기를 거부하는 최악의 장애가 됩니다. → 트러블슈팅 케이스

✍️ 이해도 체크

UPDATE가 실행될 때 PostgreSQL이 실제로 하는 일은?
✅ PostgreSQL의 UPDATE = "기존 버전에 xmax 마킹" + "새 버전 INSERT"입니다. 기존 버전은 dead tuple로 남아 VACUUM을 기다립니다. 제자리 덮어쓰기(in-place update)를 하지 않는 것이 MVCC의 핵심입니다.
스냅샷이 100:105:101,103일 때, xmin=103인 (커밋된) 튜플이 보이지 않는 이유는?
✅ 지금은 커밋되었더라도, 내 스냅샷을 찍던 순간에 진행 중이었다면(xip_list 포함) 그 트랜잭션의 변경은 보이지 않습니다. 스냅샷은 "그 순간의 세상"을 고정하는 사진이기 때문입니다.
📚
원문으로 더 깊이

이 장의 원문 문서: chapters/ch03_mvcc.md — 힌트 비트, HOT 업데이트, CLOG 구조 등 심화 내용이 담겨 있습니다.