---
title: "Phantom read와 next-key lock: MySQL이 범위 조건을 잠그는 이유"
description: "MySQL InnoDB가 Phantom read를 제어하는 MVCC와 next-key lock의 역할, 격리 수준별 차이와 운영 진단법을 설명한다."
tags: [ MySQL, InnoDB, 트랜잭션, 락, 운영 ]
image: "mysql-report-bg.png"
published: "2026-08-03"
updated: "2026-08-03"
author: "MySQL 기술 노트"
source_url: ""
---

범위 조건으로 주문 목록을 읽은 뒤 합계를 갱신하거나, 재고가 없는 구간을 확인한 뒤 새 행을 삽입한다고 가정해 보자. 같은 트랜잭션 안에서 동일한 조건을 다시 평가했을 때 다른 세션이 넣은 행이 갑자기 나타난다면, 첫 번째 판단의 전제가 무너진다. 이 현상이 **Phantom read**다.

MySQL InnoDB는 Phantom read를 하나의 장치로만 해결하지 않는다. 일반 `SELECT`에는 MVCC의 일관된 스냅샷을 사용하고, `SELECT ... FOR UPDATE` 같은 locking read에는 index record와 그 사이의 gap을 함께 잠그는 **next-key lock**을 사용한다. 두 메커니즘을 구분하지 않으면 “`REPEATABLE READ`에서는 행이 보이지 않으니 잠금도 걸렸다”거나 “범위 잠금은 조회된 행만 보호한다”는 잘못된 결론에 이르기 쉽다.

이 글은 Phantom read의 정의부터 Read View, next-key lock, 격리 수준별 차이, index 설계와 잠금 범위의 관계를 순서대로 설명한다. 예제는 MySQL 8.0 계열 전용 검증 인스턴스에서 실행한다.

## 1. Phantom read는 값 변경이 아니라 행 집합의 변경이다

동시성 이상 현상을 구분할 때는 무엇이 달라졌는지를 먼저 보아야 한다.

- **Non-repeatable read**: 이미 읽었던 같은 행의 값이 달라진다.
- **Phantom read**: 같은 검색 조건을 만족하는 행의 집합이 달라진다.
- **Lost update**: 한 트랜잭션의 변경이 다른 변경으로 덮인다.

예를 들어 첫 번째 문장이 `WHERE score > 10 AND score < 20`에서 0행을 반환했다고 하자. 다른 트랜잭션이 `score=15`인 행을 커밋한 뒤 두 번째 문장이 1행을 반환하면 새 행이 범위 안에 나타난 것이다. 삭제로 행이 사라지는 경우도 조건 결과 집합이 달라진다는 점에서는 같은 문제 범주로 해석할 수 있다.

중요한 것은 Phantom이 SQL 문장 한 번의 오류가 아니라 **트랜잭션 안에서 같은 predicate를 다시 평가할 때 발생하는 관찰의 불일치**라는 점이다. 따라서 격리 수준, 읽기 종류, index 접근 경로를 함께 보아야 한다.

## 2. InnoDB는 두 경로로 Phantom을 다룬다

```mermaid
flowchart TD
    A[범위 조건 SELECT] --> B{읽기 종류}
    B -->|일반 SELECT| C[Consistent read]
    C --> D[MVCC Read View]
    D --> E[같은 snapshot에서<br/>동일한 행 집합 관찰]
    B -->|FOR UPDATE / FOR SHARE| F[Locking read · current read]
    F --> G[Index 범위 탐색]
    G --> H[Record lock + Gap lock]
    H --> I[Next-key lock으로<br/>범위 내 삽입 억제]
```

### 2.1 Consistent read: 과거 버전을 읽어 결과를 고정한다

InnoDB의 일반 `SELECT`는 보통 잠금을 걸지 않는 consistent read다. `REPEATABLE READ`에서는 트랜잭션의 Read View가 정한 snapshot을 기준으로 undo log의 이전 버전을 찾아 읽는다. 다른 트랜잭션이 범위 안에 새 행을 커밋해도 기존 Read View에서는 그 행이 보이지 않는다.

이때 새 행이 물리적으로 삽입되지 못한 것은 아니다. 삽입은 이미 커밋됐지만, 현재 트랜잭션의 snapshot이 그 버전을 보이지 않게 한다. 따라서 이것은 **가시성으로 Phantom을 막는 방식**이지, gap을 잠가 삽입을 차단한 방식이 아니다.

