InnoDB MVCC — 락 없이 읽기를 처리하는 방법
한 트랜잭션이 데이터를 수정하는 동안, 다른 트랜잭션이 같은 데이터를 읽으면 어떤 값을 보게 될까요?
동시에 여러 트랜잭션이 실행되는 환경에서, 읽기와 쓰기가 서로를 차단하면 성능이 크게 떨어집니다. InnoDB의 MVCC(Multi-Version Concurrency Control) 는 데이터의 여러 버전을 유지하여 이 문제를 해결합니다. 읽기는 락 없이 처리하면서도 일관성을 보장하는 핵심 메커니즘입니다.
개념 정의
MVCC는 데이터를 변경할 때 이전 버전을 Undo Log에 보관하여, 각 트랜잭션이 자신에게 맞는 버전을 읽을 수 있게 하는 동시성 제어 방식입니다.
트랜잭션 A: UPDATE users SET name='김영희' WHERE id=1; (name='김철수' → '김영희')
트랜잭션 B: SELECT name FROM users WHERE id=1; ← 어떤 값을 보는가?
MVCC 없이: B가 A의 커밋을 기다려야 함 (읽기 차단)
MVCC 있을 때: B가 이전 버전('김철수')을 Undo Log에서 읽음 (읽기 비차단)
왜 필요한가
락 기반 동시성 제어의 한계:
락 기반:
읽기-읽기: 동시 가능
읽기-쓰기: 차단 (읽기가 쓰기를 기다리거나, 쓰기가 읽기를 기다림)
쓰기-쓰기: 차단
MVCC:
읽기-읽기: 동시 가능
읽기-쓰기: 동시 가능 (읽기는 이전 버전 참조)
쓰기-쓰기: 차단 (여전히 락 필요)
읽기가 쓰기를 차단하지 않고, 쓰기가 읽기를 차단하지 않습니다. 이것이 MVCC의 핵심 이점입니다.
InnoDB의 MVCC 구현
행의 숨겨진 컬럼
InnoDB의 모든 행에는 사용자가 보이지 않는 3개의 숨겨진 컬럼이 있습니다.
| 컬럼 | 크기 | 설명 |
|---|---|---|
DB_TRX_ID | 6바이트 | 이 행을 마지막으로 수정한 트랜잭션 ID |
DB_ROLL_PTR | 7바이트 | Undo Log에서 이전 버전을 가리키는 포인터 |
DB_ROW_ID | 6바이트 | 행 ID (PK가 없을 때 자동 생성) |
실제 행 구조:
┌──────────┬──────────────┬──────────┬─────┬──────┬───────┐
│ DB_TRX_ID│ DB_ROLL_PTR │ DB_ROW_ID│ id │ name │ age │
│ (trx 10) │ (→ undo log) │ │ 1 │ 김영희│ 28 │
└──────────┴──────────────┴──────────┴─────┴──────┴───────┘
Undo Log 기반 버전 체인
데이터가 변경되면 이전 버전이 Undo Log에 저장되고, 체인으로 연결됩니다.
-- 초기 상태 (trx 5에서 INSERT)
-- id=1, name='김철수', trx_id=5
-- trx 10: UPDATE users SET name='김영희' WHERE id=1;
-- trx 15: UPDATE users SET name='이수진' WHERE id=1;
현재 행 (최신 버전):
name='이수진', trx_id=15, roll_ptr → Undo Log [2]
Undo Log [2]:
name='김영희', trx_id=10, roll_ptr → Undo Log [1]
Undo Log [1]:
name='김철수', trx_id=5, roll_ptr → NULL (최초 버전)
버전 체인:
이수진(trx 15) → 김영희(trx 10) → 김철수(trx 5)
Read View — 가시성 판단
Read View는 트랜잭션이 어떤 버전의 데이터를 볼 수 있는지 결정하는 스냅샷입니다.
Read View의 구성 요소
Read View 생성 시점에 기록되는 정보:
- m_ids: 현재 활성(커밋되지 않은) 트랜잭션 ID 목록
- m_low_limit_id: 아직 할당되지 않은 가장 작은 트랜잭션 ID (이것 이상은 미래)
- m_up_limit_id: m_ids에서 가장 작은 트랜잭션 ID
- m_creator_trx_id: 이 Read View를 생성한 트랜잭션 ID
가시성 판단 알고리즘
행의 DB_TRX_ID가 trx_id일 때:
1. trx_id < m_up_limit_id
→ 이 버전은 Read View 생성 전에 커밋됨 → 보임
2. trx_id >= m_low_limit_id
→ 이 버전은 Read View 생성 후에 시작됨 → 안 보임
3. m_up_limit_id <= trx_id < m_low_limit_id
→ m_ids에 trx_id가 있으면: 아직 커밋 안 됨 → 안 보임
→ m_ids에 trx_id가 없으면: 커밋 완료됨 → 보임
4. trx_id == m_creator_trx_id
→ 자기 자신의 변경 → 보임
구체적 예시
시간 순서:
trx 5: INSERT INTO users VALUES (1, '김철수'); COMMIT;
trx 10: BEGIN;
trx 12: BEGIN;
trx 10: UPDATE users SET name='김영희' WHERE id=1;
trx 12: SELECT name FROM users WHERE id=1; ← 여기서 Read View 생성
trx 12의 Read View:
m_ids = [10] (trx 10이 아직 활성)
m_up_limit_id = 10
m_low_limit_id = 13 (다음 할당될 trx id)
m_creator_trx_id = 12
행의 현재 버전: name='김영희', trx_id=10
판단:
trx_id(10) >= m_up_limit_id(10) → 조건 3으로
m_ids에 10이 있는가? → 있음 → 안 보임!
→ Undo Log에서 이전 버전으로 이동
이전 버전: name='김철수', trx_id=5
trx_id(5) < m_up_limit_id(10) → 보임!
→ 결과: '김철수'
격리 수준별 Read View 동작
READ COMMITTED
매 SELECT마다 새로운 Read View를 생성합니다.
-- Session A
BEGIN;
UPDATE users SET name = '김영희' WHERE id = 1;
-- Session B (READ COMMITTED)
BEGIN;
SELECT name FROM users WHERE id = 1; -- Read View 생성 → '김철수' (A 미커밋)
-- Session A
COMMIT;
-- Session B
SELECT name FROM users WHERE id = 1; -- 새 Read View 생성 → '김영희' (A 커밋됨)
-- 같은 트랜잭션 내에서 결과가 달라짐! (Non-Repeatable Read)
REPEATABLE READ (InnoDB 기본값)
트랜잭션의 첫 SELECT에서만 Read View를 생성하고, 끝까지 유지합니다.
-- Session A
BEGIN;
UPDATE users SET name = '김영희' WHERE id = 1;
-- Session B (REPEATABLE READ)
BEGIN;
SELECT name FROM users WHERE id = 1; -- Read View 생성 → '김철수'
-- Session A
COMMIT;
-- Session B
SELECT name FROM users WHERE id = 1; -- 기존 Read View 재사용 → '김철수'
-- 같은 트랜잭션 내에서 결과가 항상 동일! (Repeatable Read 보장)
COMMIT;
비교 정리
| READ COMMITTED | REPEATABLE READ | |
|---|---|---|
| Read View 생성 | 매 SELECT마다 | 첫 SELECT 시 1회 |
| Non-Repeatable Read | 발생 가능 | 발생하지 않음 |
| Phantom Read | 발생 가능 | InnoDB에서는 MVCC로 방지 |
| 용도 | 최신 데이터 필요 시 | 일관된 읽기 필요 시 |
Consistent Read (일관된 읽기)
MVCC를 통한 읽기를 Consistent Read(일관된 읽기) 라 합니다.
-- 일반 SELECT는 Consistent Read (락 없음)
SELECT * FROM users WHERE id = 1;
-- 락을 거는 읽기 (MVCC가 아닌 현재 버전 읽기)
SELECT * FROM users WHERE id = 1 FOR SHARE; -- 공유 락
SELECT * FROM users WHERE id = 1 FOR UPDATE; -- 배타 락
FOR SHARE/FOR UPDATE를 사용하면 MVCC가 아닌 현재 최신 버전을 읽으며, 해당 행에 락을 겁니다.
장기 트랜잭션의 위험
MVCC에서 장기 실행 트랜잭션은 심각한 문제를 일으킬 수 있습니다.
트랜잭션 A: BEGIN; (2시간 전에 시작, 커밋 안 함)
→ A의 Read View가 참조할 수 있는 모든 이전 버전을 유지해야 함
→ 2시간 동안의 모든 UPDATE/DELETE의 Undo Log가 purge 불가
→ Undo Log 크기 급증 → 디스크 공간 부족, 성능 저하
-- 장기 트랜잭션 확인
SELECT trx_id, trx_state, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_seconds
FROM information_schema.INNODB_TRX
ORDER BY trx_started;
-- Undo Log 크기 확인
SHOW ENGINE INNODB STATUS\G
-- History list length: 이 값이 계속 증가하면 purge가 밀리고 있는 것
예방책:
- 트랜잭션을 가능한 짧게 유지합니다
wait_timeout,interactive_timeout을 적절히 설정합니다- 모니터링으로 장기 트랜잭션을 조기 감지합니다
UPDATE/DELETE에서의 MVCC
UPDATE는 다음 순서로 처리됩니다.
1. 해당 행에 배타 락(X Lock) 설정
2. 현재 버전을 Undo Log에 복사
3. 행의 데이터를 새 값으로 변경
4. DB_TRX_ID를 현재 트랜잭션 ID로 업데이트
5. DB_ROLL_PTR이 Undo Log의 이전 버전을 가리키도록 설정
DELETE는 실제 삭제가 아닌 삭제 플래그(delete mark) 를 설정합니다. 실제 물리적 삭제는 purge 스레드가 나중에 처리합니다.
정리
- MVCC는 Undo Log에 이전 버전을 보관하여, 읽기와 쓰기가 서로를 차단하지 않게 합니다
- Read View가 트랜잭션의 가시성 범위를 결정합니다
- READ COMMITTED는 매 SELECT마다 Read View를 생성하고, REPEATABLE READ는 첫 SELECT에서만 생성합니다
- 일반 SELECT는 Consistent Read(락 없음)이고,
FOR UPDATE/FOR SHARE는 현재 버전을 읽으며 락을 겁니다 - 장기 트랜잭션은 Undo Log purge를 막아 성능 저하를 유발하므로, 가능한 짧게 유지해야 합니다