MySQL InnoDB Lock 구조 심화

InnoDB Lock 개요

MySQL InnoDB의 락(Lock)은 동시성 제어의 핵심입니다. 단순한 행 잠금(Row Lock)처럼 보이지만, 내부적으로는 Record Lock, Gap Lock, Next-Key Lock 세 가지가 조합되어 동작합니다. 이 구조를 이해하지 못하면 예상치 못한 데드락과 대기(waiting)에 시달리게 됩니다.

이 글에서는 InnoDB 락의 3가지 유형, 격리 수준별 동작 차이, 실전 데드락 분석, performance_schema를 활용한 모니터링까지 심층적으로 다룹니다. MySQL InnoDB Deadlock 재시도 전략과 함께 읽으면 락 문제 해결의 전체 그림을 파악할 수 있습니다.

Lock 3가지 유형

Lock 유형 잠금 대상 목적 Phantom Read 방지
Record Lock 인덱스 레코드 자체 특정 행 보호
Gap Lock 인덱스 레코드 사이 간격 간격에 새 행 INSERT 방지
Next-Key Lock Record Lock + Gap Lock 조합 레코드 + 앞쪽 간격 동시 보호

Record Lock 동작

Record Lock은 인덱스 레코드 하나에 대한 잠금입니다. InnoDB는 항상 인덱스를 통해 락을 겁니다. 인덱스가 없으면 클러스터드 인덱스(PK) 전체를 스캔하며 락을 걸어 사실상 테이블 락이 됩니다.

-- 테이블 구조
CREATE TABLE orders (
  id BIGINT PRIMARY KEY,
  user_id BIGINT,
  status VARCHAR(20),
  amount DECIMAL(10,2),
  INDEX idx_user_id (user_id),
  INDEX idx_status (status)
);

-- 데이터: id = 10, 20, 30, 40, 50

-- Record Lock 예시 (PK로 조회)
-- TX1:
SELECT * FROM orders WHERE id = 30 FOR UPDATE;
-- → id=30 레코드에 Record Lock (X Lock)

-- TX2:
UPDATE orders SET amount = 100 WHERE id = 30;
-- → 대기! (TX1이 id=30의 Record Lock 보유)

UPDATE orders SET amount = 100 WHERE id = 20;
-- → 즉시 성공! (id=20은 잠겨있지 않음)

Gap Lock 동작

Gap Lock은 인덱스 레코드 사이의 간격을 잠급니다. 간격에 새로운 행이 INSERT되는 것을 방지하여 Phantom Read를 차단합니다.

-- user_id 값: 10, 20, 30 (인덱스 존재)
-- Gap 구조: (-∞,10), (10,20), (20,30), (30,+∞)

-- TX1: 존재하지 않는 값 조회
SELECT * FROM orders WHERE user_id = 15 FOR UPDATE;
-- → user_id = 15는 없음
-- → (10, 20) 간격에 Gap Lock 설정

-- TX2:
INSERT INTO orders (id, user_id, status, amount) VALUES (100, 12, 'new', 50);
-- → 대기! (user_id=12는 (10,20) 간격에 속함)

INSERT INTO orders (id, user_id, status, amount) VALUES (101, 25, 'new', 50);
-- → 즉시 성공! (user_id=25는 (20,30) 간격 → 잠기지 않음)

-- ⚠️ 핵심: Gap Lock끼리는 호환됨!
-- TX1: SELECT ... WHERE user_id = 15 FOR UPDATE; → Gap Lock (10,20)
-- TX2: SELECT ... WHERE user_id = 18 FOR UPDATE; → Gap Lock (10,20) ← 성공!
-- 둘 다 같은 간격에 Gap Lock을 가질 수 있음
-- 하지만 둘 다 INSERT는 불가 → 데드락 가능성!

Next-Key Lock 동작

Next-Key Lock = Record Lock + Gap Lock입니다. REPEATABLE READ 격리 수준에서 InnoDB의 기본 락 방식입니다.

-- user_id 값: 10, 20, 30
-- Next-Key Lock 범위: (-∞,10], (10,20], (20,30], (30,+∞)

-- TX1: 범위 조회
SELECT * FROM orders WHERE user_id BETWEEN 15 AND 25 FOR UPDATE;
-- 스캔하는 인덱스 범위:
-- 1. user_id=20에 Record Lock
-- 2. (10,20] Next-Key Lock (Gap + Record)
-- 3. (20,30] Next-Key Lock (Gap + Record)
-- → 결과: user_id 10~30 사이 전체가 잠김

