SELECT * FROM t WHERE id BETWEEN 10 AND 20 FOR UPDATE를 실행하면, 테이블에 없는 id=15를 다른 세션이 INSERT하려 할 때 그 INSERT가 블록된다. 아직 존재하지도 않는 행을 어떻게 미리 잠글 수 있을까?
나는 오랫동안 "REPEATABLE READ가 팬텀 리드를 막는 건 MVCC(스냅샷 읽기) 덕분"이라고만 설명해 왔다. 그런데 FOR UPDATE나 UPDATE ... WHERE range 같은 잠금 읽기(locking read) 는 스냅샷이 아니라 최신 커밋본을 읽는다. 즉 MVCC의 보호 밖이다. 여기서 팬텀을 실제로 막는 건 MVCC가 아니라 넥스트키 락(next-key lock) 이고, 그 정체는 "행 잠금 + 존재하지 않는 틈에 대한 잠금"의 결합이다. MySQL 8.0 Reference Manual §15.7.1을 따라가며 정리한 내용이다.
공식 매뉴얼(§15.7.1)은 잠금 읽기가 거는 락을 세 종류로 정의한다.
GEN_CLUST_INDEX)의 레코드에 건다. 즉 InnoDB의 행 잠금은 언제나 "인덱스 엔트리 잠금"이다.세 번째가 핵심이다. 범위 스캔이 지나간 레코드마다 "그 레코드 + 그 앞의 빈 공간"을 한 덩어리로 잠그기 때문에, 그 빈 공간으로 새 행이 끼어들 자리가 사라진다.
인덱스에 값 10, 11, 13, 20이 들어 있다고 하자. 매뉴얼 예시대로, 넥스트키 락이 커버할 수 있는 구간은 아래와 같다. 왼쪽 경계는 열려 있고 오른쪽 레코드는 포함되는 (앞 gap, 레코드] 형태의 반개구간이다.
(-∞, 10]
(10, 11]
(11, 13]
(13, 20]
(20, +∞) ← supremum pseudo-record
마지막 줄이 흥미롭다. 인덱스 페이지에는 supremum이라는 가짜 레코드(pseudo-record) 가 맨 끝에 항상 존재한다. 최댓값(20) 위쪽 틈을 잠글 때 InnoDB는 이 supremum에 넥스트키 락을 건다. 매뉴얼은 "the supremum is not a real index record, so, in effect, this next-key lock locks only the gap following the largest index value"라고 설명한다 — supremum은 실재 행이 아니므로 record lock 부분은 사실상 무의미하고, 20 위의 gap만 잠기는 셈이다. 덕분에 "20보다 큰 값의 INSERT"까지 조건 범위에 맞게 통제된다.
팬텀 로우는 "같은 조건의 범위 쿼리를 두 번 돌렸을 때, 그 사이 다른 트랜잭션이 INSERT한 새 행이 두 번째 결과에 나타나는 것"이다(§15.7.4). 잠금 읽기는 최신본을 읽으므로 MVCC 스냅샷의 보호를 받지 못한다. 대신 넥스트키 락이 두 가지를 동시에 한다.
결과적으로 "조건을 만족하는 값이 들어올 수 있는 모든 위치"가 잠기므로, 다른 트랜잭션이 그 범위에 새 행을 넣지 못한다. §15.7.2.3의 요약 문장 그대로다: "InnoDB uses next-key locks for searches and index scans, which prevents phantom rows."
-- 세션 A (REPEATABLE READ)
SELECT * FROM t WHERE id BETWEEN 10 AND 20 FOR UPDATE;
-- id=10,11,13,20 record lock + (10,11](11,13](13,20] gap 잠금
-- 세션 B
INSERT INTO t(id) VALUES (15); -- (13,20] gap에 막혀 BLOCK (A 커밋까지 대기)
INSERT INTO t(id) VALUES (25); -- 잠긴 범위 밖 → 통과
매뉴얼이 강조하는 두 가지가 자주 오해를 부른다.
그 INSERT 쪽이 거는 게 insert intention lock이다. INSERT 직전 대상 gap에 거는 특수한 gap lock으로, 여러 트랜잭션이 같은 gap의 서로 다른 위치에 INSERT하려 할 때는 서로 기다릴 필요가 없다는 신호다. 다만 그 gap에 이미 (넥스트키 락 등으로) gap lock이 잡혀 있으면 insert intention lock은 대기한다 — 위 세션 B의 INSERT 15가 막히는 정확한 지점이 이것이다.
같은 잠금 읽기라도 격리 수준에 따라 gap locking 동작이 갈린다.
| 격리 수준 | 잠금 읽기의 gap locking | 팬텀 방지(잠금 읽기) |
|---|---|---|
| READ COMMITTED | 비활성 — 외래키·중복키 검사에만 사용 | 안 됨 |
| REPEATABLE READ (기본) | 활성 — 넥스트키 락 사용 | 됨 |
READ COMMITTED에서는 gap이 사라지고 매칭된 레코드에만 record lock이 남는다. 매칭 안 된 레코드의 락은 스캔 직후 바로 해제(semi-consistent read) 된다. 그래서 동시성은 오르지만 팬텀은 열린다.
실제로 어떤 락이 잡혔는지 궁금하면 MySQL 8.0의 performance_schema.data_locks를 조회하면 된다. LOCK_MODE 컬럼에 X(next-key), X,GAP(gap only), X,REC_NOT_GAP(record only) 형태로 각 락의 실체가 드러난다.
-- 세션 A가 FOR UPDATE 후, 다른 세션에서:
SELECT INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_DATA
FROM performance_schema.data_locks WHERE OBJECT_NAME = 't';
-- 기대: X,GAP / X,REC_NOT_GAP / X(next-key) 조합이 관측됨
넥스트키 락 = 인덱스 레코드에 대한 record lock + 그 레코드 앞 gap에 대한 gap lock의 결합이며, gap lock이 "그 틈으로의 INSERT"를 막기 때문에 잠금 읽기가 보는 범위에 팬텀이 끼어들 수 없다.
정리하며 바로잡은 오해가 셋이다. (1) REPEATABLE READ의 팬텀 방지를 MVCC가 다 하는 게 아니다 — 순수 SELECT는 MVCC가, 잠금 읽기는 넥스트키 락이 막는다. 두 메커니즘은 별개 축이다. (2) record lock은 딱 그 행만 잠그는 게 아니라, 범위 조건에선 앞쪽 gap까지 함께 잠근다. (3) gap lock끼리는 충돌하지 않는다 — 데드락은 gap 보유 vs insert intention 사이에서 난다.
더 파고들 만한 주제로는, 유니크 인덱스 동등 조건(WHERE uk = ?)에서 넥스트키 락이 순수 record lock으로 축약(degenerate) 되는 최적화 조건, 그리고 gap lock이 얽힌 대표적 데드락 시나리오와 innodb_deadlock_detect가 있다.