같은 조건으로 범위 조회를 두 번 했을 때, 처음엔 없던 행이 나타나는 이상현상. "유령처럼 나타난다"는 뜻이다.
어떻게 일어나는가
T1: SELECT COUNT(*) FROM orders WHERE amount > 1000; → 5건
T2: INSERT INTO orders (amount) VALUES (5000); COMMIT;
T1: SELECT COUNT(*) FROM orders WHERE amount > 1000; → 6건 ← 유령 행
같은 트랜잭션 안에서 같은 질문을 했는데 답이 달라졌다.
왜 행 락으로 못 막나 — 핵심
Non-Repeatable Read는 이미 읽은 행을 잠그면 막을 수 있다. 그런데 팬텀은 아직 존재하지 않는 행이 문제다.
없는 행을 어떻게 미리 잠그는가?
불가능하다. 그래서 범위 자체를 잠가야 한다.
두 가지 해법
① 갭 락 / 넥스트 키 락 (InnoDB)
인덱스 레코드 사이의 빈 공간(gap) 을 잠근다.
인덱스: … [900] ──갭── [1200] ──갭── [1500] …
↑
amount > 1000 조건이면 이 갭도 잠근다
→ amount=1100 INSERT 시도가 대기한다
MySQL의 REPEATABLE READ가 팬텀을 실질적으로 막는 이유다. 대가는 데드락 증가 — 넓은 범위를 잠그니 충돌이 잦아진다.
② SERIALIZABLE
읽기에도 공유 락을 걸어 사실상 직렬 실행처럼 만든다. 확실하지만 동시성이 크게 떨어진다.
PostgreSQL은 다르게 푼다
PostgreSQL은 갭 락이 없다. 대신 SERIALIZABLE에서 SSI(Serializable Snapshot Isolation) 를 쓴다 — 일단 낙관적으로 진행하고, 커밋 시점에 직렬 가능성이 깨졌다고 판단되면 트랜잭션을 실패시킨다.
그래서 PostgreSQL에서 SERIALIZABLE을 쓰면 재시도 로직이 필수다.
실무에서는
격리수준을 올리기보다 문제가 되는 지점만 다르게 다룬다.
-- 예약 중복을 막고 싶다면 격리수준 대신 유니크 제약
ALTER TABLE reservation ADD UNIQUE (room_id, date);
-- 두 번째 INSERT 는 무조건 실패한다. 격리수준과 무관하게 확실하다
DB의 제약조건은 어떤 격리수준에서도 뚫리지 않는다. 가장 튼튼한 방어선이다.