FOR UPDATE는 왜 존재하지도 않는 행까지 잠그는가 — InnoDB 넥스트키 락

seonwoo_jung·2026년 7월 17일

1. 존재하지 않는 행을 잠근다는 말

SELECT * FROM t WHERE id BETWEEN 10 AND 20 FOR UPDATE를 실행하면, 테이블에 없는 id=15를 다른 세션이 INSERT하려 할 때 그 INSERT가 블록된다. 아직 존재하지도 않는 행을 어떻게 미리 잠글 수 있을까?

나는 오랫동안 "REPEATABLE READ가 팬텀 리드를 막는 건 MVCC(스냅샷 읽기) 덕분"이라고만 설명해 왔다. 그런데 FOR UPDATEUPDATE ... WHERE range 같은 잠금 읽기(locking read) 는 스냅샷이 아니라 최신 커밋본을 읽는다. 즉 MVCC의 보호 밖이다. 여기서 팬텀을 실제로 막는 건 MVCC가 아니라 넥스트키 락(next-key lock) 이고, 그 정체는 "행 잠금 + 존재하지 않는 틈에 대한 잠금"의 결합이다. MySQL 8.0 Reference Manual §15.7.1을 따라가며 정리한 내용이다.

2. 세 가지 락의 층위

공식 매뉴얼(§15.7.1)은 잠금 읽기가 거는 락을 세 종류로 정의한다.

  • Record lock: 인덱스 레코드 하나에 걸리는 락. 매뉴얼은 "record locks always lock index records"라고 명시한다. 테이블에 인덱스가 하나도 없어도 InnoDB가 내부적으로 만든 숨은 클러스터드 인덱스(GEN_CLUST_INDEX)의 레코드에 건다. 즉 InnoDB의 행 잠금은 언제나 "인덱스 엔트리 잠금"이다.
  • Gap lock: "a lock on a gap between index records, or a lock on the gap before the first or after the last index record." 인덱스 레코드 사이의 틈, 혹은 최솟값 앞·최댓값 뒤의 틈에 거는 락이다.
  • Next-key lock: 위 둘의 결합. "a combination of a record lock on the index record and a gap lock on the gap before the index record" — 레코드와 그 앞쪽 틈을 함께 잠근다.

세 번째가 핵심이다. 범위 스캔이 지나간 레코드마다 "그 레코드 + 그 앞의 빈 공간"을 한 덩어리로 잠그기 때문에, 그 빈 공간으로 새 행이 끼어들 자리가 사라진다.

3. 넥스트키 락이 덮는 구간

인덱스에 값 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"까지 조건 범위에 맞게 통제된다.

4. 왜 이게 팬텀을 막나

팬텀 로우는 "같은 조건의 범위 쿼리를 두 번 돌렸을 때, 그 사이 다른 트랜잭션이 INSERT한 새 행이 두 번째 결과에 나타나는 것"이다(§15.7.4). 잠금 읽기는 최신본을 읽으므로 MVCC 스냅샷의 보호를 받지 못한다. 대신 넥스트키 락이 두 가지를 동시에 한다.

  1. 범위 스캔이 지나간 각 인덱스 레코드에 record lock → 기존 행의 수정·삭제 차단.
  2. 그 레코드들 사이의 gap에 gap lock → 그 틈으로의 INSERT 차단.

결과적으로 "조건을 만족하는 값이 들어올 수 있는 모든 위치"가 잠기므로, 다른 트랜잭션이 그 범위에 새 행을 넣지 못한다. §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);  -- 잠긴 범위 밖 → 통과

5. gap lock의 반직관적 성질

매뉴얼이 강조하는 두 가지가 자주 오해를 부른다.

  • "purely inhibitive": gap lock의 "only purpose is to prevent other transactions from inserting to the gap." 즉 gap lock은 오직 INSERT만 막는다. 이미 그 틈엔 값이 없으니 UPDATE/DELETE 대상 자체가 없다.
  • gap lock끼리는 충돌하지 않는다: "A gap lock taken by one transaction does not prevent another transaction from taking a gap lock on the same gap." 그래서 S-gap과 X-gap의 구분조차 사실상 없다("there is no difference between shared and exclusive gap locks. They do not conflict... perform the same function"). 충돌은 오직 "gap을 잠근 쪽 vs 그 gap에 INSERT하려는 쪽" 사이에서만 난다.

그 INSERT 쪽이 거는 게 insert intention lock이다. INSERT 직전 대상 gap에 거는 특수한 gap lock으로, 여러 트랜잭션이 같은 gap의 서로 다른 위치에 INSERT하려 할 때는 서로 기다릴 필요가 없다는 신호다. 다만 그 gap에 이미 (넥스트키 락 등으로) gap lock이 잡혀 있으면 insert intention lock은 대기한다 — 위 세션 B의 INSERT 15가 막히는 정확한 지점이 이것이다.

6. 격리 수준에 따라 gap이 사라진다

같은 잠금 읽기라도 격리 수준에 따라 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) 조합이 관측됨

7. 정리

넥스트키 락 = 인덱스 레코드에 대한 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가 있다.

참고 자료

  • MySQL 8.0 Reference Manual §15.7.1 InnoDB Locking
  • MySQL 8.0 Reference Manual §15.7.2.3 Consistent Nonlocking Reads / §15.7.4 Phantom Rows
  • MySQL 8.0 Reference Manual §15.7.3 Locks Set by Different SQL Statements in InnoDB

0개의 댓글