MySQL InnoDB Gap Lock, 직접 테스트하며 이해하기

Rookedsysc·2025년 12월 28일
post-thumbnail

들어가며

InnoDB의 락에 대해 공부하면서 Gap Lock, Next-Key Lock 같은 개념을 훑어봤지만, 스터디에서 "Gap Lock이 정확히 어떤 범위까지 걸리나요?"라는 질문을 받았을 때 제대로 답변하지 못했다.

"다음 레코드까지 걸린다"는 건 알겠는데, 다음 레코드가 없으면? PK로 조회하면? 인덱스가 없으면? 이런 세부적인 상황에서 어떻게 동작하는지 명확하게 설명하지 못했다.

그래서 직접 MySQL 8.0에서 테스트하면서 정리해봤다. 이번 글에서는 Gap Lock이 실제로 어떻게 동작하는지, 언제 걸리고 언제 안 걸리는지, 테이블 수준 락은 언제 발생하는지까지 상세히 설명하겠다.


InnoDB 락의 종류

본격적인 테스트에 앞서 기본 개념부터 정리하고 가자.

행 수준 락 (Row-Level Lock)

Record Lock

특정 인덱스 레코드 하나에 거는 락이다. 가장 기본적인 행 단위 락.

Gap Lock

인덱스 레코드 사이의 간격에 거는 락이다. 다른 트랜잭션이 그 간격에 새로운 레코드를 삽입하는 것을 막는다.

Next-Key Lock

Record Lock + Gap Lock의 조합이다. InnoDB가 REPEATABLE READ 격리 수준에서 기본으로 사용하는 락 방식이다. Phantom Read를 방지하기 위해 레코드 자체와 그 앞의 Gap을 함께 잠근다.

테이블 수준 락 (Table-Level Lock)

Intention Lock (IX, IS)

행 단위 락을 걸기 전에 테이블 수준에서 먼저 의도를 표시하는 락이다.

  • IS (Intention Shared): "이 테이블의 특정 행에 공유 락을 걸 예정이다"
  • IX (Intention Exclusive): "이 테이블의 특정 행에 배타 락을 걸 예정이다"

Table Lock (X, S)

테이블 전체에 거는 락이다. LOCK TABLES 명령으로 명시적으로 걸 수 있다.

AUTO_INC Lock

AUTO_INCREMENT 컬럼이 있는 테이블에 INSERT 시 발생하는 특수한 테이블 수준 락이다.

LOCK_MODE 해석표

performance_schema.data_locks에서 락을 조회하면 LOCK_MODE 컬럼에 다양한 값이 나온다.

LOCK_MODE의미
SShared Lock (공유 락)
XExclusive Lock (배타 락)
ISIntention Shared (테이블 레벨)
IXIntention Exclusive (테이블 레벨)
S,GAPGap Lock (공유)
X,GAPGap Lock (배타)
S,REC_NOT_GAPRecord Lock만 (Gap 없이)
X,REC_NOT_GAPRecord Lock만 (Gap 없이)
X,INSERT_INTENTIONInsert Intention Lock

테이블 수준 락은 언제 걸리나?

📊 테이블 락 발생 상황 정리

작업테이블 락설명
SELECT ... FOR UPDATEIX행에 X Lock을 걸 예정
SELECT ... FOR SHAREIS행에 S Lock을 걸 예정
INSERTIX행에 X Lock을 걸 예정
UPDATEIX행에 X Lock을 걸 예정
DELETEIX행에 X Lock을 걸 예정
LOCK TABLES ... WRITEX테이블 전체 배타 락
LOCK TABLES ... READS테이블 전체 공유 락

Intention Lock (IX, IS)의 역할

모든 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 락의 목적:

  • "나 지금 이 테이블 안에서 행 단위 작업 중이야"라고 선언
  • 다른 트랜잭션이 테이블 전체를 잠그려 할 때 충돌 감지
  • IX 락끼리는 서로 충돌하지 않음 → 여러 트랜잭션이 동시에 다른 행 수정 가능

테이블 락 호환성 매트릭스

XIXSIS
X
IX
S
IS
  • ✅: 호환 (동시에 가질 수 있음)
  • ❌: 충돌 (대기해야 함)

핵심: IX끼리는 호환되므로 여러 트랜잭션이 동시에 INSERT, UPDATE, DELETE 가능하다. 하지만 LOCK TABLES ... WRITE(X Lock)는 모든 것과 충돌한다.

