락을 잘 모르면 비효율적인 쿼리를 작성하거나, 데드락을 유발하거나, 불필요한 성능저하 등을 일으킬 수 있다고 생각한다. 또한 디버깅시 실마리를 못잡을 수도 있다. 이런 일을 최대한 겪지 않도록 MySQL에서는 어떤 락을 제공하는지 살펴보자.
이 글은 두 편으로 나눈다. (1편) 락이 무엇이고, 어떤 인덱스 구조 위에서, 어떤 과정으로 잡히는지. (2편) MySQL - LOCK 살펴보기 (2) — 락의 타입과 종류 에서 개별 락(S/X, 레코드·갭·넥스트키 등)을 하나씩.
Lock이란?
여러 트랜잭션이 같은 데이터에 동시에 접근할 때 서로를 방해해 데이터가 깨지는 걸 막는 동시 접근 제어 수단이다.
- 동시에 실행되는 트랜잭션들이 아무 통제 없이 같은 행을 읽고 쓰면 lost update, 팬텀 같은 이상 현상이 생긴다.
- 락은 특정 순간에 어떤 트랜잭션이 어떤 데이터를 읽고 쓸 수 있는지를 통제해 이를 막는다.
읽기는 MVCC, 쓰기·잠금 읽기는 락
- InnoDB에서 일반
SELECT는 (READ COMMITTED / REPEATABLE READ 기준) 락을 걸지 않는다. - 언두 로그로 스냅샷 시점의 버전을 읽는 MVCC로 일관성을 확보하기 때문이다.
- 따라서 락이 실제로 필요한 건 쓰기(
INSERT/UPDATE/DELETE)와 잠금 읽기(SELECT ... FOR UPDATE,FOR SHARE) 다.
| 문제 | 해결 수단 |
|---|---|
일반 SELECT의 일관된 읽기 | MVCC (락 없음) |
| 같은 행 동시 수정 (lost update) | 레코드 락 |
| 범위 안에 새 행이 끼어듦 (팬텀) | 갭 락 / 넥스트키 락 |
- 레코드 락만으로는 이미 존재하는 행만 지킬 수 있고, 아직 없는 행이 나중에 생기는 건 막지 못한다.
- REPEATABLE READ에서 팬텀을 막기 위해 갭 락이 추가로 필요한 이유이자, READ COMMITTED가 갭 락을 쓰지 않는 이유(애초에 팬텀을 허용하는 격리 수준)이기도 하다.
※ SERIALIZABLE은 예외
SERIALIZABLE에서는 일반SELECT도 락을 건다.autocommit이 꺼져 있으면 InnoDB가 평범한SELECT를 암묵적으로SELECT ... FOR SHARE로 바꿔, 읽은 행과 그 범위에 S 넥스트키 락을 건다.- MVCC 스냅샷 없이 락만으로 일관성을 보장하는 셈이다. (
autocommit이 켜진 단일SELECT는 예외로 락 없는 consistent read로 처리된다)
각 락이 구체적으로 무엇을 어떻게 막는지는 2편에서 다룬다.
인덱스 구조
락은 인덱스를 스캔하며 잡힌다. “무엇이 잠기나”를 이해하려면 먼저 InnoDB가 데이터를 어떤 구조로 저장하고, “스캔한다”는 게 물리적으로 무슨 일인지 알아야 한다.
앞으로 이 테이블을 예시로 쓴다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CREATE TABLE t (
id INT PRIMARY KEY,
name VARCHAR(10),
age INT,
KEY idx_age (age) -- age에 세컨더리 인덱스
);
-- 삽입한 데이터
id | name | age
----+------+-----
10 | kim | 25
20 | lee | 30
30 | park | 25
40 | choi | 40
50 | jung | 30
모든 테이블은 클러스터드 인덱스다
- InnoDB는 테이블 자체를 PK 기준 B+트리(클러스터드 인덱스) 로 저장한다.
- 내부 노드는 “어느 자식으로 내려갈지” 길잡이(키)만 갖고, 실제 행 데이터는 리프 노드에 통째로 들어 있다.
- 리프끼리는 좌→우 포인터로 이어져 있어 범위 스캔이 가로로 흐른다.
1
2
3
4
5
6
7
8
9
10
11
12
13
┌───────────────────┐
│ 루트(내부) 노드 │ 자식 페이지의 최소 키만 보관
│ [ 10 | 40 ] │ (실제 행 데이터는 없음)
└──┬──────────┬──────┘
┌────────────┘ └────────────┐
▼ ▼
┌────────────────────────┐ ┌────────────────────────┐
────▶│ 리프 페이지 P1 │────next──▶ 리프 페이지 P2 │────▶
│ 10 │ kim │ 25 │ │ 40 │ choi │ 40 │
│ 20 │ lee │ 30 │ │ 50 │ jung │ 30 │
│ 30 │ park │ 25 │ │ │
└────────────────────────┘ └────────────────────────┘
리프에 행 전체(name·age 포함)가 있다 리프끼리 연결돼 범위 스캔이 가로로 흐른다
- PK가 없으면
NOT NULL유니크 인덱스를, 그것도 없으면 InnoDB가 내부적으로 숨은row_id를 만들어 클러스터드 인덱스로 쓴다. - 위 그림은 구조를 보이려 리프를 둘로 나눴지만, 행이 몇 개뿐이면 실제로는 16KB 리프 한 페이지에 다 들어간다.
세컨더리 인덱스 리프 = 인덱스 컬럼 + PK
idx_age의 리프에는 행 전체가 아니라age값과 PK(id) 만 들어 있고,age기준으로 정렬된다.
1
2
3
4
5
idx_age 리프 (age 순 정렬, 값 = age + PK)
────▶ 25→10 ─▶ 25→30 ─▶ 30→20 ─▶ 30→50 ─▶ 40→40 ─▶
│ │
└── name 같은 다른 컬럼이 필요하면 PK(10, 30…)로 클러스터드 인덱스를 다시 탄다
(SELECT가 age·id만 읽으면 여기서 끝 = 커버링 인덱스)
- 어느 쪽이든 행을 찾고 수정하는 일은 항상 인덱스를 통해 이뤄진다.
- 그래서 락도 “행”이 아니라 인덱스 레코드에 걸린다.
WHERE age = 30은idx_age위 레코드에,WHERE id = 20은 클러스터드 인덱스 위 레코드에 잠금이 걸린다.
“행”은 어디 있고, 락은 어느 칸에 걸리나
InnoDB에는 행을 따로 담아두는 별도 저장소가 없다. 행의 실제 저장 위치가 클러스터드 인덱스 리프 레코드 그 자체다.
- “인덱스 레코드”란 리프 안의 엔트리 한 칸으로,
레코드 헤더(heap_no·next_record) + 키 + 값구조다. - 클러스터드 리프 레코드는 여기에 나머지 컬럼 전부와 InnoDB 숨은 컬럼(
DB_TRX_ID·DB_ROLL_PTR)까지 담아 사실상 행 전체가 된다. - 레코드는 페이지에 키 순서로 저장되는 게 아니라, 삽입된 순서대로 쌓이고, 키 정렬은 각 레코드 헤더의
next_record(다음 레코드로의 오프셋) 포인터로만 표현된다. - 페이지 양 끝에는 가상 경계 레코드
infimum·supremum이 고정돼 있어, 체인은 항상infimum → 최소키 → … → 최대키 → supremum으로 이어진다. - 이렇게 체인으로 늘어선 레코드가 한 페이지에 여러 개 쌓여 있고, 락은 그중 조건에 걸린 한 칸에만 걸린다.
UPDATE t SET name='x' WHERE id = 20을 예로 보면:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
UPDATE t SET name='x' WHERE id = 20 ← PK(클러스터드 인덱스)로 접근
클러스터드 인덱스 리프 P1 (리프 레코드 = 행 전체, … = DB_TRX_ID·DB_ROLL_PTR)
[infimum] ← 페이지 시작 경계(가상 레코드)
│ next_record
▼
┌──────┬────┬──────┬─────┬───┐
│ 헤더 │ 10 │ kim │ 25 │ … │
└──────┴────┴──────┴─────┴───┘
│ next_record
▼
┌──────┬────┬──────┬─────┬───┐
│ 헤더 │ 20 │ lee │ 30 │ … │ ◀━━ 🔒 X 락은 이 레코드 한 칸에만
└──────┴────┴──────┴─────┴───┘
│ next_record
▼
┌──────┬────┬──────┬─────┬───┐
│ 헤더 │ 30 │ park │ 25 │ … │
└──────┴────┴──────┴─────┴───┘
│ next_record
▼
[supremum] ← 페이지 끝 경계(가상 레코드)
1
2
3
idx_age 리프 (age 순, 값 = age+PK) — 이 쿼리는 여기를 안 탐 → 안 잠김
[infimum] ─▶ 25→10 ─▶ 25→30 ─▶ 30→20 ─▶ 30→50 ─▶ 40→40 ─▶ [supremum]
└ 같은 행(id=20)의 '다른 칸'. 지금은 잠기지 않는다
- 같은 논리적 행
id=20이라도 클러스터드 레코드와 세컨더리 레코드는 서로 다른 칸이라, 둘 다 잠기는 게 아니라 쿼리가 실제로 탄 인덱스 위의 칸만 잠긴다. WHERE age=30으로 접근했다면 그때는idx_age의30→20레코드가 잠겼을 것이다.- 스캔한다 = 이
next_record체인을 한 칸씩 밟는 것이고, 락은 밟은 레코드에 하나씩 붙는다. - 범위가 어디서 끝나는지 알려면 조건을 벗어나는 첫 레코드까지 한 번 밟아야 하므로, 잠기는 범위는 접근 경로에 따라 달라진다.
- 페이지 내부의 더 자세한 구조(Page Directory, File Header/Trailer 등)와 인덱스 세부 동작은 별도 문서로 정리한다. 여기서는 락이
next_record로 이어진 인덱스 레코드에 걸린다는 점만 붙잡으면 된다. - 갭이 정확히 무엇이고 INSERT가 왜 특정 링크에서 막히는지는 2편의 Gap Lock에서 이어서 다룬다.
쿼리 수행 과정과 락을 잡는 단계
락은 쿼리 시작 시 한꺼번에 잡히는 게 아니라, 스토리지 엔진이 인덱스를 스캔하며 레코드를 만나는 순간마다 하나씩 잡힌다.
앞의 인덱스 구조에서 본 “next_record 체인을 밟으며 스캔한다”가 락과 어떻게 연결되는지, 쿼리 한 건이 처리되는 흐름을 따라가며 보자.
MySQL의 두 계층 — 서버 레이어와 스토리지 엔진
MySQL은 서버 레이어와 스토리지 엔진으로 나뉜다. 락을 거는 주체와 WHERE 조건을 판정하는 주체가 서로 다른 계층이고, 이 분리가 뒤에서 볼 잠금 동작의 원인이 된다.
서버 레이어(server layer)
- 어떤 스토리지 엔진을 쓰든 공통인 상위층. 커넥션·인증, 파서, 옵티마이저, 실행기(executor), 내장 함수·
LIKE·표현식 평가가 여기 있다. - 데이터를 직접 갖고 있진 않고, 실행기가 “행 하나 줘”라고 엔진을 호출한 뒤 올라온 행에 남은 WHERE 조건을 최종 판정한다.
스토리지 엔진(storage engine, 여기선 InnoDB)
- 실제 데이터·인덱스를 저장하고 읽는 하위층. 인덱스를 타고 레코드를 읽으며 읽는 레코드에 락을 건다.
- 트랜잭션·MVCC·버퍼풀도 이 계층 담당이다.
- 둘 사이는 핸들러 API로 연결된다.
- 실행기가 “인덱스로 첫 행 읽어”, “다음 행” 하고 부르면 엔진이 레코드를 하나씩 돌려주는 구조다.

