락의 종류와 데드락 — 동시성 제어의 실체 (4편)
들어가며
3편에서 MVCC가 읽기-쓰기 충돌을 해결하는 구조를 살펴봤습니다. 읽기는 잠금 없이 스냅샷을 보고, 쓰기는 독립적으로 진행됩니다. 하지만 두 트랜잭션이 동시에 같은 행을 수정하려 할 때는? MVCC만으로는 해결할 수 없습니다. 잠금(Lock)이 필요합니다.
"MySQL은 행 수준 잠금을 지원한다"는 말의 실체는 무엇일까요? InnoDB는 단순히 행 하나를 잠그는 게 아니라, 상황에 따라 레코드 락, 갭 락, 넥스트키 락을 조합합니다. 이 메커니즘을 이해하면 "왜 이 쿼리에서 데드락이 발생했는지"를 진단할 수 있습니다.
잠금의 기본 단위: 공유 락과 배타 락
모든 잠금은 두 가지 모드 중 하나입니다:
| 잠금 모드 | 약어 | 용도 | 호환성 |
|---|---|---|---|
| 공유 락 (Shared Lock) | S | 읽기 (SELECT ... FOR SHARE) | S와 호환, X와 비호환 |
| 배타 락 (Exclusive Lock) | X | 쓰기 (UPDATE, DELETE, SELECT ... FOR UPDATE) | S, X 모두 비호환 |
호환성 매트릭스
| S (기존) | X (기존) | |
|---|---|---|
| S (요청) | ✅ 호환 | ❌ 대기 |
| X (요청) | ❌ 대기 | ❌ 대기 |
여러 트랜잭션이 동시에 S 락을 잡을 수 있지만, X 락은 배타적입니다. S 락이 걸린 행에 X 락을 요청하면, 모든 S 락이 해제될 때까지 대기합니다.
인텐션 락: 테이블 수준의 신호
InnoDB는 행 수준 잠금을 사용하지만, 테이블 수준에서도 인텐션 락(Intention Lock)을 겁니다:
- IS (Intention Shared): "이 테이블의 어떤 행에 S 락을 걸겠다"
- IX (Intention Exclusive): "이 테이블의 어떤 행에 X 락을 걸겠다"
왜 필요할까요? LOCK TABLES t WRITE처럼 테이블 전체를 잠그려 할 때, 행 수준 잠금이 있는지 모든 행을 스캔하면 너무 느립니다. 인텐션 락이 있으면, 테이블 수준에서 빠르게 충돌 여부를 판단할 수 있습니다.
인텐션 락끼리는 서로 호환됩니다 (IX와 IX도 호환). 실제 충돌은 행 수준에서 판단합니다.
InnoDB의 행 수준 잠금 3종
여기서부터가 InnoDB 잠금의 핵심입니다. 행 수준 잠금은 세 가지 유형이 있습니다.
1. 레코드 락 (Record Lock)
인덱스 레코드에 거는 잠금입니다. 정확히 말하면, InnoDB의 잠금은 행이 아니라 인덱스 엔트리에 겁니다.
-- id = 5인 레코드에 X 락
SELECT * FROM users WHERE id = 5 FOR UPDATE;
PK나 유니크 인덱스로 단일 행을 정확히 지정하면 레코드 락만 겁니다.
인덱스가 없는 테이블은? InnoDB가 내부적으로 생성한 클러스터드 인덱스(ROW_ID)를 사용합니다. 하지만 적절한 인덱스가 없으면 풀 스캔 → 모든 행에 잠금이 걸릴 수 있습니다.
2. 갭 락 (Gap Lock)
인덱스 레코드 사이의 빈 공간을 잠급니다. 그 구간에 새로운 행이 삽입되는 것을 방지합니다.
-- age 인덱스에 값이 [10, 20, 30]이 있다고 가정
SELECT * FROM users WHERE age = 15 FOR UPDATE;
-- age = 15는 존재하지 않음
-- (10, 20) 구간에 갭 락이 걸림
-- 이 구간에 INSERT가 블로킹됨
갭 락의 목적은 Phantom Read 방지입니다. 범위 검색 중에 새로운 행이 끼어드는 것을 막습니다.
갭 락의 특이한 점: 갭 락끼리는 호환됩니다. 여러 트랜잭션이 같은 갭에 S 갭 락과 X 갭 락을 동시에 잡을 수 있습니다. 갭 락의 유일한 목적은 INSERT를 막는 것이므로, 서로 블로킹할 필요가 없습니다.
3. 넥스트키 락 (Next-Key Lock)
레코드 락 + 그 앞의 갭 락을 결합한 것입니다. InnoDB의 기본 잠금 단위입니다.
-- age 인덱스에 [10, 20, 30]이 있을 때
SELECT * FROM users WHERE age >= 15 AND age <= 25 FOR UPDATE;
-- 넥스트키 락이 걸리는 범위:
-- (10, 20] → 갭 (10,20) + 레코드 20
-- (20, 30] → 갭 (20,30) + 레코드 30
넥스트키 락은 REPEATABLE READ에서 Phantom Read를 방지하는 핵심 메커니즘입니다. SQL 표준에서는 REPEATABLE READ가 Phantom Read를 허용하지만, InnoDB는 넥스트키 락으로 이를 대부분 방지합니다.
넥스트키 락이 레코드 락으로 축소되는 경우
유니크 인덱스로 단일 행을 정확히 지정하면, 넥스트키 락이 레코드 락으로 축소됩니다:
-- PK로 단일 행 조회 → 레코드 락만
SELECT * FROM users WHERE id = 5 FOR UPDATE;
-- 범위 조건 → 넥스트키 락
SELECT * FROM users WHERE id > 3 AND id < 7 FOR UPDATE;
-- 비유니크 인덱스 → 항상 넥스트키 락
SELECT * FROM users WHERE name = '홍길동' FOR UPDATE;
INSERT 인텐션 락
INSERT를 실행하면, 먼저 INSERT 인텐션 락(Insert Intention Lock)을 요청합니다. 이것은 갭 락의 특수한 형태로, 같은 갭에 서로 다른 위치에 삽입하는 트랜잭션끼리는 대기하지 않습니다.
-- 갭 (10, 20) 구간에:
-- TX A: INSERT age = 12 → INSERT 인텐션 락 (위치 12)
-- TX B: INSERT age = 15 → INSERT 인텐션 락 (위치 15)
-- 서로 다른 위치이므로 동시 진행 가능!
-- 하지만 갭 락이 걸려 있으면:
-- TX C가 (10, 20) 구간에 갭 락을 잡고 있으면
-- TX A의 INSERT 인텐션 락 요청이 블로킹됨
이것이 "갭 락은 INSERT를 막는다"의 정확한 메커니즘입니다.
락과 인덱스의 관계
InnoDB의 행 잠금은 인덱스를 통해 동작합니다. 인덱스가 없으면 잠금 범위가 극적으로 넓어집니다.
인덱스 없는 WHERE 조건
-- name에 인덱스가 없는 경우
SELECT * FROM users WHERE name = '홍길동' FOR UPDATE;
-- InnoDB는 클러스터드 인덱스(PK)를 풀 스캔
-- 스캔하는 모든 행에 넥스트키 락!
-- 사실상 테이블 전체가 잠김
이것이 "적절한 인덱스가 없으면 잠금 범위가 넓어진다"의 실체입니다. WHERE 조건에 사용하는 컬럼에 인덱스가 있는지 확인하는 것이 동시성에 직접적인 영향을 미칩니다.
세컨더리 인덱스와 클러스터드 인덱스의 이중 잠금
-- name에 세컨더리 인덱스가 있는 경우
UPDATE users SET email = 'new@test.com' WHERE name = '홍길동';
-- 1. 세컨더리 인덱스(name)에서 해당 레코드에 넥스트키 락
-- 2. 클러스터드 인덱스(PK)에서 해당 레코드에 레코드 락
-- 두 개의 인덱스에 모두 잠금이 걸림
데드락: 왜 발생하고 어떻게 해결하는가
데드락이란
두 트랜잭션이 서로가 잡고 있는 잠금을 기다리며 무한 대기에 빠지는 상황입니다.
-- TX A -- TX B
BEGIN; BEGIN;
UPDATE users SET name = 'A' UPDATE users SET name = 'B'
WHERE id = 1; WHERE id = 2;
-- id=1에 X 락 획득 -- id=2에 X 락 획득
UPDATE users SET name = 'A2' UPDATE users SET name = 'B2'
WHERE id = 2; WHERE id = 1;
-- id=2에 X 락 요청 → 대기 -- id=1에 X 락 요청 → 대기
-- (B가 잡고 있음) -- (A가 잡고 있음)
-- ← 데드락! →
InnoDB의 데드락 감지
InnoDB는 Wait-for Graph를 사용합니다. 트랜잭션 간 대기 관계를 그래프로 관리하고, 사이클이 감지되면 즉시 데드락으로 판단합니다.
데드락이 감지되면 다음과 같이 처리됩니다:
- 하나의 트랜잭션을 희생양(victim)으로 선택
- 가장 적은 Undo 레코드를 가진 트랜잭션을 롤백 (복구 비용이 적으므로)
- 롤백된 트랜잭션은
ERROR 1213 (40001): Deadlock found을 받음
-- 데드락 정보 확인
SHOW ENGINE INNODB STATUS\G
-- LATEST DETECTED DEADLOCK 섹션에서
-- 어떤 트랜잭션이 어떤 잠금을 대기했는지,
-- 어떤 트랜잭션이 롤백되었는지 확인 가능
데드락 vs 락 타임아웃
데드락 감지가 비활성화되면(innodb_deadlock_detect=OFF), innodb_lock_wait_timeout(기본 50초)에 의존합니다. 대규모 시스템에서 데드락 감지의 오버헤드가 클 때 사용하지만, 일반적으로는 감지를 켜두는 것이 좋습니다.
갭 락으로 인한 예상 밖의 데드락
레코드 락 간의 데드락은 직관적이지만, 갭 락이 관여하면 예측이 어려워집니다.
-- age 인덱스에 [10, 20, 30] 존재
-- TX A -- TX B
BEGIN; BEGIN;
SELECT * FROM users SELECT * FROM users
WHERE age = 15 FOR UPDATE; WHERE age = 25 FOR UPDATE;
-- (10,20) 갭 락 -- (20,30) 갭 락
INSERT INTO users (age) INSERT INTO users (age)
VALUES (18); VALUES (22);
-- (10,20) 구간 → B의 갭 락에 -- (20,30) 구간 → A의 갭 락에
-- 막히지 않음... -- 막히지 않음...
-- 실제로는 각자의 갭이므로 OK
-- 하지만 다른 시나리오:
-- TX A: (10,20) 갭 락
-- TX B: (10,20) 갭 락 (갭 락끼리 호환!)
-- TX A: INSERT age=15 → B의 갭 락 때문에 대기
-- TX B: INSERT age=12 → A의 갭 락 때문에 대기
-- → 데드락!
같은 갭에 두 트랜잭션이 갭 락을 잡고, 그 갭에 각각 INSERT를 시도하면 데드락이 발생합니다. 갭 락끼리는 호환되지만, INSERT 인텐션 락과 갭 락은 충돌하기 때문입니다.
데드락 예방 전략
1. 잠금 순서 통일
-- ❌ TX A: id=1 → id=2, TX B: id=2 → id=1 → 데드락 가능
-- ✅ 항상 id 오름차순으로 잠금
여러 행을 수정할 때, 항상 같은 순서(예: PK 오름차순)로 접근하면 순환 대기를 방지할 수 있습니다.
2. 트랜잭션을 짧게 유지
잠금 보유 시간이 짧을수록 충돌 가능성이 줄어듭니다. 트랜잭션 안에서 외부 API 호출, 사용자 입력 대기 등은 피해야 합니다.
3. 적절한 인덱스 사용
인덱스가 없으면 잠금 범위가 넓어집니다. WHERE 조건에 인덱스가 있으면 필요한 행에만 잠금이 걸립니다.
4. READ COMMITTED 격리 수준 고려
갭 락으로 인한 데드락이 빈번하다면, READ COMMITTED로 변경을 고려할 수 있습니다. READ COMMITTED에서는 갭 락이 최소화됩니다.
5. 애플리케이션 수준 재시도
// 데드락은 정상적인 상황 — 재시도로 처리
async function withRetry(fn, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
if (error.code === 'ER_LOCK_DEADLOCK' && i < maxRetries - 1) {
await sleep(Math.random() * 100); // 랜덤 백오프
continue;
}
throw error;
}
}
}
데드락은 완전히 제거할 수 없습니다. 발생 빈도를 줄이되, 발생 시 재시도하는 것이 현실적인 전략입니다.
잠금 모니터링
-- 현재 잠금 대기 상황
SELECT * FROM performance_schema.data_lock_waits;
-- 현재 걸려 있는 잠금 목록
SELECT * FROM performance_schema.data_locks;
-- 잠금 대기 중인 트랜잭션
SELECT
r.trx_id AS waiting_trx_id,
r.trx_query AS waiting_query,
b.trx_id AS blocking_trx_id,
b.trx_query AS blocking_query
FROM information_schema.INNODB_LOCK_WAITS w
JOIN information_schema.INNODB_TRX r ON w.requesting_trx_id = r.trx_id
JOIN information_schema.INNODB_TRX b ON w.blocking_trx_id = b.trx_id;
READ COMMITTED vs REPEATABLE READ: 잠금 관점
| 특성 | READ COMMITTED | REPEATABLE READ |
|---|---|---|
| 갭 락 | 거의 사용 안 함 | 범위 검색/비유니크 인덱스에 사용 |
| 넥스트키 락 | 레코드 락으로 축소 | 기본 잠금 단위 |
| Phantom Read | 발생 가능 | 방지 (갭 락) |
| 데드락 빈도 | 상대적으로 낮음 | 갭 락 때문에 높을 수 있음 |
| 일관된 읽기 | 매 SELECT마다 스냅샷 | 첫 SELECT 시점 스냅샷 유지 |
정리
| 잠금 유형 | 대상 | 목적 |
|---|---|---|
| 레코드 락 | 인덱스 레코드 | 특정 행 보호 |
| 갭 락 | 인덱스 레코드 사이 구간 | INSERT 방지 (Phantom Read 방지) |
| 넥스트키 락 | 레코드 + 앞쪽 갭 | InnoDB RR의 기본 잠금 |
| INSERT 인텐션 락 | 갭 내 특정 위치 | 같은 갭의 다른 위치 INSERT 허용 |
다음 편 예고
5편에서는 EXPLAIN으로 읽는 쿼리 실행 계획을 다룹니다. 1-4편의 지식(페이지, 인덱스, MVCC, 잠금)이 실전에서 어떻게 적용되는지, EXPLAIN의 각 컬럼이 무엇을 의미하는지, 느린 쿼리를 어떻게 진단하고 개선하는지 추적합니다.
관련 글
트랜잭션과 MVCC — 동시에 읽고 쓸 수 있는 이유 (3편)
READ COMMITTED와 REPEATABLE READ의 차이를 코드 없이 설명할 수 있는가? InnoDB의 MVCC가 Undo Log 체인과 Read View로 어떻게 동작하는지, 격리 수준별로 어떤 이상 현상이 허용되는지 추적합니다.
데이터는 디스크에 어떻게 저장되는가 — InnoDB 스토리지 엔진의 내부 구조 (1편)
MySQL에서 INSERT를 실행하면 데이터는 어디에, 어떤 형태로 저장되는가? InnoDB의 페이지 구조, Buffer Pool, 그리고 WAL(Redo/Undo Log)까지 — 디스크 I/O를 최소화하면서 데이터 무결성을 보장하는 구조를 추적합니다.
인덱스는 왜 빠른가 — B+Tree부터 커버링 인덱스까지 (2편)
인덱스를 걸면 빨라진다는 건 알지만, 왜 빠른지 설명할 수 있는가? B+Tree의 구조, 클러스터드 인덱스와 세컨더리 인덱스의 차이, 커버링 인덱스가 디스크 접근을 줄이는 원리, 복합 인덱스의 최좌선 규칙까지 정리합니다.