트랜잭션과 MVCC — 동시에 읽고 쓸 수 있는 이유 (3편)
들어가며
트랜잭션 A가 잔액을 읽고 있는 동안, 트랜잭션 B가 같은 잔액을 수정합니다. A는 어떤 값을 읽어야 할까요? B가 수정한 값? 수정 전 값? B가 커밋했느냐에 따라 달라질까요?
이 질문에 대한 답이 격리 수준(Isolation Level)이고, InnoDB가 이를 구현하는 핵심 메커니즘이 MVCC(Multi-Version Concurrency Control)입니다.
MVCC를 이해하면, "왜 REPEATABLE READ에서는 같은 쿼리가 같은 결과를 반환하는지", "왜 긴 트랜잭션이 Undo 로그를 비대하게 만드는지"가 명확해집니다.
ACID: 트랜잭션의 4가지 보장
먼저 트랜잭션이 무엇을 보장하는지 정리하겠습니다.
Atomicity (원자성)
"전부 되거나, 전부 안 되거나." 계좌 이체에서 출금은 됐는데 입금이 안 되는 상황은 없어야 합니다.
InnoDB 구현: Undo Log. 실패하면 Undo Log를 역순으로 적용해서 원래 상태로 되돌립니다.
Consistency (일관성)
"트랜잭션 전후로 데이터베이스가 유효한 상태여야 한다." FK 제약, UNIQUE 제약, CHECK 제약 등이 항상 만족되어야 합니다.
InnoDB 구현: 제약 조건 검사. 스토리지 엔진 + 서버 계층이 함께 담당합니다.
Isolation (격리성)
"동시에 실행되는 트랜잭션들이 서로 간섭하지 않아야 한다." 이것이 이 글의 핵심 주제입니다.
InnoDB 구현: MVCC + 잠금. 이 글에서 MVCC를, 4편에서 잠금을 다룹니다.
Durability (영속성)
"커밋된 트랜잭션은 서버가 죽어도 살아남아야 한다."
InnoDB 구현: Redo Log + WAL. 1편에서 다뤘습니다.
격리 수준: 4단계
SQL 표준은 4가지 격리 수준을 정의합니다. 높은 수준일수록 더 안전하지만, 동시성(성능)이 떨어집니다.
격리 수준별 이상 현상
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | ⭕ 발생 | ⭕ 발생 | ⭕ 발생 |
| READ COMMITTED | ❌ 방지 | ⭕ 발생 | ⭕ 발생 |
| REPEATABLE READ | ❌ 방지 | ❌ 방지 | ⭕ 발생 (표준) |
| SERIALIZABLE | ❌ 방지 | ❌ 방지 | ❌ 방지 |
InnoDB의 기본 격리 수준은 REPEATABLE READ이며, 넥스트키 락(4편)으로 Phantom Read까지 대부분 방지합니다. PostgreSQL의 기본은 READ COMMITTED입니다.
각 이상 현상을 시나리오로 이해하기
Dirty Read: 커밋 안 된 데이터를 읽음
-- TX A -- TX B
BEGIN; BEGIN;
UPDATE accounts SET balance = 0 WHERE id = 1;
SELECT balance FROM accounts -- B가 커밋 안 했는데
WHERE id = 1; -- A가 balance = 0을 읽음 ← Dirty Read
ROLLBACK; -- B가 롤백!
-- A는 존재하지 않는 값을 읽은 셈
READ UNCOMMITTED에서만 발생합니다. 실무에서 이 격리 수준을 쓰는 경우는 거의 없습니다.
Non-Repeatable Read: 같은 행을 두 번 읽었는데 값이 다름
-- TX A -- TX B
BEGIN;
SELECT balance FROM accounts
WHERE id = 1; -- balance = 1000
BEGIN;
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT;
SELECT balance FROM accounts
WHERE id = 1; -- balance = 500 ← 값이 바뀜!
COMMIT;
READ COMMITTED에서 발생합니다. 같은 트랜잭션 안에서 같은 쿼리의 결과가 달라집니다.
Phantom Read: 없던 행이 생김
-- TX A -- TX B
BEGIN;
SELECT * FROM users
WHERE age > 20; -- 3건
BEGIN;
INSERT INTO users (age) VALUES (25);
COMMIT;
SELECT * FROM users
WHERE age > 20; -- 4건 ← 행이 늘어남!
COMMIT;
Non-Repeatable Read는 기존 행의 값 변경이고, Phantom Read는 행의 추가/삭제입니다. REPEATABLE READ(표준)에서 발생 가능하지만, InnoDB는 갭 락으로 대부분 방지합니다.
MVCC: 잠금 없이 일관된 읽기
격리 수준을 구현하는 가장 직관적인 방법은 잠금입니다. 읽을 때 공유 잠금, 쓸 때 배타 잠금. 하지만 이러면 읽기와 쓰기가 서로 블로킹됩니다.
MVCC는 다른 접근을 취합니다: 데이터의 여러 버전을 동시에 유지하고, 트랜잭션마다 자신에게 보여야 할 버전을 선택합니다. 읽기가 쓰기를 블로킹하지 않고, 쓰기도 읽기를 블로킹하지 않습니다.
Undo Log 체인: 버전 관리의 실체
1편에서 Undo Log가 "변경 이전의 값"을 저장한다고 했습니다. MVCC에서 Undo Log는 데이터의 이전 버전을 체인으로 연결하는 역할을 합니다.
현재 행 (balance=500, trx_id=200)
│ DB_ROLL_PTR
↓
Undo 레코드 (balance=1000, trx_id=150)
│ DB_ROLL_PTR
↓
Undo 레코드 (balance=800, trx_id=100)
│
↓
(더 이전 버전...)
각 행에는 숨겨진 컬럼 DB_TRX_ID(마지막으로 수정한 트랜잭션 ID)와 DB_ROLL_PTR(이전 버전 Undo 레코드 포인터)가 있습니다. 이 포인터를 따라가면 과거 버전을 재구성할 수 있습니다.
Read View: "나에게 보이는 것"의 기준
MVCC의 핵심 메커니즘은 Read View입니다. Read View는 트랜잭션이 "어떤 버전까지 볼 수 있는지"를 결정하는 스냅샷입니다.
Read View에는 다음 정보가 포함됩니다:
- m_ids: Read View 생성 시점에 활성(커밋 안 된) 트랜잭션 ID 목록
- min_trx_id: m_ids 중 가장 작은 값
- max_trx_id: 다음에 할당될 트랜잭션 ID (현재 최대 + 1)
- creator_trx_id: 이 Read View를 만든 트랜잭션의 ID
가시성 판단 알고리즘
행의 DB_TRX_ID를 Read View와 비교해서 보일지 말지 결정합니다:
DB_TRX_ID == creator_trx_id→ 보임 (내가 수정한 것)DB_TRX_ID < min_trx_id→ 보임 (Read View 생성 전에 커밋 완료)DB_TRX_ID >= max_trx_id→ 안 보임 (Read View 생성 후에 시작된 트랜잭션)min_trx_id <= DB_TRX_ID < max_trx_id→DB_TRX_ID가 m_ids에 있으면 → 안 보임 (아직 커밋 안 된 트랜잭션)DB_TRX_ID가 m_ids에 없으면 → 보임 (이미 커밋된 트랜잭션)
안 보이면? Undo Log 체인을 따라 이전 버전으로 이동하고, 다시 가시성을 판단합니다. 보이는 버전을 찾을 때까지 반복합니다.
격리 수준별 Read View 생성 시점
READ COMMITTED와 REPEATABLE READ의 차이는 단 하나: Read View를 언제 만드느냐입니다.
READ COMMITTED: 매 SELECT마다 새 Read View
-- TX A (trx_id=200) -- TX B (trx_id=201)
BEGIN;
SELECT balance FROM accounts -- Read View 생성 (m_ids: [201])
WHERE id = 1; -- 1000 -- trx_id=201이 활성이므로 B의 변경은 안 보임
BEGIN;
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT;
SELECT balance FROM accounts -- 새 Read View 생성 (m_ids: [])
WHERE id = 1; -- 500 -- trx_id=201이 이미 커밋 → 보임!
COMMIT;
두 번째 SELECT에서 새 Read View를 만들기 때문에, B의 커밋된 변경이 보입니다. 이것이 Non-Repeatable Read입니다.
REPEATABLE READ: 첫 SELECT에서만 Read View 생성
-- TX A (trx_id=200) -- TX B (trx_id=201)
BEGIN;
SELECT balance FROM accounts -- Read View 생성 (m_ids: [201])
WHERE id = 1; -- 1000
BEGIN;
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT;
SELECT balance FROM accounts -- 기존 Read View 재사용 (m_ids: [201])
WHERE id = 1; -- 1000 -- trx_id=201이 m_ids에 있으므로 안 보임!
COMMIT;
첫 번째 SELECT에서 만든 Read View를 트랜잭션이 끝날 때까지 재사용합니다. B가 커밋해도 A의 Read View에서는 여전히 "활성 트랜잭션"이므로 안 보입니다.
핵심 정리
| 격리 수준 | Read View 생성 | 결과 |
|---|---|---|
| READ COMMITTED | 매 SELECT마다 | 다른 TX의 커밋이 즉시 보임 |
| REPEATABLE READ | 첫 SELECT 시 1번 | 트랜잭션 시작 시점의 스냅샷 유지 |
Consistent Read vs Current Read
MVCC가 적용되는 것은 일반 SELECT(Consistent Read, 스냅샷 읽기)뿐입니다.
다음 구문들은 Current Read(현재 읽기)를 수행합니다 — 항상 최신 커밋 데이터를 읽고, 잠금을 겁니다:
SELECT ... FOR UPDATE; -- 배타 잠금
SELECT ... FOR SHARE; -- 공유 잠금 (= LOCK IN SHARE MODE)
UPDATE ...; -- 암묵적 배타 잠금
DELETE ...; -- 암묵적 배타 잠금
INSERT ...; -- 암묵적 배타 잠금
이것이 중요한 이유: REPEATABLE READ에서 일반 SELECT는 스냅샷을 읽지만, UPDATE는 최신 데이터를 읽습니다. 읽기와 쓰기가 다른 버전을 보는 셈입니다.
-- TX A -- TX B
BEGIN;
SELECT balance FROM accounts
WHERE id = 1; -- 1000 (스냅샷)
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT;
UPDATE accounts
SET balance = balance - 100
WHERE id = 1;
-- balance = 400 (500 - 100)
-- SELECT에서는 1000을 봤지만,
-- UPDATE는 최신 값 500에서 차감
잠금에 대한 자세한 내용은 4편에서 다룹니다.
긴 트랜잭션의 위험
MVCC를 이해하면 긴 트랜잭션이 위험한 이유가 명확해집니다:
- Undo Log 비대화: 오래된 Read View가 존재하면, 그 시점 이후의 모든 Undo 레코드를 보관해야 합니다. Purge 스레드가 정리할 수 없습니다.
- 디스크 사용량 증가: Undo 테이블스페이스가 계속 커집니다.
- 쿼리 성능 저하: Undo 체인이 길어지면, 버전 탐색에 더 많은 시간이 걸립니다.
-- 위험한 패턴
BEGIN;
SELECT * FROM big_table; -- Read View 생성
-- 30분간 다른 작업...
-- 이 사이 big_table에 수만 건의 변경이 발생
-- 모든 변경의 Undo 레코드가 보관됨
SELECT * FROM big_table; -- Undo 체인을 따라 과거 버전 재구성 → 느림
COMMIT;
모니터링 방법:
-- 오래 실행 중인 트랜잭션 확인
SELECT trx_id, trx_state, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec
FROM information_schema.INNODB_TRX
ORDER BY trx_started ASC;
-- Undo History Length 확인 (높으면 경고)
SHOW ENGINE INNODB STATUS\G
-- History list length: 1234
정리
| 개념 | 핵심 |
|---|---|
| ACID | 원자성(Undo), 일관성(제약), 격리성(MVCC+Lock), 영속성(Redo) |
| 격리 수준 | RU → RC → RR → Serializable (안전성 ↑, 동시성 ↓) |
| MVCC | 다중 버전 유지, 읽기가 쓰기를 블로킹하지 않음 |
| Undo Log 체인 | DB_ROLL_PTR로 이전 버전 연결 |
| Read View | 가시성 판단의 기준. RC는 매번, RR은 한 번 생성 |
| Consistent vs Current Read | SELECT은 스냅샷, UPDATE/DELETE/FOR UPDATE는 최신 |
| 긴 트랜잭션 | Undo 비대화, 디스크 증가, 쿼리 성능 저하 |
다음 편 예고
4편에서는 잠금(Lock)을 다룹니다. MVCC가 읽기-쓰기 충돌을 해결한다면, 잠금은 쓰기-쓰기 충돌을 해결합니다. InnoDB의 레코드 락, 갭 락, 넥스트키 락의 동작 원리와 데드락 감지 메커니즘을 추적합니다.
관련 글
락의 종류와 데드락 — 동시성 제어의 실체 (4편)
MVCC가 읽기-쓰기 충돌을 해결한다면, 락은 쓰기-쓰기 충돌을 해결한다. InnoDB의 레코드 락, 갭 락, 넥스트키 락이 각각 어떤 문제를 방지하는지, 데드락은 왜 발생하고 어떻게 감지되는지 추적합니다.
데이터는 디스크에 어떻게 저장되는가 — InnoDB 스토리지 엔진의 내부 구조 (1편)
MySQL에서 INSERT를 실행하면 데이터는 어디에, 어떤 형태로 저장되는가? InnoDB의 페이지 구조, Buffer Pool, 그리고 WAL(Redo/Undo Log)까지 — 디스크 I/O를 최소화하면서 데이터 무결성을 보장하는 구조를 추적합니다.
인덱스는 왜 빠른가 — B+Tree부터 커버링 인덱스까지 (2편)
인덱스를 걸면 빨라진다는 건 알지만, 왜 빠른지 설명할 수 있는가? B+Tree의 구조, 클러스터드 인덱스와 세컨더리 인덱스의 차이, 커버링 인덱스가 디스크 접근을 줄이는 원리, 복합 인덱스의 최좌선 규칙까지 정리합니다.