Row Lock, Gap Lock, Next-Key Lock — InnoDB 잠금의 종류
같은 조건으로
SELECT ... FOR UPDATE를 했는데, 어떤 경우에는 한 행만 잠기고, 어떤 경우에는 범위 전체가 잠기는 이유가 뭘까요?
InnoDB 잠금의 기본 개념
InnoDB는 행 단위 잠금을 지원하는 스토리지 엔진입니다. 하지만 "행 단위"라고 해서 항상 딱 그 행 하나만 잠기는 것은 아닙니다. InnoDB는 상황에 따라 레코드 자체, 레코드 사이의 빈 공간, 또는 그 둘을 합친 범위를 잠급니다.
이렇게 다양한 잠금이 존재하는 이유는 하나입니다. Phantom Read를 방지하면서도 동시성을 최대한 유지하기 위해서입니다.
InnoDB의 주요 행 수준 잠금은 네 가지입니다.
| 잠금 종류 | 잠그는 대상 | 목적 |
|---|---|---|
| Record Lock | 인덱스 레코드 자체 | 특정 행의 수정/삭제 방지 |
| Gap Lock | 레코드 사이의 빈 공간 | 범위 내 새로운 삽입 방지 |
| Next-Key Lock | Record Lock + Gap Lock | Phantom Read 방지 |
| Insert Intention Lock | 삽입 지점의 갭 | 동시 삽입 허용 최적화 |
왜 이런 잠금이 필요한가
단순한 Record Lock만으로는 해결할 수 없는 문제가 있습니다.
Phantom Read 문제
-- 트랜잭션 A
BEGIN;
SELECT * FROM orders WHERE amount > 100 FOR UPDATE;
-- 결과: 3건
-- 트랜잭션 B (동시 실행)
INSERT INTO orders (amount) VALUES (200);
COMMIT;
-- 트랜잭션 A (같은 쿼리 재실행)
SELECT * FROM orders WHERE amount > 100 FOR UPDATE;
-- 결과: 4건? → Phantom Read!
Record Lock만 있다면, 트랜잭션 A가 기존 3건에 락을 걸어도 트랜잭션 B는 새로운 행을 삽입할 수 있습니다. 아직 존재하지 않는 행에는 Record Lock을 걸 수 없기 때문입니다.
이 문제를 해결하기 위해 InnoDB는 "빈 공간"까지 잠그는 Gap Lock과 Next-Key Lock을 도입했습니다.
Record Lock — 인덱스 레코드 잠금
Record Lock은 인덱스 레코드 하나를 잠급니다. 가장 직관적인 형태의 락입니다.
-- employees 테이블에 id = 10인 행이 존재할 때
SELECT * FROM employees WHERE id = 10 FOR UPDATE;
이 쿼리는 id = 10인 인덱스 레코드에 배타적 Record Lock(X Lock)을 겁니다.
핵심 포인트
- InnoDB는 항상 인덱스 레코드를 잠급니다. 테이블에 인덱스가 없으면 자동 생성되는 클러스터형 인덱스(GEN_CLUST_INDEX)를 잠급니다
- Record Lock은 공유(S) 또는 배타적(X)일 수 있습니다
- 유니크 인덱스로 단일 행을 검색하면 Next-Key Lock 대신 Record Lock만 걸립니다 (최적화)
-- S Lock (공유 락)
SELECT * FROM employees WHERE id = 10 FOR SHARE;
-- X Lock (배타적 락)
SELECT * FROM employees WHERE id = 10 FOR UPDATE;
UPDATE employees SET name = 'Kim' WHERE id = 10;
DELETE FROM employees WHERE id = 10;
Gap Lock — 빈 공간 잠금
Gap Lock은 인덱스 레코드 사이의 빈 공간을 잠급니다. 존재하지 않는 값의 범위를 보호하는 것입니다.
동작 예시
age 컬럼에 인덱스가 있고, 현재 테이블에 age 값이 10, 20, 30인 행이 있다고 가정합니다.
인덱스 레코드: 10 --- 20 --- 30
갭: (−∞,10) (10,20) (20,30) (30,+∞)
-- REPEATABLE READ 격리 수준
SELECT * FROM users WHERE age = 15 FOR UPDATE;
age = 15인 행은 존재하지 않습니다. 하지만 InnoDB는 (10, 20) 갭에 Gap Lock을 겁니다. 이 갭에 다른 트랜잭션이 age = 11, 12, ..., 19 값을 가진 행을 삽입하는 것을 차단합니다.
Gap Lock의 특수한 성질
Gap Lock은 다른 락과 좀 다른 특성이 있습니다.
- Gap Lock끼리는 충돌하지 않습니다. S Gap Lock과 X Gap Lock은 동일하게 동작합니다
- Gap Lock의 유일한 목적은 갭에 삽입하는 것을 방지하는 것입니다
- 같은 갭에 여러 트랜잭션이 동시에 Gap Lock을 가질 수 있습니다
-- 트랜잭션 A
SELECT * FROM users WHERE age = 15 FOR UPDATE;
-- (10, 20) 갭에 Gap Lock
-- 트랜잭션 B
SELECT * FROM users WHERE age = 18 FOR SHARE;
-- (10, 20) 갭에 Gap Lock → 허용됨! Gap Lock끼리는 충돌 안 함
-- 트랜잭션 B
INSERT INTO users (age) VALUES (17);
-- 대기! Gap Lock이 삽입을 차단
Next-Key Lock — Record Lock + Gap Lock
Next-Key Lock은 InnoDB의 기본 잠금 단위입니다. 인덱스 레코드 자체와 그 레코드 앞의 갭을 함께 잠급니다.
구조
age 값이 10, 20, 30인 행이 있을 때, Next-Key Lock이 잠그는 범위는 이렇습니다.
Next-Key Lock 범위:
(−∞, 10] → 10 이하 갭 + 10 레코드
(10, 20] → 10~20 사이 갭 + 20 레코드
(20, 30] → 20~30 사이 갭 + 30 레코드
(30, +∞) → 30 초과 갭 (supremum pseudo-record)
괄호 표기에 주목합니다. (10, 20]은 10은 포함하지 않고, 20은 포함합니다. 즉 왼쪽은 열린 구간, 오른쪽은 닫힌 구간입니다.
범위 검색에서의 동작
-- age에 일반 인덱스가 있을 때
SELECT * FROM users WHERE age BETWEEN 15 AND 25 FOR UPDATE;
이 쿼리에서 InnoDB는 다음 범위를 잠급니다.
(10, 20]— Next-Key Lock (age = 20 레코드 + 앞의 갭)(20, 30]— Next-Key Lock (age = 30 레코드 + 앞의 갭)
결과적으로 age = 11부터 age = 30까지의 삽입, 수정, 삭제가 모두 차단됩니다.
왜 이렇게 넓게 잠글까요?
공부하다 보니 이 부분이 가장 헷갈렸습니다. BETWEEN 15 AND 25인데 왜 age = 30까지 잠기는 걸까요?
이유는 갭 기반으로 동작하기 때문입니다. InnoDB는 인덱스에서 조건을 만족하는 범위의 시작과 끝에 해당하는 인덱스 레코드를 찾고, 그 레코드들의 Next-Key Lock을 겁니다. age = 25를 포함하는 갭은 (20, 30]이므로, age = 30 레코드까지 잠기게 됩니다.
Insert Intention Lock — 삽입 의도 잠금
Insert Intention Lock은 INSERT 수행 전에 획득하는 특수한 Gap Lock입니다.
왜 필요한가
일반 Gap Lock만 있으면 같은 갭에 삽입하려는 트랜잭션들이 모두 서로를 차단합니다. 하지만 서로 다른 위치에 삽입한다면 충돌이 일어나지 않습니다.
-- 현재 age 값: 10, 20 (사이 갭: (10, 20))
-- 트랜잭션 A
INSERT INTO users (age) VALUES (12);
-- (10, 20) 갭에 Insert Intention Lock 획득
-- 트랜잭션 B
INSERT INTO users (age) VALUES (17);
-- (10, 20) 갭에 Insert Intention Lock 획득 → 허용됨!
-- 서로 다른 위치이므로 충돌 없음
Insert Intention Lock과 Gap Lock의 관계
이 부분이 중요합니다.
- Insert Intention Lock끼리는 충돌하지 않습니다 (다른 위치에 삽입하는 경우)
- Gap Lock이 이미 걸려 있으면 Insert Intention Lock은 대기합니다
- Insert Intention Lock이 걸려 있어도 Gap Lock은 바로 획득할 수 있습니다
-- 트랜잭션 A
SELECT * FROM users WHERE age = 15 FOR UPDATE;
-- (10, 20) 갭에 Gap Lock 획득
-- 트랜잭션 B
INSERT INTO users (age) VALUES (13);
-- Insert Intention Lock 대기... (Gap Lock이 차단)
-- 트랜잭션 A가 커밋해야 진행 가능
격리 수준별 락 범위
InnoDB의 잠금 동작은 트랜잭션 격리 수준에 따라 크게 달라집니다.
READ UNCOMMITTED / READ COMMITTED
-- READ COMMITTED에서
SELECT * FROM users WHERE age = 15 FOR UPDATE;
- Gap Lock을 사용하지 않습니다 (외래 키 검사와 중복 키 검사 제외)
- Record Lock만 사용합니다
- WHERE 조건 평가 후, 일치하지 않는 레코드의 락을 즉시 해제합니다
- Phantom Read가 발생할 수 있습니다
REPEATABLE READ (기본값)
-- REPEATABLE READ에서 (MySQL 기본)
SELECT * FROM users WHERE age = 15 FOR UPDATE;
- Next-Key Lock을 기본으로 사용합니다
- Gap Lock으로 Phantom Read를 방지합니다
- 유니크 인덱스로 단일 행을 검색하면 Record Lock으로 최적화됩니다
SERIALIZABLE
-- SERIALIZABLE에서
SELECT * FROM users WHERE age = 15;
-- autocommit이 꺼져 있으면 일반 SELECT도 FOR SHARE처럼 동작
- 모든 일반 SELECT가
SELECT ... FOR SHARE로 변환됩니다 (autocommit = OFF일 때) - Next-Key Lock을 사용합니다
- 가장 안전하지만 동시성이 가장 낮습니다
격리 수준별 비교 요약
| 격리 수준 | 기본 락 단위 | Gap Lock | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | Record Lock | 미사용 | 발생 |
| READ COMMITTED | Record Lock | 미사용 | 발생 |
| REPEATABLE READ | Next-Key Lock | 사용 | 방지 |
| SERIALIZABLE | Next-Key Lock | 사용 | 방지 |
실전 SQL로 확인하기
실제로 어떤 락이 걸리는지 확인하는 방법입니다.
테이블 준비
CREATE TABLE products (
id INT PRIMARY KEY,
price INT,
INDEX idx_price (price)
);
INSERT INTO products VALUES (1, 100), (3, 300), (5, 500), (7, 700);
Case 1: 유니크 인덱스로 단일 행 조회
-- 트랜잭션 A
BEGIN;
SELECT * FROM products WHERE id = 3 FOR UPDATE;
-- Record Lock만 (id = 3)
-- 다른 트랜잭션에서 확인
INSERT INTO products VALUES (2, 200); -- 성공! (갭 잠기지 않음)
INSERT INTO products VALUES (4, 400); -- 성공!
UPDATE products SET price = 999 WHERE id = 3; -- 대기 (Record Lock)
Case 2: 일반 인덱스로 범위 조회
-- 트랜잭션 A
BEGIN;
SELECT * FROM products WHERE price = 300 FOR UPDATE;
-- Next-Key Lock: (100, 300] + Gap Lock: (300, 500)
-- 다른 트랜잭션에서 확인
INSERT INTO products VALUES (8, 200); -- 대기! (갭에 포함)
INSERT INTO products VALUES (9, 400); -- 대기! (갭에 포함)
INSERT INTO products VALUES (10, 100); -- 성공! (갭 바깥)
INSERT INTO products VALUES (11, 500); -- 성공! (갭 바깥)
Case 3: 존재하지 않는 값 조회
-- 트랜잭션 A
BEGIN;
SELECT * FROM products WHERE price = 400 FOR UPDATE;
-- Gap Lock: (300, 500) — 행이 없으므로 Record Lock 없음
-- 다른 트랜잭션에서 확인
INSERT INTO products VALUES (12, 350); -- 대기! (갭에 포함)
INSERT INTO products VALUES (13, 450); -- 대기! (갭에 포함)
UPDATE products SET price = 999 WHERE id = 3; -- 성공! (Record Lock 없음)
현재 락 상태 확인
-- InnoDB 락 모니터링
SELECT * FROM performance_schema.data_locks;
-- 대기 중인 락 확인
SELECT * FROM performance_schema.data_lock_waits;
-- InnoDB 상태에서 락 정보 확인
SHOW ENGINE INNODB STATUS;
performance_schema.data_locks 테이블의 LOCK_MODE 컬럼에서 락의 종류를 확인할 수 있습니다.
| LOCK_MODE 값 | 의미 |
|---|---|
X,REC_NOT_GAP | Record Lock (배타적) |
X,GAP | Gap Lock (배타적) |
X | Next-Key Lock (배타적) |
S,REC_NOT_GAP | Record Lock (공유) |
X,INSERT_INTENTION | Insert Intention Lock |
데드락과 InnoDB 잠금
이 잠금들이 복합적으로 작용하면 데드락이 발생할 수 있습니다.
-- 데드락 시나리오: Gap Lock + Insert Intention Lock
-- 트랜잭션 A
BEGIN;
SELECT * FROM products WHERE price = 400 FOR UPDATE;
-- (300, 500) 갭에 Gap Lock
-- 트랜잭션 B
BEGIN;
SELECT * FROM products WHERE price = 400 FOR UPDATE;
-- (300, 500) 갭에 Gap Lock — Gap Lock끼리는 허용!
-- 트랜잭션 A
INSERT INTO products VALUES (20, 400);
-- Insert Intention Lock 대기 (B의 Gap Lock 때문)
-- 트랜잭션 B
INSERT INTO products VALUES (21, 400);
-- Insert Intention Lock 대기 (A의 Gap Lock 때문)
-- 데드락 발생! InnoDB가 한쪽을 롤백
Gap Lock끼리는 호환되지만 Gap Lock과 Insert Intention Lock은 충돌하기 때문에, 이 패턴은 실무에서 자주 겪게 되는 데드락 시나리오입니다.
정리
InnoDB의 잠금 체계를 이해하려면 "무엇을 잠그느냐"에 집중하면 됩니다.
- Record Lock: 존재하는 레코드 하나를 잠근다
- Gap Lock: 레코드 사이의 빈 공간을 잠근다 (삽입 방지)
- Next-Key Lock: 레코드 + 앞의 갭을 함께 잠근다 (Phantom Read 방지)
- Insert Intention Lock: 같은 갭 내 다른 위치 삽입은 허용하는 최적화된 갭 잠금
격리 수준에 따라 잠금 범위가 달라진다는 점도 기억해야 합니다. READ COMMITTED에서는 Gap Lock이 없어서 동시성이 높지만 Phantom Read가 발생할 수 있고, REPEATABLE READ에서는 Next-Key Lock으로 Phantom Read를 방지하지만 잠금 범위가 넓어집니다.
실무에서 "왜 이 쿼리가 다른 쿼리를 블로킹하지?"라는 의문이 생기면, performance_schema.data_locks를 확인하는 습관을 들이는 게 좋습니다. 어떤 종류의 락이 어떤 인덱스 레코드에 걸려 있는지 직접 보면, 이론으로만 이해하던 것이 훨씬 명확해집니다.