팬텀 리드가 왜 문제인가

팬텀 리드(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로 낮추면 갭 락이 대부분 사라져 동시성은 올라가지만, 팬텀 방지 책임은 애플리케이션으로 넘어온다는 점을 반드시 인지하고 선택해야 한다.