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 스레드 가 나중에 처리합니다.
주의할 점
장기 트랜잭션이 Undo Log를 폭발시킨다
BEGIN만 해놓고 커밋하지 않은 트랜잭션이 있으면, 그 시점 이후의 모든 Undo Log를 purge할 수 없습니다. 수 시간 방치되면 Undo 테이블스페이스가 수 GB로 커지고, MVCC 조회 시 긴 버전 체인을 따라가야 하므로 SELECT 성능도 저하됩니다.
FOR UPDATE는 MVCC가 아니라 Current Read다
일반 SELECT는 스냅샷을 읽지만, FOR UPDATE는 최신 커밋된 데이터를 읽습니다. 같은 트랜잭션 안에서 일반 SELECT와 FOR UPDATE의 결과가 다를 수 있어 혼란을 일으킵니다.
-- RR 격리 수준에서
SELECT balance FROM accounts WHERE id = 1; -- 1000 (스냅샷)
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- 500 (Current Read!)
History list length를 모니터링해야 한다
SHOW ENGINE INNODB STATUS에서 History list length 값이 계속 증가하면 purge가 밀리고 있다는 신호입니다. 장기 트랜잭션을 찾아 종료해야 합니다.
정리
| 항목 | 설명 |
|---|---|
| MVCC 핵심 | Undo Log에 이전 버전 보관, 읽기와 쓰기가 서로 비차단 |
| Read View | 트랜잭션이 볼 수 있는 데이터 버전 범위를 결정하는 스냅샷 |
| RC vs RR | RC는 매 SELECT마다 새 Read View, RR은 첫 SELECT에서만 생성 |
| Consistent Read | 일반 SELECT, 락 없이 스냅샷 읽기 |
| Current Read | FOR UPDATE/FOR SHARE, 최신 데이터 읽기 + 락 |
| 장기 트랜잭션 위험 | Undo Log purge 차단 → 디스크 증가 + 성능 저하 |