3장. MVCC — PostgreSQL 동시성의 비밀
읽기가 쓰기를 막지 않고, 쓰기가 읽기를 막지 않는 마법. 그 대가로 생기는 Bloat와 VACUUM까지 — PostgreSQL 운영의 심장을 그림으로 이해합니다.
① 왜 MVCC인가 ② 튜플 헤더(xmin/xmax)로 버전을 기록하는 방법 ③ 스냅샷으로 "내게 보이는 버전"을 고르는 방법 ④ UPDATE가 남기는 dead tuple과 Bloat의 탄생
3.1 왜 MVCC인가 — "읽기는 쓰기를 막지 않는다"
전통적인 Two-Phase Locking(2PL) 방식에서는 읽기(shared lock)와 쓰기(exclusive lock)가 서로를 막습니다. 한 트랜잭션이 행을 읽는 동안 다른 트랜잭션의 UPDATE는 줄을 서서 기다려야 합니다.
PostgreSQL의 선택은 다릅니다. 데이터를 덮어쓰는 대신, 새 버전(version)을 만듭니다. 각 트랜잭션은 자신만의 일관된 스냅샷(snapshot)을 보기 때문에 락 경합 없이 동시에 일할 수 있습니다.
락 방식에서 읽기 트랜잭션이 행에 shared lock을 잡습니다.
이때 쓰기 트랜잭션이 오면? exclusive lock을 얻지 못해 대기(블로킹)합니다. 읽기가 쓰기를 막는 것이죠.
MVCC에서는 읽기 트랜잭션이 락 없이 현재 버전 v1을 읽습니다.
동시에 쓰기 트랜잭션은 v1을 건드리지 않고 새 버전 v2를 만듭니다. 아무도 기다리지 않습니다! 이것이 "reading never blocks writing"입니다.
"In MVCC ... reading never blocks writing and writing never blocks reading." — postgresql.org/docs/current/mvcc-intro.html
그 결과:
- SELECT는 락을 잡지 않습니다 (
FOR UPDATE를 명시하지 않는 한). - UPDATE는 새 버전을 씁니다 → 이전 버전은 "dead tuple"로 남습니다.
- 대가: Bloat, XID 관리, VACUUM 운영 오버헤드 (8장에서 자세히).
3.2 튜플 헤더 — 버전은 어디에 기록되는가
MVCC를 떠받치는 것은 모든 heap 튜플 앞에 붙는 23바이트 헤더입니다. 이 중 버전 판단의 핵심은 네 가지입니다.
| 필드 | 크기 | 의미 |
|---|---|---|
t_xmin | 4 B | 이 행을 INSERT한 트랜잭션의 XID |
t_xmax | 4 B | 이 행을 DELETE/UPDATE한 XID (없으면 0) |
t_cid | 4 B | 같은 트랜잭션 안에서의 명령 순번(Command ID) |
t_ctid | 6 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은 절대 제자리에서 덮어쓰지 않는다는 것이 포인트입니다.
TX 100이 INSERT: 튜플 헤더에 xmin=100, xmax=0이 기록됩니다. xmax=0은 "아직 살아있음"이라는 뜻.
TX 200이 UPDATE를 시작하면, 기존 튜플을 지우는 대신 xmax=200으로 "200번이 나를 무효화했다"고 표시만 합니다.
그리고 새 버전 (0,2)가 만들어집니다(xmin=200). 이전 버전의 t_ctid는 새 버전을 가리키는 버전 체인이 됩니다.
TX 200이 커밋되면 이전 버전은 dead tuple — 어떤 트랜잭션에게도 보이지 않지만 디스크 공간은 그대로 차지합니다. 이것이 쌓이면 Bloat!
물리적 삭제는 VACUUM의 몫입니다. dead tuple을 회수해 그 공간을 재사용할 수 있게 만듭니다. (8장에서 계속)
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
xmin= 100 : 100 미만의 XID는 모두 결과 확정 (커밋됐으면 보임)xmax= 105 : 105 이상의 XID는 스냅샷 이후 시작 → 무조건 안 보임xip_list= 101,103 : 스냅샷 시점에 아직 진행 중이던 트랜잭션 → 안 보임
xmin=99: 스냅샷 xmin(100)보다 과거이고 커밋됨 → 보입니다.
xmin=101: xip_list(진행 중 목록)에 있음 → 스냅샷 시점에 커밋되지 않았으므로 안 보입니다.
xmin=107: 스냅샷 xmax(105) 이후에 시작된 "미래" 트랜잭션 → 안 보입니다.
xmax=99가 커밋됨: 내 스냅샷 기준 "과거에 이미 지워진" 튜플 → 안 보입니다. 이렇게 4개 버전 중 내게 보이는 것은 단 1개!
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가 쓰기를 거부하는 최악의 장애가 됩니다. → 트러블슈팅 케이스
✍️ 이해도 체크
100:105:101,103일 때, xmin=103인 (커밋된) 튜플이 보이지 않는 이유는?이 장의 원문 문서: chapters/ch03_mvcc.md — 힌트 비트, HOT 업데이트, CLOG 구조 등 심화 내용이 담겨 있습니다.