행 락, 갭 락, 넥스트 키 락 — MySQL InnoDB가 Phantom을 막는 방법
락의 세 종류가 필요한 이유
앞 장에서 MVCC는 "이미 있는 행의 값 변경"은 막지만 "아직 없는 행의 등장(Phantom)"은 막지 못한다고 했습니다. InnoDB는 이 문제를 순수 락으로 해결하는데, 존재하지 않는 행에 락을 걸 수는 없으니 대신 행과 행 사이의 빈 공간에 락을 겁니다. 그래서 InnoDB의 락 체계는 세 층으로 나뉩니다.
flowchart TD A["레코드 락 (Record Lock)"] --> D["이미 존재하는 특정 행 하나를 잠근다"] B["갭 락 (Gap Lock)"] --> E["행과 행 사이의 빈 구간을 잠근다\n— 여기에 INSERT를 못 하게 막는다"] C["넥스트 키 락 (Next-Key Lock)"] --> F["레코드 락 + 갭 락\nInnoDB Repeatable Read의 기본 동작"]
재현: 갭 락이 Phantom을 막는 순간
-- 테이블: orders(id, amount), id에 인덱스 있음. 현재 id 10, 20 두 행만 존재
-- 세션 A (Repeatable Read, InnoDB 기본값)
BEGIN;
SELECT * FROM orders WHERE id BETWEEN 10 AND 20 FOR UPDATE;
-- id=10, id=20 레코드 락 + 그 사이 갭에 갭 락이 걸린다
-- 세션 B
BEGIN;
INSERT INTO orders (id, amount) VALUES (15, 1000);
-- 대기 상태로 멈춘다. 15는 10과 20 사이 갭에 속하기 때문이다세션 A가 커밋하거나 롤백하기 전까지 B의 INSERT는 블록됩니다. PostgreSQL이었다면
이 상황은 다르게 흘러갑니다 — PostgreSQL은 FOR UPDATE가 실제로 존재하는 행에만
락을 걸고, 갭이라는 개념 자체가 없습니다. 대신 PostgreSQL의 SERIALIZABLE(SSI)이
같은 문제를 다른 방식으로, 즉 "이 트랜잭션이 나중에 문제를 일으킬 읽기 패턴을
가졌는지" 추적해서 처리합니다(8장). InnoDB는 애초에 락으로 미리 막고, PostgreSQL은
사후에 충돌을 감지해서 한쪽을 실패시키는 셈입니다 — 같은 목표, 다른 철학입니다.
넥스트 키 락이 만드는 데드락
넥스트 키 락은 Phantom을 확실히 막아주지만 그 대가로 락의 범위가 넓어집니다. 인덱스를 스캔하는 범위 전체에 걸쳐 갭 락이 걸리기 때문에, 겉보기엔 관계없어 보이는 두 트랜잭션이 서로의 갭을 침범하면서 대기하게 됩니다. 실무에서 InnoDB 데드락 로그를 분석하다 보면 "분명 다른 행을 건드렸는데 왜 락이 충돌하지?"라는 의문이 자주 나오는데, 답은 대부분 갭 락입니다. 이 내용은 다음 장에서 실제 데드락 로그를 읽으며 다시 다룹니다.
인덱스가 없으면 락 범위가 넓어진다
한 가지 실무에서 자주 놓치는 함정이 있습니다. InnoDB의 갭 락은 인덱스를 기준으로
걸립니다. WHERE 절에 쓰인 컬럼에 인덱스가 없으면, 옵티마이저는 풀 스캔을 하고
InnoDB는 스캔한 모든 행(그리고 그 사이 모든 갭)에 락을 겁니다. 즉 인덱스 하나
빠뜨린 게 "한 행만 잠글 작업"을 "테이블 전체를 잠그는 작업"으로 바꿔버립니다. 저는
이 문제를 실제 장애에서 두 번 봤는데, 두 번 다 원인은 느린 쿼리가 아니라 "인덱스가
없는 컬럼으로 UPDATE ... WHERE 를 실행해서 락이 필요 이상으로 번진" 경우였습니다.
-- amount 컬럼에 인덱스가 없다면
UPDATE orders SET status = 'shipped' WHERE amount > 10000;
-- 조건에 맞는 행뿐 아니라 스캔 경로 전체에 갭 락이 걸릴 수 있다
EXPLAIN UPDATE orders SET status = 'shipped' WHERE amount > 10000;
-- 실행 계획에서 인덱스 사용 여부를 반드시 확인한다PostgreSQL에는 갭 락이 없다는 것의 의미
이 장 전체가 InnoDB 이야기인 이유는, PostgreSQL을 쓴다면 이 절 전체가 적용되지 않기 때문입니다. PostgreSQL로 넘어오면서 InnoDB 경험만 믿고 "Repeatable Read는 갭까지 잠근다"고 가정하면 틀립니다 — PostgreSQL은 갭 락 없이 스냅샷만으로 Phantom을 막습니다(5장). 두 DB가 같은 결과(Phantom 차단)를 완전히 다른 메커니즘으로 만든다는 것이, 이 장과 5장을 나란히 읽어야 하는 이유입니다.