13장. 시간을 여러 겹으로 — MVCC

만화로 보는 요약 — 먼저 읽어보세요
MVCC는 덮어쓰지 않고 여러 버전을 남긴다 → 각 트랜잭션은 자기 시작 시점에 유효한 버전을 읽어 읽기와 쓰기가 덜 막힌다 → 대신 옛 버전 청소와 쓰기 충돌 처리가 필요하다.
면접 실전 질문: ① MVCC가 스냅샷을 구현하는 방식 ② 읽기와 쓰기가 서로 덜 막히는 이유 ③ undo 로그와 VACUUM/purge의 역할
배경 — 잠그지 않고 어떻게?
12장의 락은 확실했지만 기다리게 했습니다. 그런데 11장에서 스냅샷 격리는 “아무도 안 기다린다”고 했죠. 읽는 사람은 자기 사진만 보고, 쓰는 사람도 읽는 사람을 안 막는다고요. 잠그지도 않는데, 대체 그 “사진”은 어떻게 만드는 걸까요?
답은 놀랍도록 단순한 발상의 전환에 있습니다. 덮어쓰지 말고, 옛날 버전을 남겨두자.
스토리 — 지우지 않는 데이터베이스
보통 우리는 데이터를 “고친다”고 생각하죠. 잔고 100을 70으로 덮어쓴다. 그런데 MVCC는 다르게 합니다. 70을 새로 쓰되, 옛 100도 안 지우고 남겨둬요. 한 행에 여러 시점의 버전이 겹겹이 쌓이는 겁니다. 그래서 이름이 MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어) 예요.
각 버전에는 만든 트랜잭션(생성 도장) 이 찍히고, 덮어쓰이면 지운 트랜잭션(삭제
도장) 도 찍혀요(PostgreSQL의 xmin/xmax가 이겁니다). 그래서 한 버전은 “언제부터
언제까지 살아 있었나”라는 유효 구간을 갖죠. 이제 마법이 가능해져요. 트랜잭션이
시작할 때 “내 시점”을 기억해두고, 읽을 때는 그 시점에 (이미 커밋되어) 유효했던
버전을 골라 봅니다.
계좌 42의 버전들 (시간 순으로 쌓임)
v1: 100 (시각10에 생성 → 시각20에 덮어써짐, 이미 커밋)
v2: 70 (시각20에 생성, 현재 유효)
[Tx-시작 15] → 시각15엔 v1(100)이 살아 있던 구간 → 100을 본다
[Tx-시작 25] → 시각25엔 v2(70)가 유효 → 70을 본다같은 계좌 42를 두 트랜잭션이 동시에 읽어도, 각자 자기 시점의 버전을 봅니다. 누가 중간에 값을 바꿔 커밋해도 내 버전은 안 변해요. 이게 11장의 “사진”의 정체입니다. 사진은 찍는 게 아니라, 원래부터 쌓여 있는 여러 버전 중 내 시점 것을 고르는 것이었어요.
핵심 — 읽기와 쓰기가 서로를 놓아준다
MVCC의 진짜 값어치는 이 한 문장입니다.
읽기는 쓰기를 막지 않고, 쓰기는 읽기를 막지 않는다.
왜냐고요? 쓰는 사람은 새 버전을 추가할 뿐, 읽는 사람이 보던 옛 버전은 그대로거든요.
그러니 읽기가 쓰기를 기다릴 이유도, 쓰기가 읽기를 기다릴 이유도 없어요. 12장 락에서는
X락 하나가 모두를 줄 세웠는데, MVCC에서는 그냥 읽기(SELECT)는 락을 아예 안 겁니다
(FOR UPDATE 같은 잠금 읽기는 예외). 이게 대부분의 실무 DB(PostgreSQL, MySQL InnoDB)가
읽기 위주 부하에서 빠른 이유예요. 눈으로 봅시다.
-- [세션 A] 스냅샷을 연다 (PostgreSQL: REPEATABLE READ / MySQL InnoDB: 기본 RR)
BEGIN;
SELECT balance FROM account WHERE id = 42; -- 100-- [세션 B] 그 사이 값을 바꾸고 커밋 — A를 안 기다린다
BEGIN;
UPDATE account SET balance = 70 WHERE id = 42; COMMIT;-- [세션 A] 다시 읽어도 — 안 멈췄고, 여전히 내 시점의 옛 버전
SELECT balance FROM account WHERE id = 42; -- 여전히 100
COMMIT;12장에선 세션 B의 커서가 멈췄죠. MVCC에선 B가 안 멈추고, A도 안 변합니다. 아무도 아무도 안 기다려요.
그럼 이 “옛 버전”들은 어디서 오냐면 — 5장·8장에서 만난 로그의 사촌입니다. PostgreSQL은 행 자체에 옛 버전을 쌓아두고, MySQL InnoDB는 undo 로그로 옛 모습을 재구성해요. (그래서 undo 로그는 롤백에도, MVCC 읽기에도 쓰입니다. 일석이조죠.)
물론 공짜는 아닙니다. 안 지운 옛 버전이 계속 쌓이면 저장 공간이 불어납니다. 그래서 아무도 더는 안 보는 옛 버전을 청소하는 과정이 필요해요. PostgreSQL의 VACUUM, InnoDB의 purge가 그 청소부입니다. MVCC는 “잠그지 않는 대신, 뒤에서 청소를 한다”는 거래예요.
한 가지만 분명히 하죠. MVCC가 읽기-쓰기는 놓아줬지만, 쓰기-쓰기까지 봐주진 않아요. 두 트랜잭션이 같은 행을 동시에 쓰려 하면, 여기선 여전히 잠금이나 충돌 감지가 필요합니다. 그리고 11장의 write skew도 MVCC만으론 못 막았죠. MVCC는 만능이 아니라, 읽기를 자유롭게 풀어준 영리한 타협입니다.
정리
- MVCC = 덮어쓰지 않고 여러 버전을 남긴다. 각 버전에 생성/삭제 도장(유효 구간) → 트랜잭션은 자기 시점에 유효한(이미 커밋된) 버전을 읽는다. 이게 11장 스냅샷의 구현체.
- 그 결과: 읽기는 쓰기를, 쓰기는 읽기를 막지 않는다. 읽기가 락을 안 걸어 빠르다 (PostgreSQL, MySQL InnoDB).
- 옛 버전 출처 = 옛 행/undo 로그(롤백에도 MVCC에도 쓰임). 대가 = 버전 청소 (VACUUM/purge)와 공간.
- 단, 쓰기-쓰기 충돌과 write skew는 MVCC만으론 못 막는다. 만능 아님.
생각해볼 질문: 지금까지 두 전략을 봤어요. 비관적 락(“일단 잠근다”)과 MVCC(“버전을 쌓아 읽기를 푼다”). 둘 다 미리 뭔가를 합니다. 그런데 만약 “어차피 충돌 잘 안 나니까, 일단 진행하고 커밋할 때만 확인하자” 는 배짱은 어떨까요? (14장, 낙관적 제어의 도박입니다.)