[MySQL] InnoDB MVCC 동작 원리
여러 트랜잭션이 같은 행을 동시에 다루면, 읽기와 쓰기를 락으로 막는 방식은 조회가 많을 때 병목이 된다. MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어)는 한 행에 여러 버전을 유지하는 기법이다. 읽기 트랜잭션은 락 없이도 자신에게 맞는 버전을 읽어 일관된 데이터를 본다. InnoDB는 undo log에 옛 버전을 체인으로 쌓고, Read View로 트랜잭션마다 보이는 버전을 가른다. 격리 수준의 표준 정의는 MySQL 트랜잭션 격리 수준과 MVCC 에서 다루며, 이 글은 undo log가 옛 버전을 어떻게 남기고 언제 정리되는지에 집중한다.
다이어그램
sequenceDiagram autonumber participant T as 조회 트랜잭션 participant REC as 현재 레코드 (account id=1) participant UL as Undo Log T->>REC: SELECT balance WHERE id=1 Note over REC: 현재 버전 balance=300, 내 스냅샷 이후에 바뀐 값 REC->>UL: DB_ROLL_PTR로 이전 버전 조회 UL-->>REC: balance=200, 이 버전도 스냅샷 이후 REC->>UL: DB_ROLL_PTR로 한 번 더 이전 버전 조회 UL-->>REC: balance=100, 스냅샷 시점에 커밋돼 있던 버전 REC-->>T: balance = 100 Note over T,UL: 현재 값 300이 아니라 체인을 두 번 거슬러 올라간 100을 반환한다
MVCC가 필요한 이유와 네 가지 구성 요소
- 읽기와 쓰기를 서로 막는 락 방식은 조회 트래픽이 많은 서비스에서 병목이 된다.
- MVCC는 한 행에 여러 버전을 유지해 읽기가 락 없이도 일관된 데이터를 보게 하는 기법이다.
- InnoDB의 네 격리 수준은 모두 이 MVCC 위에서 구현된다.
MVCC는 네 요소가 맞물려 동작한다.
- 트랜잭션 ID(trx_id): 트랜잭션 시작 시 고유하게 배정된다.
- trx_id는 뒤에 시작한 트랜잭션일수록 값이 크다.
- DB_TRX_ID: 그 행을 마지막으로 변경한 트랜잭션 ID를 담는 숨은 컬럼이다.
- DB_ROLL_PTR: undo log의 이전 버전을 가리키는 포인터를 담는 숨은 컬럼이다.
- Undo Log: UPDATE·DELETE가 발생하면 변경 전 데이터를 보관한다.
- Undo Log의 이전 버전들은 DB_ROLL_PTR로 체인처럼 연결된다.
- Read View: 트랜잭션이 어느 시점의 데이터를 볼지 고정하는 스냅샷이다.
- Read View는 만들어진 시점에 어떤 트랜잭션이 커밋돼 있었는지를 기록한다.
Undo Log에 버전이 체인으로 쌓이는 방식
- 같은 행을 여러 트랜잭션이 순차적으로 UPDATE하면 버전이 undo log에 체인으로 쌓인다.
- balance = 100인 행을 두 트랜잭션이 차례로 바꾸는 예시로 확인한다.
-- 초기 상태: id=1, balance=100, DB_TRX_ID=50
-- trx_id=90 이 변경
UPDATE account SET balance = 200 WHERE id = 1;
-- 현재 레코드: balance=200, DB_TRX_ID=90, DB_ROLL_PTR → (balance=100, trx=50)
-- trx_id=100 이 변경
UPDATE account SET balance = 300 WHERE id = 1;
-- 현재 레코드: balance=300, DB_TRX_ID=100
-- roll_ptr 체인: (balance=200, trx=90) 다음 (balance=100, trx=50)sequenceDiagram autonumber participant T90 as 트랜잭션 (trx_id=90) participant T100 as 트랜잭션 (trx_id=100) participant REC as 현재 레코드 (id=1) participant UL as Undo Log Note over REC: balance=100, DB_TRX_ID=50 T90->>REC: UPDATE balance = 200 REC->>UL: 변경 전 버전 복사 (balance=100, trx=50) UL-->>REC: DB_ROLL_PTR 연결 Note over REC: balance=200, DB_TRX_ID=90 T100->>REC: UPDATE balance = 300 REC->>UL: 변경 전 버전 복사 (balance=200, trx=90) UL-->>REC: DB_ROLL_PTR 연결 Note over REC: balance=300, DB_TRX_ID=100 Note over REC,UL: 체인 = 300(trx=100) → 200(trx=90) → 100(trx=50)
- 현재 레코드는 balance=300이다.
- DB_ROLL_PTR을 따라가면 200(trx=90)이 나온다.
- 다시 DB_ROLL_PTR을 따라가면 100(trx=50)이 나온다.
- 어떤 트랜잭션이 어느 버전을 보는지는 그 트랜잭션의 Read View가 결정한다.
격리 수준마다 Read View를 만드는 시점이 다르다
- 같은 MVCC라도 Read View를 언제 만드는가가 격리 수준을 가른다.
- 이 한 가지 차이가 Non-repeatable Read 발생 여부를 결정한다.
sequenceDiagram autonumber participant T1 as T1 (조회 트랜잭션) participant DB as MySQL participant T2 as T2 (변경 트랜잭션) T1->>DB: BEGIN, SELECT balance Note over DB: 첫 조회 시점에 Read View 생성 DB-->>T1: balance = 100 T2->>DB: UPDATE balance = 200, COMMIT T1->>DB: SELECT balance (같은 트랜잭션) alt READ COMMITTED Note over DB: 매 SELECT마다 Read View 를 새로 생성 DB-->>T1: balance = 200 (Non-repeatable Read 발생) else REPEATABLE READ Note over DB: 첫 Read View 재사용, undo log 옛 버전 조회 DB-->>T1: balance = 100 (Non-repeatable Read 차단) end
| 격리 수준 | Read View 생성 시점 | 결과 |
|---|---|---|
| READ COMMITTED | 매 SELECT마다 새로 생성 | 매번 최신 커밋 반영, Non-repeatable Read 발생 |
| REPEATABLE READ | 트랜잭션 첫 일관성 읽기 때 1회 생성 후 고정 | 트랜잭션 내내 같은 스냅샷, Non-repeatable Read 차단 |
- READ UNCOMMITTED는 Read View도 undo log도 거치지 않고 현재 버전을 그대로 읽는다.
- SERIALIZABLE은 잠금 읽기(current read)라 MVCC 스냅샷이 아니라 락으로 현재 버전을 읽는다.
- 격리 수준별 실제 동작 시퀀스와 이상 현상 비교는 MySQL 트랜잭션 격리 수준과 MVCC 에서 다룬다.
undo log는 격리 수준마다 얼마나 관여하는가?
| 격리 수준 | undo log 관여 | 읽는 버전 |
|---|---|---|
| READ UNCOMMITTED | 안 봄 | 현재 버전 직접(미커밋 포함) |
| READ COMMITTED | 봄(매 SELECT 스냅샷) | 미가시 현재 버전이면 DB_ROLL_PTR로 옛 버전 |
| REPEATABLE READ | 봄(고정 스냅샷) | 첫 스냅샷 시점의 커밋된 버전 |
| SERIALIZABLE | 안 봄(잠금 읽기) | 락으로 현재 버전 |
Undo Log는 언제 정리되는가
- undo log는 무한히 쌓이지 않는다.
- 참조하는 Read View가 모두 사라지면 백그라운드 purge 스레드가 정리한다.
- 오래 열려 있는 트랜잭션은 옛 스냅샷을 계속 붙잡는다.
- 그 뒤의 undo log는 그 트랜잭션이 끝나야 purge된다.
- 쌓인 undo log는 스토리지와 조회 성능에 영향을 준다.
운영 중인 InnoDB에서 트랜잭션과 undo log 상태를 직접 조회할 수 있다.
-- 현재 실행 중인 트랜잭션 목록 (시작 시각, 상태, 격리 수준)
SELECT trx_id, trx_state, trx_started, trx_isolation_level
FROM information_schema.INNODB_TRX;
-- 아직 purge 되지 못하고 쌓인 옛 버전 길이 (History List Length)
SHOW ENGINE INNODB STATUS;
-- 출력의 TRANSACTIONS 섹션에서 History list length 확인- History List Length가 지속적으로 커지면 위험 신호다.
- 오래 열려 있는 트랜잭션이 purge를 막고 있다는 뜻이다.
-- 5초 이상 열려 있는 트랜잭션 탐지 (긴 트랜잭션 조기 발견)
SELECT trx_id, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS open_seconds,
trx_query
FROM information_schema.INNODB_TRX
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 5
ORDER BY open_seconds DESC;트랜잭션 안에서 외부 API를 부르면 왜 위험한가?
- REPEATABLE READ에서는 트랜잭션이 열려 있는 동안 스냅샷이 고정된다.
- 그 시점 이후의 undo log 버전은 트랜잭션이 끝나야 purge된다.
- 트랜잭션 안에서 외부 API를 부르면 응답 지연이 그대로 트랜잭션 수명이 된다.
- 지연이 길수록 undo log가 그만큼 더 쌓인다.
// 잘못된 패턴: 트랜잭션 안에서 외부 호출로 스냅샷을 오래 붙잡는다
@Transactional
public void process(Long id) {
Account account = accountRepository.findById(id).orElseThrow();
externalApiClient.call(account); // 30초 지연되면 트랜잭션도 30초 유지된다
account.apply();
}// 올바른 패턴: 외부 호출을 트랜잭션 밖으로 빼서 트랜잭션을 짧게 유지한다
public void process(Long id) {
ExternalResult result = externalApiClient.call(id); // 트랜잭션 밖
applyResult(id, result); // 짧은 트랜잭션
}
@Transactional
public void applyResult(Long id, ExternalResult result) {
Account account = accountRepository.findById(id).orElseThrow();
account.apply(result);
}오래 걸리는 집계·리포트 조회도 스냅샷을 붙잡는가?
- 집계·리포트처럼 오래 걸리는 조회도 REPEATABLE READ 트랜잭션 안에서 실행되면 스냅샷을 계속 유지한다.
- 그동안 purge가 막힌다.
- 대용량 배치 조회는 트랜잭션을 청크 단위로 끊어 스냅샷을 자주 갱신하는 방법을 검토한다.
일반 SELECT와 잠금 SELECT는 같은 스냅샷을 보는가?
- 일반 SELECT는 MVCC 스냅샷(consistent read)을 읽는다.
SELECT ... FOR UPDATE나LOCK IN SHARE MODE는 최신 버전을 잠그며 읽는다(current read).- 같은 트랜잭션 안에서 두 방식을 섞으면 값이 서로 다르게 보일 수 있다.
MVCC는 동시 쓰기 충돌도 막아주는가?
- MVCC는 읽기 일관성을 위한 장치일 뿐이다.
- 두 트랜잭션이 같은 행을 동시에 갱신하는 경합(Lost Update)은 막지 않는다.
- 동시 갱신 정합성이 필요하면
SELECT ... FOR UPDATE(비관적 락)를 쓴다. - 또는 version 컬럼(낙관적 락)을 쓴다.
언제 무엇을 쓰는가
| 상황 | 선택 | 근거 |
|---|---|---|
| 일반적인 온라인 서비스 트랜잭션 | REPEATABLE READ 기본 동작을 그대로 사용 | 첫 조회 시점 스냅샷이 고정되어 트랜잭션 내내 반복 읽기가 보장된다 |
| 대시보드·모니터링처럼 최신 커밋 값이 필요한 조회 | READ COMMITTED | 매 SELECT마다 새 Read View를 만들어 최신 커밋 값을 즉시 반영한다 |
| 트랜잭션 안에서 외부 API를 호출해야 하는 로직 | 외부 호출을 트랜잭션 밖으로 분리 | 스냅샷이 열려 있는 시간을 최소화해 undo log 누적을 막는다 |
| 대용량 배치·리포트 조회 | READ COMMITTED로 낮추거나 트랜잭션을 청크로 분할 | 오래 고정된 스냅샷이 purge를 막아 History List Length가 커지는 것을 막는다 |
| 최신 값을 잠그고 읽어야 하는 갱신 전 조회 | SELECT ... FOR UPDATE(current read) | 일반 SELECT의 MVCC 스냅샷은 최신 값을 보장하지 않는다 |
| 동시 갱신이 잦은 잔액·재고 컬럼 수정 | 명시적 락(비관적 락 또는 버전 컬럼) | MVCC는 Lost Update를 막지 않는다 |
| History List Length가 계속 커지는 상황 진단 | SHOW ENGINE INNODB STATUS와 INNODB_TRX로 장기 트랜잭션 탐지 | 옛 스냅샷을 붙잡은 트랜잭션을 찾아 종료해야 purge가 재개된다 |