Theme:

같은 조건으로 SELECT ... FOR UPDATE를 했는데, 어떤 경우에는 한 행만 잠기고, 어떤 경우에는 범위 전체가 잠기는 이유가 뭘까요?

InnoDB 잠금의 기본 개념

InnoDB는 행 단위 잠금을 지원하는 스토리지 엔진입니다. 하지만 "행 단위"라고 해서 항상 딱 그 행 하나만 잠기는 것은 아닙니다. InnoDB는 상황에 따라 레코드 자체, 레코드 사이의 빈 공간, 또는 그 둘을 합친 범위를 잠급니다.

이렇게 다양한 잠금이 존재하는 이유는 하나입니다. Phantom Read를 방지하면서도 동시성을 최대한 유지하기 위해서입니다.

InnoDB의 주요 행 수준 잠금은 네 가지입니다.

잠금 종류잠그는 대상목적
Record Lock인덱스 레코드 자체특정 행의 수정/삭제 방지
Gap Lock레코드 사이의 빈 공간범위 내 새로운 삽입 방지
Next-Key LockRecord Lock + Gap LockPhantom Read 방지
Insert Intention Lock삽입 지점의 갭동시 삽입 허용 최적화

왜 이런 잠금이 필요한가

단순한 Record Lock만으로는 해결할 수 없는 문제가 있습니다.

Phantom Read 문제

SQL
-- 트랜잭션 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은 인덱스 레코드 하나를 잠급니다. 가장 직관적인 형태의 락입니다.

SQL
-- 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만 걸립니다 (최적화)
SQL
-- 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인 행이 있다고 가정합니다.

PLAINTEXT
인덱스 레코드:  10 --- 20 --- 30
갭:          (−∞,10) (10,20) (20,30) (30,+∞)
SQL
-- 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을 가질 수 있습니다
SQL
-- 트랜잭션 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이 잠그는 범위는 이렇습니다.

PLAINTEXT
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은 포함합니다. 즉 왼쪽은 열린 구간, 오른쪽은 닫힌 구간입니다.

범위 검색에서의 동작

SQL
-- age에 일반 인덱스가 있을 때
SELECT * FROM users WHERE age BETWEEN 15 AND 25 FOR UPDATE;

이 쿼리에서 InnoDB는 다음 범위를 잠급니다.

  1. (10, 20] — Next-Key Lock (age = 20 레코드 + 앞의 갭)
  2. (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만 있으면 같은 갭에 삽입하려는 트랜잭션들이 모두 서로를 차단합니다. 하지만 서로 다른 위치에 삽입한다면 충돌이 일어나지 않습니다.

SQL
-- 현재 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은 바로 획득할 수 있습니다
SQL
-- 트랜잭션 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

SQL
-- READ COMMITTED에서
SELECT * FROM users WHERE age = 15 FOR UPDATE;
  • Gap Lock을 사용하지 않습니다 (외래 키 검사와 중복 키 검사 제외)
  • Record Lock만 사용합니다
  • WHERE 조건 평가 후, 일치하지 않는 레코드의 락을 즉시 해제합니다
  • Phantom Read가 발생할 수 있습니다

REPEATABLE READ (기본값)

SQL
-- REPEATABLE READ에서 (MySQL 기본)
SELECT * FROM users WHERE age = 15 FOR UPDATE;
  • Next-Key Lock을 기본으로 사용합니다
  • Gap Lock으로 Phantom Read를 방지합니다
  • 유니크 인덱스로 단일 행을 검색하면 Record Lock으로 최적화됩니다

SERIALIZABLE

SQL
-- SERIALIZABLE에서
SELECT * FROM users WHERE age = 15;
-- autocommit이 꺼져 있으면 일반 SELECT도 FOR SHARE처럼 동작
  • 모든 일반 SELECT가 SELECT ... FOR SHARE로 변환됩니다 (autocommit = OFF일 때)
  • Next-Key Lock을 사용합니다
  • 가장 안전하지만 동시성이 가장 낮습니다

격리 수준별 비교 요약

격리 수준기본 락 단위Gap LockPhantom Read
READ UNCOMMITTEDRecord Lock미사용발생
READ COMMITTEDRecord Lock미사용발생
REPEATABLE READNext-Key Lock사용방지
SERIALIZABLENext-Key Lock사용방지

실전 SQL로 확인하기

실제로 어떤 락이 걸리는지 확인하는 방법입니다.

테이블 준비

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: 유니크 인덱스로 단일 행 조회

SQL
-- 트랜잭션 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: 일반 인덱스로 범위 조회

SQL
-- 트랜잭션 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: 존재하지 않는 값 조회

SQL
-- 트랜잭션 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 없음)

현재 락 상태 확인

SQL
-- 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_GAPRecord Lock (배타적)
X,GAPGap Lock (배타적)
XNext-Key Lock (배타적)
S,REC_NOT_GAPRecord Lock (공유)
X,INSERT_INTENTIONInsert Intention Lock

데드락과 InnoDB 잠금

이 잠금들이 복합적으로 작용하면 데드락이 발생할 수 있습니다.

SQL
-- 데드락 시나리오: 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를 확인하는 습관을 들이는 게 좋습니다. 어떤 종류의 락이 어떤 인덱스 레코드에 걸려 있는지 직접 보면, 이론으로만 이해하던 것이 훨씬 명확해집니다.

댓글 로딩 중...