
InnoDB의 락에 대해 공부하면서 Gap Lock, Next-Key Lock 같은 개념을 훑어봤지만, 스터디에서 "Gap Lock이 정확히 어떤 범위까지 걸리나요?"라는 질문을 받았을 때 제대로 답변하지 못했다.
"다음 레코드까지 걸린다"는 건 알겠는데, 다음 레코드가 없으면? PK로 조회하면? 인덱스가 없으면? 이런 세부적인 상황에서 어떻게 동작하는지 명확하게 설명하지 못했다.
그래서 직접 MySQL 8.0에서 테스트하면서 정리해봤다. 이번 글에서는 Gap Lock이 실제로 어떻게 동작하는지, 언제 걸리고 언제 안 걸리는지, 테이블 수준 락은 언제 발생하는지까지 상세히 설명하겠다.
본격적인 테스트에 앞서 기본 개념부터 정리하고 가자.
특정 인덱스 레코드 하나에 거는 락이다. 가장 기본적인 행 단위 락.
인덱스 레코드 사이의 간격에 거는 락이다. 다른 트랜잭션이 그 간격에 새로운 레코드를 삽입하는 것을 막는다.
Record Lock + Gap Lock의 조합이다. InnoDB가 REPEATABLE READ 격리 수준에서 기본으로 사용하는 락 방식이다. Phantom Read를 방지하기 위해 레코드 자체와 그 앞의 Gap을 함께 잠근다.
행 단위 락을 걸기 전에 테이블 수준에서 먼저 의도를 표시하는 락이다.
테이블 전체에 거는 락이다. LOCK TABLES 명령으로 명시적으로 걸 수 있다.
AUTO_INCREMENT 컬럼이 있는 테이블에 INSERT 시 발생하는 특수한 테이블 수준 락이다.
performance_schema.data_locks에서 락을 조회하면 LOCK_MODE 컬럼에 다양한 값이 나온다.
| LOCK_MODE | 의미 |
|---|---|
S | Shared Lock (공유 락) |
X | Exclusive Lock (배타 락) |
IS | Intention Shared (테이블 레벨) |
IX | Intention Exclusive (테이블 레벨) |
S,GAP | Gap Lock (공유) |
X,GAP | Gap Lock (배타) |
S,REC_NOT_GAP | Record Lock만 (Gap 없이) |
X,REC_NOT_GAP | Record Lock만 (Gap 없이) |
X,INSERT_INTENTION | Insert Intention Lock |
| 작업 | 테이블 락 | 설명 |
|---|---|---|
SELECT ... FOR UPDATE | IX | 행에 X Lock을 걸 예정 |
SELECT ... FOR SHARE | IS | 행에 S Lock을 걸 예정 |
INSERT | IX | 행에 X Lock을 걸 예정 |
UPDATE | IX | 행에 X Lock을 걸 예정 |
DELETE | IX | 행에 X Lock을 걸 예정 |
LOCK TABLES ... WRITE | X | 테이블 전체 배타 락 |
LOCK TABLES ... READ | S | 테이블 전체 공유 락 |
모든 DML 작업은 먼저 테이블에 IX 또는 IS 락을 건다.
+------------+-----------+-----------+-------------+-----------+
| table_name | LOCK_TYPE | LOCK_MODE | LOCK_STATUS | LOCK_DATA |
+------------+-----------+-----------+-------------+-----------+
| post | TABLE | IX | GRANTED | NULL | ← 테이블 수준 IX
| post | RECORD | X | GRANTED | 5, 14 | ← 행 수준 락
+------------+-----------+-----------+-------------+-----------+
MySQL 공식 문서에 따르면:
"Intention locks do not block anything except full table requests (for example, LOCK TABLES ... WRITE)"
IX 락의 목적:
| X | IX | S | IS | |
|---|---|---|---|---|
| X | ❌ | ❌ | ❌ | ❌ |
| IX | ❌ | ✅ | ❌ | ✅ |
| S | ❌ | ❌ | ✅ | ✅ |
| IS | ❌ | ✅ | ✅ | ✅ |
핵심: IX끼리는 호환되므로 여러 트랜잭션이 동시에 INSERT, UPDATE, DELETE 가능하다. 하지만 LOCK TABLES ... WRITE(X Lock)는 모든 것과 충돌한다.
-- 세션 1
LOCK TABLES post WRITE; -- 테이블에 X Lock
-- 다른 세션의 모든 읽기/쓰기 블로킹
-- 세션 2
SELECT * FROM post; -- 블로킹! 대기...
INSERT INTO post ...; -- 블로킹! 대기...
InnoDB 락과는 별개로, MySQL 서버 레벨에서 Metadata Lock(MDL)이라는 것이 있다.
| 작업 | 필요한 MDL |
|---|---|
ALTER TABLE | Exclusive MDL |
DROP TABLE | Exclusive MDL |
CREATE INDEX | Exclusive MDL (일부) |
OPTIMIZE TABLE | Exclusive MDL |
RENAME TABLE | Exclusive MDL |
| 일반 DML | Shared MDL |
-- 세션 1
START TRANSACTION;
SELECT * FROM post WHERE id = 1; -- Shared MDL 획득
-- COMMIT 안 함, 트랜잭션 유지
-- 세션 2
ALTER TABLE post ADD COLUMN new_col INT;
-- 블로킹! "Waiting for table metadata lock"
MySQL 공식 문서:
"The server must not permit one session to perform a DDL statement on a table that is used in an uncompleted transaction in another session."
SELECT * FROM performance_schema.metadata_locks;
lock_wait_timeout 조정ALGORITHM=INPLACE 옵션테스트하면서 락 상태를 확인하려면 이 쿼리를 사용한다.
-- InnoDB 행/테이블 락 확인
SELECT
OBJECT_NAME AS table_name,
INDEX_NAME,
LOCK_TYPE,
LOCK_MODE,
LOCK_STATUS,
LOCK_DATA
FROM performance_schema.data_locks;
-- Metadata Lock 확인
SELECT * FROM performance_schema.metadata_locks
WHERE OBJECT_TYPE = 'TABLE';
CREATE TABLE post (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT,
INDEX idx_user_id (user_id)
);
INSERT INTO post (user_id, title, content) VALUES (5, 'test', 'content');
CREATE TABLE user (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
age INT NOT NULL
);
INSERT INTO user (name, age) VALUES ('Alice', 25);
INSERT INTO user (name, age) VALUES ('Bob', 30);
CREATE TABLE uniquetable (
id INT PRIMARY KEY,
col INT UNIQUE
);
INSERT INTO uniquetable (id, col) VALUES (5, 500);
START TRANSACTION;
SELECT * FROM post WHERE id = 1 FOR UPDATE;
+------------+------------+-----------+---------------+-------------+-----------+
| table_name | INDEX_NAME | LOCK_TYPE | LOCK_MODE | LOCK_STATUS | LOCK_DATA |
+------------+------------+-----------+---------------+-------------+-----------+
| post | NULL | TABLE | IX | GRANTED | NULL |
| post | PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 1 |
+------------+------------+-----------+---------------+-------------+-----------+
X,REC_NOT_GAP - Gap 없이 레코드 락만 걸렸다!
MySQL 공식 문서:
"only an index record lock is required for statements that lock rows using a unique index to search for a unique row"
핵심 이유:
1. PK는 Unique Index다
2. 유일성이 보장되므로 Phantom Read 걱정이 없다
3. 따라서 Gap Lock이 불필요하다
-- id = 100인 레코드가 없다고 가정
SELECT * FROM post WHERE id = 100 FOR UPDATE;
| INDEX_NAME | LOCK_MODE | LOCK_DATA |
|------------|-----------|-----------|
| PRIMARY | X,GAP | supremum |
레코드가 없으면 해당 위치에 삽입을 방지하기 위해 Gap Lock을 건다.
SELECT * FROM post WHERE user_id = 5 FOR UPDATE;
| INDEX_NAME | LOCK_MODE | LOCK_DATA |
|--------------|---------------|------------------------|
| NULL | IX | NULL |
| idx_user_id | X | supremum pseudo-record |
| idx_user_id | X | 5, 14 |
| PRIMARY | X,REC_NOT_GAP | 14 |
supremum까지 Gap Lock이 걸렸다!
-- 세션 2
INSERT INTO post (user_id, ...) VALUES (6, ...); -- 블로킹!
INSERT INTO post (user_id, title, content) VALUES (6, 'existing', 'content');
이제 Gap Lock 범위가 (5, 6)으로 좁혀져서 user_id = 6 삽입이 가능하다.
SELECT * FROM user WHERE age > 24 AND age < 31 FOR UPDATE;
| INDEX_NAME | LOCK_MODE | LOCK_DATA |
|------------|-----------|------------------------|
| NULL | IX | NULL |
| PRIMARY | X | supremum pseudo-record |
| PRIMARY | X | 1 |
| PRIMARY | X | 2 |
모든 레코드 + supremum까지 락! 사실상 테이블 전체 락이다.
EXPLAIN SELECT * FROM user WHERE age > 24 AND age < 31 FOR UPDATE;
-- type: ALL, key: NULL → Full Table Scan
MySQL 공식 문서:
"If you have no indexes suitable for your statement and MySQL must scan the entire table, every row of the table becomes locked."
CREATE INDEX idx_age ON user(age);
인덱스 추가 후 다시 테스트하면 age 범위만 락이 걸린다.
| INDEX_NAME | LOCK_MODE | LOCK_DATA |
|------------|---------------|-----------|
| idx_age | X | 30, 1 |
| idx_age | X | 25, 2 |
| idx_age | X | supremum | ← age=30이 최댓값이라 supremum까지
age=40을 먼저 삽입하면 Gap Lock 범위가 (30, 40)으로 좁혀진다.
이게 실무에서 자주 만나는 케이스다. Unique 제약조건이 있는 컬럼에 중복 값을 삽입하면 어떻게 될까?
CREATE TABLE uniquetable (
id INT PRIMARY KEY,
col INT UNIQUE
);
INSERT INTO uniquetable (id, col) VALUES (5, 500);
START TRANSACTION;
INSERT INTO uniquetable (id, col) VALUES (5, 500);
-- COMMIT 하지 않음
START TRANSACTION;
INSERT INTO uniquetable (id, col) VALUES (6, 500); -- col=500 중복!
-- 블로킹 상태...
+-------------+-------+---------------+---------+-----------+
| INDEX_NAME | TRX | LOCK_MODE | STATUS | LOCK_DATA |
+-------------+-------+---------------+---------+-----------+
| col | 1854 | X,REC_NOT_GAP | GRANTED | 500, 5 |
| col | 1859 | S | WAITING | 500, 5 |
+-------------+-------+---------------+---------+-----------+
핵심 발견:
X,REC_NOT_GAP - Gap Lock 없이 Record Lock만!S Lock - 중복 체크를 위해 공유 락 대기PK와 마찬가지로 Unique Index도 값의 유일성이 보장된다. 같은 값이 두 번 들어올 수 없으니 Gap Lock이 필요 없다.
Unique Index (col):
[500, 5] ← X,REC_NOT_GAP (Gap 없음!)
→ col=501 삽입은 블로킹 안 됨
→ col=500 삽입만 블로킹 (중복 체크)
-- 세션 1
COMMIT;
-- 세션 2 결과
[23000][1062] Duplicate entry '500' for key 'uniquetable.col'
세션 1이 COMMIT하면 세션 2는 Duplicate entry 에러가 발생한다.
1. 세션 1: INSERT col=500
→ Unique Index에 X,REC_NOT_GAP 획득
2. 세션 2: INSERT col=500 (중복 시도)
→ 중복 체크를 위해 S Lock 요청
→ 세션 1의 X Lock과 충돌 → WAITING
3. 세션 1: COMMIT
→ X Lock 해제
4. 세션 2: S Lock 획득
→ 중복 확인됨 → Duplicate entry 에러!
-- 세션 1
INSERT INTO uniquetable (id, col) VALUES (6, 600); -- X Lock 획득
-- 세션 2
INSERT INTO uniquetable (id, col) VALUES (7, 600); -- S Lock 대기 (중복 체크)
-- 세션 3
INSERT INTO uniquetable (id, col) VALUES (8, 600); -- S Lock 대기 (중복 체크)
-- 세션 1 ROLLBACK 하면?
-- 세션 2, 3 모두 S Lock 획득
-- 둘 다 X Lock으로 업그레이드 시도 → 데드락!
MySQL 공식 문서:
"If a duplicate-key error occurs, a shared lock on the duplicate index record is set. This use of a shared lock can result in deadlock should there be multiple sessions trying to insert the same row if another session already has an exclusive lock."
테스트하다 보면 PK로 작업할 때 락이 적게 보이는 경우가 있다. 이건 Implicit Lock(묵시적 잠금) 때문이다.
InnoDB는 성능 최적화를 위해 INSERT 시 명시적인 락을 생성하지 않는다. 대신 삽입한 레코드의 헤더에 트랜잭션 ID(trx_id)를 기록해두고, "이 행은 내가 작업 중"이라고 표시한다.
MySQL 공식 블로그:
"The most common reason implicit locks are created is an INSERT operation: it is cheaper to not create explicit locks for newly inserted rows."
다른 트랜잭션이 해당 행에 접근하려 할 때만 Implicit → Explicit 변환이 일어난다.
트랜잭션 A: INSERT INTO post VALUES (...)
→ Implicit Lock (performance_schema에 안 보임)
트랜잭션 B: SELECT * FROM post WHERE id = ? FOR UPDATE
→ A의 Implicit Lock이 Explicit으로 변환됨
→ 이제 performance_schema에서 보임
→ B는 대기 상태
| 상황 | Gap Lock | 이유 |
|---|---|---|
| PK로 단건 조회 (레코드 존재) | ❌ | Unique → Phantom Read 불가 |
| PK로 단건 조회 (레코드 없음) | ✅ | 해당 위치 삽입 방지 필요 |
| Unique Index 조회 (레코드 존재) | ❌ | Unique → Phantom Read 불가 |
| 세컨더리 인덱스 조회 | ✅ | 중복 가능 → Phantom Read 방지 |
| 인덱스 없이 조회 | ✅ (전체) | Full Scan → 모든 레코드 락 |
| 상황 | Gap Lock 범위 |
|---|---|
| 다음 레코드 없음 | 현재 레코드 ~ supremum(무한대) |
| 다음 레코드 있음 | 현재 레코드 ~ 다음 레코드 직전 |
| 락 종류 | 발생 상황 | 다른 DML 블로킹? |
|---|---|---|
| IX | 모든 INSERT, UPDATE, DELETE | ❌ (IX끼리 호환) |
| IS | SELECT ... FOR SHARE | ❌ (IS끼리 호환) |
| X | LOCK TABLES ... WRITE | ✅ (모든 것 블로킹) |
| S | LOCK TABLES ... READ | ✅ (쓰기만 블로킹) |
| MDL | DDL 작업 (ALTER, DROP 등) | ✅ (트랜잭션 종료 대기) |
| 구분 | PK | Unique Index | 세컨더리 인덱스 |
|---|---|---|---|
| 단건 조회 시 락 | REC_NOT_GAP | REC_NOT_GAP | Next-Key |
| Gap Lock | ❌ | ❌ | ✅ |
| 중복 삽입 대기 시 | S Lock | S Lock | - |
| 데드락 위험 | 낮음 | 중간 | 높음 |
PK/Unique Index 조회는 안전하다: Gap Lock이 안 걸려서 동시성에 유리하다.
세컨더리 인덱스 조회는 Gap Lock 발생: FOR UPDATE 사용 시 예상치 못한 블로킹이 발생할 수 있다.
인덱스가 없으면 재앙: Full Table Scan으로 전체 테이블이 잠긴다.
Unique 제약조건 중복 삽입 시 데드락 주의: 여러 세션이 동시에 같은 값을 삽입하면 데드락 가능성이 있다.
DDL 작업 전 트랜잭션 확인: 긴 트랜잭션이 있으면 ALTER TABLE이 무한 대기할 수 있다.
IX 락은 정상이다: 모든 DML에서 발생하며, IX끼리는 충돌하지 않는다.
Gap Lock이 "다음 레코드까지"라는 건 알고 있었지만, 직접 테스트해보니 훨씬 복잡한 규칙들이 있었다.
특히 동시성이 중요한 서비스에서 FOR UPDATE를 사용할 때, 데이터 분포에 따라 예상치 못한 블로킹이 발생할 수 있다는 걸 알게 됐다. 단순히 "행 단위 락"이라고 생각하면 큰코다칠 수 있다.
혹시 Gap Lock 관련해서 더 좋은 테스트 방법이나 실무에서 겪은 경험이 있으신 분이 계시다면 조언해주시면 정말 감사하겠습니다.