이 글과 직접 관련된 층은 가운데 MySQL Server Process(파서·옵티마이저·실행기 = 서버 레이어)와 그 아래 Storage Engines(InnoDB = 스토리지 엔진)다. 실행기가 핸들러 API로 InnoDB를 호출해 레코드를 받고, 락은 아래층 InnoDB가, WHERE 최종 판정은 위층 서버 레이어가 맡는다.
위 그림은 널리 쓰이는 클래식 다이어그램이라 MySQL 8.0+ 기준으로 두 군데가 낡았다. NoSQL Interface(InnoDB memcached 플러그인)는 8.0.22에서 deprecated → 8.3.0에서 제거됐고, Caches & Buffers 안의 쿼리 캐시는 8.0에서 삭제됐다. 나머지 구조는 8.0에서도 유효하다.
데이터는 엔진에 다 있는데 왜 서버 레이어가 따로 있나
- 엔진이 제공하는 건 SQL이 아니라 행 단위 읽기/쓰기 API(“이 인덱스에서 키 X 찾아줘”, “다음 행 줘”)뿐이다.
JOIN·GROUP BY·LIKE '%x%'같은 개념은 이 API에 없다. 엔진은 컬럼을 바이트로 돌려줄 뿐, SQL 표현식으로 해석하진 않는다. - 그래서 한 테이블/한 행을 넘어서는 일은 서버 레이어 몫이다. 여러 테이블을 엮는
JOIN(심지어 InnoDB↔MEMORY처럼 엔진이 달라도), 여러 행을 모으는 집계·정렬, 표현식·함수 평가, 인덱스 선택 같은 옵티마이저 판단이 여기 있다. - 이렇게 나눈 이유는 스토리지 엔진이 플러그인이라서다. SQL 파싱·최적화·조인을 엔진마다 구현하면 중복이 크니, SQL 레벨 작업을 공용 상위층에 한 번만 빼두고 엔진엔 단순 저장·조회 API만 요구한다.
- 앞서 본 ICP는 이 원칙의 예외다. 라운드트립이 아까우니 엔진이 인덱스로 값싸게 판단 가능한 조건만 골라 아래로 내려보낼 뿐, 임의의 SQL은 여전히 서버 레이어로 올라온다.
쿼리 한 건이 락을 잡기까지
두 계층이 실제로 어떻게 주고받는지, 예시 테이블
t에 쿼리 하나를 던져 따라가 보자. (REPEATABLE READ)
1
SELECT * FROM t WHERE id BETWEEN 20 AND 40 AND name LIKE '%a%' FOR UPDATE;
id BETWEEN 20 AND 40은 PK(클러스터드 인덱스)로 범위를 좁힐 수 있다 → 옵티마이저가 여기까지를 엔진에 맡긴다.name LIKE '%a%'는 앞이%라 인덱스로 못 거른다 → 서버 레이어가 행을 올려받아 직접 판정해야 한다.
1
2
3
4
5
6
7
8
9
옵티마이저: "PRIMARY로 id 20~40 구간을 스캔" 결정 (잠금 범위가 여기서 사실상 확정)
실행기 ─"20~40 첫 행 줘"─▶ InnoDB : id=20(lee) 읽음 → 🔒 락 ─올림─▶ 실행기
name '%a%'? lee ✗ → 버림 (락은 남음)
실행기 ─"다음 행"────────▶ InnoDB : id=30(park) 읽음 → 🔒 락 ─올림─▶ 실행기
name '%a%'? park ✓ → 결과로
실행기 ─"다음 행"────────▶ InnoDB : id=40(choi) 읽음 → 🔒 락 ─올림─▶ 실행기
name '%a%'? choi ✗ → 버림 (락은 남음)
→ 40까지 읽으면 구간 끝. 최종 결과는 park(30) 한 행뿐
performance_schema.data_locks로 실제 잡힌 락을 확인하면(위 트랜잭션을 열어둔 채 조회):
| INDEX_NAME | LOCK_MODE | LOCK_DATA | |
|---|---|---|---|
| PRIMARY | X,REC_NOT_GAP | 20 | lee — 조건 불일치인데도 잠김 |
| PRIMARY | X | 30 | park — 결과 행 |
| PRIMARY | X | 40 | choi — 조건 불일치인데도 잠김 |
(이 외에 테이블에 IX 의도 락이 하나 잡힌다.)
- 결과로는
park한 행만 나왔지만, 엔진이 스캔하며 읽은 20·40도 잠겨 있다. - 서버 레이어가
name LIKE '%a%'를 판정하려면 그 행을 일단 엔진이 읽어 올려야 하고, InnoDB는 읽는 순간 락부터 걸기 때문이다.- “결과에 없는 행인데 왜 잠겨 있지?”의 정체가 이것이다.
- READ COMMITTED였다면 조건에서 탈락한 20·40의 락은 판정 직후 풀린다. REPEATABLE READ라서 트랜잭션 끝까지 남는다.
- 왜
20은 레코드만(REC_NOT_GAP) 잠기고30·40은 갭까지 포함한 넥스트키(X)로 잠기는지는 2편의 락 타입에서 다룬다.- 여기서 볼 것은 읽힌 세 레코드가 전부 잠긴다는 점이다.
이 흐름에서 일반화할 수 있는 원칙은 넷이다.
- 잠금 범위는 최적화 단계에서 정해진다.
- 옵티마이저가 어떤 인덱스로 어디부터 어디까지 스캔할지 결정하는 순간, 잠글 레코드와 갭의 범위가 사실상 확정된다.
- 조건에 맞는 인덱스가 없으면 스캔 범위 = 테이블 전체가 되어 잠금도 그만큼 커진다.
- 락은 스캔하면서 점진적으로 획득된다.
- 쿼리 시작 시 전부 잡는 게 아니라, 엔진이 레코드를 하나 읽을 때마다 그 레코드에 락을 건다.
- 그래서 같은 쿼리라도 인덱스를 얼마나 잘 타느냐에 따라 실제로 걸리는 락 수가 달라진다.
- 엔진이 읽은 행은 조건에 안 맞아도 일단 잠긴다.
- InnoDB는 서버 레이어의 WHERE 평가보다 먼저 레코드를 잠근다.
- REPEATABLE READ에서는 조건에 안 맞는 행의 락도 트랜잭션 끝까지 유지되고, READ COMMITTED에서만 평가 후 즉시 해제된다. (인덱스 컨디션 푸시다운으로 조건을 엔진에 내려보내면 애초에 안 읽는 행이 줄어 락도 준다)
- 락은 트랜잭션이 끝나야 풀린다.
- 개별 문장이 끝날 때가 아니라
COMMIT/ROLLBACK시점에 해제된다. - InnoDB는 실행 중에 락을 획득만 하고, 트랜잭션 종료 때 한꺼번에 해제하는 엄격한 2단계 잠금(strict 2PL) 을 따른다.
- 트랜잭션을 짧게 유지해야 하는 이유가 이것이다.
- 개별 문장이 끝날 때가 아니라
즉, “이 쿼리가 무슨 락을 거는가”는 SQL 구문만 봐서는 알 수 없고, 옵티마이저가 어떤 인덱스를 어떻게 타는지까지 봐야 한다.
EXPLAIN으로 실행 계획을,performance_schema.data_locks로 실제 잡힌 락을 확인하는 습관이 필요하다.
스캔 도중 락을 못 잡으면
이미 다른 트랜잭션이 충돌하는 락을 쥐고 있으면, 스캔은 그 레코드에서 멈춰서 기다린다. 실패로 즉시 끝나는 게 아니라 블로킹된다.
락을 바로 못 잡은 트랜잭션은 세 갈래로 갈린다.
대기 (blocking) — 기본 동작. 앞 트랜잭션이
COMMIT/ROLLBACK으로 락을 풀면 그때 이어서 진행한다. 이 대기 관계는performance_schema.data_locks(해당 락의LOCK_STATUS = WAITING)와data_lock_waits(누가 누구를 기다리는지)로 확인한다.락 대기 타임아웃 — 대기가
innodb_lock_wait_timeout(기본 50초)을 넘기면 에러가 난다.
1
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
- 기본값(
innodb_rollback_on_timeout = OFF)에서는 현재 문장만 롤백되고 트랜잭션은 열린 채 남는다. - 이미 잡아둔 락도 그대로다. 그래서 애플리케이션이 재시도하거나 직접
ROLLBACK으로 정리해야 한다.
- 데드락 — 서로가 서로의 락을 기다려 순환이 생기면, InnoDB의 데드락 감지(
innodb_deadlock_detect, 기본 ON)가 타임아웃을 기다리지 않고 즉시 한쪽을 희생자로 골라 롤백시킨다.
1
ERROR 1213 (HY000): Deadlock found when trying to get lock; try restarting transaction
- 희생자는 보통 되돌릴 양이 가장 적은(수정한 행이 적은) 트랜잭션이 선택되며, 이 경우엔 문장이 아니라 트랜잭션 전체가 롤백된다.
대기 자체를 원치 않으면 잠금 읽기에 옵션을 붙일 수 있다 (MySQL 8.0+).
SELECT ... FOR UPDATE NOWAIT— 잠긴 행을 만나면 대기 없이 즉시 에러(3572)를 반환한다.SELECT ... FOR UPDATE SKIP LOCKED— 잠긴 행은 건너뛰고 잠기지 않은 행만 반환한다. 여러 워커가 큐에서 서로 다른 행을 집어가게 할 때 쓰는 방식이다.
LIMIT 1이면 한 행만 잠길까
아니다.
LIMIT은 결과로 돌려주는 행 수를 제한할 뿐, 잠금은 그 한 건을 찾기까지 스캔한 범위에 걸린다.
LIMIT 1은 조건에 맞는 행을 하나 찾으면 거기서 스캔을 멈추게 한다. 그래서 테이블 전체를 잠그진 않지만, 멈추기 전까지 읽은 행은 다 잠근다. 그 개수는 인덱스에 따라 갈린다.
- 인덱스를 잘 타면 → 적게 잠근다. 아래 쿼리는
status인덱스로 첫 매칭 행에 바로 도달해 그 행(+RR이면 앞 갭)만 잠그고 멈춘다.
1
2
-- status 컬럼에 인덱스 있음
SELECT * FROM t WHERE status = 'A' ORDER BY id LIMIT 1 FOR UPDATE;
- 인덱스를 못 타면 → 스캔한 만큼 다 잠근다. 아래 쿼리는 처음부터 한 행씩 읽으며
non_idx = 5를 찾는데, 그 값이 100번째 행에 있으면 1~100번째 행이 전부 잠긴다. - 조건에 안 맞는 99개까지 포함해서다. (앞서 본 “읽는 순간 잠기고, 조건 평가는 그 다음”이 그대로 적용)
1
2
-- non_idx 컬럼에 인덱스 없음
SELECT * FROM t WHERE non_idx = 5 LIMIT 1 FOR UPDATE;
- 격리 수준에 따라 잔여 락이 다르다. REPEATABLE READ는 스캔 중 안 맞은 99개 행과 갭까지 트랜잭션 끝까지 유지하고, READ COMMITTED는 조건 평가 후 즉시 풀어 결국 매칭된 1개만 남긴다. (단 RC도 스캔 도중엔 걸었다 푸는 것이라 그 순간의 경합은 있다)
정리하면, LIMIT 1이 보장하는 건 “결과 1행”이지 “잠금 1행”이 아니다. 잠금 수 = 매칭을 찾기까지 스캔한 행 수이고, 이건 인덱스가 결정한다.
안 맞는 행까지 잠그는 걸 줄이려면 — ICP
앞 예시의
20·40처럼 조건에서 탈락할 행까지 잠기는 건,name LIKE '%a%'같이 인덱스로 못 거르는 조건이 서버 레이어에서야 판정되기 때문이다. 인덱스 컨디션 푸시다운(ICP) 은 이 낭비를 줄이는 최적화다.
- 세컨더리 인덱스로 스캔할 때, 조건 중 그 인덱스 컬럼만으로 판단 가능한 것은 서버까지 올리지 않고 InnoDB에 내려보내(push down) 인덱스 단계에서 미리 걸러낸다.
- 안 맞는 행은 아예 읽지도 잠그지도 않으므로 락이 줄어든다. (
EXPLAIN의Extra에Using index condition으로 표시된다) - 다만
LIKE '%...%'처럼 인덱스로는 못 거르는 조건은 여전히 서버 레이어까지 올라가, 위에서 본 “읽고 → 잠그고 → 버리는” 흐름이 그대로 남는다.
“읽는 순간 잠긴다”와 “경계 레코드는 갭만 잠긴다”는 모순이 아니다
앞의 “엔진이 읽은 행은 조건에 안 맞아도 일단 잠긴다”와, 2편에서 볼 “범위를 벗어난 경계 레코드는 레코드는 안 잠기고 앞 갭만 잠긴다”는 얼핏 반대로 들린다. 둘은 불일치를 어디서 판정하느냐가 다를 뿐이다.
| 불일치 판정 위치 | 예 | InnoDB 동작 |
|---|---|---|
| 인덱스 레벨 (인덱스 키 값만 보고 범위 밖/불일치임을 앎) | BETWEEN 10 AND 18에서 만난 id=20, 등값 miss id=15 | 레코드 안 잠금, 앞 갭만 (X,GAP) |
| 서버 레벨 (인덱스로는 판단 불가, 행을 올려 서버가 걸러냄) | memo LIKE '%urgent%', 인덱스 없는 컬럼 조건 | 레코드 먼저 잠그고 → 서버가 버림 (RR은 유지, RC는 해제) |
- “읽는 순간 잠긴다”는 서버 레벨 케이스다.
- 엔진이 인덱스만으론 매칭 여부를 몰라 일단 레코드 락을 걸고 서버로 올려 판단하게 하므로, 버려진 행에도 락이 남는다(RR).
- “경계 레코드는 갭만”은 인덱스 레벨 케이스다.
id=20이 범위 밖이라는 걸 인덱스 키 값만 보고 아니까, 레코드 부분은 빼고 팬텀 방지용 갭만 잠근다.
- 지배 규칙은 “접근한 레코드가 조건에 매칭되면 레코드 부분을 잡고, 매칭 안 되면(그걸 인덱스에서 알 수 있는 한) 레코드 부분을 빼고 갭만 잡는다” 이다.
- 경계 레코드의 네 케이스별 실측은 2편 Next-Key Lock에서 다룬다.