[MySQL] 트랜잭션 격리 수준과 MVCC

이 글은 격리 수준과 MVCC 를 다룬다. InnoDB 락(Record/Gap/Next-Key Lock)·데드락·비관적/낙관적 락·베스트프랙티스는 별도 글로 분리했다.

ACID 와 격리(Isolation)의 의미

트랜잭션은 ACID 4가지 속성을 만족하는 작업 단위다.

  • Atomicity(원자성): 트랜잭션 내 작업은 모두 성공하거나 모두 실패한다.
  • Consistency(일관성): 트랜잭션 전후로 DB는 제약 조건과 무결성을 유지한다.
  • Isolation(격리성): 동시에 실행되는 트랜잭션들이 서로 영향을 주지 않는다.
  • Durability(지속성): 커밋된 결과는 시스템 장애가 나도 영구히 반영된다.

격리 수준은 곧 어떤 이상 현상까지 허용할 것인가라는 질문이고, 다음 세 가지로 정의된다.

이상 현상의미발생 시나리오
Dirty Read다른 트랜잭션이 커밋하지 않은 값을 읽음T1 이 UPDATE 후 미커밋 상태에서 T2 가 그 값을 SELECT
Non-repeatable Read같은 행을 같은 트랜잭션에서 두 번 읽었는데 값이 다름T1 이 SELECT 후, T2 가 UPDATE COMMIT, T1 이 다시 SELECT
Phantom Read같은 범위를 두 번 조회했는데 행 개수가 다름T1 이 범위 SELECT 후, T2 가 INSERT COMMIT, T1 이 다시 범위 SELECT

표준 격리 수준과 MySQL InnoDB 매핑

격리 수준Dirty ReadNon-repeatable ReadPhantom ReadInnoDB 구현
READ UNCOMMITTEDOOO락 없이 최신 데이터 직접 읽기
READ COMMITTEDXOO매 SELECT 마다 새 스냅샷(Statement-level MVCC)
REPEATABLE READ (기본값)XXX (사실상 차단)트랜잭션 첫 SELECT 시 스냅샷 고정 + 갭락
SERIALIZABLEXXX모든 SELECT 을 SELECT ... LOCK IN SHARE MODE 로 변환

표준 SQL에선 REPEATABLE READ가 Phantom Read를 허용하지만, MySQL InnoDB는 갭락(Gap Lock) 덕분에 Phantom Read까지 차단한다. 이것이 PostgreSQL의 REPEATABLE READ와 결정적으로 다른 점이다.

격리 수준별 동작 시퀀스

네 가지 표준 격리 수준을 각각 시퀀스 다이어그램으로 정리한다. 같은 두 트랜잭션(T1, T2)이 격리 수준에 따라 어떻게 다르게 동작하는지가 핵심이다.

READ UNCOMMITTED: Dirty Read

가장 낮은 수준이다. 다른 트랜잭션이 커밋하지 않은 값까지 그대로 읽는다.

sequenceDiagram
    autonumber
    participant T1
    participant DB
    participant T2

    Note over T1,T2: 초기 balance = 100
    T1->>DB: BEGIN
    T1->>DB: UPDATE balance = 200 (미커밋)
    Note over DB: 버퍼풀 현재 버전 = 200, undo log 에 옛 버전 100 보관
    T2->>DB: BEGIN, SELECT balance
    Note over DB: Read View·undo log 무시, 최신 버전 직접 읽음
    DB-->>T2: 200 (Dirty Read: 미커밋 값을 읽음)
    T1->>DB: ROLLBACK
    Note over DB: undo log 로 200 → 100 복원
    Note over T2: T2 는 존재한 적 없는 200 을 읽어 버렸다

락 없이 최신 데이터를 직접 읽기 때문에 T1이 롤백하면 T2가 읽은 값은 유령 데이터가 된다. READ UNCOMMITTED는 Read View도 undo log도 거치지 않고 현재 버전을 그대로 읽는 유일한 수준이다. 다만 T1의 ROLLBACK 자체는 undo log로 옛 버전을 되돌린다.

READ COMMITTED: Dirty Read 차단, Non-repeatable Read 발생

