MySQL InnoDB MVCC가 일관된 읽기를 만드는 방법

seonwoo_jung·2026년 5월 28일

1. 도입

여러 트랜잭션이 같은 행을 동시에 읽고 쓰는데도, SELECT가 한 번도 락에 막히지 않는다. 이게 신기해서 처음 InnoDB를 공부할 때 "락을 안 걸고 어떻게 일관된 읽기가 되지?"라는 의문이 가장 오래 남았다. 답은 MVCC(Multi-Version Concurrency Control) 였다.

MySQL Reference Manual §15.7(InnoDB Locking and Transaction Model)에 따르면 InnoDB는 동일 행의 여러 버전을 undo log로 함께 유지하고, 트랜잭션 시작 시점의 "스냅샷"을 통해 읽기를 처리한다. 락을 걸지 않으니 쓰기와 읽기가 서로를 막지 않는다.

이 글에서는 다음 네 가지를 정리한다.

  • 행마다 붙는 숨은 컬럼(DB_TRX_ID, DB_ROLL_PTR)이 어떤 의미인가
  • ReadView가 어떻게 "이 버전을 보여줘도 되는가"를 판정하는가
  • REPEATABLE READ와 READ COMMITTED에서 스냅샷이 잡히는 시점이 어떻게 달라지는가
  • 결과적으로 어떤 케이스에서 "내가 본 데이터"가 다른 트랜잭션과 어긋날 수 있는가

2. 핵심 개념

MVCC는 같은 행을 여러 "버전"으로 두고, 각 트랜잭션에게 자기 시점에 맞는 버전을 보여주는 동시성 제어 방식이다.

전통적인 락 기반 동시성 제어는 "쓰는 동안 못 읽고, 읽는 동안 못 쓴다"가 기본이다. MVCC는 이걸 깨기 위한 트릭이다. 핵심 아이디어는 단순하다.

  • 변경하면 새 버전을 만든다. 기존 행을 지우지 않고, 새 값으로 덮되 이전 값을 undo log에 남겨둔다.
  • 읽을 때는 자기 스냅샷에 맞는 버전을 거슬러 올라간다. 트랜잭션 A가 시작된 시점 이후에 커밋된 변경은 A에게 보이지 않아야 한다. 그러면 그 버전을 건너뛰고 undo log로 한 단계 이전 버전을 가져온다.

이 구조 덕분에 일반 SELECT(consistent read)는 락을 잡지 않고, 쓰는 트랜잭션과 충돌 없이 진행된다. MySQL Reference Manual §15.7.2.3 (Consistent Nonlocking Reads)이 같은 내용을 설명한다.

왜 이게 필요한가

  • 읽기-쓰기 충돌로 인한 대기 시간을 줄이기 위해. (특히 OLTP 워크로드)
  • "내가 본 결과가 트랜잭션 도중에 바뀌면 곤란하다"는 반복 가능한 읽기 요구를 충족하기 위해.
  • 락 없이도 격리 수준(특히 REPEATABLE READ)을 구현하기 위해.

3. 내부 동작

3.1 행에 붙는 숨은 컬럼

InnoDB는 사용자에게 안 보이는 시스템 컬럼을 모든 행에 끼워 넣는다. MySQL Reference Manual §15.6.1(Clustered and Secondary Indexes)·§15.7.2.2 부근에서 언급된다.

컬럼크기역할
DB_TRX_ID6 byte이 버전을 마지막으로 쓴 트랜잭션의 ID
DB_ROLL_PTR7 byte이전 버전을 가리키는 undo record 포인터
DB_ROW_ID6 byte사용자가 PK를 안 줬을 때 InnoDB가 만드는 내부 ID

DB_ROLL_PTR을 따라가면 undo log에 저장된 이전 버전이 나온다. 그 버전에도 또 DB_ROLL_PTR이 있어서, 결국 행 하나는 시간 축을 따라 연결된 버전 체인을 이룬다.

[현재 행 v3]            DB_TRX_ID=110, DB_ROLL_PTR ──┐
                                                    ▼
[undo log: v2]          DB_TRX_ID=104, DB_ROLL_PTR ──┐
                                                    ▼
[undo log: v1]          DB_TRX_ID= 97, DB_ROLL_PTR=NULL

3.2 ReadView (가시성 판정 구조)

트랜잭션이 읽기를 시작할 때 InnoDB는 ReadView라는 스냅샷 객체를 만든다. 핵심 필드는 다음과 같다(소스 코드와 일치하는 이름 그대로 부르는 경우가 많다).

필드의미
m_creator_trx_id이 ReadView를 만든 트랜잭션 자신의 ID
m_idsReadView 생성 시점에 활성 상태(아직 커밋·롤백 안 된)인 트랜잭션 ID 집합
m_up_limit_idm_ids 중 가장 작은 ID. 이보다 작은 trx_id는 이미 커밋된 변경
m_low_limit_idReadView 생성 시점에 아직 할당되지 않은 다음 trx_id. 이 값 이상이면 나중에 시작된 트랜잭션

가시성(visibility) 규칙은 직관적이다.

  1. 행의 DB_TRX_ID == m_creator_trx_id → 내가 만든 변경이니까 보인다.
  2. 행의 DB_TRX_ID < m_up_limit_id → 내가 시작하기 전에 이미 커밋된 변경이니까 보인다.
  3. 행의 DB_TRX_ID >= m_low_limit_id → 내가 시작한 뒤에 등장한 트랜잭션이니까 안 보인다.
  4. 그 사이라면 m_ids에 포함되어 있는가?
    • 포함 → 아직 커밋 안 된 변경. 안 보인다.
    • 미포함 → 이미 커밋된 변경. 보인다.

