분명히 다른 행을 INSERT했는데 데드락이 났거나, 존재하지도 않는 범위에서 Lock wait timeout이 떴다면 범인은 대부분 **갭 락(gap lock)**입니다. InnoDB는 기본 격리수준인 REPEATABLE READ에서 팬텀 읽기를 막기 위해 실제 행뿐 아니라 인덱스 행 사이의 빈 공간까지 잠급니다.
세 가지 잠금 구분
| 잠금 | 잠그는 대상 | 목적 |
|---|---|---|
| 레코드 락 | 인덱스 레코드 1건 | 그 행 변경 차단 |
| 갭 락 | 인덱스 행 사이의 빈 구간 | 그 구간에 INSERT 차단 |
| 넥스트키 락 | 레코드 락 + 직전 갭 | 팬텀 방지(기본 동작) |
예를 들어 id가 10, 20, 30인 테이블에서 WHERE id BETWEEN 15 AND 25 FOR UPDATE를 실행하면, 존재하는 20 한 건만이 아니라 10과 30 사이 구간 전체에 넥스트키 락이 걸립니다. 그래서 다른 트랜잭션이 id = 17을 INSERT하려 하면 대기에 걸립니다.
데드락이 생기는 흐름
재현하려면 먼저 user_id에 인덱스가 있고 두 세션이 InnoDB·REPEATABLE READ인지 확인해야 합니다. 인덱스가 없으면 테이블 스캔과 잠금 범위가 달라져 아래 결과를 그대로 기대할 수 없습니다.
가장 흔한 패턴은 한 트랜잭션이 없는 키 범위를 잠근 뒤 다른 트랜잭션이 같은 범위에 접근하는 경우입니다.
-- 사전 조건: CREATE INDEX idx_orders_user_id ON orders(user_id);
-- 트랜잭션 A (REPEATABLE READ)
SELECT * FROM orders WHERE user_id = 7 FOR UPDATE; -- user_id 7 없음 → 갭을 배타적으로 잠금
-- 트랜잭션 B
SELECT * FROM orders WHERE user_id = 7 FOR UPDATE; -- A가 COMMIT/ROLLBACK할 때까지 대기
-- B가 대기 중인 동안 A가 INSERT하고 COMMIT하면 B의 SELECT가 재개됨
SELECT ... FOR UPDATE는 일반적인 공유 읽기가 아니라 잠금 읽기이므로, 같은 범위에 대한 두 번째 잠금 읽기는 먼저 잠근 트랜잭션이 끝날 때까지 대기합니다. 두 세션이 같은 갭에 각각 INSERT를 시도하는 복잡한 데드락은 실행 순서·제약조건·인덱스에 따라 달라지므로, 항상 재현된다고 단정하지 말고 SHOW ENGINE INNODB STATUS에서 실제 대기 그래프를 확인하세요.
진단과 완화
- 현재 잠금 확인 — 최신 MySQL은 성능 스키마로 본다.
SELECT * FROM performance_schema.data_locks;
SHOW ENGINE INNODB STATUS\G -- LATEST DETECTED DEADLOCK 섹션
-
인덱스를 정확히 태운다 — 갭 락 범위는 옵티마이저가 사용한 인덱스에 좌우된다. 조회 조건이 인덱스를 못 타면 잠금 범위·대기 양상이 달라질 수 있다. 유니크 인덱스로 존재하는 단건(
WHERE id = 20)을 조회하면 보통 레코드 락만 걸리지만, DBMS·조건·격리수준을EXPLAIN과 잠금 메타데이터로 확인한다. -
격리수준 조정 —
READ COMMITTED로 낮추면 넥스트키 락 대신 레코드 락만 사용해 갭 락이 사실상 사라진다. 단 팬텀 읽기는 허용되므로 트레이드오프를 이해하고 적용한다. -
트랜잭션을 짧게 — 잠금 구간을 좁히고, 여러 트랜잭션이 같은 순서로 행에 접근하도록 정렬해 교착 가능성을 줄인다.
요점 정리
- REPEATABLE READ의 기본 잠금은 넥스트키 락 = 레코드 락
+갭 락이다. - 없는 행을 조회해도 갭은 잠기므로, 같은 갭 INSERT 경쟁이 데드락의 단골 원인.
data_locks와SHOW ENGINE INNODB STATUS로 실제 잠금을 확인한다.- 단건 유니크 조회·
READ COMMITTED·짧은 트랜잭션으로 갭 락 영향을 줄인다.
갭 락을 직접 재현하고 data_locks 출력으로 잠금 범위를 확인하는 실습은 데이터베이스 트랙에서 회원가입 없이 무료로 할 수 있습니다.