-- TX2:
INSERT INTO orders VALUES (102, 22, 'new', 50);
-- → 대기! (22는 (20,30) 간격에 속함)

INSERT INTO orders VALUES (103, 5, 'new', 50);
-- → 즉시 성공! (5는 (-∞,10) 간격 → 잠기지 않음)

UPDATE orders SET amount = 200 WHERE user_id = 30;
-- → 대기! (30은 (20,30] Next-Key Lock에 포함)

격리 수준별 Lock 동작

격리 수준 Record Lock Gap Lock Next-Key Lock
READ UNCOMMITTED
READ COMMITTED ❌ (INSERT 충돌 시만)
REPEATABLE READ (기본)
SERIALIZABLE ✅ (SELECT도 자동)
-- READ COMMITTED에서는 Gap Lock 없음 → INSERT 자유로움
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- TX1:
SELECT * FROM orders WHERE user_id = 15 FOR UPDATE;
-- → Record Lock만 (해당 레코드가 없으면 락 없음)

-- TX2:
INSERT INTO orders VALUES (104, 15, 'new', 50);
-- → 즉시 성공! (Gap Lock 없으므로)

-- ⚠️ 대신 Phantom Read 발생 가능
-- TX1이 다시 SELECT하면 TX2가 INSERT한 행이 보임

인덱스와 Lock의 관계

InnoDB 락의 가장 중요한 규칙: 락은 인덱스 위에 걸린다. 인덱스가 없으면 풀 스캔하며 모든 행에 락을 겁니다.

-- ❌ 안티패턴: 인덱스 없는 컬럼으로 FOR UPDATE
-- status 컬럼에 인덱스가 없는 경우
SELECT * FROM orders WHERE status = 'pending' FOR UPDATE;
-- → 클러스터드 인덱스 풀 스캔
-- → 모든 행에 Next-Key Lock! (사실상 테이블 락)
-- → status='completed'인 행도 잠김

-- ✅ 해결: 적절한 인덱스 추가
ALTER TABLE orders ADD INDEX idx_status (status);
SELECT * FROM orders WHERE status = 'pending' FOR UPDATE;
-- → idx_status 인덱스에서 'pending' 레코드만 Lock

-- 인덱스 선택에 따라 Lock 범위가 달라짐
-- EXPLAIN으로 어떤 인덱스를 사용하는지 반드시 확인!
EXPLAIN SELECT * FROM orders WHERE user_id = 20 AND status = 'pending' FOR UPDATE;

Lock 모니터링 실전

performance_schema로 현재 Lock 확인

-- 현재 걸려있는 모든 Lock 조회
SELECT
  ENGINE_LOCK_ID,
  ENGINE_TRANSACTION_ID,
  OBJECT_NAME,
  INDEX_NAME,
  LOCK_TYPE,      -- TABLE 또는 RECORD
  LOCK_MODE,      -- S, X, IS, IX, GAP, ...
  LOCK_STATUS,    -- GRANTED 또는 WAITING
  LOCK_DATA       -- 잠긴 레코드 값
FROM performance_schema.data_locks
WHERE OBJECT_SCHEMA = 'mydb'
ORDER BY ENGINE_TRANSACTION_ID;

-- LOCK_MODE 해석:
-- X           → Record Lock (Exclusive)
-- X,GAP       → Gap Lock (Exclusive)
-- X,REC_NOT_GAP → Record Lock만 (Gap 없이)
-- S           → Record Lock (Shared)
-- S,GAP       → Gap Lock (Shared)

-- Lock 대기 관계 조회 (누가 누구를 기다리는가)
SELECT
  r.trx_id AS waiting_trx_id,
  r.trx_mysql_thread_id AS waiting_thread,
  r.trx_query AS waiting_query,
  b.trx_id AS blocking_trx_id,
  b.trx_mysql_thread_id AS blocking_thread,
  b.trx_query AS blocking_query
FROM performance_schema.data_lock_waits w
JOIN information_schema.innodb_trx r ON r.trx_id = w.REQUESTING_ENGINE_TRANSACTION_ID
JOIN information_schema.innodb_trx b ON b.trx_id = w.BLOCKING_ENGINE_TRANSACTION_ID;

InnoDB Status로 데드락 분석

-- 최근 데드락 정보
SHOW ENGINE INNODB STATUSG

