![[MySQL] 갭 락과 넥스트키 락 재현: 없는 값을 조회해도 INSERT가 막히는 이유](https://blog.kakaocdn.net/dna/bxw3UP/dJMb9916jcn/AAAAAAAAAAAAAAAAAAAAAKpnzeW3YuBkZxcaaWKxgXDWeDE2vg5lWIPGEuMsLJXW/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1793458799&allow_ip=&allow_referer=&signature=WpxiCTrFN2jYhj0jhGGS56zwVKs%3D)
데이터베이스를 다루다 보면 분명히 존재하지 않는 값을 조회했을 뿐인데, 다른 세션에서 실행한 INSERT 문이 아무 이유 없이 멈춰버리는 상황을 마주하게 됩니다. 개발자 입장에서는 "그 값은 테이블에 없는데 왜 잠기지?"라는 의문이 들 수밖에 없습니다. 이때 원인은 대부분 InnoDB 스토리지 엔진의 갭 락(Gap Lock)과 넥스트키 락(Next-Key Lock)입니다. 이 락들은 존재하는 레코드가 아니라 인덱스 레코드와 레코드 사이의 빈 공간에 걸리기 때문에, 조회 결과가 0건이어도 다른 트랜잭션의 INSERT를 차단할 수 있습니다. 이번 글에서는 갭 락과 넥스트키 락의 동작 원리를 정리하고, 두 세션을 이용한 재현 SQL과 잠긴 구간을 실제로 확인하는 방법, 그리고 실무에서 이 락을 다룰 때의 주의사항까지 단계적으로 살펴보겠습니다.
1. InnoDB 잠금 체계의 기본: 레코드 락, 갭 락, 넥스트키 락
InnoDB는 행 단위 잠금(Row-Level Locking)을 사용하지만, 실제로는 레코드 자체가 아니라 인덱스 레코드에 락을 겁니다. 테이블에 별도의 인덱스가 없어도 내부적으로 클러스터드 인덱스(Clustered Index)가 존재하므로, 모든 잠금은 결국 인덱스를 기준으로 동작한다는 점이 가장 중요한 전제입니다. InnoDB가 사용하는 잠금은 크게 세 가지로 나뉩니다.
| 구분 | 잠그는 대상 | 발생 조건 | 주요 목적 |
|---|---|---|---|
| 레코드 락(Record Lock) | 실제로 존재하는 인덱스 레코드 하나 | 등호 조건이 유니크 인덱스의 존재하는 값과 정확히 일치할 때 | 해당 행에 대한 동시 수정 방지 |
| 갭 락(Gap Lock) | 인덱스 레코드와 레코드 사이의 빈 구간(레코드 자체는 포함하지 않음) | 존재하지 않는 값 조회, 범위 조건의 경계 등 | 같은 트랜잭션 내에서 팬텀 행이 새로 삽입되는 것을 방지 |
| 넥스트키 락(Next-Key Lock) | 레코드 락 + 그 레코드 바로 앞의 갭을 합친 범위 | 범위 검색, 유니크가 아닌 인덱스의 등호 조건 등 | 레코드 보호와 팬텀 방지를 동시에 수행 |
넥스트키 락은 "레코드 락 + 갭 락"의 조합이라고 이해하면 됩니다. 예를 들어 인덱스에 10, 15, 20이라는 값이 있을 때 15에 대한 넥스트키 락은 (10, 15] 구간, 즉 10을 초과하고 15 이하인 범위 전체를 잠급니다. 이 범위 표현 방식 때문에 15보다 작은 값을 새로 INSERT하려는 시도가 차단되는 것입니다.
2. 왜 존재하지 않는 값을 조회해도 잠기는가
갭 락이 존재하는 근본적인 이유는 팬텀 리드(Phantom Read) 방지입니다. MySQL의 기본 격리 수준인 REPEATABLE READ에서는 같은 트랜잭션 안에서 동일한 조건으로 두 번 조회했을 때 항상 같은 결과가 나와야 합니다. 그런데 만약 갭 락이 없다면, 세션 A가 id = 12를 조회해 0건을 확인한 직후 세션 B가 id = 12를 INSERT하고 커밋한 뒤, 세션 A가 같은 조건으로 다시 조회하면 갑자기 1건이 나타나는 팬텀 행 문제가 발생합니다. InnoDB는 이를 막기 위해, 등호 조건이 인덱스를 스캔하다가 값을 찾지 못하고 다음 레코드를 만나는 순간 그 사이의 갭에 락을 걸어버립니다. 즉 레코드가 없어도 인덱스 스캔이 지나간 구간에는 락이 남는다는 것이 핵심입니다.
여기서 흔히 하는 오해가 하나 있습니다. 갭 락은 서로 다른 트랜잭션끼리 같은 갭에 대해 공존이 가능합니다. 즉 세션 A와 세션 B가 동시에 같은 구간에 갭 락(X, GAP)을 보유할 수 있으며, 이것만으로는 서로를 차단하지 않습니다. 대기가 발생하는 것은 한쪽이 갭 락을 보유한 상태에서 다른 쪽이 그 갭 안에 실제로 INSERT를 시도할 때입니다. 이때 INSERT는 삽입 의도 락(Insert Intention Lock)을 요청하는데, 이 락은 기존 갭 락과 충돌하기 때문에 대기 상태로 전환됩니다. 참고로 이러한 범위 검색의 인덱스 사용 방식은 BETWEEN 연산자 가이드에서 다룬 범위 검색(Range Scan)의 인덱스 활용 방식과 그대로 연결됩니다.
3. 두 세션으로 직접 재현하기
실제로 재현해 보는 것이 가장 확실합니다. 다음 예제는 로컬 MySQL 8.4에서 그대로 실행할 수 있습니다. 먼저 테스트용 테이블을 만들고, 의도적으로 값 사이에 빈 구간을 만듭니다.
CREATE DATABASE gaplock_demo;
USE gaplock_demo;
CREATE TABLE members (
id INT PRIMARY KEY,
name VARCHAR(50)
) ENGINE=InnoDB;
INSERT INTO members VALUES (5,'A'), (10,'B'), (15,'C'), (20,'D');
id 값은 5, 10, 15, 20만 존재하므로 (5,10), (10,15), (15,20), (20, +∞) 구간이 모두 빈 갭입니다. 이제 두 개의 터미널(또는 두 개의 MySQL 클라이언트 세션)을 열어 각각 접속합니다.
세션 A (터미널 1)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT * FROM members WHERE id = 12 FOR UPDATE;
-- 결과: 0 rows. 하지만 (10, 15) 구간에 갭 락이 걸린 상태로 트랜잭션은 열려 있습니다.
세션 B (터미널 2)
START TRANSACTION;
INSERT INTO members VALUES (12, 'E');
-- 이 INSERT는 응답 없이 대기 상태(hang)가 됩니다.
세션 B의 INSERT는 세션 A가 COMMIT 또는 ROLLBACK을 실행하거나, innodb_lock_wait_timeout(기본값 50초)이 지날 때까지 대기합니다. 여기서 반드시 확인해야 할 것은, 같은 갭이라도 구간 밖의 값은 차단되지 않는다는 점입니다. 세션 B에서 INSERT INTO members VALUES (16, 'F');를 실행하면 이 값은 (15, 20) 구간에 속하므로 즉시 성공합니다. 이 차이를 직접 확인해 보면 갭 락이 "테이블 전체"가 아니라 "특정 인덱스 구간"만 잠근다는 것이 명확해집니다.
실무 팁: 재현 시에는 세션 B의 INSERT 실행 직후 시각과, 대기가 풀리는 시각(또는 ERROR 1205 (HY000): Lock wait timeout exceeded 메시지가 출력되는 시각)을 함께 기록해 두면 대기 시간을 근거로 남길 수 있습니다.
4. 잠긴 구간을 눈으로 확인하기: performance_schema.data_locks
세션 B가 대기 중인 상태에서 세 번째 터미널을 열어 다음 쿼리를 실행하면 실제로 어떤 락이 걸려 있는지 확인할 수 있습니다.
SELECT ENGINE_TRANSACTION_ID, OBJECT_NAME, INDEX_NAME,
LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA
FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;
이 쿼리 결과의 LOCK_MODE 컬럼에서 다음과 같은 값을 볼 수 있습니다.
- X: 순수 레코드 락. 실제 존재하는 행을 잠급니다.
- X,GAP: 순수 갭 락. 레코드 자체가 아니라 그 앞의 빈 구간만 잠급니다. 세션 A가
id=12를 조회했을 때 걸리는 락이 바로 이 형태입니다. - X,GAP,INSERT_INTENTION: 세션 B가 INSERT를 시도하면서 대기 중인 락의 형태입니다.
LOCK_STATUS가WAITING으로 표시됩니다.
LOCK_DATA 값을 보면 갭이 어느 레코드를 기준으로 잡히는지 알 수 있습니다. InnoDB의 갭 락은 항상 구간의 상한(더 큰 값)이 되는 레코드를 기준으로 표기됩니다. 즉 (10, 15) 구간의 갭 락은 LOCK_DATA에 15로 나타나며, 만약 인덱스의 가장 큰 값보다 더 큰 값을 조회한 경우에는 인덱스 끝을 의미하는 supremum 유사(pseudo) 레코드를 기준으로 잡힙니다. 참고로 MySQL 5.7까지는 INFORMATION_SCHEMA.INNODB_LOCKS와 INNODB_LOCK_WAITS를 사용했지만, 이 테이블들은 8.0에서 제거되었으므로 8.0 이상 환경에서는 반드시 performance_schema.data_locks 계열을 사용해야 합니다. 이 부분은 8.0 이상에서만 등장하는 대표적인 버전별 동작 차이이니, 예전 자료를 참고할 때 주의가 필요합니다.
5. 흔히 하는 실수: 조건과 인덱스 종류에 따른 잠금 범위 차이
같은 "조회"처럼 보여도 조건과 인덱스 구조에 따라 실제로 걸리는 락의 범위는 크게 달라집니다. 이 차이를 모르면 "분명 등호 조건인데 왜 범위가 잠기지?"라는 함정에 빠지기 쉽습니다.
5-1. 유니크 인덱스의 등호 조건
기본 키(Primary Key)나 UNIQUE 인덱스에 존재하는 값으로 등호 조회를 하면 해당 레코드에 대한 레코드 락만 걸리고 갭 락은 걸리지 않습니다. 예를 들어 SELECT * FROM members WHERE id = 15 FOR UPDATE;는 15라는 레코드만 잠급니다. 반면 존재하지 않는 값으로 등호 조회를 하면, 실제로 잠글 레코드가 없으므로 순수 갭 락만 남습니다. 이 두 가지 동작 차이가 바로 이번 글에서 재현한 현상의 핵심 원리입니다.
5-2. 유니크가 아닌(세컨더리) 인덱스의 등호 조건
중복을 허용하는 세컨더리 인덱스는 값이 같은 레코드가 여러 개 있을 수 있기 때문에, 등호 조건이 존재하는 값과 일치하더라도 그 값을 갖는 마지막 레코드까지 넥스트키 락으로 잠급니다. 이는 같은 값을 가진 새 레코드가 인덱스 스캔 도중 끼어드는 것을 막기 위한 안전장치입니다. 따라서 유니크 인덱스보다 세컨더리 인덱스에서 잠금 범위가 더 넓어지는 경향이 있으며, 동시성이 중요한 테이블일수록 유니크 제약을 적극적으로 활용하는 것이 더 안전합니다.
5-3. 범위 조건(BETWEEN, >, <, LIKE 접두어 검색)
범위 조건은 그 범위에 걸친 모든 레코드와 갭에 대해 연쇄적으로 넥스트키 락을 겁니다. WHERE id BETWEEN 10 AND 20 FOR UPDATE를 실행하면 10, 15, 20에 대한 레코드 락뿐 아니라 그 사이의 모든 갭까지 잠기므로, 사실상 해당 범위 전체에 새로운 값을 넣을 수 없게 됩니다. WHERE 절 완전 정복에서 다룬 것처럼 조건절을 얼마나 좁게, 그리고 인덱스에 맞게 작성하느냐가 잠금 범위를 최소화하는 데에도 그대로 적용됩니다. 이때 실제로 옵티마이저가 인덱스 레인지 스캔을 사용하는지는 EXPLAIN ANALYZE 해석법으로 확인한 실행 계획의 접근 방식(type 컬럼과 실제 스캔 행 수)과 함께 점검하는 것이 정확합니다.
6. 갭 락을 회피하거나 영향을 줄이는 방법
갭 락 자체는 데이터 정합성을 위한 정상적인 동작이므로 무조건 "나쁜 것"은 아닙니다. 하지만 동시 INSERT가 잦은 테이블에서 불필요하게 넓은 갭 락이 걸리면 처리량 저하와 데드락 위험이 커집니다. 다음 기준으로 완화할 수 있습니다.
- 격리 수준을 READ COMMITTED로 낮춘다: READ COMMITTED에서는 조회(잠금 없는 일반 SELECT는 물론, 잠금 읽기인 FOR UPDATE 포함)에 대해 갭 락이 사용되지 않고 레코드 락만 사용됩니다(외래 키 검사나 유니크 제약 검사 등 내부적인 예외는 존재합니다). 다만 이 경우 팬텀 리드가 발생할 수 있고, 복제(Replication) 방식이 반드시 ROW 기반이어야 한다는 전제가 따르므로 격리 수준 변경은 애플리케이션 전체에 대한 검토 후에 결정해야 합니다.
- 등호 조건은 가능한 한 유니크 인덱스로 조회한다: 존재 여부 확인이나 중복 체크 로직이라면 UNIQUE 제약이 걸린 컬럼으로 조회하도록 설계하면 갭 락의 범위가 최소화됩니다.
- 범위 조건과 잠금 읽기(FOR UPDATE, LOCK IN SHARE MODE)를 함께 쓰지 않는다: 정말 필요한 경우가 아니면 대량의 행을 대상으로 하는 범위 조회에는 잠금을 걸지 않는 것이 안전합니다.
- 트랜잭션을 짧게 유지한다: 갭 락은 트랜잭션이 끝날 때까지 유지되므로, 커밋을 미루는 로직이 있다면 대기 중인 다른 세션까지 함께 지연됩니다.
- innodb_lock_wait_timeout을 상황에 맞게 조정하고 모니터링한다: 기본값 50초를 그대로 사용하면 배치성 트랜잭션 하나가 온라인 서비스의 INSERT를 오랫동안 막을 수 있으므로, 서비스 특성에 맞춰 값을 낮추고
performance_schema.data_lock_waits를 주기적으로 점검하는 것이 좋습니다.
정리하면, 존재하지 않는 값을 조회했는데 다른 세션의 INSERT가 막히는 현상은 버그가 아니라 InnoDB가 REPEATABLE READ 격리 수준에서 팬텀 리드를 막기 위해 갭에 락을 거는 정상 동작입니다. 다만 이 동작을 이해하지 못한 상태로 운영 환경에서 마주치면 원인을 찾는 데 상당한 시간이 걸릴 수 있으므로, 이번 글의 재현 SQL로 직접 확인해 보고 자신의 서비스에서 어떤 조건이 넓은 갭 락을 유발하는지 점검해 보는 것이 좋습니다.
'SQL > MYSQL' 카테고리의 다른 글
| [MySQL] 복합 인덱스 컬럼 순서: 등가 조건을 앞에, 범위 조건을 뒤에 배치하는 원칙 (0) | 2026.10.05 |
|---|---|
| [MySQL] EXPLAIN ANALYZE 카디널리티 오차 진단과 실행 계획 튜닝 순서 (0) | 2026.10.03 |
| [MySQL] 메타데이터 락 때문에 멈춘 DDL 진단과 안전한 해소 절차 (0) | 2026.10.01 |
| [MySQL] NOWAIT와 SKIP LOCKED 활용: 대기 없는 작업 큐 구현과 한계 (0) | 2026.09.30 |
| [MySQL] SELECT FOR UPDATE와 FOR SHARE 비교: 예약·재고 처리 잠금 설계 (0) | 2026.09.29 |
| [MySQL] 긴 트랜잭션이 Undo와 Purge를 지연시키는 과정 측정하기 (0) | 2026.09.28 |
| [MySQL] performance_schema로 현재 잠금 대기와 차단 세션 찾기 (0) | 2026.09.27 |
| [MySQL] 데드락 로그 읽는 법: 피해 트랜잭션과 잠금 순서 복원하기 (0) | 2026.09.26 |