### 2.2 Locking read: 최신 버전을 읽고 범위를 잠근다

`SELECT ... FOR UPDATE`와 `SELECT ... FOR SHARE`는 current read다. 단순히 트랜잭션 시작 시점의 과거 버전을 읽는 것이 아니라, 잠글 수 있는 최신 커밋 버전을 기준으로 읽는다. 이후 변경 작업의 전제로 사용할 행을 보호해야 하므로 index record lock이 필요하다.

범위 조건에서는 현재 존재하는 record만 잠그는 것으로 충분하지 않다. `10`과 `20` 사이에 아직 `15`가 없다면 잠글 record 자체가 없기 때문이다. InnoDB는 이 빈 구간까지 보호하기 위해 next-key lock을 사용한다. 다른 트랜잭션이 그 구간에 index entry를 삽입하려 하면 insert intention lock을 요청한 채 기다린다.

## 3. Next-key lock의 경계 모델

InnoDB의 next-key lock은 개념적으로 다음 두 부분을 결합한다.

1. index record에 대한 record lock
2. 그 record 바로 앞의 gap에 대한 gap lock

정렬된 index key가 `10, 20, 30`이라면 next-key interval은 대략 다음처럼 이해할 수 있다.

```text
(-∞, 10]  (10, 20]  (20, 30]  (30, +∞)
```

이 표기는 잠금 사고 모델이지, 모든 실행에서 `performance_schema.data_locks`가 정확히 같은 행 수와 경계를 보여 준다는 뜻은 아니다. 실제 잠금은 predicate의 열린·닫힌 경계, 선택한 index, optimizer access path, 유일성, 격리 수준에 따라 달라진다. 마지막 구간 보호에는 실제 사용자 record가 아닌 supremum pseudo-record가 관여할 수도 있다.

### 3.1 존재하지 않는 값도 잠글 수 있는 이유

`score=15`인 행이 없더라도 secondary index에서 `15`가 들어갈 위치는 `10`과 `20` 사이로 정해진다. InnoDB는 다음 index record 쪽의 gap을 보호함으로써 그 위치에 대한 삽입을 막을 수 있다. 따라서 locking read가 `Empty set`을 반환했다고 해서 “아무것도 잠그지 않았다”고 판단하면 안 된다.

### 3.2 유일한 한 건 조회는 범위 잠금이 아닐 수 있다

모든 검색이 next-key lock을 요구하는 것은 아니다. 모든 열을 사용한 unique index의 동등 조건으로 존재하는 한 행을 찾으면 InnoDB는 일반적으로 `REC_NOT_GAP` record lock만으로 충분하다. 반대로 non-unique index, 부분 unique key, 범위 조건, 존재하지 않는 key 탐색은 gap 보호가 필요할 수 있다.

## 4. MVCC가 일반 SELECT의 Phantom을 숨기는 과정

다음 예제는 `REPEATABLE READ` consistent read와 current read가 같은 트랜잭션에서도 서로 다른 가시성 규칙을 사용한다는 점을 보여 준다. Event Scheduler는 별도 서버 세션에서 `score=15` 행을 커밋하기 위한 검증 장치다. 운영 서버에서 예제 목적으로 Event Scheduler를 활성화해서는 안 된다.

```sql
SET GLOBAL event_scheduler = ON;
DROP EVENT IF EXISTS ev_mvcc_insert;
DROP TABLE IF EXISTS phantom_mvcc;

CREATE TABLE phantom_mvcc (
    id INT PRIMARY KEY,
    score INT NOT NULL,
    KEY ix_score (score, id)
) ENGINE=InnoDB;

INSERT INTO phantom_mvcc VALUES (1, 10), (3, 20), (5, 30);

CREATE EVENT ev_mvcc_insert
ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 2 SECOND
DO INSERT INTO mysql_tech_note.phantom_mvcc VALUES (2, 15);

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION WITH CONSISTENT SNAPSHOT;

SELECT COUNT(*) AS first_snapshot_count
FROM phantom_mvcc
WHERE score > 10 AND score < 20;

DO SLEEP(4);

SELECT COUNT(*) AS second_snapshot_count
FROM phantom_mvcc
WHERE score > 10 AND score < 20;

SELECT id, score
FROM phantom_mvcc
WHERE score > 10 AND score < 20
FOR UPDATE;

COMMIT;

SELECT id, score
FROM phantom_mvcc
ORDER BY id;

DROP TABLE phantom_mvcc;
```