"안 보인다"는 결론이 나오면 DB_ROLL_PTR을 따라 이전 버전으로 한 단계 더 거슬러 올라가서 같은 판정을 반복한다. 첫 번째로 가시 판정이 통과한 버전이 그 트랜잭션이 보게 될 값이다.

3.3 격리 수준에 따른 스냅샷 시점

자주 헷갈리는 지점이 두 개 있다.

(1) ReadView가 만들어지는 시점이 격리 수준마다 다르다.

  • REPEATABLE READ (기본값): 트랜잭션 내 첫 번째 consistent read 시점에 ReadView를 한 번 만들고, 그 트랜잭션이 끝날 때까지 재사용한다. 그래서 같은 트랜잭션 안에서 같은 쿼리를 반복하면 결과가 같다.
  • READ COMMITTED: 각 SELECT 문마다 ReadView를 새로 만든다. 그래서 같은 트랜잭션 안에서 두 번 읽으면, 중간에 커밋된 다른 트랜잭션의 결과가 끼어들 수 있다(non-repeatable read).

MySQL Reference Manual §15.7.2.1(Transaction Isolation Levels)에서 이 차이를 명시한다.

(2) consistent read와 locking read는 다른 길을 탄다.

  • SELECT ... (그냥 SELECT): consistent read. 스냅샷에서 읽고, 락 안 잡음.
  • SELECT ... FOR UPDATE, SELECT ... FOR SHARE: locking read. 현재 버전을 잠그고 읽음. MVCC가 아니라 락 경로.
  • UPDATE, DELETE: 마찬가지로 현재 버전(latest committed)을 본다. 자기 스냅샷이 아니라 실제로 존재하는 최신 행을 잡아야 데이터를 갱신할 수 있기 때문이다.

이 부분이 한 번 꼬이면 "내 SELECT 결과랑 UPDATE 결과가 다르다"는 현상으로 나타난다. (소위 write skew의 변종.)

4. 예시

REPEATABLE READ에서 두 트랜잭션이 같은 행을 다루는 흐름을 보면 가시성 규칙이 한눈에 들어온다.

-- 초기 상태: products(id=1, stock=10), DB_TRX_ID=97 라고 가정

-- 트랜잭션 A (trx_id=110)
START TRANSACTION;                 -- 아직 ReadView 안 만들어짐
SELECT stock FROM products WHERE id=1;
                                   -- 여기서 첫 read → ReadView 생성
                                   -- m_up_limit_id=110 직전 활성 트랜잭션 기준
                                   -- 결과: 10

-- (동시에) 트랜잭션 B (trx_id=111)
START TRANSACTION;
UPDATE products SET stock=8 WHERE id=1;  -- 새 버전 생성 (DB_TRX_ID=111)
COMMIT;

-- 다시 트랜잭션 A
SELECT stock FROM products WHERE id=1;
                                   -- 현재 버전의 DB_TRX_ID=111
                                   -- A의 ReadView 기준 111 >= low_limit_id → 안 보임
                                   -- DB_ROLL_PTR 따라 undo log의 v1(stock=10)으로 이동
                                   -- 결과: 10  ← 반복 가능한 읽기

UPDATE products SET stock = stock - 1 WHERE id=1;
                                   -- 이건 consistent read가 아니라 latest read
                                   -- 현재 stock=8 위에서 갱신 → stock=7
COMMIT;

마지막 UPDATE가 묘하다. SELECT로는 분명 10을 봤는데, UPDATE가 만든 결과는 7이다. 스냅샷에서는 10 - 1 = 9가 자연스러워 보이지만, UPDATE는 스냅샷이 아니라 실제 최신 버전(8) 을 잡아 갱신하기 때문이다.

consistent read는 "과거의 스냅샷에서 읽는다"는 것, locking write는 "현재 최신 버전에 손댄다"는 것 — 이 두 개가 한 트랜잭션 안에 공존한다.

엣지 케이스 하나 더. REPEATABLE READ에서 같은 행을 두 번 읽었는데 두 번째 결과가 첫 번째와 다르게 나오는 경우가 있다. 트랜잭션 A가 직접 그 행을 UPDATE했을 때다. A 자신의 변경은 자기 스냅샷에 보여야 하기 때문이다(가시성 규칙 1). MVCC가 깨진 게 아니라 자기 변경은 즉시 반영이라는 규칙이 잘 작동하고 있는 것.

5. 정리

  • 한 줄 요약: MVCC는 undo log 기반 버전 체인 + ReadView 가시성 판정으로 일관된 읽기를 락 없이 제공하는 메커니즘이다.
  • 격리 수준은 결국 "ReadView를 언제 만드느냐"의 차이로 표현된다. REPEATABLE READ는 트랜잭션 시작 시점(정확히는 첫 consistent read), READ COMMITTED는 문장 단위.
  • SELECTUPDATE가 같은 트랜잭션 안에서도 다른 버전을 본다는 점은 한 번은 직접 부딪혀 보는 게 좋다.

다음에 더 파고들 만한 주제:

  • Purge 스레드와 history list length: 오래된 undo record가 언제 정리되는가, long-running 트랜잭션이 어떻게 storage 부담을 키우는가.
  • Gap lock / Next-key lock: REPEATABLE READ에서 phantom read를 막기 위한 락 메커니즘. MVCC가 아니라 락 경로에서 동작.

참고 자료

  • MySQL 8.0 Reference Manual §15.7 — InnoDB Locking and Transaction Model
    • 특히 §15.7.2.1 Transaction Isolation Levels, §15.7.2.3 Consistent Nonlocking Reads
  • MySQL 8.0 Reference Manual §15.6.1 — Clustered and Secondary Indexes (숨은 컬럼 설명)
  • MySQL 소스 트리 storage/innobase/include/read0read.hReadView 정의 (필드 이름 검증용)

0개의 댓글