⚠️ LOCK TABLES 사용 시 주의

-- 세션 1
LOCK TABLES post WRITE;  -- 테이블에 X Lock
-- 다른 세션의 모든 읽기/쓰기 블로킹

-- 세션 2
SELECT * FROM post;  -- 블로킹! 대기...
INSERT INTO post ...;  -- 블로킹! 대기...

Metadata Lock - DDL 작업 시 주의사항

InnoDB 락과는 별개로, MySQL 서버 레벨에서 Metadata Lock(MDL)이라는 것이 있다.

Metadata Lock이 필요한 상황

작업필요한 MDL
ALTER TABLEExclusive MDL
DROP TABLEExclusive MDL
CREATE INDEXExclusive MDL (일부)
OPTIMIZE TABLEExclusive MDL
RENAME TABLEExclusive MDL
일반 DMLShared MDL

⚠️ 문제 상황: 트랜잭션이 끝나지 않으면 DDL 블로킹

-- 세션 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."

Metadata Lock 확인 방법

SELECT * FROM performance_schema.metadata_locks;

🛠️ 해결 방법

  1. 긴 트랜잭션 피하기: 트랜잭션은 최대한 짧게 유지
  2. 락 타임아웃 설정: lock_wait_timeout 조정
  3. Online DDL 활용: MySQL 8.0의 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';

테스트 환경 구성

post 테이블 생성

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');

user 테이블 생성

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);

uniquetable 테이블 생성

CREATE TABLE uniquetable (
    id INT PRIMARY KEY,
    col INT UNIQUE
);

INSERT INTO uniquetable (id, col) VALUES (5, 500);

시나리오 1: PK로 조회하면 Gap Lock이 안 걸린다

테스트

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 없이 레코드 락만 걸렸다!

🤔 왜 PK는 Gap Lock이 안 걸릴까?

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이 불필요하다

⚠️ 주의: 레코드가 없으면 Gap Lock이 걸린다

-- id = 100인 레코드가 없다고 가정
SELECT * FROM post WHERE id = 100 FOR UPDATE;
| INDEX_NAME | LOCK_MODE | LOCK_DATA |
|------------|-----------|-----------|
| PRIMARY    | X,GAP     | supremum  |

레코드가 없으면 해당 위치에 삽입을 방지하기 위해 Gap Lock을 건다.


시나리오 2: 세컨더리 인덱스로 조회하면 Gap Lock이 걸린다

테스트: user_id = 5만 있을 때

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이 걸렸다!

⚠️ user_id = 6 삽입 블로킹

-- 세션 2
INSERT INTO post (user_id, ...) VALUES (6, ...);  -- 블로킹!

🛠️ 해결: user_id = 6을 먼저 삽입

INSERT INTO post (user_id, title, content) VALUES (6, 'existing', 'content');

이제 Gap Lock 범위가 (5, 6)으로 좁혀져서 user_id = 6 삽입이 가능하다.


시나리오 3: 인덱스가 없으면 전체 테이블이 잠긴다

테스트: age 컬럼 (인덱스 없음)

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까지 락! 사실상 테이블 전체 락이다.

원인: Full Table Scan

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);

시나리오 4: 인덱스 추가 후 범위만 잠긴다

인덱스 추가 후 다시 테스트하면 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)으로 좁혀진다.


시나리오 5: Unique Constraint 중복 삽입 시 락 동작

이게 실무에서 자주 만나는 케이스다. Unique 제약조건이 있는 컬럼에 중복 값을 삽입하면 어떻게 될까?

테스트 환경

CREATE TABLE uniquetable (
    id INT PRIMARY KEY,
    col INT UNIQUE
);

INSERT INTO uniquetable (id, col) VALUES (5, 500);

세션 1: 삽입 후 COMMIT 안 함

START TRANSACTION;
INSERT INTO uniquetable (id, col) VALUES (5, 500);
-- COMMIT 하지 않음

세션 2: 같은 값 삽입 시도

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    |
+-------------+-------+---------------+---------+-----------+

핵심 발견:

  • 세션 1: X,REC_NOT_GAP - Gap Lock 없이 Record Lock만!
  • 세션 2: S Lock - 중복 체크를 위해 공유 락 대기

🤔 왜 Unique Index도 REC_NOT_GAP일까?