실행 결과(MySQL 8.0.x):

준비·정리 DDL과 `DO SLEEP` 출력은 줄이고, snapshot과 current read의 차이를 보여 주는 핵심 결과를 발췌했다.

```text
mysql> CREATE TABLE phantom_mvcc (...);
Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO phantom_mvcc VALUES (1, 10), (3, 20), (5, 30);
Query OK, 3 rows affected (0.01 sec)
Records: 3  Duplicates: 0  Warnings: 0

mysql> SELECT COUNT(*) AS first_snapshot_count ...;
+----------------------+
| first_snapshot_count |
+----------------------+
|                    0 |
+----------------------+
1 row in set (0.00 sec)

mysql> SELECT COUNT(*) AS second_snapshot_count ...;
+-----------------------+
| second_snapshot_count |
+-----------------------+
|                     0 |
+-----------------------+
1 row in set (0.00 sec)

mysql> SELECT id, score FROM phantom_mvcc
    -> WHERE score > 10 AND score < 20 FOR UPDATE;
+----+-------+
| id | score |
+----+-------+
|  2 |    15 |
+----+-------+
1 row in set (0.00 sec)
```

첫 번째와 두 번째 일반 `SELECT`는 같은 Read View를 사용하므로 모두 0을 반환해야 한다. 그러나 뒤의 `FOR UPDATE`는 current read이므로 이미 커밋된 `score=15` 행을 본다. 이 차이는 일반 `SELECT`가 gap lock으로 삽입을 막은 것이 아니라 MVCC로 새 버전을 숨겼다는 직접적인 증거다.

또한 한 트랜잭션 안에서 consistent read와 locking read를 섞으면 서로 다른 시점의 데이터를 관찰할 수 있다. “트랜잭션 전체가 언제나 하나의 물리적 시점만 본다”는 식으로 단순화해서는 안 된다.

## 5. REPEATABLE READ locking read가 삽입을 기다리게 하는 과정

범위를 읽은 뒤 그 결과를 근거로 갱신하거나 삽입할 예정이라면 snapshot 가시성만으로는 부족하다. 다른 트랜잭션이 판단 대상 범위에 새 행을 추가하지 못하도록 해야 한다.

```mermaid
sequenceDiagram
    participant A as 세션 A
    participant I as InnoDB index
    participant B as 세션 B

    A->>I: 10 < score < 20 FOR UPDATE
    I-->>A: 빈 결과 + gap/next-key 보호
    B->>I: INSERT score=15
    I-->>B: insert intention WAITING
    A->>I: 범위 기준 업무 처리
    A->>I: COMMIT
    I-->>B: 잠금 허용 및 INSERT 커밋
```

먼저 `REPEATABLE READ`에서 빈 범위를 locking read로 보호한다. Event 세션의 삽입은 기다리고, `performance_schema.data_lock_waits`에는 요청한 insert intention과 이를 막는 잠금의 관계가 나타난다.

```sql
SET GLOBAL event_scheduler = ON;
DROP EVENT IF EXISTS ev_rr_gap_insert;
DROP TABLE IF EXISTS phantom_lock;

CREATE TABLE phantom_lock (
    id INT PRIMARY KEY,
    score INT NOT NULL,
    KEY ix_score (score, id)
) ENGINE=InnoDB;

INSERT INTO phantom_lock VALUES (1, 10), (3, 20), (5, 30);

CREATE EVENT ev_rr_gap_insert
ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 2 SECOND
DO INSERT INTO mysql_tech_note.phantom_lock VALUES (2, 15);

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;

SELECT id, score
FROM phantom_lock FORCE INDEX (ix_score)
WHERE score > 10 AND score < 20
FOR UPDATE;

DO SLEEP(4);

SELECT req.INDEX_NAME AS requesting_index,
       req.LOCK_MODE AS requesting_mode,
       req.LOCK_STATUS AS requesting_status,
       blk.INDEX_NAME AS blocking_index,
       blk.LOCK_MODE AS blocking_mode,
       blk.LOCK_STATUS AS blocking_status
FROM performance_schema.data_lock_waits w
JOIN performance_schema.data_locks req
  ON req.ENGINE_LOCK_ID = w.REQUESTING_ENGINE_LOCK_ID
 AND req.ENGINE = w.ENGINE
JOIN performance_schema.data_locks blk
  ON blk.ENGINE_LOCK_ID = w.BLOCKING_ENGINE_LOCK_ID
 AND blk.ENGINE = w.ENGINE
WHERE req.OBJECT_SCHEMA = 'mysql_tech_note'
  AND req.OBJECT_NAME = 'phantom_lock';

SELECT COUNT(*) AS inserted_before_rr_commit
FROM phantom_lock
WHERE id = 2;

COMMIT;
DO SLEEP(2);

SELECT COUNT(*) AS inserted_after_rr_commit
FROM phantom_lock
WHERE id = 2;

DROP TABLE phantom_lock;
```

