MVCC 완전 정복 — Undo 로그부터 스냅샷 읽기까지
MVCC, 왜 알아야 하나요?
트랜잭션 격리 수준 글과 데이터베이스 락 글을 읽다 보면 이런 궁금증이 생깁니다.
Repeatable Read인데 왜 일반SELECT는 막히지 않을까요?- 다른 트랜잭션이 값을 바꿨는데, 나는 왜 예전 값을 계속 읽을 수 있을까요?
- 락을 줄이면서도 읽기 일관성은 어떻게 유지할까요?
이 질문의 핵심에 있는 것이 MVCC(Multi-Version Concurrency Control) 입니다. MVCC는 데이터를 한 버전만 두지 않고 여러 버전으로 관리해서, 읽기와 쓰기가 서로를 덜 막으면서도 일관성을 유지하게 해줍니다.
이 글에서는 먼저 MVCC가 필요한 이유를 살펴보고, InnoDB 기준으로 Undo 로그, Read View, 스냅샷 읽기가 어떻게 동작하는지 정리한 뒤, PostgreSQL과의 차이도 함께 정리합니다.
Phase 1. MVCC가 필요한 이유
락만으로 동시성을 제어하면 가장 단순합니다. 읽을 때는 공유 락, 쓸 때는 배타 락을 걸면 됩니다. 문제는 읽기까지 서로 막히기 시작한다는 점입니다.
락만으로 읽기를 보호하면 어떤 일이 생길까?
트랜잭션 A 트랜잭션 B
─────────────────────────────────────────────────
SELECT balance
FROM accounts
WHERE id = 1; (공유 락)
UPDATE accounts
SET balance = 5000
WHERE id = 1;
→ 대기 (A의 공유 락 해제까지)
SELECT balance
FROM accounts
WHERE id = 1; (다시 읽기)
COMMIT;
→ UPDATE 실행
이 방식은 읽기 일관성은 확보할 수 있지만, 읽기 때문에 쓰기가 막히고, 반대로 쓰기 때문에 읽기가 막히는 상황이 자주 생깁니다. 동시성이 크게 떨어집니다.
MVCC의 핵심 아이디어
MVCC는 데이터를 수정할 때 기존 값을 바로 덮어쓴 뒤 끝내지 않고, 이전 버전을 별도로 보관합니다. 그래서 읽는 쪽은 자신이 봐야 할 시점의 버전을 읽고, 쓰는 쪽은 최신 버전을 갱신할 수 있습니다.
최신 버전: balance = 5000
이전 버전: balance = 10000
읽는 트랜잭션 A → 자신의 스냅샷에 맞는 버전 선택
쓰는 트랜잭션 B → 최신 버전 수정
즉, MVCC는 "누가 먼저 락을 잡았는가" 보다 "이 트랜잭션에게 어떤 버전을 보여줘야 하는가" 에 집중하는 방식입니다.
참고: MVCC가 락을 완전히 없애는 것은 아닙니다. 일반
SELECT같은 스냅샷 읽기를 락 없이 처리할 수 있게 해주지만,UPDATE,DELETE,SELECT ... FOR UPDATE같은 쓰기나 잠금 읽기에는 여전히 락이 필요합니다.
Phase 2. InnoDB에서 MVCC는 어떻게 동작할까?
InnoDB 기준으로 보면 MVCC는 크게 세 가지 요소로 설명할 수 있습니다.
- 최신 레코드 — 현재 데이터 페이지에 있는 최신 값
- Undo 로그 — 이전 버전으로 되돌릴 수 있는 정보
- Read View — 지금 이 트랜잭션이 어떤 버전을 볼 수 있는지 판단하는 기준
최신 레코드와 Undo 로그
예를 들어 계좌 잔액이 10000원인 행이 있다고 가정합니다.
UPDATE accounts
SET balance = 5000
WHERE id = 1;
이 UPDATE가 실행되면 InnoDB는 최신 레코드를 5000으로 바꾸고, 이전 값 10000은 Undo 로그에 기록합니다.
데이터 페이지(최신):
[id=1, balance=5000]
Undo 로그(이전 버전):
[id=1, balance=10000]
이전 버전이 따로 남아 있으므로, 어떤 트랜잭션은 최신 값 5000을 읽고, 다른 트랜잭션은 예전 값 10000을 읽을 수 있습니다.
버전 체인
행이 여러 번 수정되면 Undo 로그는 하나만 생기지 않습니다. 수정될 때마다 이전 버전이 체인처럼 연결됩니다.
최신 레코드: balance = 3000
↓
Undo #1: balance = 5000
↓
Undo #2: balance = 10000
트랜잭션은 자신의 스냅샷에 맞는 버전을 찾을 때까지 이 체인을 따라 내려갑니다.
숨은 컬럼
InnoDB의 각 행에는 내부적으로 MVCC 판단에 필요한 정보가 함께 붙어 있습니다.
DB_TRX_ID— 이 행을 마지막으로 수정한 트랜잭션 IDDB_ROLL_PTR— 이전 버전(Undo 로그)으로 가는 포인터
우리가 직접 보는 컬럼은 아니지만, InnoDB는 이 정보를 이용해 이 버전이 내 스냅샷에서 보여야 하는지 판단합니다.
Phase 3. Read View — 어떤 버전을 보여줄지 결정하는 기준
Undo 로그만 있다고 해서 MVCC가 완성되지는 않습니다. 여러 버전 중에서 지금 이 트랜잭션이 어떤 버전을 읽어야 하는지 결정하는 기준이 필요합니다. InnoDB에서는 이것을 Read View라고 부릅니다.
Read View를 쉽게 이해하기
트랜잭션 A가 시작된 시점에 활성 트랜잭션이 다음과 같다고 가정합니다.
활성 트랜잭션: 101, 102, 105
새로 시작한 트랜잭션 A의 시점
이때 트랜잭션 A는 대략 이런 기준을 갖습니다.
- 이미 오래전에 커밋된 트랜잭션이 만든 버전은 볼 수 있습니다
- 아직 커밋되지 않은 트랜잭션이 만든 버전은 볼 수 없습니다
- 내가 직접 수정한 내용은 볼 수 있습니다
즉, Read View는 "지금 시점에서 커밋이 끝난 버전만 본다"는 규칙을 트랜잭션 단위로 들고 있는 셈입니다.
버전 선택 예시
행 id=1의 버전 히스토리
[balance=3000, modified_by=txn_110] ← 최신 버전
↓
[balance=5000, modified_by=txn_105]
↓
[balance=10000, modified_by=txn_90]
트랜잭션 A의 Read View에서 txn_105, txn_110이 아직 보이면 안 되는 상태라면, A는 위에서부터 내려가다가 처음으로 읽을 수 있는 버전인 txn_90의 값 10000을 읽습니다.
이것이 "다른 트랜잭션이 값을 바꿨는데도, 나는 예전 값을 계속 읽는다"는 현상의 정체입니다.
Read Committed와 Repeatable Read의 차이
같은 MVCC라도 언제 Read View를 새로 만들 것인가에 따라 격리 수준의 동작이 달라집니다.
| 격리 수준 | Read View 생성 시점 | 결과 |
|---|---|---|
| Read Committed | SELECT마다 새로 생성 |
같은 트랜잭션에서도 다시 읽으면 값이 달라질 수 있음 |
| Repeatable Read | 트랜잭션의 첫 일관된 읽기 시점에 생성 | 같은 트랜잭션에서는 같은 값을 계속 읽음 |
-- Read Committed
SELECT balance FROM accounts WHERE id = 1; -- 10000
-- 다른 트랜잭션이 5000으로 수정 후 COMMIT
SELECT balance FROM accounts WHERE id = 1; -- 5000
-- Repeatable Read
SELECT balance FROM accounts WHERE id = 1; -- 10000
-- 다른 트랜잭션이 5000으로 수정 후 COMMIT
SELECT balance FROM accounts WHERE id = 1; -- 여전히 10000
핵심은 락이 아니라 스냅샷 생성 시점입니다.
Phase 4. 일반 SELECT와 잠금 읽기는 왜 다를까?
MVCC를 이해할 때 가장 많이 헷갈리는 부분이 여기입니다. SELECT는 같은 읽기인데, 왜 어떤 것은 안 막히고 어떤 것은 대기할까요?
일반 SELECT — 스냅샷 읽기
SELECT balance
FROM accounts
WHERE id = 1;
일반 SELECT는 현재 시점의 최신 값을 무조건 읽는 것이 아니라, 내 Read View에서 허용되는 버전을 읽습니다. 그래서 다른 트랜잭션이 행에 배타 락을 잡고 있어도, InnoDB에서는 Undo 로그를 따라가 이전 버전을 읽을 수 있습니다.
잠금 읽기 — 현재 버전에 대한 충돌 확인
SELECT balance
FROM accounts
WHERE id = 1
FOR UPDATE;
FOR UPDATE, FOR SHARE 같은 잠금 읽기는 단순히 "보여주는 값"만 결정하면 끝나지 않습니다. 이 행을 지금 내가 보호해야 하는가, 다른 트랜잭션과 충돌하는가를 확인해야 하므로 현재 버전을 기준으로 락을 잡습니다.
그래서 잠금 읽기와 쓰기는 MVCC만으로 처리되지 않고, 락과 함께 동작합니다.
| 읽기 종류 | 무엇을 읽는가 | 락 사용 여부 |
|---|---|---|
일반 SELECT |
스냅샷에 맞는 버전 | 보통 락 없음 |
SELECT ... FOR UPDATE |
현재 버전 + 충돌 여부 확인 | 행 락 사용 |
SELECT ... FOR SHARE |
현재 버전 + 충돌 여부 확인 | 공유 락 사용 |
참고: 그래서 "MVCC가 있으니 락이 필요 없다"는 말은 틀립니다. 정확히는 일반 읽기를 락 없이 처리할 수 있다가 맞습니다.
Phase 5. MySQL InnoDB와 PostgreSQL의 차이
MVCC라는 큰 아이디어는 비슷하지만, 세부 구현은 다릅니다.
InnoDB
- 최신 레코드는 데이터 페이지에 저장
- 이전 버전은 Undo 로그를 통해 복원
DB_TRX_ID,DB_ROLL_PTR같은 내부 정보를 활용- Repeatable Read에서 MVCC + 넥스트 키 락으로 팬텀을 상당 부분 방지
PostgreSQL
- 행 자체에
xmin,xmax같은 트랜잭션 정보를 함께 저장 - 이전 버전을 Undo 로그에서 복원하기보다, 행 버전 자체를 새로 생성하는 쪽에 가깝습니다
- VACUUM이 오래된 버전을 정리합니다
- 기본 격리 수준은 Read Committed입니다
한눈에 보기
| 항목 | MySQL InnoDB | PostgreSQL |
|---|---|---|
| 이전 버전 관리 | Undo 로그 기반 | 행 버전 자체를 유지 |
| 행 메타데이터 | DB_TRX_ID, DB_ROLL_PTR |
xmin, xmax |
| 오래된 버전 정리 | Purge | VACUUM |
| 기본 격리 수준 | Repeatable Read | Read Committed |
실무에서는 둘 다 "읽기와 쓰기의 충돌을 줄이기 위해 여러 버전을 관리한다"는 점이 중요합니다. 다만 성능 이슈를 분석할 때는 Undo 로그, Purge 지연, VACUUM 지연처럼 엔진별 키워드를 구분해서 봐야 합니다.
Phase 6. 실무에서 꼭 기억할 점
1. MVCC는 읽기를 공짜로 만드는 기술이 아닙니다
읽기 락을 줄여 주는 것은 맞지만, 버전을 유지하는 비용이 사라지는 것은 아닙니다. 오래 열린 트랜잭션이 많으면 오래된 버전을 정리하지 못해 Undo 로그나 dead tuple이 쌓일 수 있습니다.
2. 오래 열린 트랜잭션은 생각보다 비쌉니다
사용자가 트랜잭션을 열어 둔 채 오랫동안 응답을 보내지 않거나, 배치 작업이 한 트랜잭션으로 너무 오래 실행되면 오래된 버전을 계속 붙잡게 됩니다.
- InnoDB: purge가 늦어질 수 있습니다
- PostgreSQL: VACUUM이 회수할 수 없는 버전이 늘어날 수 있습니다
즉, 트랜잭션은 짧을수록 좋다는 원칙은 락 때문만이 아니라 MVCC 때문이기도 합니다.
3. 일반 SELECT와 SELECT ... FOR UPDATE는 완전히 다릅니다
둘 다 SELECT라서 비슷해 보이지만, 전자는 스냅샷 읽기이고 후자는 잠금 읽기입니다. 실무에서 동시성 문제를 분석할 때 이 둘을 섞어서 보면 원인을 잘못 짚기 쉽습니다.
4. 격리 수준 문제와 락 문제를 분리해서 봐야 합니다
같은 "동시성 문제"라도
- 값이 왜 다르게 보이는가는 MVCC/격리 수준 문제일 수 있고
- 왜 서로 대기하는가는 락 문제일 수 있습니다
둘은 함께 동작하지만, 원인은 같지 않습니다.
정리
- MVCC는 여러 버전을 관리해서 읽기 일관성을 제공하는 방식입니다 — 읽기와 쓰기가 서로를 덜 막게 해줍니다
- InnoDB는 Undo 로그와 Read View로 MVCC를 구현합니다 — 최신 레코드만 보는 것이 아니라, 내 스냅샷에 맞는 버전을 선택합니다
- Read Committed와 Repeatable Read의 차이는 스냅샷 생성 시점에 있습니다 — 락보다 Read View 재생성 여부가 핵심입니다
- 일반
SELECT와 잠금 읽기는 다릅니다 — 일반SELECT는 스냅샷 읽기이고,FOR UPDATE는 락을 동반하는 현재 버전 읽기입니다 - MySQL과 PostgreSQL은 같은 MVCC 철학을 공유하지만 구현은 다릅니다 — InnoDB는 Undo 로그, PostgreSQL은 행 버전과 VACUUM 중심으로 이해하면 됩니다
- 실무에서는 오래 열린 트랜잭션을 특히 조심해야 합니다 — 락뿐 아니라 오래된 버전 정리까지 지연시킬 수 있기 때문입니다