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

트랜잭션의 격리(Isolation)는 동시에 도는 트랜잭션이 서로의 미완료 상태를 못 보게 막는 성질이다. 격리 수준을 낮추면 처리량은 오르지만 Dirty Read, Non-repeatable Read, Phantom Read가 차례로 허용된다. InnoDB는 기본값 REPEATABLE READ 에서 표준 SQL과 달리 갭락(Gap Lock)으로 Phantom Read까지 막는다.

다이어그램

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

표준 격리 수준과 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를 허용하지만 InnoDB는 갭락 덕분에 차단한다.
  • PostgreSQL의 REPEATABLE READ와 갈리는 지점이 여기다.
  • 같은 이름의 격리 수준이라도 구현마다 실제 차단 범위가 다르다.

격리 수준별 두 번째 조회 값

한 트랜잭션(T1)이 같은 값을 두 번 조회할 때, 두 번째 조회가 무엇을 보는지가 격리 수준마다 다르다.

격리 수준두 번째 조회가 보는 값근거
READ UNCOMMITTEDT2 의 미커밋 값 그대로Read View, undo log를 거치지 않고 현재 버전을 직접 읽는다
READ COMMITTED조회 시점의 최신 커밋 값SELECT 마다 새 Read View를 만든다
REPEATABLE READ트랜잭션 시작 시점 값첫 SELECT의 Read View를 트랜잭션 내내 재사용한다
SERIALIZABLE항상 현재 값(락 대기 후)SELECT이 S 락을 잡는 잠금 읽기라 undo log를 보지 않는다
  • 락 없이 읽는 세 수준(RU, RC, RR)은 순서대로 허용 범위가 좁아지고, SERIALIZABLE만 락으로 순서 자체를 막는다.

READ UNCOMMITTED는 왜 다른 트랜잭션의 미커밋 값을 보나?

  • 락 없이 최신 데이터를 직접 읽는다. Read View도 undo log도 거치지 않는다.
  • T1이 롤백하면 T2가 읽은 값은 존재한 적 없는 유령 데이터가 된다.
  • T1의 롤백 자체는 undo log로 옛 버전을 되돌린다. 이 롤백 경로만은 undo log를 탄다.

REPEATABLE READ인데 왜 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
  • 트랜잭션 첫 SELECT 시점에 Read View를 고정해 이후 조회는 같은 스냅샷을 본다.
  • 잠금 읽기(FOR UPDATE 등)는 Next-Key Lock으로 다음 구간의 갭까지 함께 잠근다.
  • 그 구간에 INSERT가 들어오면 Gap Lock과 충돌해 대기한다. 이 대기가 Phantom Read를 차단한다.

undo log 관여는 수준마다 갈린다.

  • READ UNCOMMITTED는 현재 버전을 직접 읽어 undo log를 보지 않는다.
  • READ COMMITTED, REPEATABLE READ는 미가시 최신 버전을 만나면 DB_ROLL_PTR로 undo log 옛 버전을 반환한다.
  • 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  # 모든 데드락을 에러 로그에 기록
  • SESSION 설정은 그 세션의 다음 트랜잭션부터, GLOBAL 설정은 재시작 전까지 새로 접속하는 세션에 적용된다.
  • innodb_lock_wait_timeout 은 락 대기의 상한이다. 넘기면 예외로 실패한다.
  • innodb_deadlock_detect 는 대기 그래프에서 순환을 찾아 한쪽 트랜잭션을 강제로 롤백시킨다.
  • innodb_print_all_deadlocks 를 켜면 감지된 데드락 전부가 에러 로그에 남아 경합 패턴을 사후 분석할 수 있다.

JPA 1차 캐시와 격리 수준

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

em.find를 두 번 부르면 DB를 두 번 가나?

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) (같은 인스턴스)
  • 같은 트랜잭션에서 같은 @Id 로 findById 를 두 번 호출하면 두 번째는 DB에 가지 않는다.
  • 1차 캐시에 있는 같은 인스턴스를 그대로 반환한다.
  • DB가 READ COMMITTED 였다면 두 번째 SELECT은 200을 조회한다.
  • 이 반복 읽기는 MVCC 스냅샷이 아니라 1차 캐시의 객체 동일성(identity) 보장에서 나온다.