실행 결과(MySQL 8.0.x):

준비·정리 DDL과 대기 출력은 줄이고, 검증된 잠금 대기 관계와 커밋 전후 행 수를 발췌했다.

```text
mysql> SELECT id, score FROM phantom_lock FORCE INDEX (ix_score)
    -> WHERE score > 10 AND score < 20 FOR UPDATE;
Empty set (0.00 sec)

mysql> SELECT req.INDEX_NAME AS requesting_index, ...;
+------------------+------------------------+-------------------+----------------+---------------+-----------------+
| requesting_index | requesting_mode        | requesting_status | blocking_index | blocking_mode | blocking_status |
+------------------+------------------------+-------------------+----------------+---------------+-----------------+
| ix_score         | X,GAP,INSERT_INTENTION | WAITING           | ix_score       | X             | GRANTED         |
+------------------+------------------------+-------------------+----------------+---------------+-----------------+
1 row in set (0.01 sec)

mysql> SELECT COUNT(*) AS inserted_before_rr_commit ...;
+---------------------------+
| inserted_before_rr_commit |
+---------------------------+
|                         0 |
+---------------------------+
1 row in set (0.00 sec)

mysql> COMMIT;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT COUNT(*) AS inserted_after_rr_commit ...;
+--------------------------+
| inserted_after_rr_commit |
+--------------------------+
|                        1 |
+--------------------------+
1 row in set (0.00 sec)
```

같은 빈 범위 locking read를 `READ COMMITTED`에서 실행하면 일반 검색의 gap 보호가 완화되므로, 별도 세션의 범위 내 삽입이 트랜잭션 종료 전에 커밋될 수 있다.

```sql
SET GLOBAL event_scheduler = ON;
DROP EVENT IF EXISTS ev_rc_gap_insert;
DROP TABLE IF EXISTS phantom_lock_rc;

CREATE TABLE phantom_lock_rc (
    id INT PRIMARY KEY,
    score INT NOT NULL,
    KEY ix_score (score, id)
) ENGINE=InnoDB;

INSERT INTO phantom_lock_rc VALUES (1, 10), (3, 20), (5, 30);

CREATE EVENT ev_rc_gap_insert
ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 2 SECOND
DO INSERT INTO mysql_tech_note.phantom_lock_rc VALUES (2, 15);

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;

SELECT id, score
FROM phantom_lock_rc FORCE INDEX (ix_score)
WHERE score > 10 AND score < 20
FOR UPDATE;

DO SLEEP(4);

SELECT COUNT(*) AS inserted_during_rc_transaction
FROM phantom_lock_rc
WHERE id = 2;

COMMIT;
DROP TABLE phantom_lock_rc;
```

실행 결과(MySQL 8.0.x):

준비·정리 DDL과 대기 출력은 줄이고, 빈 범위 locking read 뒤에도 삽입이 완료된 핵심 결과를 발췌했다.

```text
mysql> CREATE TABLE phantom_lock_rc (...);
Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO phantom_lock_rc VALUES (1, 10), (3, 20), (5, 30);
Query OK, 3 rows affected (0.00 sec)
Records: 3  Duplicates: 0  Warnings: 0

mysql> SELECT id, score FROM phantom_lock_rc FORCE INDEX (ix_score)
    -> WHERE score > 10 AND score < 20 FOR UPDATE;
Empty set (0.00 sec)

mysql> SELECT COUNT(*) AS inserted_during_rc_transaction
    -> FROM phantom_lock_rc WHERE id = 2;
+--------------------------------+
| inserted_during_rc_transaction |
+--------------------------------+
|                              1 |
+--------------------------------+
1 row in set (0.00 sec)

mysql> COMMIT;
Query OK, 0 rows affected (0.00 sec)
```

