격리 수준 심화 — Phantom Read와 InnoDB의 해결 방식
같은 쿼리를 두 번 실행했는데 결과가 다르다면, 데이터베이스의 격리 수준이 무엇을 보장하고 있는 걸까요?
격리 수준이란
트랜잭션 격리 수준(Isolation Level)은 동시에 실행되는 트랜잭션들이 서로의 변경 사항을 얼마나 볼 수 있는지를 결정하는 규칙입니다. SQL 표준은 네 가지 수준을 정의합니다.
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | 가능 | 가능 | 가능 |
| READ COMMITTED | 방지 | 가능 | 가능 |
| REPEATABLE READ | 방지 | 방지 | 가능(표준) |
| SERIALIZABLE | 방지 | 방지 | 방지 |
InnoDB의 기본 격리 수준은 REPEATABLE READ 이며, 여기서 주목할 점은 표준과 달리 Phantom Read까지 방지한다는 것입니다.
각 격리 수준의 실제 동작
READ UNCOMMITTED
다른 트랜잭션이 커밋하지 않은 데이터까지 볼 수 있습니다.
-- 세션 A
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 1000
-- 세션 B
BEGIN;
UPDATE accounts SET balance = 500 WHERE id = 1;
-- 아직 커밋하지 않음
-- 세션 A
SELECT balance FROM accounts WHERE id = 1; -- 500 (Dirty Read!)
-- 세션 B
ROLLBACK; -- 롤백했으므로 500은 존재하지 않는 데이터
실무에서는 거의 사용하지 않습니다. 데이터 정합성을 전혀 보장하지 못합니다.
READ COMMITTED
커밋된 데이터만 읽습니다. 하지만 같은 행을 다시 읽으면 값이 달라질 수 있습니다.
-- 세션 A
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 1000
-- 세션 B
BEGIN;
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT;
-- 세션 A
SELECT balance FROM accounts WHERE id = 1; -- 500 (Non-Repeatable Read)
Oracle, PostgreSQL의 기본 격리 수준입니다. InnoDB에서도 많은 서비스가 RC로 변경해서 사용합니다.
**RC의 MVCC 동작 **: 매 SELECT마다 새로운 스냅샷을 생성합니다. 그래서 다른 트랜잭션의 커밋이 바로 반영됩니다.
REPEATABLE READ (InnoDB 기본)
트랜잭션 시작 시점의 스냅샷을 유지합니다. 같은 행을 다시 읽어도 값이 변하지 않습니다.
-- 세션 A
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 1000
-- 세션 B
BEGIN;
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT;
-- 세션 A
SELECT balance FROM accounts WHERE id = 1; -- 여전히 1000 (스냅샷 읽기)
COMMIT;
**RR의 MVCC 동작 **: 트랜잭션의 첫 번째 읽기 시점에 스냅샷이 생성되고, 그 이후로는 같은 스냅샷을 계속 사용합니다.
SERIALIZABLE
모든 SELECT가 자동으로 SELECT ... FOR SHARE로 변환됩니다. 사실상 트랜잭션이 순차적으로 실행되는 것과 같은 결과를 보장합니다.
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
SELECT * FROM accounts WHERE id = 1;
-- 내부적으로 FOR SHARE가 붙어 공유 락이 걸림
동시성이 크게 떨어지므로 특수한 경우가 아니면 사용하지 않습니다.
Phantom Read란
Phantom Read는 같은 범위 조건으로 조회했을 때, 다른 트랜잭션의 INSERT나 DELETE로 인해 결과 행의 개수가 달라지는 현상입니다.
-- 세션 A
BEGIN;
SELECT * FROM orders WHERE amount > 100;
-- 결과: 3행
-- 세션 B
BEGIN;
INSERT INTO orders (id, amount) VALUES (99, 200);
COMMIT;
-- 세션 A
SELECT * FROM orders WHERE amount > 100;
-- 표준 RR에서는 4행이 될 수 있음 (Phantom Read)
-- InnoDB RR에서는 여전히 3행 (방지!)
Non-Repeatable Read는 같은 행의 값이 변하는 것이고, Phantom Read는 행 자체가 생기거나 사라지는 것입니다.
InnoDB가 Phantom Read를 방지하는 방식
1. 일반 SELECT — MVCC Consistent Read
일반 SELECT 문(락을 걸지 않는 읽기)은 MVCC 스냅샷을 사용합니다. 트랜잭션 시작 시점의 데이터를 읽으므로 다른 트랜잭션의 INSERT가 보이지 않습니다.
이 방식은 락이 전혀 필요 없으므로 성능에 영향이 없습니다.
2. 락킹 Read — Next-Key Lock
SELECT ... FOR UPDATE나 SELECT ... FOR SHARE 같은 락킹 읽기에서는 MVCC만으로는 부족합니다. 실제 데이터에 접근하면서 락을 걸어야 하기 때문입니다.
여기서 InnoDB의 Next-Key Lock 이 등장합니다.
Next-Key Lock = Record Lock + Gap Lock
- Record Lock: 인덱스 레코드 자체에 거는 락
- Gap Lock: 인덱스 레코드 사이의 빈 공간(갭)에 거는 락
- Next-Key Lock: 레코드와 그 앞의 갭을 함께 잠급니다
Next-Key Lock 동작 예시
-- 테이블에 id = 10, 20, 30이 존재
-- 세션 A
BEGIN;
SELECT * FROM t WHERE id BETWEEN 15 AND 25 FOR UPDATE;
이때 InnoDB가 거는 락은 다음과 같습니다.
Gap Lock: (10, 20) -- id 10과 20 사이의 갭
Record Lock: id = 20 -- 실제 레코드
Gap Lock: (20, 30) -- id 20과 30 사이의 갭
이제 다른 트랜잭션이 이 범위에 INSERT를 시도하면 대기합니다.
-- 세션 B
INSERT INTO t VALUES (17, ...); -- 갭 (10, 20)에 걸려서 대기
INSERT INTO t VALUES (22, ...); -- 갭 (20, 30)에 걸려서 대기
INSERT INTO t VALUES (5, ...); -- 범위 밖이므로 성공
INSERT INTO t VALUES (35, ...); -- 범위 밖이므로 성공
READ COMMITTED에서의 트레이드오프
RC에서는 갭 락을 사용하지 않습니다. 이로 인한 차이점은 아래와 같습니다.
RC의 장점
- **높은 동시성 **: 갭 락이 없으므로 INSERT가 차단되지 않습니다
- ** 데드락 감소 **: 락 범위가 좁아져 충돌이 줄어듭니다
- ** 복제 호환 **: ROW 기반 복제와 함께 사용하면 안전합니다
RC의 단점
- **Phantom Read 발생 **: 범위 쿼리의 결과가 달라질 수 있습니다
- Non-Repeatable Read: 같은 행을 다시 읽으면 값이 변할 수 있습니다
실무에서의 선택
-- 전역 설정으로 RC 변경
SET GLOBAL transaction_isolation = 'READ-COMMITTED';
-- 세션 레벨 변경
SET SESSION transaction_isolation = 'READ-COMMITTED';
많은 대규모 서비스에서 RC를 선택하는 이유는 다음과 같습니다.
- 갭 락으로 인한 데드락이 줄어듭니다
- INSERT 성능이 향상됩니다
- Phantom Read는 애플리케이션 로직으로 처리 가능한 경우가 많습니다
단, RC를 사용하면 binlog 포맷이 반드시 ROW 또는 MIXED여야 합니다. STATEMENT 기반 복제와는 호환되지 않습니다.
격리 수준별 락 동작 비교
같은 쿼리가 격리 수준에 따라 다른 락을 사용합니다.
-- 테이블: id = 10, 20, 30
SELECT * FROM t WHERE id = 15 FOR UPDATE;
| 격리 수준 | 락 동작 |
|---|---|
| RC | id = 15인 레코드가 없으므로 락 없음 |
| RR | 갭 (10, 20)에 Gap Lock |
SELECT * FROM t WHERE id >= 20 FOR UPDATE;
| 격리 수준 | 락 동작 |
|---|---|
| RC | id = 20, 30에 Record Lock |
| RR | id = 20, 30에 Next-Key Lock + supremum 갭 락 |
Consistent Read vs Current Read
InnoDB에서 꼭 구분해야 하는 두 가지 읽기 방식이 있습니다.
- Consistent Read (일관된 읽기): 일반 SELECT. MVCC 스냅샷을 읽습니다. 락 없음
- Current Read (현재 읽기):
SELECT ... FOR UPDATE/SHARE,UPDATE,DELETE. 최신 커밋된 데이터를 읽고 락을 겁니다
BEGIN;
-- Consistent Read: 스냅샷 읽기
SELECT * FROM accounts WHERE id = 1;
-- Current Read: 최신 데이터 읽기 + 락
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
RR에서도 Current Read는 최신 데이터를 읽습니다. 이 점을 모르면 혼란스러운 결과를 만날 수 있습니다.
-- 세션 A (RR)
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 1000 (스냅샷)
-- 세션 B가 balance를 500으로 변경하고 커밋
SELECT balance FROM accounts WHERE id = 1; -- 1000 (여전히 스냅샷)
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- 500 (Current Read!)
주의할 점
RR에서도 UPDATE/DELETE는 Current Read다
일반 SELECT는 스냅샷을 읽지만, UPDATE와 DELETE의 WHERE 절은 최신 커밋된 데이터를 기준으로 동작합니다. 스냅샷에서 보이지 않는 행도 UPDATE/DELETE의 대상이 될 수 있어 혼란을 일으킵니다.
RC로 전환할 때 binlog_format을 확인해야 한다
READ COMMITTED에서 STATEMENT 기반 Binlog를 사용하면 복제 불일치가 발생할 수 있습니다. RC를 사용하려면 binlog_format이 반드시 ROW 또는 MIXED여야 합니다.
Consistent Read와 Current Read의 결과가 다를 수 있다
같은 트랜잭션 안에서 일반 SELECT와 FOR UPDATE의 결과가 다를 수 있습니다. 일반 SELECT는 트랜잭션 시작 시점의 스냅샷을 읽고, FOR UPDATE는 최신 커밋된 데이터를 읽기 때문입니다.
정리
| 항목 | 설명 |
|---|---|
| RR (InnoDB 기본) | 첫 SELECT 시점의 스냅샷 유지, Phantom Read까지 방지 |
| RC | 매 SELECT마다 새 스냅샷, Non-Repeatable Read 발생 가능 |
| Phantom Read 방지 | 일반 SELECT → MVCC, 락킹 읽기 → Next-Key Lock |
| RC의 장점 | 갭 락 없어 동시성 높고 데드락 감소 |
| Consistent Read | 일반 SELECT, 락 없이 스냅샷 읽기 |
| Current Read | FOR UPDATE/SHARE, UPDATE, DELETE — 최신 데이터 + 락 |