매 SELECT 마다 새 Read View를 만든다. 커밋된 값만 읽지만, 같은 트랜잭션 안에서 두 번 읽으면 값이 달라질 수 있다.

sequenceDiagram
    autonumber
    participant T1
    participant DB
    participant T2

    Note over T1,T2: 초기 balance = 100
    T1->>DB: BEGIN
    T1->>DB: SELECT balance (Read View 생성)
    DB-->>T1: 100
    T2->>DB: UPDATE balance = 200 (미커밋, 현재 버전 = 200)
    T1->>DB: SELECT balance
    Note over DB: 현재 버전(200) 미커밋 → DB_ROLL_PTR 로 undo log 탐색
    DB-->>T1: 100 (undo log 의 커밋된 옛 버전 = Dirty Read 차단)
    T2->>DB: COMMIT
    T1->>DB: SELECT balance (새 Read View 생성)
    Note over DB: 현재 버전(200) 이제 커밋됨 → undo log 안 거침
    DB-->>T1: 200 (Non-repeatable Read 발생)
    T1->>DB: COMMIT

REPEATABLE READ: 스냅샷 고정 + 갭락 (기본값)

트랜잭션 첫 일관성 읽기 시점에 Read View를 고정한다. 같은 값을 반복해서 읽어도 스냅샷이 유지되고, 잠금 읽기에는 Next-Key Lock으로 Phantom까지 막는다.

sequenceDiagram
    autonumber
    participant T1
    participant DB
    participant T2

    Note over T1,T2: 초기 balance = 100, orders(id=10, 20, 30)
    T1->>DB: BEGIN, SELECT balance
    DB-->>T1: 100 (Read View 고정)
    T2->>DB: UPDATE balance = 200, COMMIT (현재 버전 = 200)
    T1->>DB: SELECT balance
    Note over DB: 고정 Read View 기준 현재 버전(200) 안 보임 → undo log 옛 버전
    DB-->>T1: 100 (undo log 스냅샷 시점 버전 = Non-repeatable Read 차단)
    T1->>DB: SELECT * FROM orders WHERE id > 15 FOR UPDATE
    Note over DB: Next-Key Lock (10,20], (20,30], (30,+무한)
    T2->>DB: INSERT INTO orders VALUES (25, ...)
    DB--xT2: 대기 (Gap Lock 충돌 = Phantom Read 차단)
    T1->>DB: COMMIT

SERIALIZABLE: 모든 SELECT 을 잠금 읽기로

가장 높은 수준이다. 일반 SELECT 조차 LOCK IN SHARE MODE 로 변환되어 S 락을 잡는다. 읽기와 쓰기가 완전히 직렬화된다.

sequenceDiagram
    autonumber
    participant T1
    participant DB
    participant T2

    Note over T1,T2: 초기 balance = 100
    T1->>DB: BEGIN, SELECT balance
    Note over DB: 일반 SELECT → LOCK IN SHARE MODE, S 락 획득
    DB-->>T1: 100
    T2->>DB: UPDATE balance = 200
    Note over DB: T1 의 S 락과 X 락 충돌
    DB--xT2: 대기 (T1 커밋까지)
    T1->>DB: COMMIT (S 락 해제)
    DB-->>T2: UPDATE 성공

SERIALIZABLE의 SELECT은 스냅샷 읽기가 아니라 잠금 읽기(current read)다. 즉 undo log의 옛 버전을 보는 게 아니라 락으로 현재 버전을 직접 읽는다. 그래서 이 수준에는 undo log 흐름이 없다.

undo log 관여 정리: READ UNCOMMITTED 는 현재 버전 직접 읽기(undo log 안 봄), READ COMMITTED·REPEATABLE READ 는 미커밋·미가시 현재 버전을 만나면 DB_ROLL_PTR 로 undo log 옛 버전을 반환(MVCC), SERIALIZABLE 은 잠금 읽기라 undo log 대신 락으로 현재 버전을 읽는다.

격리 수준 설정 방법

-- 현재 세션의 격리 수준 확인
SELECT @@transaction_isolation;
 
-- 세션 단위 설정
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
 
-- 다음 트랜잭션에만 적용
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
 
-- 전역 기본값 변경 (서버 재시작 시까지)
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;

my.cnf 에서 영구 설정도 가능하다.

[mysqld]
transaction-isolation = READ-COMMITTED
innodb_lock_wait_timeout = 50    # 락 대기 타임아웃 (초)
innodb_deadlock_detect  = ON     # 데드락 감지 활성화
innodb_print_all_deadlocks = ON  # 모든 데드락을 에러 로그에 기록

JPA 1차 캐시와 격리 수준

영속성 컨텍스트(Persistence Context)의 1차 캐시는 DB 격리 수준과 별개로 애플리케이션 레이어에서 반복 읽기를 한 겹 더 만든다.

em.find 는 1차 캐시를 먼저 본다

같은 트랜잭션에서 같은 @Idem.find()(Spring Data 의 findById)를 두 번 호출하면, 두 번째는 DB에 가지 않고 1차 캐시의 같은 인스턴스를 그대로 반환한다.

sequenceDiagram
    autonumber
    participant App as 애플리케이션(T1)
    participant PC as 영속성 컨텍스트<br/>(1차 캐시)
    participant DB as MySQL (READ COMMITTED)
    participant T2

    App->>PC: findById(1)
    PC->>DB: SELECT ... WHERE id=1 (캐시 미스)
    DB-->>PC: balance = 100
    PC-->>App: Account(100) 캐시에 저장
    T2->>DB: UPDATE balance = 200, COMMIT
    App->>PC: findById(1)
    Note over PC: 1차 캐시 히트 → DB 안 감
    PC-->>App: Account(100) (같은 인스턴스)

DB가 READ COMMITTED라 두 번째 SELECT이 DB로 갔다면 200을 봤을 것이다. 하지만 findById는 두 번째에 DB로 가지 않으므로 애플리케이션은 100을 다시 본다. 이 반복 읽기는 DB 의 MVCC 스냅샷이 아니라 1차 캐시의 객체 동일성(identity) 보장에서 나온다.

JPQL 은 DB를 호출

findById 와 달리 JPQL, Criteria 쿼리는 항상 DB에서 실행된다. 그래서 다른 트랜잭션이 커밋한 새 행, 변경 행이 결과 집합에 나타날 수 있다. 하지만 조회된 행의 엔티티가 이미 1차 캐시에 있으면, JPA는 DB 에서 읽은 값을 버리고 캐시에 있던 인스턴스를 반환한다(같은 @Id = 같은 객체 규칙).

  • JPQL 재조회로 결과의 행 개수는 달라질 수 있어도(Phantom 유사), 이미 관리 중인 엔티티의 필드 값은 첫 로딩 시점 그대로다.
  • 최신 값을 강제로 다시 읽으려면 em.refresh(entity) 를 호출해 1차 캐시를 무시하고 DB 값으로 덮어야 한다.

접근 방식별 정리

접근 방식DB 접근반복 조회 시 값근거
findById(id) 두 번두 번째는 캐시항상 동일1차 캐시 identity
JPQL 두 번항상 DB새 행은 보일 수 있음, 기존 엔티티 필드는 캐시값DB 격리 + 캐시 override
em.refresh(entity)항상 DB최신값캐시 무시 강제

1차 캐시는 격리 수준, 동시성 제어를 대체하지 않는다

1차 캐시는 트랜잭션, 영속성 컨텍스트 하나에 국한된 로컬 캐시다. 다른 트랜잭션의 동시 변경(Lost Update)을 막아주지 않는다.

  • 조회한 값을 캐시에서 믿고 계산 후 UPDATE 하면, 그 사이 다른 트랜잭션이 값을 바꿔도 1차 캐시는 이를 모른다.
  • 동시 갱신 정합성이 필요하면 @Version(낙관적 락) 또는 LockModeType.PESSIMISTIC_WRITE(비관적 락, SELECT ... FOR UPDATE)를 명시해야 한다.

정리

구성반복 읽기 보장 주체
REPEATABLE READ DB + JPADB는 MVCC 스냅샷으로, 앱은 1차 캐시로 이중 보장
READ COMMITTED DB + JPADB는 매번 최신이지만, findById 접근은 1차 캐시로 앱 레벨 반복 읽기처럼 보임