`FORCE INDEX`는 이 작은 교육용 데이터에서 secondary index 범위 접근을 고정해 잠금 차이를 관찰하기 위한 통제다. 실제 운영 SQL에 무조건 적용하라는 권고가 아니다. 운영에서는 optimizer가 선택한 실제 access path와 `EXPLAIN` 결과를 먼저 확인해야 한다.

MySQL 8.0의 `READ COMMITTED`에서는 일반적인 검색과 index scan의 gap lock이 비활성화되고, record lock은 조건 평가 후 불필요한 잠금이 더 일찍 해제될 수 있다. 다만 foreign key 검사와 duplicate-key 검사에는 gap 관련 잠금이 여전히 사용될 수 있다. 따라서 “`READ COMMITTED`에는 gap lock이 절대 없다”는 표현은 정확하지 않다.

## 6. 격리 수준별로 무엇이 달라지는가

| 구분 | `REPEATABLE READ` 일반 `SELECT` | `REPEATABLE READ` locking read | `READ COMMITTED` 일반 `SELECT` | `READ COMMITTED` locking read |
|---|---|---|---|---|
| 읽기 기준 | 같은 Read View 재사용 | 최신 커밋 버전 중심 current read | 문장마다 새 Read View | 최신 커밋 버전 중심 current read |
| Phantom 제어 | MVCC 가시성 | next-key/gap lock | 다음 문장에서 새 행이 보일 수 있음 | 일반 검색 gap 보호가 완화됨 |
| 다른 세션 삽입 | 커밋 가능하지만 기존 snapshot에 안 보임 | 보호 범위에서는 대기 가능 | 커밋 후 다음 문장에 보임 | 보통 범위 삽입이 진행 가능 |
| 주요 장점 | 반복 조회의 안정성 | predicate 기반 변경의 안전성 | 잠금 경합과 오래된 snapshot 완화 | 범위 쓰기 동시성 향상 |
| 주요 위험 | 긴 snapshot과 purge 지연 | 넓은 gap lock과 대기/교착 | 애플리케이션이 행 집합 변화를 감당해야 함 | read-modify-write 전제 약화 가능 |

`SERIALIZABLE`은 일반 `SELECT`도 상황에 따라 shared next-key lock을 사용하는 더 강한 격리 의미를 제공한다. 하지만 동시성이 크게 낮아질 수 있으므로 Phantom 하나만을 이유로 전역 기본값을 바꾸기보다, 업무 불변식과 접근 패턴을 먼저 분석해야 한다.

## 7. Index가 잠금의 공간을 결정한다

InnoDB의 row lock은 SQL의 논리 조건 문자열에 직접 걸리는 것이 아니라, 실행 과정에서 탐색한 **index record와 interval**에 걸린다. 이 사실은 성능과 정확성 모두에 중요하다.

### 7.1 적합한 index가 있으면 보호 범위를 좁힐 수 있다

`WHERE tenant_id = ? AND created_at >= ? AND created_at < ?` 조건을 `(tenant_id, created_at, id)` index로 탐색하면 특정 tenant와 시간 범위에 대응하는 index interval을 비교적 좁게 보호할 수 있다. 반대로 적합한 index가 없어 넓은 scan이 발생하면 훨씬 많은 record와 gap이 잠겨 무관한 쓰기까지 대기시킬 수 있다.

### 7.2 조건 결과가 작다고 잠금 범위도 작은 것은 아니다

최종 반환 행이 1건이어도 그 1건을 찾기 위해 넓은 index 구간을 scan했다면 잠금 footprint는 클 수 있다. DBA는 결과 행 수만 보지 말고 다음을 함께 확인해야 한다.

- `EXPLAIN`의 `type`, `key`, `key_len`, `rows`, `Extra`
- 실제 predicate가 index의 leftmost prefix를 활용하는지
- unique equality인지 range scan인지
- `performance_schema.data_locks`의 `INDEX_NAME`, `LOCK_MODE`, `LOCK_DATA`
- `performance_schema.data_lock_waits`의 blocker/waiter 관계