JPQL은 1차 캐시를 우회하나?

  • JPQL, Criteria 쿼리는 항상 DB에서 실행된다. 다른 트랜잭션이 커밋한 새 행이 결과에 나타날 수 있다.
  • 조회된 행의 엔티티가 이미 1차 캐시에 있으면 JPA는 DB 값을 버리고 캐시 인스턴스를 반환한다.
  • 같은 @Id 는 같은 객체라는 규칙이 DB 값보다 우선하기 때문이다.
  • 최신 값을 강제로 읽으려면 em.refresh(entity) 로 캐시를 무시하고 DB 값으로 덮는다.
접근 방식DB 접근반복 조회 시 값근거
findById(id) 두 번두 번째는 캐시항상 동일1차 캐시 identity
JPQL 두 번항상 DB새 행은 보일 수 있음, 기존 필드는 캐시값DB 격리 + 캐시 override
em.refresh(entity)항상 DB최신값캐시 무시 강제

1차 캐시가 동시 갱신도 막아주나?

  • 1차 캐시는 트랜잭션 하나에 국한된 로컬 캐시다. 다른 트랜잭션의 동시 변경(Lost Update)을 막지 않는다.
  • 조회한 값을 캐시에서 믿고 계산 후 UPDATE 하면, 그 사이 다른 트랜잭션이 값을 바꿔도 1차 캐시는 이를 모른다.
  • 동시 갱신 정합성이 필요하면 @Version(낙관적 락) 또는 PESSIMISTIC_WRITE(비관적 락)를 명시해야 한다.
// 잘못된 패턴: 조회한 값을 그대로 믿고 계산 후 저장한다. 그 사이 값이 바뀌어도 알 방법이 없다
Account account = em.find(Account.class, id);
account.withdraw(amount);
em.flush();
// 올바른 패턴: @Version 필드로 낙관적 락을 걸어, 저장 시점에 값이 이미 바뀌었으면 예외로 걸러낸다
@Entity
class Account {
    @Id
    Long id;
 
    @Version
    Long version;
 
    void withdraw(long amount) {
        this.balance -= amount;
    }
}

두 겹의 반복 읽기를 합치면 다음과 같다.

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

언제 무엇을 쓰는가

상황선택근거
일반적인 온라인 서비스 트랜잭션REPEATABLE READ (기본값)Non-repeatable Read는 스냅샷으로, Phantom은 갭락으로 InnoDB가 막는다
최신 커밋 값을 즉시 봐야 하는 대시보드·모니터링 조회READ COMMITTED매 SELECT 마다 새 스냅샷을 떠 최신 커밋 값을 즉시 반영한다
락 경합 원인을 임시로 확인하는 디버깅READ UNCOMMITTED프로덕션 사용 금지. 락 없이 최신 버전을 그대로 읽어 정합성이 없다
정합성이 절대적인 정산·결제 배치SERIALIZABLE모든 SELECT이 S 락을 잡아 완전히 직렬화되지만 처리량이 가장 낮다
조회한 값으로 화면을 여러 번 그리는 요청JPA 1차 캐시(findById 반복 호출)DB 재조회 없이 같은 인스턴스를 돌려줘 반복 조회 비용이 없다
다른 트랜잭션의 커밋 여부를 즉시 반영해야 하는 조회JPQL 또는 em.refresh()1차 캐시를 우회해 DB 최신 상태를 강제로 읽는다
동시 갱신이 잦은 잔액·재고 컬럼 수정@Version 낙관적 락 또는 PESSIMISTIC_WRITE1차 캐시는 Lost Update를 막지 않는다. 명시적 락이 필요하다
  • REPEATABLE READ가 기본값인 이유는 대부분의 온라인 트랜잭션이 반복 읽기 정합성과 처리량을 함께 원해서다.
  • 1차 캐시는 격리 수준을 대체하지 않는다.
  • 동시 갱신 보호가 필요하면 명시적 락을 따로 건다.