한 줄 정의
InnoDB는 MySQL 엔진과 별개로 레코드 기반의 잠금 을 제공하며, 레코드 자체가 아니라 인덱스를 잠그는 방식으로 동작합니다.
쉽게 말하면
도서관에서 책을 빌리는 상황을 생각해 보면:
- 레코드 락 은 특정 책 한 권에 “대출 중” 표시를 거는 것입니다
- 갭 락 은 책꽂이의 빈 자리에 “여기에 새 책 꽂지 마세요”라고 표시하는 것입니다
- 넥스트 키 락 은 책 한 권 + 그 옆의 빈 자리까지 함께 잠그는 것입니다
그런데 InnoDB의 특이한 점은 책 자체가 아니라 “색인(인덱스)“을 잠근다 는 것입니다. 색인에서 검색되는 모든 항목에 잠금이 걸리므로, 인덱스 설계가 잠금 범위를 직접 결정합니다.
핵심 내용
InnoDB 잠금의 종류
flowchart LR subgraph 잠금_종류 RL["레코드 락<br/>(Record Lock)<br/>특정 인덱스 레코드"] GL["갭 락<br/>(Gap Lock)<br/>레코드 사이 간격"] NKL["넥스트 키 락<br/>(Next Key Lock)<br/>= 레코드 락 + 갭 락"] AIL["자동 증가 락<br/>(Auto Increment Lock)<br/>AUTO_INCREMENT 채번"] end
레코드 락 (Record Lock)
레코드 자체만을 잠그는 잠금입니다. 다른 DBMS의 레코드 락과 역할은 같지만, InnoDB는 레코드가 아니라 인덱스의 레코드를 잠급니다.
- 프라이머리 키 또는 유니크 인덱스에 의한 변경 작업에서는 갭 없이 레코드 자체에 대해서만 락을 겁니다
- 인덱스가 하나도 없는 테이블이더라도 내부적으로 자동 생성된 클러스터 인덱스를 이용해 잠금을 설정합니다
갭 락 (Gap Lock)
레코드와 레코드 사이의 간격 을 잠그는 InnoDB 고유의 잠금입니다.
- 간격에 새로운 레코드가 INSERT되는 것을 방지합니다
- PHANTOM READ를 방지하기 위해 사용됩니다
- 그 자체보다는 넥스트 키 락의 일부로 자주 사용됩니다
넥스트 키 락 (Next Key Lock)
레코드 락 + 갭 락 을 합쳐 놓은 형태입니다.
- 바이너리 로그에 기록되는 쿼리가 레플리카 서버에서 동일한 결과를 만들어내도록 보장하는 것이 주목적입니다
- 넥스트 키 락으로 인해 데드락이 발생하거나 다른 트랜잭션이 대기 하는 일이 자주 발생합니다
- 바이너리 로그 포맷을 ROW 형태로 바꾸면 넥스트 키 락과 갭 락을 줄일 수 있습니다 (MySQL 8.0의 기본값이 ROW)
자동 증가 락 (Auto Increment Lock)
AUTO_INCREMENT 칼럼이 있는 테이블에서 동시 INSERT 시 중복 없이 순차적인 번호를 채번 하기 위한 테이블 수준 잠금입니다.
- INSERT/REPLACE에서만 필요하며, UPDATE/DELETE에서는 걸리지 않습니다
- 트랜잭션과 관계없이 값을 가져오는 순간만 잠기고 즉시 해제됩니다
innodb_autoinc_lock_mode 설정
| 값 | 모드 | 동작 | MySQL 8.0 기본값 |
|---|---|---|---|
| 0 | 전통 모드 | 모든 INSERT에 자동 증가 락 사용 | |
| 1 | 연속 모드 (Consecutive) | 건수 예측 가능한 INSERT는 경량 래치 사용, INSERT … SELECT 등은 자동 증가 락 사용 | |
| 2 | 인터리빙 모드 (Interleaved) | 절대 자동 증가 락 사용 안 함, 경량 래치만 사용 | 기본값 |
- mode=2에서는 유니크한 값만 보장 하고, 연속성은 보장하지 않습니다
- MySQL 8.0에서 기본값이 2로 변경된 이유: 바이너리 로그 기본 포맷이 ROW로 변경되어 연속성 보장이 불필요해졌기 때문입니다
- STATEMENT 포맷의 바이너리 로그를 사용한다면 mode=1로 변경할 것을 권장합니다
인덱스와 잠금 — InnoDB에서 가장 중요한 개념
InnoDB의 잠금은 레코드를 잠그는 것이 아니라 인덱스를 잠그는 방식 으로 처리됩니다. 변경해야 할 레코드를 찾기 위해 검색한 인덱스의 레코드를 모두 잠급니다.
실전 예제
-- employees 테이블에 ix_firstname(first_name) 인덱스만 존재
-- first_name='Georgi'인 사원: 253명
-- first_name='Georgi' AND last_name='Klassen'인 사원: 1명
UPDATE employees SET hire_date=NOW()
WHERE first_name='Georgi' AND last_name='Klassen';이 UPDATE는 1건만 변경 하지만, 인덱스를 이용할 수 있는 조건은 first_name='Georgi'뿐입니다. last_name은 인덱스에 없으므로, 253건 전체가 잠깁니다.
flowchart LR subgraph 인덱스_검색["ix_firstname 인덱스"] direction TB R1["Georgi, emp_no=1001 🔒"] R2["Georgi, emp_no=1002 🔒"] R3["... (253건 모두 잠금) 🔒"] end subgraph 실제_변경["실제 변경"] U1["Georgi Klassen ✅<br/>1건만 UPDATE"] end 인덱스_검색 --> 실제_변경
인덱스가 없다면?
테이블에 인덱스가 하나도 없으면, 테이블 풀 스캔 이 발생하며 테이블의 모든 레코드(30만 건)가 잠깁니다. 이것이 MySQL에서 인덱스 설계가 성능뿐 아니라 동시성에 직결 되는 이유입니다.
레코드 수준의 잠금 확인 및 해제
MySQL 8.0에서는 performance_schema의 data_locks와 data_lock_waits 테이블로 잠금 상태를 확인할 수 있습니다.
-- 잠금 대기 순서 확인
SELECT
r.trx_id waiting_trx_id,
r.trx_mysql_thread_id waiting_thread,
r.trx_query waiting_query,
b.trx_id blocking_trx_id,
b.trx_mysql_thread_id blocking_thread,
b.trx_query blocking_query
FROM performance_schema.data_lock_waits w
INNER JOIN information_schema.innodb_trx b
ON b.trx_id = w.blocking_engine_transaction_id
INNER JOIN information_schema.innodb_trx r
ON r.trx_id = w.requesting_engine_transaction_id;잠금을 가진 스레드가 오래 멈춰 있다면 강제 종료할 수 있습니다:
KILL 17; -- 해당 스레드 번호시각화
InnoDB 잠금 종류 비교
| 잠금 종류 | 잠금 대상 | 목적 | 사용 시점 |
|---|---|---|---|
| 레코드 락 | 인덱스 레코드 | 특정 레코드 변경 보호 | PK/유니크 인덱스 변경 시 |
| 갭 락 | 레코드 사이 간격 | 새 레코드 INSERT 방지 | PHANTOM READ 방지 |
| 넥스트 키 락 | 레코드 + 간격 | 레코드 변경 + INSERT 방지 | 보조 인덱스 기반 변경 시 |
| 자동 증가 락 | 테이블 (AUTO_INCREMENT) | 순차적 번호 채번 | INSERT/REPLACE 시 |
내 생각
-
인덱스 설계는 조회 성능만의 문제가 아닙니다. WHERE 조건에 적절한 인덱스가 없으면 UPDATE/DELETE가 불필요하게 많은 레코드를 잠그게 되고, 이는 다른 트랜잭션의 대기로 이어집니다. 인덱스를 설계할 때 “이 조건으로 UPDATE하는 쿼리가 있는가?”도 반드시 고려해야 합니다.
-
MySQL 8.0에서는 바이너리 로그 포맷이 ROW가 기본이므로, 넥스트 키 락으로 인한 불필요한 잠금이 많이 줄었습니다. STATEMENT 포맷을 사용 중이라면 ROW로의 전환을 검토할 가치가 있습니다.
관련 개념
- Ch05-2 MySQL 엔진의 잠금 — MySQL 엔진 레벨의 잠금
- Ch05-4 MySQL의 격리 수준 — 격리 수준과 잠금의 관계
- Ch08 인덱스 — 인덱스 구조와 설계
출처
- Real MySQL 8.0 (1권), 5.3 InnoDB 스토리지 엔진 잠금