-- LATEST DETECTED DEADLOCK 섹션:
-- *** (1) TRANSACTION:
-- TRANSACTION 12345, ACTIVE 2 sec
-- LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
-- *** (1) HOLDS THE LOCK(S):
-- Record lock, heap no 5 PHYSICAL RECORD: n_fields 3
-- *** (1) WAITING FOR THIS LOCK TO BE GRANTED:
-- Record lock, heap no 8 PHYSICAL RECORD: n_fields 3
--
-- *** (2) TRANSACTION:
-- ...
-- *** WE ROLL BACK TRANSACTION (2)

-- 데드락 로그 자동 저장
SET GLOBAL innodb_print_all_deadlocks = ON;
-- → error log에 모든 데드락이 기록됨

Gap Lock 데드락 실전 사례

-- 전형적인 "INSERT 의도 락" 데드락
-- user_id 값: 10, 20, 30

-- TX1:
SELECT * FROM orders WHERE user_id = 15 FOR UPDATE;
-- → (10,20) Gap Lock 획득

-- TX2:
SELECT * FROM orders WHERE user_id = 18 FOR UPDATE;
-- → (10,20) Gap Lock 획득 (Gap Lock끼리 호환!)

-- TX1:
INSERT INTO orders VALUES (200, 15, 'new', 100);
-- → 대기! TX2의 Gap Lock이 (10,20) 간격의 INSERT를 막음
-- → INSERT Intention Lock 대기

-- TX2:
INSERT INTO orders VALUES (201, 18, 'new', 200);
-- → 데드락! TX1의 Gap Lock이 (10,20) 간격의 INSERT를 막음
-- → MySQL이 TX2를 롤백

-- ✅ 해결 방법 1: READ COMMITTED 사용 (Gap Lock 없음)
-- ✅ 해결 방법 2: INSERT 전에 불필요한 SELECT FOR UPDATE 제거
-- ✅ 해결 방법 3: 유니크 제약으로 충돌 처리 (INSERT ON DUPLICATE KEY)

Lock 최소화 전략

-- 1. 유니크 인덱스는 Gap Lock을 줄임
-- 유니크 인덱스로 단일 행 조회 시 Record Lock만 걸림 (Gap Lock 없음)
SELECT * FROM orders WHERE id = 30 FOR UPDATE;
-- → Record Lock만! (PK는 유니크)

-- 2. 트랜잭션을 짧게 유지
BEGIN;
SELECT * FROM orders WHERE id = 30 FOR UPDATE;
-- 비즈니스 로직은 최소한으로
UPDATE orders SET status = 'shipped' WHERE id = 30;
COMMIT;  -- 즉시 커밋하여 Lock 보유 시간 최소화

-- 3. 동일한 순서로 Lock 획득 (데드락 방지)
-- ❌ TX1: Lock A → Lock B, TX2: Lock B → Lock A → 데드락
-- ✅ TX1: Lock A → Lock B, TX2: Lock A → Lock B → 대기만 발생

-- 4. SELECT ... FOR UPDATE 대신 NOWAIT/SKIP LOCKED (MySQL 8.0+)
SELECT * FROM orders WHERE status = 'pending' FOR UPDATE NOWAIT;
-- → Lock 획득 실패 시 즉시 에러 (대기하지 않음)

SELECT * FROM orders WHERE status = 'pending' FOR UPDATE SKIP LOCKED;
-- → 잠긴 행은 건너뛰고 잠기지 않은 행만 반환 (큐 패턴에 유용)

큐 패턴에서 SKIP LOCKED는 특히 유용합니다. 여러 워커가 동시에 작업을 가져갈 때, 이미 다른 워커가 처리 중인 행을 건너뛰어 Lock 대기 없이 작업을 분배할 수 있습니다. MySQL Optimizer Hints 쿼리 튜닝 가이드와 함께 활용하면 더 효과적입니다.

정리

InnoDB Lock의 핵심은 “락은 인덱스 위에 걸린다”는 원칙입니다. Record Lock은 직관적이지만, Gap Lock과 Next-Key Lock은 REPEATABLE READ에서 Phantom Read를 방지하기 위한 장치로, 예상보다 넓은 범위를 잠글 수 있습니다. 인덱스 설계, 격리 수준 선택, 트랜잭션 최소화, 그리고 performance_schema를 통한 모니터링이 Lock 문제 해결의 4대 축입니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux