[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 UPDATELOCK 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 STATUSINNODB_TRX로 장기 트랜잭션 탐지옛 스냅샷을 붙잡은 트랜잭션을 찾아 종료해야 purge가 재개된다