PK와 마찬가지로 Unique Index도 값의 유일성이 보장된다. 같은 값이 두 번 들어올 수 없으니 Gap Lock이 필요 없다.

Unique Index (col):
    [500, 5] ← X,REC_NOT_GAP (Gap 없음!)
    
→ col=501 삽입은 블로킹 안 됨
→ col=500 삽입만 블로킹 (중복 체크)

세션 1 COMMIT 후 결과

-- 세션 1
COMMIT;

-- 세션 2 결과
[23000][1062] Duplicate entry '500' for key 'uniquetable.col'

세션 1이 COMMIT하면 세션 2는 Duplicate entry 에러가 발생한다.

📊 Unique Index 중복 삽입 시 락 흐름

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

테스트하다 보면 PK로 작업할 때 락이 적게 보이는 경우가 있다. 이건 Implicit Lock(묵시적 잠금) 때문이다.

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."

언제 Explicit Lock으로 변환되나?

다른 트랜잭션이 해당 행에 접근하려 할 때만 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 발생 조건 비교

상황Gap Lock이유
PK로 단건 조회 (레코드 존재)Unique → Phantom Read 불가
PK로 단건 조회 (레코드 없음)해당 위치 삽입 방지 필요
Unique Index 조회 (레코드 존재)Unique → Phantom Read 불가
세컨더리 인덱스 조회중복 가능 → Phantom Read 방지
인덱스 없이 조회✅ (전체)Full Scan → 모든 레코드 락

Gap Lock 범위 결정 원리

상황Gap Lock 범위
다음 레코드 없음현재 레코드 ~ supremum(무한대)
다음 레코드 있음현재 레코드 ~ 다음 레코드 직전

테이블 수준 락 정리

락 종류발생 상황다른 DML 블로킹?
IX모든 INSERT, UPDATE, DELETE❌ (IX끼리 호환)
ISSELECT ... FOR SHARE❌ (IS끼리 호환)
XLOCK TABLES ... WRITE✅ (모든 것 블로킹)
SLOCK TABLES ... READ✅ (쓰기만 블로킹)
MDLDDL 작업 (ALTER, DROP 등)✅ (트랜잭션 종료 대기)

PK vs Unique Index vs 세컨더리 인덱스 비교

구분PKUnique Index세컨더리 인덱스
단건 조회 시 락REC_NOT_GAPREC_NOT_GAPNext-Key
Gap Lock
중복 삽입 대기 시S LockS Lock-
데드락 위험낮음중간높음

실무에서 주의할 점

  1. PK/Unique Index 조회는 안전하다: Gap Lock이 안 걸려서 동시성에 유리하다.

  2. 세컨더리 인덱스 조회는 Gap Lock 발생: FOR UPDATE 사용 시 예상치 못한 블로킹이 발생할 수 있다.

  3. 인덱스가 없으면 재앙: Full Table Scan으로 전체 테이블이 잠긴다.

  4. Unique 제약조건 중복 삽입 시 데드락 주의: 여러 세션이 동시에 같은 값을 삽입하면 데드락 가능성이 있다.

  5. DDL 작업 전 트랜잭션 확인: 긴 트랜잭션이 있으면 ALTER TABLE이 무한 대기할 수 있다.

  6. IX 락은 정상이다: 모든 DML에서 발생하며, IX끼리는 충돌하지 않는다.


마치며

Gap Lock이 "다음 레코드까지"라는 건 알고 있었지만, 직접 테스트해보니 훨씬 복잡한 규칙들이 있었다.

  • PK와 Unique Index는 Gap Lock이 안 걸린다 (레코드가 있을 때)
  • 인덱스 없으면 전체 락이다 (이건 정말 위험하다)
  • 다음 레코드가 없으면 무한대까지 잠긴다
  • Unique 중복 삽입 시 S Lock으로 대기 → Duplicate entry 에러
  • IX 락은 모든 DML에서 발생하는 정상적인 테이블 수준 락이다

특히 동시성이 중요한 서비스에서 FOR UPDATE를 사용할 때, 데이터 분포에 따라 예상치 못한 블로킹이 발생할 수 있다는 걸 알게 됐다. 단순히 "행 단위 락"이라고 생각하면 큰코다칠 수 있다.

혹시 Gap Lock 관련해서 더 좋은 테스트 방법이나 실무에서 겪은 경험이 있으신 분이 계시다면 조언해주시면 정말 감사하겠습니다.


참고 자료

0개의 댓글