들어가며
트랜잭션 격리 수준(Transaction Isolation Level)은 동시에 실행되는 트랜잭션 사이에서 데이터의 일관성을 어디까지 보장할지를 결정하는 설정이다. 격리 수준이 높을수록 일관성은 강해지지만 동시성은 떨어지고, 낮을수록 동시성은 좋지만 이상현상(anomaly)에 노출될 수 있다.
대부분의 개발자가 REPEATABLE READ, READ COMMITTED 같은 격리 수준 이름을 알고 있다.
그런데 ANSI SQL-92 표준이 정의한 REPEATABLE READ와 MySQL InnoDB가 실제로 제공하는 REPEATABLE READ는 같은 수준의 격리를 보장할까?
결론부터 말하면, 같은 이름이지만 보장하는 범위가 다르다.
이 글에서는 ANSI SQL-92 (이하 ANSI SQL) 표준의 격리 수준 정의를 먼저 살펴보고, A Critique of ANSI SQL Isolation Levels 비판 논문을 통해 그 한계를 분석한 뒤, MySQL InnoDB의 실제 구현과 비교한다.
1. ANSI SQL의 격리 수준
1.1 세 가지 이상현상
ANSI SQL-92 표준은 트랜잭션 격리 수준을 "어떤 이상현상을 허용하느냐"로 정의한다. 표준이 정의한 세 가지 이상현상은 다음과 같다.
P1: Dirty Read (더티 읽기)
다른 트랜잭션이 수정했지만 아직 커밋하지 않은 데이터를 읽는 현상이다. 해당 트랜잭션이 롤백되면, 읽은 데이터는 DB에 존재한 적 없는 값이 된다.
(*참고) 용어설명, w: write, r: read, a: abort, c: commit, p: 조건
w1[x] ... r2[x] ... (a1 and c2 in either order)
T1이 x를 수정 → T2가 x를 읽음 → T1이 ROLLBACK
→ T2는 존재한 적 없는 데이터를 기반으로 동작하게 됨
예를 들어, T1이 계좌 잔고를 1000원에서 500원으로 수정한 뒤 아직 커밋하지 않은 상태에서 T2가 잔고를 읽으면 500원이 보인다. 이후 T1이 롤백되면 실제 잔고는 1000원인데, T2는 500원을 기준으로 출금 처리를 진행하게 된다.
P2: Non-Repeatable Read (반복 불가능한 읽기)
한 트랜잭션 안에서 같은 데이터를 두 번 읽었을 때, 그 사이에 다른 트랜잭션이 해당 데이터를 수정·커밋하여 결과가 달라지는 현상이다.
r1[x] ... w2[x] ... c2 ... r1[x] ... c1
T1이 x를 읽음 → T2가 x를 수정하고 COMMIT → T1이 x를 다시 읽음
→ 같은 트랜잭션 안에서 같은 데이터의 값이 다름
잔고가 1000원 이상이면 1000원을 출금하는 로직을 생각해보자. T1이 잔고를 읽어 1000원을 확인하고 검증을 통과한 뒤, 실제 출금 전에 다시 잔고를 읽었더니 다른 트랜잭션이 이미 500원만 남겨놓은 상태다. 트랜잭션 내에서 읽은 데이터를 신뢰할 수 없게 된다.
P3: Phantom Read (팬텀 읽기)
P2가 "특정 row의 값이 바뀌는" 문제라면, P3는 "조건에 맞는 row의 집합 자체가 바뀌는" 문제다.
r1[P] ... w2[y in P] ... c2 ... r1[P] ... c1
T1이 조건 P로 조회 → T2가 P를 만족하는 데이터를 INSERT/UPDATE/DELETE하고 COMMIT → T1이 같은 조건으로 다시 조회
→ 결과 목록이 달라짐
SELECT * FROM employee WHERE dept = 'engineering'으로 3명을 조회한 뒤, 다른 트랜잭션이 engineering 부서에 신규 직원을 추가하고 커밋하면, 같은 쿼리를 다시 실행했을 때 4명이 조회된다.
1.2 네 가지 격리 수준
ANSI SQL은 이 세 가지 이상현상의 허용 여부로 네 가지 격리 수준을 정의한다.
| 격리 수준 | P1 (Dirty Read) | P2 (Non-Repeatable Read) | P3 (Phantom) |
| READ UNCOMMITTED | 허용 | 허용 | 허용 |
| READ COMMITTED | 차단 | 허용 | 허용 |
| REPEATABLE READ | 차단 | 차단 | 허용 |
| SERIALIZABLE | 차단 | 차단 | 차단 |
위에서 아래로 갈수록 더 많은 이상현상을 차단하며, 그만큼 격리가 강해진다.
이 테이블만 보면 깔끔한 체계처럼 보인다. 하지만 1995년, 이 정의에 근본적인 문제가 있다는 비판이 제기되었다.
2. Berenson 논문의 비판: ANSI 표준의 한계
1995년 Hal Berenson, Phil Bernstein, Jim Gray 등은 "A Critique of ANSI SQL Isolation Levels"이라는 논문을 발표했다.
- 참고
Jim Gray는 트랜잭션 이론의 창시자 중 한 명이고, Phil Bernstein은 동시성 제어 교과서의 저자다. DB 이론의 거장들이 표준을 정면으로 비판한 것이다.
논문이 지적한 핵심 문제는 세 가지다.
2.1 이상현상 정의의 모호성: Strict (엄격) vs Broad (포괄) 해석
ANSI 원문의 P1(Dirty Read) 설명은 "T1이 수정한 데이터를 T2가 읽은 뒤, T1이 Abort되어 ROLLBACK하면 문제가 된다"라는 뉘앙스로 작성되어 있다. 이를 문자 그대로 해석하면 다음과 같다.
Anomaly 1 (Strict)
A1 (strict): w1[x] ... r2[x] ... a1
→ T1이 반드시 abort해야 Dirty Read로 인정
그런데 다음 예제 히스토리를 보자.
예제 History 1
H1: r1[x=50] w1[x=10] r2[x=10] r2[y=50] c2 r1[y=50] w1[y=90] c1
t1 -> x=50을 읽음, x=10을 씀
t2 -> x=10을 읽음, y=50을 읽음 (t2 커밋)
t1 -> y=50을 읽음, y=90을 씀 (t1 커밋)
즉, T1이 x에서 y로 40을 이체하는 중에, T2가 x(=10)와 y(=50)를 읽어 합계를 계산한다.
T2가 본 합계는 60인데, 실제 총액은 100이어야 한다. 이러한 합계 결과는 분명히 비직렬적(non-serializable)인 히스토리다.
즉 트랜잭션을 하나씩 순서대로 실행했다면 절대 나올 수 없는 결과가 나온 실행 이력이다.
하지만 위 strict 해석(Anomaly 1)에서는 이 히스토리 1번 예제가 Dirty Read에 해당하지 않는다.
T1이 abort하지 않고 commit했기 때문이다.
- 하지만 분명히 커밋되지 않은 트랜잭션의 데이터를 읽어서 생긴 문제는 맞다.
Berenson은 이 문제를 해결하기 위해 아래와 같은 broad (포괄적) 해석이 ANSI의 진짜 의도라고 말한다.
P1 (broad): w1[x] ... r2[x] ... (c1 or a1)
→ T1이 commit하든 abort하든, 커밋 전에 읽은 것 자체가 문제
broad 해석에서 예제 1 히스토리는 P1 위반에 해당한다. P2, P3에 대해서도 같은 구조의 문제가 존재하며, strict 해석으로는 잡히지 않는 비직렬적인 히스토리들이 있다.
2.2 누락된 이상현상
ANSI 표준은 세 가지 이상현상만 정의했지만, 실제로는 그 외에도 발생할 수 있는 이상현상들이 존재한다.
P0: Dirty Write
두 트랜잭션이 모두 커밋 전에 같은 데이터를 덮어쓰는 현상이다.
P0: w1[x] ... w2[x] ... (c1 or a1)
T1이 x를 수정한 뒤 아직 커밋 전인데, T2가 같은 x를 다시 수정한다. 이 상태에서 T1이 롤백하면, DB는 T1의 before-image로 복원하는데 그 과정에서 T2의 수정까지 함께 사라진다. 이후 T2까지 롤백하려고 하면 복구 자체가 불가능해진다.
ANSI SQL에서는 P0을 아예 정의하지 않았다. READ UNCOMMITTED조차 Dirty Write를 금지해야 롤백이 가능한데, 표준에는 이 제약이 빠져 있다.
Berenson 논문은 모든 격리 수준에서 P0을 금지해야 한다는 것이고 실제로 현대 데이터베이스에서는 P0가 모두 지켜지고 있다.
ANSI 표준 문서에 누락된게 문제인것이다.
이걸 방지하는건 간단한데, x 대상에 대한 UPDATE, INSERT, DELETE 등 수정 시 x 대상에 배타락인 Long duration Write lock을 거는 방법으로 방지하면 된다.
Read Skew [A5A]
한 트랜잭션이 서로 관련된 두 데이터를 각각 다른 시점에 읽어서, 일관성이 깨진 상태를 보게 되는 현상이다.
r1[x] ... w2[x] ... w2[y] ... c2 ... r1[y]
→ T1은 x의 구버전과 y의 신버전을 보게 됨
x=50, y=50(합계 100 유지 제약) 상태에서, T2가 x=25, y=75로 수정하고 커밋한다.
T1이 x를 먼저 읽어 50을 얻고, t2 트랜잭션 이후 나중에 y를 읽어 75를 얻으면, T1이 보는 합계는 125가 된다.
한 트랜잭션 안에서 서로 다른 시점의 데이터를 보게 되는 것이다.
Write Skew [A5B]
두 트랜잭션이 겹치는 데이터를 읽고, 각각 서로 다른 데이터를 수정하여 전체적으로 제약조건이 깨지는 현상이다.
r1[x] ... r2[y] ... w1[y] ... w2[x] ... (c1 and c2)
병원 당직 시나리오로 이해해보자.
- 제약조건: "최소 1명은 당직"이어야하며, 현재 Andy와 Brad 모두 당직이다.
- T1(Andy)은 "Brad가 당직이니 나는 빠져도 된다"고 판단해 자기 당직을 해제
- T2(Brad)는 "Andy가 당직이니 나는 빠져도 된다"고 판단해 자기 당직을 해제
각 트랜잭션은 자신이 읽은 시점에는 제약조건을 만족했지만, 결과적으로 당직자가 0명이 된다. 핵심은 두 트랜잭션이 서로 다른 row를 수정했다는 점이다. write-write 충돌이 없으니 일반적인 충돌 감지로는 잡히지 않는다.
2.3 ANSI 한계: MVCC 기반 구현을 다루지 못함
ANSI 표준은 기본적으로 Lock 기반 동시성 제어를 전제로 설계되었다. 그런데 현대 데이터베이스 대부분은 MVCC(Multi-Version Concurrency Control)를 사용한다.
Berenson 논문은 MVCC 기반 구현에서 등장하는 Snapshot Isolation이라는 격리 수준을 새로 정의했다.
Snapshot Isolation는 ANSI가 정의한 세 가지 이상현상(P1, P2, P3)과 Read Skew 모두 차단하지만, Write Skew(A5B)를 허용하므로 Serializable이 아니다.
즉, ANSI 테이블에 깔끔하게 들어맞지 않는 이상현상과 격리 수준인 것이다.
다만, MySQL(MVCC)에서 Snapshot Read와 Locking Read를 혼용하여 사용하면 Read Skew는 발생한다.
2.4 격리 수준 간의 강약 관계
Berenson 논문은 Snapshot Isolation을 포함한 격리 수준 간의 강약 관계를 다음과 같은 순서로 정리했다.
Serializable
/ \
Repeatable Read Snapshot Isolation
\ /
Read Committed
|
Read Uncommitted
여기서 주목할 점은 ANSI Repeatable Read와 Snapshot Isolation이 비교 불가능한 관계라는 것이다.
- Snapshot Isolation은 Write Skew를 허용하지만 Phantom을 차단하고,
- ANSI Repeatable Read는 Phantom을 허용하지만 Write Skew를 차단한다.
3. Locking 기반 ANSI 구현 vs MVCC 기반 MySQL 구현
ANSI 표준과 MySQL의 차이를 이해하려면, 격리를 구현하는 메커니즘의 차이를 이해해야 한다.
3.1 Locking 기반 ANSI 구현
Locking 기반 REPEATABLE READ의 특성은 다음과 같다.
- 개별 데이터 항목에 대한 Read Lock: Long duration (트랜잭션 끝까지 유지)
- Predicate Lock (범위 조건): Short duration (즉시 해제)
- Write Lock: Long duration (트랜잭션 끝까지 유지)
Read Lock이 트랜잭션 끝까지 유지되므로, 읽은 row를 다른 트랜잭션이 수정하려면 반드시 현재 트랜잭션이 끝나기를 기다려야 한다.
이 때문에 Write Skew 시나리오에서는 데드락이 발생해 하나가 롤백되고, 결과적으로 Write Skew가 방지된다.
반면 Predicate Lock은 즉시 해제되므로, 범위 조건에 새 row가 삽입되는 Phantom은 방지하지 못한다.
ANSI Locking 기반 Write Skew 방지 흐름
[공유자원 읽기]
T1: SELECT ... WHERE oncall=TRUE FOR SHARE
→ Andy, Brad 두 row에 S 락 획득
T2: SELECT ... WHERE oncall=TRUE FOR SHARE
→ Andy, Brad 두 row에 S 락 획득 (S + S는 호환되므로 성공!)
[공유자원 업데이트]
T1: UPDATE SET oncall=FALSE WHERE name='Andy'
→ Andy row에 X 락 필요 → T2가 S 락 보유 중 → 대기...
T2: UPDATE SET oncall=FALSE WHERE name='Brad'
→ Brad row에 X 락 필요 → T1이 S 락 보유 중 → 대기...
[데드락]
→ DEADLOCK 발생! → DB가 하나를 강제 rollback
3.2 MySQL InnoDB의 MVCC + Locking 하이브리드
InnoDB는 MVCC를 핵심 동시성 제어 메커니즘으로 사용한다. 이 구현을 이해하려면 InnoDB의 두 가지 읽기 방식을 구분해야 한다.
Consistent Nonlocking Read (Snapshot Read)
일반 SELECT 문이 이 방식으로 동작한다. 락을 걸지 않고, MVCC를 통해 특정 시점의 스냅샷을 읽는다.
- REPEATABLE READ에서는 트랜잭션 내 첫 번째 SELECT 시점의 스냅샷이 고정되어, 이후 몇 번을 읽어도 같은 결과를 반환한다.
- READ COMMITTED에서는 매 SELECT마다 새로운 스냅샷을 생성한다.
Current Read (Locking Read)
SELECT ... FOR UPDATE, SELECT ... FOR SHARE, 그리고 UPDATE, DELETE 문이 이 방식으로 동작한다.
- 스냅샷이 아닌 최신 커밋 데이터를 읽으며, 읽은 row에 락을 건다.
4. MySQL InnoDB 격리 수준별 동작
4.1 READ UNCOMMITTED
InnoDB는 내부적으로 undo log 기반으로 항상 다중 버전을 유지하고 있다. 이 다중 버전을 격리 수준에 따라 스냅샷 시점을 기준으로 "이 버전이 내 트랜잭션에게 보여야 하는가?"를 판단하게 되는데, Snapshot Read에서 가시성 판단 없이 가장 최신 버전을 커밋 여부와 관계없이 읽는다. 사실상 MVCC 스냅샷을 쓰지 않는 유일한 레벨이다.
ANSI 정의와 거의 동일하다.
4.2 READ COMMITTED
Snapshot Read에서 매 SELECT마다 새로운 스냅샷을 생성한다. 따라서 같은 트랜잭션 안에서도 다른 트랜잭션의 커밋이 반영된 결과를 볼 수 있다.
Locking Read에서는 record lock만 사용하고, gap lock이 비활성화된다. 따라서, gap lock이 없으므로 다른 세션이 갭에 새 row를 삽입할 수 있어 phantom이 발생할 수 있다.
또한 UPDATE나 DELETE 시, WHERE 조건에 맞지 않는 row에 대한 lock은 조건 평가 후 즉시 해제된다.
4.3 REPEATABLE READ (MySQL InnoDB 기본값)
MySQL의 기본 격리 수준이며, ANSI 정의와 가장 큰 차이가 나는 레벨이다.
Snapshot Read 측면
- 트랜잭션 내 첫 번째 SELECT 시점의 스냅샷이 고정된다.
이후 같은 트랜잭션 안에서는 몇 번을 SELECT해도 동일한 결과를 반환한다. 이 메커니즘만으로 P2(Non-Repeatable Read)와 P3(Phantom) 모두 원천 차단된다. 스냅샷이 고정되어 있으므로 다른 트랜잭션의 INSERT, UPDATE, DELETE가 보이지 않기 때문이다.
Locking Read 측면
- SELECT ... FOR UPDATE, UPDATE, DELETE 등은 최신 커밋 데이터를 읽으며 next-key lock(record lock + gap lock)을 사용한다.
유니크 인덱스로 단일 row를 검색하는 경우에는 record lock만 걸지만, 범위 조건이나 비유니크 인덱스를 사용하는 경우에는 gap lock을 포함한 next-key lock으로 해당 구간의 INSERT를 차단한다.
핵심: ANSI REPEATABLE READ는 Phantom(P3)을 허용하지만, MySQL REPEATABLE READ는 Snapshot Read의 스냅샷 고정과 Locking Read의 gap lock 두 가지 메커니즘으로 Phantom을 방지한다.
단, MySQL 공식 문서는 Snapshot Read(일반 SELECT)와 Locking Read(SELECT ... FOR UPDATE 등)를 하나의 REPEATABLE READ 트랜잭션에서 혼용하지 말라고 권고한다.
왜나하면, Snapshot Read는 스냅샷 시점의 데이터를, Locking Read는 현재 시점의 커밋된 최신 데이터를 보기 때문에, 한 트랜잭션 안에서 두 개의 서로 다른 현실을 보게 되는 상황이 발생할 수 있다. 즉, Phantom Read 문제가 발생하는 것이다.
START TRANSACTION;
-- Snapshot Read: 스냅샷 시점 기준
SELECT COUNT(*) FROM employee WHERE dept = 'engineering'; -- 결과: 3
-- 이 사이에 다른 트랜잭션이 INSERT & COMMIT
-- Locking Read: 최신 커밋 데이터 기준
SELECT COUNT(*) FROM employee WHERE dept = 'engineering' FOR UPDATE; -- 결과: 4
참고: Next-Key Lock의 범위는 인덱스 구조에 의해 결정된다
REPEATABLE READ에서 Locking Read가 Phantom을 방지하는 원리를 이해하려면, next-key lock이 어디에 걸리는지를 정확히 알아야 한다.
next-key lock은 쿼리 결과로 반환된 row에 거는 것이 아니라, 쿼리가 스캔한 인덱스 레코드와 그 주변 gap에 건다.
이 때문에 WHERE 절에 사용된 컬럼의 인덱스 존재 여부가 lock 범위를 결정적으로 좌우한다.
인덱스가 있는 경우: 필요한 범위만 잠금
age 컬럼에 secondary index가 있는 상태에서 다음 쿼리를 실행한다고 가정하자.
SELECT * FROM employee WHERE age = 20 FOR UPDATE;
InnoDB는 age 인덱스를 스캔하면서 age=20에 해당하는 인덱스 레코드들과 그 주변 gap에 next-key lock을 건다.
예를 들어 age 인덱스에 ..., 18, 20, 20, 20, 25, ... 값이 있다면, (18, 20] 구간의 next-key lock과 (20, 25) 구간의 gap lock이 설정된다.
이 상태에서 다른 트랜잭션이 id=2330인 row의 age를 20으로 UPDATE하려고 하면, age 인덱스의 20 구간에 새 엔트리를 삽입해야 하는데 해당 구간이 잠겨 있으므로 블록된다. 결과적으로 Phantom이 방지된다.
인덱스가 없는 경우: 사실상 테이블 전체 잠금
만약 age 컬럼에 인덱스가 없으면 상황이 달라진다. InnoDB는 age=20인 row를 찾기 위해 클러스터드 인덱스(= primary key 인덱스)를 풀스캔해야 한다. MySQL에서 모든 테이블은 클러스터드 인덱스 구조이므로, 인덱스가 없는 컬럼으로 검색하면 PK 인덱스 전체를 스캔하게 된다.
이때 InnoDB는 스캔하는 모든 PK 인덱스 레코드에 next-key lock을 건다. age=20이 아닌 row까지 전부 포함해서 잠기므로, 사실상 테이블 전체가 잠기는 것과 다름없다.
실무적 관점
Locking Read의 lock 범위가 인덱스 구조에 의해 결정되기 때문에, gap lock이 관여하는 REPEATABLE READ 환경에서는
적절한 인덱스 설계가 동시성에 직결된다. 인덱스가 없으면 불필요하게 넓은 범위를 잠그게 되어, 동시성이 크게 저하되고 데드락 발생 확률도 높아진다.
참고: 어떻게 Read Skew가 발생하지 않는지 궁금하면 봐보자.
Read Skew 상황을 다시 봐보자.
- T1이 x를 읽음 → T2가 x, y를 수정하고 커밋 → T1이 y를 읽음
t1의 read lock이 long duration이 적용된다.
T1이 x를 읽으면 S(공유) 락이 트랜잭션 끝까지 유지된다.
T2가 x를 수정하려고 X(배타) 락을 요청하면 T1의 S 락에 블록된다.
T2는 x를 수정할 수 없으니 y도 수정하지 못하게 된다.
T1이 나중에 y를 읽어도 T2의 트랜잭션을 적용되지 못했으므로 구버전 그대로이게 된다.
즉, T1이 공유락을 해제할 때까지 T2는 배타락을 갖지 못하여 수정을 못하게되어 최종적으로 Read Skew가 발생하지 않게 된다.
4.4 SERIALIZABLE
일반 SELECT가 자동으로 "SELECT ... FOR SHARE"로 변환된다.
즉 Snapshot Read 자체가 사라지고, 모든 읽기가 Locking Read가 된다.
읽기에도 공유(Share) 락이 걸리므로 다른 트랜잭션의 쓰기가 블록되어, Write Skew를 포함한 모든 이상현상이 방지된다. 단, 락 경합이 늘어나면서 데드락 발생 확률이 높아진다.
참고: 어떻게 Write Skew가 발생하지 않는지 궁금하면 봐보자.
Write Skew 상황을 다시 봐보자.
- r1[x] ... r2[y] ... w1[y] ... w2[x] ... (c1 and c2)
t1이 읽고 t2가 읽고, t1이 쓰고 t2가 쓰고이다.
이때, t1, t2는 공유락으로 둘다 읽기가 이뤄지지만, t1이 쓰려고할 때 t2의 공유락이 해제되어야 배타락을 잡고 쓸 수 있게 되는데
t2도 t1의 공유락이 해제되면 배타락을 잡고 쓰려고 기다리게 된다.
즉, 둘다 서로 락 해제를 기다리는 데드락이 발생하고 Write Skew가 발생하지 않게 된다.
5. 핵심 비교: ANSI vs MySQL
5.1 격리 수준별 차이점 요약
| 격리 수준 | ANSI 표준 (Locking 기반) | MySQL InnoDB (MVCC + Locking) |
| READ UNCOMMITTED | P1, P2, P3 허용 | 거의 동일 |
| READ COMMITTED | P2, P3 허용 | 유사하나, gap lock 비활성화를 명시적으로 수행 |
| REPEATABLE READ | P3(Phantom) 허용, Write Skew 차단 | Phantom 차단, Write Skew 허용 |
| SERIALIZABLE | 완전 직렬화 | 모든 SELECT를 FOR SHARE로 변환하여 구현 |
5.2 REPEATABLE READ: 가장 큰 차이
ANSI(Locking)과 MySQL(MVCC)의 REPEATABLE READ가 다른 이유는 동시성 제어 메커니즘의 차이에서 비롯된다.
| 비교 항목 | ANSI (Locking) Repeatable Read | MySQL (MVCC) Repeatable Read |
| 읽기 시 락 | 읽은 row에 Long duration Shared lock | Snapshot Read는 무 락(no lock) |
| Phantom (P3) | 허용 (predicate lock이 short duration) | 차단 (스냅샷 고정 + gap lock) |
| Write Skew | 차단 (long Shared lock → 데드락으로 방지) | 허용 (Snapshot Read가 무 락이므로) |
| Read Skew | 차단 (long Shared lock이 수정을 블록) | Snapshot Read/Locking Read 단독 사용 시 차단 (단, Snapshot & Locking Read 혼용 시 발생 가능) |
ANSI (Locking) Repeatable Read (Write Skew 차단)
- long duration read lock이 읽은 데이터를 보호하는 원리
당직 시나리오에서 T1이 Brad의 당직 상태를 읽으면 그 row에 S 락이 트랜잭션 끝까지 유지된다.
T2가 Brad의 row를 수정하려면 T1의 S 락 해제를 기다려야 하고, T1도 T2의 S 락을 기다리는 상황이 된다.
결국 데드락 상황이 발생해 하나가 롤백된다.
MySQL(MVCC) Repeatable Read (Write Skew 허용)
- Snapshot Read가 아무 락도 걸지 않아서 생기는 문제
T1이 Brad의 당직 상태를 읽어도 Brad의 row에 아무 락이 없다.
T2는 자유롭게 Brad의 당직을 해제할 수 있고, T1도 마찬가지로 Andy의 당직을 해제할 수 있다.
결국 Write Skew가 발생한다.
5.3 MySQL REPEATABLE READ 정리
- ANSI Repeatable Read 보다 강한 점: Snapshot Read의 스냅샷 고정과 Locking Read의 gap lock으로 Phantom을 방지.
- ANSI Repeatable Read 보다 약한 점: Snapshot Read에서 락을 걸지 않으므로 Write Skew가 발생할 수 있음.
- Snapshot Isolation 보다 약한 점: Snapshot Read와 Locking Read의 혼용으로 read skew, lost update가 발생할 수 있다.
MySQL의 REPEATABLE READ는 ANSI Repeatable Read도 아니고 순수한 Snapshot Isolation도 아닌, InnoDB 고유의 격리 수준이라고 보는 것이 정확하다.
6. 실무에서의 격리 수준 선택
MySQL 기본값(REPEATABLE READ)을 유지하는 경우
대부분의 일반적인 CRUD 작업에 적합하다. Snapshot Read만 사용하면 일관된 읽기가 보장되며, 단일 row 업데이트 위주의 트랜잭션에서는 충분하다. gap lock에 의한 Phantom 방지도 기본으로 제공된다.
READ COMMITTED로 내리는 경우
gap lock으로 인한 데드락이 빈번한 고처리량 환경에 적합하다. 범위 기반 UPDATE/DELETE가 많은 배치 작업, 대량 데이터 처리에서 gap lock이 동시성을 크게 저하시킬 수 있다.
READ COMMITTED에서는 gap lock이 비활성화되므로 동시성이 개선되지만, phantom이 발생할 수 있으므로 애플리케이션 레벨에서 이를 허용할 수 있는지 검토해야 한다.
SERIALIZABLE로 올리거나 별도 조치가 필요한 경우
Write Skew가 비즈니스 로직상 치명적인 경우에 해당한다. 다만 SERIALIZABLE 전체 적용보다는, 해당 트랜잭션의 핵심 읽기만 [SELECT ... FOR UPDATE]로 변경하는 것이 일반적인 실무 패턴이다.
성능 트레이드오프 요약
| 동시성 | 락 오버헤드 | 오버헤드 | 데드락 | 데이터 정합성 |
| READ UNCOMMITTED | 낮음 | 최소 | 낮음 | 최저 |
| READ COMMITTED | 낮음 | 낮음 (gap lock 없음) | 낮음 | 중간 |
| REPEATABLE READ | 중간 | 중간 (gap lock 존재) | 중간~높음 | 높음 (Write Skew 제외) |
| SERIALIZABLE | 높음 | 최대 (모든 SELECT에 S lock) | 최고 | 최고 |
마무리
ANSI SQL 표준은 세 가지 이상현상(더티 읽기, 반복 불가능한 읽기, 팬텀 읽기)으로 격리 수준을 정의했으나, 현상 정의 자체의 모호성, 누락된 이상현상(Dirty Write[P0], Read Skew[A5A], Write Skew[A5B]), 그리고 MVCC 기반 구현을 다루지 못하는 한계가 있었다.
MySQL InnoDB는 ANSI 표준의 네 가지 격리 수준 이름을 그대로 사용하지만, MVCC + Locking 하이브리드 방식으로 구현했기 때문에 실제 보장 범위가 다르다. 특히 REPEATABLE READ에서 ANSI 표준은 Phantom을 허용하고 Write Skew를 차단하는 반면, MySQL은 Phantom을 차단하고 Write Skew를 허용한다.
Isonlation Level의 이름만 보고 동작을 가정하지 말고, 사용하는 DB의 실제 구현을 확인해야 한다.
참고 자료
- ANSI X3.135-1992, American National Standard for Information Systems — Database Language SQL, November 1992
- Berenson, H., Bernstein, P., Gray, J., Melton, J., O'Neil, E., & O'Neil, P. (1995). "A Critique of ANSI SQL Isolation Levels." Proc. ACM SIGMOD 95, pp. 1-10
- MySQL 8.4 Reference Manual — Transaction Isolation Levels
- MySQL 8.4 Reference Manual — InnoDB Locking
- MySQL 8.4 Reference Manual — Consistent Nonlocking Reads
- Jepsen. MySQL 8.0.34 Analysis
- Vlad Mihalcea. A beginner's guide to the Write Skew anomaly, and how it differs between 2PL and MVCC
- Adrian Colyer. A Critique of ANSI SQL Isolation Levels — the morning paper
'기술 학습' 카테고리의 다른 글
| MSA에서 CORS 문제를 해결하는 4가지 전략 (0) | 2026.03.27 |
|---|---|
| TCP/IP 체크섬(Checksum) 내부 동작 원리 (0) | 2026.03.23 |
| Java 기반 동기/비동기, 블로킹/논블로킹 정리 (0) | 2026.03.23 |
| MSA에서 ACID의 의미 (0) | 2026.03.10 |
| 객체지향 설계 원칙 - SOLID (0) | 2026.03.08 |