팬텀 리드가 왜 문제인가
팬텀 리드(Phantom Read)는 같은 트랜잭션 안에서 동일한 조건으로 범위를 조회했는데, 그 사이 다른 트랜잭션이 조건에 맞는 행을 삽입해 결과 건수가 달라지는 현상이다. 대표적으로 "재고가 N개 이하인지 확인 후 삽입" 같은 검증 로직에서 발생한다. 조회 시점엔 조건을 만족했지만, 커밋 사이에 다른 세션이 행을 밀어 넣으면 비즈니스 제약이 깨진다.
MySQL InnoDB의 기본 격리 수준은 REPEATABLE READ다. 많은 개발자가 "REPEATABLE READ면 팬텀이 안 생긴다"고 알고 있지만, 이는 잠금을 거는 읽기(locking read)에 한해서다. 일반 SELECT는 스냅샷을 읽으므로 팬텀이 보이지 않지만, 실제로 데이터를 막는 것은 아니다.
갭 락과 넥스트키 락
InnoDB는 팬텀을 막기 위해 인덱스 레코드 자체가 아니라 레코드 사이의 간격에 락을 건다. 이것이 갭 락(Gap Lock)이다. 레코드 락과 그 앞 간격을 함께 잠그는 것이 넥스트키 락(Next-Key Lock)이며, 잠금 읽기의 기본 동작이다.
| 락 종류 | 대상 | 목적 |
|---|---|---|
| Record Lock | 인덱스 레코드 | 기존 행 변경 차단 |
| Gap Lock | 레코드 사이 간격 | 범위 내 INSERT 차단 |
| Next-Key Lock | 레코드 + 앞 간격 | 팬텀 리드 방지 |
즉 갭 락은 이미 존재하는 행을 잠그는 게 아니라 "그 범위에 새 행이 들어오지 못하게" 막는다. 그래서 갭 안에서는 서로 다른 트랜잭션이 공존 가능한 갭 락을 동시에 가질 수 있고, 이 특성이 뒤에서 다룰 데드락의 원인이 된다.
실습: 팬텀을 직접 재현
-- 세션 A
START TRANSACTION;
SELECT * FROM orders WHERE user_id = 100 FOR UPDATE; -- 넥스트키 락
-- 세션 B (블로킹됨)
INSERT INTO orders (user_id, amount) VALUES (100, 5000);
-- 세션 A가 커밋/롤백할 때까지 대기
-- 세션 A
COMMIT; -- 이제 세션 B의 INSERT 진행
FOR UPDATE를 붙이면 user_id = 100 범위에 갭 락이 걸려 세션 B의 삽입이 대기한다. 반대로 FOR UPDATE 없이 일반 SELECT만 쓰면 세션 B는 즉시 삽입되고, 세션 A가 다시 조회하면(스냅샷이라 안 보이지만) 실제로는 팬텀이 존재하게 된다.
인덱스가 없으면 락이 폭발한다
갭 락은 인덱스를 기준으로 걸린다. WHERE 조건 컬럼에 인덱스가 없으면 InnoDB는 스캔한 모든 행과 간격을 잠가, 사실상 테이블 전체가 잠긴다. 아래 두 쿼리는 락 범위가 완전히 다르다.
-- 인덱스 없음: 테이블 전체 넥스트키 락
SELECT * FROM orders WHERE status = 'PENDING' FOR UPDATE;
-- (status) 인덱스 존재: 해당 범위만 락
CREATE INDEX idx_status ON orders(status);
SELECT * FROM orders WHERE status = 'PENDING' FOR UPDATE;
운영에서 잠금 대기가 급증한다면 먼저 FOR UPDATE/UPDATE/DELETE 조건이 인덱스를 타는지 확인해야 한다. 인덱스 미스가 곧 락 폭발이다.
애플리케이션에서 안전하게 다루기
재고 검증처럼 "범위 확인 후 삽입"이 필요하면 검증 쿼리에서 락을 잡고, 커밋까지 짧게 유지한다.
with conn.begin(): # SQLAlchemy: REPEATABLE READ 트랜잭션
row = conn.execute(text(
"SELECT COUNT(*) FROM seats "
"WHERE event_id = :eid FOR UPDATE"), {"eid": eid}).scalar()
if row < capacity:
conn.execute(text(
"INSERT INTO seats(event_id, user_id) "
"VALUES(:eid, :uid)"), {"eid": eid, "uid": uid})
# 블록 종료 시 커밋 → 갭 락 해제
여기서 event_id에 인덱스가 있어야 갭 락이 해당 이벤트 범위로 좁혀진다. 트랜잭션 안에서 외부 API 호출이나 대기가 끼면 갭 락이 오래 유지되어 처리량이 급감하므로, 락을 잡은 트랜잭션은 최대한 짧게 끝낸다.
주의점과 데드락
서로 다른 세션이 겹치는 갭에 락을 잡은 뒤 동시에 삽입을 시도하면 데드락이 난다. 갭 락끼리는 호환되지만, 그 갭에 삽입할 때 필요한 삽입 인텐션 락은 상대의 갭 락과 충돌하기 때문이다. 대응 원칙은 다음과 같다.
- 애플리케이션은
1213(deadlock) 에러를 잡아 재시도하도록 설계한다. 데드락은 정상 상황으로 간주한다. - 여러 행을 갱신할 때 항상 같은 순서(예: PK 오름차순)로 접근해 교차 대기를 줄인다.
- 불필요한
FOR UPDATE를 남발하지 않는다. 단순 읽기는 스냅샷 읽기로 충분하다. - 대량 범위 갱신은 배치로 쪼개 락 유지 시간과 범위를 제한한다.
필요하면 SELECT ... FOR UPDATE 대신 유니크 제약을 두고 INSERT ... ON DUPLICATE KEY로 중복을 막는 방식이 갭 락을 피하는 대안이 된다. 격리 수준을 READ COMMITTED로 낮추면 갭 락이 대부분 사라져 동시성은 올라가지만, 팬텀 방지 책임은 애플리케이션으로 넘어온다는 점을 반드시 인지하고 선택해야 한다.