### 7.3 잠금은 검색 중 만난 후보에도 영향을 줄 수 있다

조건을 Server layer에서 나중에 평가하거나 access path가 비효율적이면 최종 결과에서 제외되는 후보도 잠금 과정에 포함될 수 있다. MySQL 버전과 격리 수준에 따른 semi-consistent read 최적화가 잠금 해제 시점을 바꿀 수 있지만, “반환된 행만 잠긴다”는 가정으로 용량과 경합을 계산해서는 안 된다.

## 8. 운영에서 관찰해야 할 신호

Phantom 보호 자체는 정상 기능이지만, 범위가 너무 넓거나 트랜잭션이 오래 지속되면 장애처럼 보이는 대기가 발생한다.

### 8.1 대표 증상

- 평소 빠른 `INSERT`가 특정 key 구간에서만 오래 기다린다.
- `UPDATE`나 `DELETE`보다 먼저 실행한 `SELECT ... FOR UPDATE`가 blocker다.
- 존재하지 않는 값을 조회했는데도 뒤따르는 삽입이 대기한다.
- 요청량은 비슷하지만 lock wait time과 transaction latency가 함께 증가한다.
- 재시도 과정에서 deadlock 또는 lock wait timeout이 늘어난다.

### 8.2 진단 순서

1. `performance_schema.data_lock_waits`로 요청 잠금과 차단 잠금의 관계를 찾는다.
2. `performance_schema.data_locks`에서 대상 schema, table, index, `LOCK_MODE`, `LOCK_DATA`를 확인한다.
3. `performance_schema.threads`와 statement instrumentation을 연결해 세션 및 현재 SQL을 확인한다.
4. blocker 트랜잭션의 시작 시각, 상태, 변경 행 수를 `information_schema.INNODB_TRX`에서 확인한다.
5. 실행 계획과 index 정의를 대조해 잠금 범위가 넓어진 이유를 찾는다.
6. 즉시 종료가 필요한 장애인지, 커밋을 기다리는 것이 더 안전한지 판단한다.

`LOCK_DATA`는 진단 힌트이며 완전한 predicate 표현이 아니다. 표시 형식과 잠금 행 수는 minor version 및 access path에 따라 달라질 수 있으므로 특정 문자열을 영구적인 모니터링 계약처럼 사용하지 않는 편이 안전하다.

## 9. 흔한 오해와 실패 패턴

### 9.1 “REPEATABLE READ에서는 모든 읽기가 같은 데이터를 본다”

일반 consistent read는 같은 Read View를 재사용하지만 locking read는 current read다. 두 읽기를 섞으면 같은 트랜잭션에서도 새로 커밋된 행이 locking read에 보일 수 있다.

### 9.2 “Empty set이면 잠금이 없다”

존재하지 않는 key가 들어갈 gap을 보호할 수 있다. 오히려 “없음을 확인한 뒤 생성”하는 패턴에서 이 잠금이 업무 불변식을 지키는 핵심일 수 있다.

### 9.3 “Gap lock끼리는 일반 X lock처럼 충돌한다”

Pure gap lock은 주로 삽입 억제를 위한 잠금이다. 같은 gap의 gap S-lock과 gap X-lock은 공존할 수 있으며, record lock의 일반적인 호환성 모델과 똑같이 해석하면 안 된다. 실제 삽입 세션의 insert intention이 충돌 관계를 드러낸다.

### 9.4 “READ COMMITTED로 바꾸면 성능 문제가 해결된다”

gap 경합은 줄어들 수 있지만, 같은 predicate의 결과가 문장마다 달라지는 것을 애플리케이션이 처리해야 한다. 범위 조회 후 쓰기를 수행하는 로직이라면 unique constraint, 원자적 DML, 명시적 locking read, 낙관적 버전 검사 등 다른 불변식 보호 장치가 필요하다.

### 9.5 “Deadlock이 있으니 next-key lock을 제거해야 한다”

Deadlock은 잠금 순서가 순환한 결과다. 먼저 트랜잭션의 접근 순서를 통일하고, 범위를 좁히며, 트랜잭션 시간을 줄이고, 적절한 index를 추가해야 한다. 필요한 격리 의미를 제거하는 것은 마지막 설계 판단이어야 한다.

