[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 Read | Non-repeatable Read | Phantom Read | InnoDB 구현 |
|---|---|---|---|---|
| READ UNCOMMITTED | O | O | O | 락 없이 최신 데이터 직접 읽기 |
| READ COMMITTED | X | O | O | 매 SELECT 마다 새 스냅샷(Statement-level MVCC) |
| REPEATABLE READ (기본값) | X | X | X (사실상 차단) | 첫 SELECT 시 스냅샷 고정 + 갭락 |
| SERIALIZABLE | X | X | X | 모든 SELECT 을 SELECT ... LOCK IN SHARE MODE 로 변환 |
- 표준 SQL은 REPEATABLE READ에서 Phantom Read를 허용하지만 InnoDB는 갭락 덕분에 차단한다.
- PostgreSQL의 REPEATABLE READ와 갈리는 지점이 여기다.
- 같은 이름의 격리 수준이라도 구현마다 실제 차단 범위가 다르다.
격리 수준별 두 번째 조회 값
한 트랜잭션(T1)이 같은 값을 두 번 조회할 때, 두 번째 조회가 무엇을 보는지가 격리 수준마다 다르다.
| 격리 수준 | 두 번째 조회가 보는 값 | 근거 |
|---|---|---|
| READ UNCOMMITTED | T2 의 미커밋 값 그대로 | 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 + JPA | DB는 MVCC 스냅샷으로, 앱은 1차 캐시로 이중 보장 |
| READ COMMITTED DB + JPA | DB는 매번 최신이지만 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_WRITE | 1차 캐시는 Lost Update를 막지 않는다. 명시적 락이 필요하다 |
- REPEATABLE READ가 기본값인 이유는 대부분의 온라인 트랜잭션이 반복 읽기 정합성과 처리량을 함께 원해서다.
- 1차 캐시는 격리 수준을 대체하지 않는다.
- 동시 갱신 보호가 필요하면 명시적 락을 따로 건다.