“REPEATABLE READ는 같은 값을 반복해서 읽을 수 있다"는 설명을 읽고 고개를 끄덕인 적은 있지만, 정말 그런지 직접 확인해본 적은 없었다. 터미널 두 개를 열고 실습해보니 MVCC와 락의 동작이 훨씬 선명하게 잡혔다.

준비

Docker로 MySQL을 띄우고 터미널 두 개(세션 A, 세션 B)로 접속한다.

docker run --name mysql-lab -e MYSQL_ROOT_PASSWORD=password -d -p 3306:3306 mysql:latest
# 터미널 두 개에서 각각 실행
docker exec -it mysql-lab mysql -uroot -ppassword

테스트용 테이블을 만든다.

CREATE DATABASE IF NOT EXISTS innodb_lab;
USE innodb_lab;

CREATE TABLE account (
  id BIGINT PRIMARY KEY,
  owner_name VARCHAR(50) NOT NULL,
  balance INT NOT NULL,
  version INT NOT NULL DEFAULT 0,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;

INSERT INTO account (id, owner_name, balance, version)
VALUES (1, 'junghwan', 1000, 0), (2, 'alice', 500, 0);

InnoDB의 기본 격리 수준을 확인한다.

SELECT @@transaction_isolation;
-- REPEATABLE-READ

Lab 1 — Dirty Read 방지

커밋되지 않은 데이터를 다른 세션에서 볼 수 있는가?

순서세션 A세션 B
1START TRANSACTION;
2UPDATE account SET balance = 100 WHERE id = 1;
3SELECT * FROM account WHERE id = 1;100
4SELECT * FROM account WHERE id = 1;1000
5ROLLBACK;

세션 A가 100으로 바꿨지만 아직 커밋하지 않았다. 세션 B에서는 여전히 1000이 보인다. InnoDB는 UPDATE 시 원본 값을 undo log에 보관하고, 다른 세션은 그 undo log의 스냅샷을 읽기 때문이다.

flowchart LR
    A["세션 A: balance → 100<br/>(미커밋)"] --> Row["현재 행<br/>balance = 100"]
    Row -->|roll_ptr| Undo["undo log<br/>balance = 1000"]
    B["세션 B: SELECT"] -->|스냅샷 읽기| Undo

세션 B는 현재 행이 아닌 undo log의 원본(1000)을 읽는다. 이것이 Dirty Read 방지다.


Lab 2 — READ COMMITTED vs. REPEATABLE READ

READ COMMITTED: 매번 새 스냅샷

순서세션 A세션 B
1SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
2START TRANSACTION;
3SELECT balance FROM account WHERE id = 1;1000
4UPDATE account SET balance = 800 WHERE id = 1;
5SELECT balance FROM account WHERE id = 1;800
6COMMIT;

같은 트랜잭션 안에서 같은 쿼리를 두 번 했는데 결과가 달라졌다. READ COMMITTED는 매 SELECT마다 새 스냅샷을 찍기 때문이다. 이것이 Non-Repeatable Read 현상이다.

REPEATABLE READ: 스냅샷 고정

순서세션 A세션 B
1SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
2START TRANSACTION;
3SELECT balance FROM account WHERE id = 1;800
4UPDATE account SET balance = 700 WHERE id = 1;
5SELECT balance FROM account WHERE id = 1;800
6COMMIT;

세션 B가 700으로 커밋했지만 세션 A에는 여전히 800이 보인다. REPEATABLE READ는 트랜잭션 내 첫 SELECT 시점에 스냅샷을 고정하고, 이후 모든 SELECT에서 그 스냅샷을 재사용한다.

flowchart TD
    subgraph RC["READ COMMITTED"]
        RC1["SELECT ①"] -->|새 스냅샷| RCV1["ReadView 생성"]
        RC2["SELECT ②"] -->|새 스냅샷| RCV2["ReadView 재생성"]
    end
    subgraph RR["REPEATABLE READ"]
        RR1["SELECT ①"] -->|스냅샷 고정| RRV1["ReadView 생성"]
        RR2["SELECT ②"] -->|재사용| RRV1
    end
항목READ COMMITTEDREPEATABLE READ
ReadView 생성 시점매 SELECT마다트랜잭션 내 첫 SELECT에서 1회
Non-Repeatable Read발생방지
같은 쿼리 반복 시중간 커밋이 있으면 결과가 바뀜항상 같은 결과

Lab 3 — Snapshot Read vs. Current Read

일반 SELECT와 SELECT ... FOR UPDATE는 전혀 다른 데이터를 읽는다.

순서세션 A세션 B
1START TRANSACTION;
2SELECT balance FROM account WHERE id = 1;700
3UPDATE account SET balance = 600 WHERE id = 1;
4SELECT balance FROM account WHERE id = 1;700
5SELECT balance FROM account WHERE id = 1 FOR UPDATE;600
6COMMIT;

같은 트랜잭션에서 같은 행을 조회했는데 일반 SELECT는 700, FOR UPDATE는 600을 반환한다.

flowchart LR
    subgraph 행["id = 1"]
        Current["현재 행<br/>balance = 600"]
        Current -->|roll_ptr| Old["undo log<br/>balance = 700"]
    end

    S1["일반 SELECT<br/>(Snapshot Read)"] -->|undo log| Old
    S2["SELECT FOR UPDATE<br/>(Current Read)"] -->|최신 값 + X Lock| Current
일반 SELECTSELECT … FOR UPDATE
읽기 방식Snapshot Read (MVCC 스냅샷)Current Read (최신 커밋 값)
없음X Lock (배타 락)
용도단순 조회수정 전 조회, 경합 방지

FOR UPDATE가 최신 값을 읽어야 하는 이유는 명확하다. 스냅샷(700)을 기준으로 UPDATE하면, 세션 B가 커밋한 600이 덮어씌워져서 Lost Update가 발생한다.

X Lock의 차단 범위

FOR UPDATE로 X Lock을 잡으면, 다른 세션에서 같은 행에 대해:

다른 세션의 시도차단 여부이유
일반 SELECT통과스냅샷을 읽으므로 락 불필요
SELECT … FOR SHARE대기S Lock 필요, X Lock과 충돌
SELECT … FOR UPDATE대기X Lock 필요, X Lock과 충돌
UPDATE / DELETE대기X Lock 필요, X Lock과 충돌

“읽기는 쓰기를 막지 않고, 쓰기는 읽기를 막지 않는다.” MVCC의 핵심이다.


Lab 4 — Lost Update

balance=500에서 두 세션이 동시에 100씩 차감한다. 정상이라면 300이 되어야 한다.

FOR UPDATE 없이 (위험)

순서세션 A세션 B
1START TRANSACTION;START TRANSACTION;
2SELECT balance ...500SELECT balance ...500
3UPDATE ... SET balance = 400;
4UPDATE ... SET balance = 400;대기
5COMMIT;(대기 해제, 실행)
6COMMIT;
최종 balance: 400
sequenceDiagram
    participant A as 세션 A
    participant DB as InnoDB (id=1)
    participant B as 세션 B

    A->>DB: SELECT balance → 500
    B->>DB: SELECT balance → 500
    Note over A: 500 - 100 = 400
    A->>DB: UPDATE balance = 400 (X Lock 획득)
    Note over B: 500 - 100 = 400
    B--xDB: UPDATE balance = 400 (대기...)
    A->>DB: COMMIT (X Lock 해제)
    B->>DB: UPDATE 실행, balance = 400
    B->>DB: COMMIT
    Note over DB: 최종: 400 ❌ (300이어야 정상)

둘 다 스냅샷(500)을 읽고 애플리케이션에서 500 - 100 = 400을 계산해서 UPDATE했다. 차감이 한 번 증발했다.

FOR UPDATE로 해결

순서세션 A세션 B
1START TRANSACTION;START TRANSACTION;
2SELECT ... FOR UPDATE;500
3SELECT ... FOR UPDATE;대기
4UPDATE ... SET balance = 400;
5COMMIT;(대기 해제)
6400 (최신 값을 읽음)
7UPDATE ... SET balance = 300;
8COMMIT;
최종 balance: 300
sequenceDiagram
    participant A as 세션 A
    participant DB as InnoDB (id=1)
    participant B as 세션 B

    A->>DB: SELECT FOR UPDATE → 500 (X Lock 획득)
    B--xDB: SELECT FOR UPDATE (대기...)
    Note over A: 500 - 100 = 400
    A->>DB: UPDATE balance = 400
    A->>DB: COMMIT (X Lock 해제)
    B->>DB: SELECT FOR UPDATE → 400 (X Lock 획득)
    Note over B: 400 - 100 = 300
    B->>DB: UPDATE balance = 300
    B->>DB: COMMIT
    Note over DB: 최종: 300 ✅

핵심은 락이 걸리는 시점이다:

방식락 시점결과
SELECT → 계산 → UPDATEUPDATE에서 비로소 락읽기-계산 사이 gap에서 Lost Update
SELECT FOR UPDATE → 계산 → UPDATESELECT에서 이미 락gap 없음, 안전

Lab 5 — 음수 차감 방지

balance=300에서 두 세션이 동시에 250씩 출금을 시도한다. 하나만 성공해야 한다.

방법 1: FOR UPDATE + 애플리케이션 체크

순서세션 A세션 B
1SELECT ... FOR UPDATE;300 (X Lock)
2SELECT ... FOR UPDATE;대기
3300 >= 250 → UPDATE ... SET balance = 50;
4COMMIT;(대기 해제)
550 (최신 값)
650 < 250 → ROLLBACK;

방법 2: WHERE 절 조건

UPDATE account SET balance = balance - 250
WHERE id = 1 AND balance >= 250;
순서세션 A세션 B
1UPDATE ... AND balance >= 250; → Rows matched: 1
2UPDATE ... AND balance >= 250;대기
3COMMIT;(대기 해제)
4Rows matched: 0 (조건 불일치)

DB가 직접 강제하므로 애플리케이션이 체크를 빼먹어도 안전하다.

방식장점단점
FOR UPDATE + 앱 체크복잡한 비즈니스 로직 가능앱이 빼먹으면 뚫림
WHERE 절 조건DB가 강제, 확실함단순 조건만 가능
둘 다 겹쳐 쓰기 (실무)벨트 + 멜빵

MVCC는 어떻게 동작하는가

여기까지의 실습을 이해하려면 InnoDB가 다중 버전을 관리하는 방식을 알아야 한다.

InnoDB의 모든 행에는 숨겨진 컬럼이 있다:

숨겨진 컬럼역할
trx_id이 행을 마지막으로 수정한 트랜잭션 ID
roll_ptr이전 버전의 undo log를 가리키는 포인터

undo log는 링크드 리스트처럼 이전 버전들이 체인으로 연결된다:

flowchart LR
    Row["현재 행<br/>balance = 400<br/>trx_id = 120"] -->|roll_ptr| U1["undo log<br/>balance = 500<br/>trx_id = 115"]
    U1 -->|roll_ptr| U2["undo log<br/>balance = 800<br/>trx_id = 110"]
    U2 -->|roll_ptr| U3["..."]

트랜잭션이 시작되면 InnoDB는 ReadView를 생성한다. ReadView에는 현재 활성 트랜잭션 ID 목록이 담겨 있다. SELECT 시 InnoDB는 이렇게 판단한다:

flowchart TD
    Start["행의 trx_id 확인"] --> Check{"내 ReadView 시점에<br/>커밋된 트랜잭션인가?"}
    Check -->|Yes| Read["이 버전을 읽음"]
    Check -->|No| Follow["roll_ptr을 따라<br/>이전 버전으로 이동"]
    Follow --> Check

이 구조 덕분에 일반 SELECT는 락 없이도 일관된 과거 버전을 읽을 수 있다. 행 자체를 건드리는 것이 아니라 undo log 체인에서 자기 시점에 맞는 버전을 골라 읽기 때문이다.

READ COMMITTED와 REPEATABLE READ의 차이도 여기서 나온다. READ COMMITTED는 매 SELECT마다 ReadView를 새로 만들고, REPEATABLE READ는 트랜잭션 내 첫 SELECT에서 만든 ReadView를 계속 재사용한다.


정리

개념핵심
MVCCundo log 체인으로 다중 버전 관리. 읽기와 쓰기가 서로 차단하지 않음
READ COMMITTED매 SELECT마다 새 스냅샷. Non-Repeatable Read 발생 가능
REPEATABLE READ첫 SELECT에서 스냅샷 고정. 같은 트랜잭션 내 일관된 읽기 보장
Snapshot Read일반 SELECT. MVCC 스냅샷을 읽고 락 없음
Current ReadFOR UPDATE. 최신 커밋 값을 읽고 X Lock
Lost Update 방지FOR UPDATE로 읽는 시점부터 락을 잡아 gap 제거
음수 방지FOR UPDATE + 앱 체크 + WHERE 절 조건을 겹쳐 사용