Row Lock과 Isolation Level
MySQL InnoDB의 Row Lock, Gap Lock, Next-Key Lock과 Isolation Level의 관계를 정리한다. job-api에서 REPEATABLE READ → READ COMMITTED로 전환하면서 정리한 내용이다. (COREPL-4500)
InnoDB의 Lock 종류
Row Lock (Record Lock)
인덱스 레코드 자체에 거는 잠금이다.
SELECT * FROM job_execution WHERE execution_id = 'abc' FOR UPDATE;
이 쿼리는 execution_id = 'abc'에 해당하는 정확히 그 행 하나에 exclusive lock을 건다.
다른 트랜잭션이 같은 행을 FOR UPDATE로 읽으려 하면 대기한다.
Gap Lock
인덱스 레코드 **사이의 간격(gap)**에 거는 잠금이다. 실제 존재하는 행이 아니라, 행과 행 사이 또는 첫 행 이전/마지막 행 이후의 빈 공간을 잠근다.
인덱스 값: [10] ---gap--- [20] ---gap--- [30]
WHERE id = 15 FOR UPDATE를 실행하면, 15라는 값이 존재하지 않더라도 (10, 20) 사이의 gap에 lock을 건다.
다른 트랜잭션이 이 범위에 INSERT를 시도하면 블로킹된다.
Next-Key Lock
Row Lock + Gap Lock의 조합이다. 해당 인덱스 레코드 자체 + 그 레코드 바로 앞의 gap까지 잠근다. 이름은 "다음 키를 잠근다"가 아니라, **"이 키까지(up to this key) 오는 길목을 잠근다"**는 의미다.
인덱스 값: [10] ---gap--- [20] ---gap--- [30]
Next-Key Lock on 20 = Row Lock(20) + Gap Lock(10, 20)
→ (10, 20] 범위를 잠금
InnoDB가 인덱스를 순방향 스캔하면서, 레코드를 만날 때마다 "여기까지 지나온 길을 잠근다"고 보면 된다. 범위 쿼리로 여러 행을 읽으면 각 행의 Next-Key Lock이 이어붙여져서 빈틈없이 잠긴다:
SELECT * FROM t WHERE column1 < 12 FOR UPDATE
인덱스: [5] ---gap--- [10] ---gap--- [15]
매칭: 5, 10
Next-Key Lock on 5: (−∞, 5] ← 5까지의 길목
Next-Key Lock on 10: (5, 10] ← 10까지의 길목
Gap Lock on 15: (10, 15) ← 스캔이 15에서 멈춤 (조건 불일치, 15 자체는 안 잠김)
→ 합치면: (−∞, 15) 전체가 잠김
column1 < 12를 조회했을 뿐인데, 12, 13, 14까지 INSERT가 막힌다. InnoDB가 스캔하다 처음으로 조건에 안 맞는 행(15) 앞의 gap까지 잠그기 때문이다. 이렇게 의도한 범위보다 잠금이 넓어지는 것이 Gap Lock / Next-Key Lock의 핵심 특성이자 함정이다.
참고: unique 인덱스에서 동등 조건(WHERE id = 10)으로 조회하면, InnoDB는 Next-Key Lock 대신 Row Lock만 건다. unique라서 gap에 같은 값이 들어올 수 없기 때문이다. Next-Key Lock은 non-unique 인덱스이거나 범위 조건일 때 발생한다.
InnoDB의 REPEATABLE READ에서 FOR UPDATE 시 기본으로 사용되는 잠금 단위다.
왜 Gap Lock / Next-Key Lock이 존재하는가
Phantom Read 방지가 목적이다.
Phantom Read란: 같은 트랜잭션 내에서 같은 범위 쿼리를 두 번 실행했는데, 그 사이에 다른 트랜잭션이 INSERT를 해서 결과 행이 달라지는 현상이다.
TX1: SELECT * FROM t WHERE state = 'QUEUED' FOR UPDATE → 3건
TX2: INSERT INTO t (state) VALUES ('QUEUED') → commit
TX1: SELECT * FROM t WHERE state = 'QUEUED' FOR UPDATE → 4건 (phantom!)
Gap Lock이 있으면 TX2의 INSERT가 블로킹되어 phantom이 발생하지 않는다. REPEATABLE READ에서 이 보장을 제공하기 위해 Next-Key Lock을 사용한다.
Isolation Level과 Lock의 관계
| Isolation Level | Row Lock | Gap Lock | Next-Key Lock | Phantom Read |
|---|---|---|---|---|
| READ UNCOMMITTED | O | X | X | 발생 가능 |
| READ COMMITTED | O | X | X | 발생 가능 |
| REPEATABLE READ | O | O | O | 방지 |
| SERIALIZABLE | O | O | O | 방지 |
핵심: READ COMMITTED로 내리면 Gap Lock / Next-Key Lock이 사라지고, 순수 Row Lock만 남는다.
job-api에서 왜 문제가 됐는가
상황
job-api는 여러 인스턴스가 동시에 FOR UPDATE SKIP LOCKED로 작업을 가져가는 구조다.
SELECT * FROM job_execution
WHERE state IN ('QUEUED', 'ACCEPTED')
ORDER BY processed_at ASC
LIMIT 10
FOR UPDATE SKIP LOCKED
REPEATABLE READ에서 이 쿼리를 실행하면:
- 실제 매칭되는 행에 Row Lock
- 인덱스 범위에 Gap Lock / Next-Key Lock 추가로 발생
Lock 호환성 이해
먼저 InnoDB의 gap 관련 lock 호환성을 정리한다:
| Gap Lock (보유) | Insert Intention Lock (보유) | |
|---|---|---|
| Gap Lock (요청) | 호환 (양립 가능) | 호환 |
| Insert Intention Lock (요청) | 충돌 (블로킹) | 호환 |
- Gap Lock끼리: 여러 트랜잭션이 같은 gap에 동시에 Gap Lock을 잡을 수 있다. 서로 블로킹하지 않는다.
- Insert Intention Lock: INSERT를 실행할 때, 삽입 위치의 gap에 대해 "여기에 넣겠다"는 의도를 선언하는 lock이다.
- Gap Lock vs Insert Intention Lock: 충돌한다. 누군가 gap에 Gap Lock을 잡고 있으면, 그 gap에 INSERT 하려는 트랜잭션은 블로킹된다.
즉, "gap을 읽기 위해 잠그는 건 서로 허용되지만, 그 gap에 INSERT/UPDATE하려는 순간 막힌다."
Deadlock 시나리오
job-api에서 두 인스턴스가 동시에 작업을 처리하는 상황:
인덱스 (state + processed_at): [Row A] ---gap--- [Row B] ---gap--- [Row C]
Step 1: 두 TX가 범위 쿼리로 FOR UPDATE 실행
TX1: SELECT ... WHERE state = 'QUEUED' FOR UPDATE
→ Row Lock(A) + Gap Lock(gap before A) + Next-Key Lock
TX2: SELECT ... WHERE state = 'QUEUED' FOR UPDATE
→ Row Lock(B) + Gap Lock(A~B 사이) + Next-Key Lock
(Gap Lock끼리는 호환되므로 여기까지는 문제없음)
Step 2: 상태 변경 후 새 레코드 INSERT 또는 인덱스 업데이트
TX1: UPDATE → state를 'RUNNING'으로 변경
→ 인덱스 키 변경 = 기존 위치 DELETE + 새 위치 INSERT
→ 새 위치에 Insert Intention Lock 필요
→ TX2의 Gap Lock 범위와 겹침 → 대기!
TX2: UPDATE → state를 'RUNNING'으로 변경
→ 마찬가지로 Insert Intention Lock 필요
→ TX1의 Gap Lock 범위와 겹침 → 대기!
→ 서로가 서로의 Gap Lock을 기다림 → Deadlock!
핵심은 인덱스 키가 포함된 컬럼(state)을 UPDATE하면, InnoDB 내부적으로 기존 인덱스 엔트리 삭제 + 새 위치에 삽입이 발생한다는 점이다. 이 "삽입"이 Insert Intention Lock을 필요로 하고, 상대방의 Gap Lock과 충돌한다.
해결: READ COMMITTED로 전환
// JobTransactionConfig.kt
@Transactional(isolation = Isolation.READ_COMMITTED)
READ COMMITTED에서는 Gap Lock이 없으므로 순수 Row Lock만 잡힌다.
SKIP LOCKED와 결합하면 lock contention이 최소화된다.
READ COMMITTED로 바꿔도 괜찮은 이유
Inconsistent Read 가능성
READ COMMITTED에서는 같은 트랜잭션 내에서도 다른 트랜잭션이 commit한 변경을 볼 수 있다. 즉, 같은 SELECT를 두 번 실행하면 결과가 다를 수 있다.
job-api에서 안전한 이유: Monotonic State Transition
상태 전이가 **단방향(monotonic)**으로만 진행된다:
QUEUED → DISPATCHED → RUNNING → SUCCEEDED / FAILED / ABORTED / CANCELLED
역방향 전이가 없기 때문에, inconsistent read가 발생해도:
| 상황 | 결과 |
|---|---|
| 이미 전이된 상태를 읽음 | 조건 불일치로 no-op (아무 일도 안 함) |
| 아직 반영 안 된 상태를 읽음 | 다음 스케줄링 cycle에서 retry |
양방향 전이가 없으므로 "잘못된 방향으로 상태가 바뀌는" 일은 구조적으로 불가능하다.
Lock 패턴 정리 (job-api 기준)
1. FOR UPDATE — Exclusive Row Lock
단일 레코드의 상태 전이처럼, 직렬화된 접근이 필요한 경우.
SELECT * FROM job_execution WHERE execution_id = :id FOR UPDATE
2. FOR UPDATE SKIP LOCKED — Non-Blocking Row Lock
여러 인스턴스가 동시에 작업을 가져가는 경우. 이미 잠긴 행은 건너뛴다.
SELECT * FROM job_execution
WHERE state IN (:states)
ORDER BY processed_at ASC
LIMIT 10
FOR UPDATE SKIP LOCKED
기존 문서 참고: Skip Locked
3. Application-Level Mutex (job_mutex 테이블)
DB row lock이 아닌, 애플리케이션 수준의 분산 잠금.
INSERT IGNORE로 lock 획득, TTL 기반 만료, random code로 소유권 검증.
mutexLockService.tryWithLock(id = "cleanup-job", lockTtl = Duration.ofSeconds(10)) {
// 단일 인스턴스만 실행하는 로직
}
판단 기준: 언제 READ COMMITTED를 써도 되는가
| 조건 | 설명 |
|---|---|
| 상태 전이가 단방향 | 역방향 전이가 없으면 inconsistent read가 안전 |
| SKIP LOCKED 패턴 사용 | Gap Lock이 없어야 contention 최소화 |
| retry 로직이 있음 | 놓친 변경은 다음 cycle에서 처리 가능 |
| 범위 쿼리 + 높은 동시성 | Gap Lock에 의한 deadlock 위험이 높은 경우 |
반대로, phantom read가 절대 발생하면 안 되는 경우 (예: 금융 트랜잭션, 잔액 계산)에는 REPEATABLE READ 이상을 유지해야 한다.
참고
- MySQL InnoDB Locking
- MySQL Gap Locks
- MySQL Deadlocks
- MySQL Transaction Isolation Levels
- PR: COREPL-4500 —
fix (job-api): accommodate row-lock only