## 10. Aurora MySQL에서의 해석

Aurora MySQL은 분산 스토리지 계층을 사용하지만 writer instance의 트랜잭션 격리와 InnoDB row/gap/next-key locking 의미가 사라지는 것은 아니다. writer에서 실행되는 범위 locking read와 DML은 여전히 논리적 동시성 제어를 받아야 한다.

운영 시 다음 차이를 고려한다.

- Aurora replica/reader endpoint는 읽기 확장용이며, writer에서 수행할 read-modify-write 잠금 절차를 대신할 수 없다.
- reader에서 관찰한 결과와 writer에서 current read한 결과는 복제 지연 및 트랜잭션 시점 때문에 다를 수 있다.
- 장애 조치로 writer가 바뀌어도 애플리케이션은 진행 중 트랜잭션 실패와 재시도를 처리해야 한다.
- Performance Insights와 Database Insights는 대기 증가를 찾는 데 유용하지만, 최종 원인 확인에는 실제 SQL, 실행 계획, transaction 경계, Performance Schema 잠금 관계가 필요하다.
- 격리 수준이나 관련 parameter 변경은 cluster parameter group 적용 범위와 재연결 후 session 설정을 함께 검증해야 한다.

즉, Aurora의 저장 내구성과 고가용성은 Phantom을 자동으로 제거하는 기능이 아니다. Phantom 제어는 여전히 SQL 격리 의미와 index 기반 잠금 설계의 문제다.

## 11. 설계 및 장애 대응 체크리스트

### 트랜잭션 설계

- [ ] 같은 predicate를 트랜잭션 안에서 반복 평가하는 이유가 명확한가?
- [ ] 일반 consistent read와 locking read를 의도적으로 구분했는가?
- [ ] 범위 조회 결과를 근거로 쓰기 작업을 한다면 불변식을 보호하는 장치가 있는가?
- [ ] `READ COMMITTED` 전환 시 나타날 수 있는 행 집합 변화를 애플리케이션이 처리하는가?
- [ ] 트랜잭션 안에 외부 API 호출이나 사용자 입력 대기가 포함되지 않는가?

### Index와 SQL

- [ ] locking predicate를 좁게 탐색할 composite index가 있는가?
- [ ] unique equality와 range scan을 구분했는가?
- [ ] 최종 결과 행 수가 아니라 실제 scan 범위를 확인했는가?
- [ ] 교육용 `FORCE INDEX`를 운영 해결책으로 그대로 복사하지 않았는가?
- [ ] `EXPLAIN`과 운영 데이터 분포를 함께 검토했는가?

### 관측과 대응

- [ ] `data_lock_waits`에서 blocker와 waiter를 양방향으로 확인했는가?
- [ ] `data_locks.INDEX_NAME`으로 어떤 index interval이 관련됐는지 확인했는가?
- [ ] blocker의 트랜잭션 나이와 업무 중요도를 확인한 뒤 종료 여부를 결정했는가?
- [ ] lock wait timeout과 deadlock 재시도가 상위 트랜잭션 전체를 안전하게 재실행하는가?
- [ ] 격리 수준 변경 전에 정확성 회귀 테스트와 동시성 부하 테스트를 수행했는가?

## 12. 정리

Phantom read는 동일한 predicate의 결과 행 집합이 트랜잭션 중 달라지는 현상이다. InnoDB의 `REPEATABLE READ` 일반 `SELECT`는 MVCC Read View로 새 행의 가시성을 고정한다. 반면 locking read는 최신 버전을 읽어 이후 작업의 전제를 보호해야 하므로, 현재 존재하는 record뿐 아니라 새 index entry가 들어올 gap도 next-key lock으로 보호한다.

따라서 Phantom 문제를 분석할 때는 “격리 수준이 무엇인가”만 물어서는 부족하다. **consistent read인가 current/locking read인가, 어떤 index와 범위를 탐색했는가, 트랜잭션이 얼마나 오래 유지되는가**를 함께 확인해야 한다. 다음 글에서는 insert intention lock과 gap lock의 대기 관계를 더 세밀하게 살펴보고, 동시 삽입이 실제 lock wait와 deadlock으로 발전하는 경로를 운영 관점에서 연결한다.
