같은 행의 여러 버전을 함께 보관해, 읽기가 쓰기를 기다리지 않게 하는 동시성 제어 방식 (Multi-Version Concurrency Control).
해결하려는 문제
락만 쓰면 이렇게 된다.
T1: UPDATE 상품 SET 재고 = 5 WHERE id = 1 (락 획득, 아직 커밋 안 함)
T2: SELECT 재고 FROM 상품 WHERE id = 1 ← T1이 끝날 때까지 대기
읽기만 하는데 기다려야 한다. 조회가 압도적으로 많은 서비스에서 치명적이다.
MVCC의 답 — 옛 버전을 보여 준다
읽기는 쓰기를 막지 않고, 쓰기는 읽기를 막지 않는다.
T2는 T1이 수정하기 전 버전을 읽는다. 기다릴 필요가 없다.
어떻게 구현되나 (InnoDB)
각 행에 숨은 컬럼이 붙어 있다.
| 행 데이터 | DB_TRX_ID | DB_ROLL_PTR |
|---|---|---|
| [id=1, 재고=10] | 100 | → undo 로그 위치 |
DB_TRX_ID— 이 버전을 만든 트랜잭션 번호DB_ROLL_PTR— 이전 버전이 있는 undo 로그를 가리키는 포인터
수정하면 새 버전을 만들고, 이전 값은 undo 로그에 남긴다. 포인터로 이어져 버전 체인이 만들어진다.
현재: [재고=5, TRX=200] ──► undo: [재고=10, TRX=100] ──► undo: [재고=15, TRX=50]
읽을 때 어느 버전을 보나 — Read View
트랜잭션이 조회를 시작하면 그 시점에 활성 상태인 트랜잭션 목록(Read View)을 만든다. 버전 체인을 따라가며 "내가 볼 수 있는 버전"을 고른다.
- 내 트랜잭션보다 먼저 커밋된 버전 → 볼 수 있음
- 아직 커밋 안 됐거나 나보다 나중 → 건너뛰고 이전 버전으로
READ COMMITTED는 쿼리마다 Read View를 새로 만들고, REPEATABLE READ는 트랜잭션 시작 시 한 번 만들어 끝까지 쓴다. 이 차이가 두 격리수준의 동작 차이를 만든다.
대가 ① — 저장 공간
옛 버전을 계속 들고 있어야 한다.
대가 ② — 긴 트랜잭션이 재앙이 된다
오래 열려 있는 트랜잭션이 하나만 있어도 그 Read View가 볼 수 있는 옛 버전을 지울 수 없다. undo 로그가 계속 쌓인다.
PostgreSQL → 테이블 부풀림(bloat), VACUUM 필요
- MySQL — → undo 테이블스페이스 증가, purge 지연
"긴 트랜잭션은 그 자체로 장애 요인" 이라는 말이 여기서 나온다. 읽기 전용이어도 마찬가지다.
면접 함정
"MVCC면 락이 필요 없나?" → 쓰기끼리는 여전히 충돌한다. 같은 행을 동시에 수정하면 뒤에 온 쪽이 기다리거나 실패한다. MVCC가 없앤 것은 읽기와 쓰기 사이의 대기뿐이다.
또 SELECT ... FOR UPDATE는 MVCC를 우회해 현재 최신 버전에 락을 건다
(current read). 재고 차감처럼 최신값이 꼭 필요할 때 쓴다.