조건에 없는 값을 INSERT했는데 왜 멈추나: InnoDB의 Record, Gap, Next-Key Lock

송현진·2026년 8월 16일

DataBase

목록 보기
11/11

조건에 없는 값을 INSERT했는데 왜 멈추나: InnoDB의 Record, Gap, Next-Key Lock

MySQL에서 SELECT ... FOR UPDATE 하나 걸었을 뿐인데, 그 조건에 걸리지도 않은 값을 INSERT하려던 다른 트랜잭션이 멈춘다. 락은 "이 행 하나"에만 걸린다고 생각하면 이 현상이 설명되지 않는다. 실제로 InnoDB의 락은 레코드가 아니라 인덱스 위의 "범위"에 걸리고, 그래서 아무 관계도 없어 보이는 INSERT가 줄줄이 대기하다 못해 서로를 물고 데드락까지 간다. 오늘은 til-lab 컨테이너로 이걸 직접 재현했다. 잠긴 gap 안으로 들어간 INSERT는 5.8초를 기다렸고, 바로 옆 잠기지 않은 gap으로 들어간 INSERT는 0.4초 만에 끝났다. 데드락도 손으로 유발해 SHOW ENGINE INNODB STATUS에 박제된 순환 구조를 읽었다.

이 글의 축은 두 가지다. Record Lock, Gap Lock, Next-Key Lock이 각각 무엇을 잠그는가, 그리고 이 "범위를 잠근다"는 성질이 어떻게 데드락으로 이어지는가. 모두 InnoDB 기본 격리 수준인 REPEATABLE READ를 기준으로 한다.

점을 잠그는 락, 사이를 잠그는 락, 둘을 함께 잠그는 락

먼저 세 락이 각각 무엇을 잠그는지부터 정확히 구분해야 한다. 셋은 잠그는 대상이 다르다.

Record Lock은 인덱스에 존재하는 레코드(행) 하나에 거는 자물쇠다. val=20이라는 행 그 자체를 잠근다. 다른 트랜잭션이 이 행을 UPDATE하거나 잠그려 하면 막히지만, 이 행 주변의 빈 공간에는 아무 영향이 없다.

Gap Lock은 반대로 레코드가 아니라 레코드와 레코드 "사이의 빈 공간(gap)"을 잠근다. 예를 들어 val이 10, 20인 두 행 사이의 (10, 20) 구간을 잠그면, 그 구간에 값이 들어오는 새 행의 INSERT만 막는다. 정작 val=10이나 val=20 행 자체는 잠그지 않는다. Gap Lock은 오직 "이 틈에 새 행이 끼어드는 것"을 막는 용도다.

Next-Key Lock은 이 둘을 합친 것으로, 레코드 자체와 그 레코드 바로 앞의 gap을 한 번에 잠근다. val=20에 걸린 next-key lock은 레코드 20과 그 앞 구간 (10, 20]을 통째로 덮는다. REPEATABLE READ에서 인덱스를 스캔하며 만나는 레코드에는 기본적으로 이 Next-Key Lock이 걸린다. 즉 평소에 우리가 FOR UPDATE로 조회할 때 실제로 걸리는 건 순수 Record Lock이 아니라 대부분 이 Next-Key Lock이다.

세 락의 관계를 한 줄로 요약하면 이렇다. Record Lock은 점(레코드), Gap Lock은 구간(사이), Next-Key Lock은 구간과 그 끝점을 함께 잠근다. 그리고 REPEATABLE READ의 기본값은 셋 중 가장 넓은 Next-Key Lock이다.

왜 gap까지 잠가야 하는가. 여기에 팬텀 리드(Phantom Read)를 막는 이유가 숨어 있다. val 값이 10, 20, 30, 40인 행이 있다고 하자. 트랜잭션 A가 WHERE val BETWEEN 10 AND 30 FOR UPDATE로 조회한 뒤, 같은 트랜잭션 안에서 다시 같은 조건으로 조회하면 REPEATABLE READ에서는 항상 같은 결과가 나와야 한다. 그런데 A가 조회한 세 행에만 락을 걸고 그 사이 공간을 열어두면, 트랜잭션 B가 val = 15인 행을 몰래 INSERT하고 커밋해버릴 수 있다. 그러면 A가 같은 쿼리를 다시 실행했을 때 없던 행이 툭 튀어나온다. 이게 팬텀이다. Gap Lock은 바로 이 틈새로 새 행이 끼어드는 것을 막으려고 존재한다.

도서관 서가로 바꿔보면 이렇다. Record Lock은 "이 책"에 붙이는 대출 딱지다. Gap Lock은 "이 책과 저 책 사이 빈 칸에 새 책을 꽂지 마시오"라는 표지판이다. Next-Key Lock은 책 한 권을 대출하면서 동시에 "이 책 바로 앞 빈 칸도 당분간 비워두세요"라고 선언하는 것이다. 그래야 누군가 그 빈 칸에 새 책을 슬쩍 꽂아 서가 배열(조회 결과)이 바뀌는 걸 막을 수 있다.

여기서 놓치기 쉬운 예외가 하나 있다. 유니크 인덱스를 등가 조건(=)으로 검색해서 존재하는 행을 정확히 찾은 경우에는 gap을 잠글 필요가 없으므로 Record Lock만 건다. 유일한 값을 콕 집어 찾았으니 그 사이로 끼어들 "같은 값"이 애초에 없기 때문이다. 반대로 유니크 인덱스로 찾았는데 그 값이 존재하지 않으면, 그 위치의 gap에는 락이 걸린다. 뒤에서 볼 데드락이 순수 Record Lock으로만 생긴 것도 이 예외 덕분이다.

잠긴 gap과 잠기지 않은 gap을 직접 갈라봤다

개념만으로는 "범위를 잠근다"가 잘 와닿지 않아서 til-lab MySQL 컨테이너로 직접 재현했다. 세컨더리 인덱스가 걸린 val 컬럼에서, 트랜잭션 A가 val=20FOR UPDATE로 잠근 채 6초간 유지한다. 그동안 다른 세션이 두 번의 INSERT를 시도한다. 하나는 A가 잠근 gap(10과 20 사이)으로, 다른 하나는 잠기지 않은 gap(30과 40 사이)으로 들어간다.

-- id PK, val 에 세컨더리 인덱스(비유니크)가 있는 테이블
CREATE TABLE lock_demo (id INT PRIMARY KEY, val INT, KEY idx_val (val));
INSERT INTO lock_demo VALUES (1,10),(2,20),(3,30),(4,40);

-- 트랜잭션 A: val=20 을 잠그면 그 앞 gap(10, 20]에 next-key lock 이 걸린다
START TRANSACTION;
SELECT * FROM lock_demo WHERE val = 20 FOR UPDATE;
-- (A 는 커밋하지 않고 유지)

-- 트랜잭션 B (다른 세션)
INSERT INTO lock_demo VALUES (5, 15); -- 잠긴 gap 안 → A 가 커밋할 때까지 대기
INSERT INTO lock_demo VALUES (6, 35); -- 잠기지 않은 gap → 즉시 성공

여기서 실습을 신뢰할 수 있게 만들려면 한 가지 함정을 넘어야 한다. 스크립트로 A를 백그라운드에 띄우고 B를 던지면, A의 FOR UPDATE가 실제로 락을 잡기 전에 B가 먼저 들어가버리는 타이밍 문제가 생긴다. 그래서 sleep으로 눈감고 넘어가는 대신, A의 락이 잡혔는지 performance_schema.data_locks를 폴링해 확인한 뒤에야 B를 던지게 했다. til 유저에겐 이 테이블 접근 권한이 기본적으로 없어서, root로 GRANT SELECT ON performance_schema.* TO 'til'@'%' 한 줄을 먼저 줬다.

잠긴 gap 안 INSERT는 5.8초를 대기하고, 바로 옆 gap INSERT는 0.4초에 끝났다

결과는 뚜렷하게 갈렸다. 잠긴 gap으로 들어간 val=15 INSERT는 real 0m5.815s, A가 남긴 시간만큼 그대로 대기했다. 반면 잠기지 않은 gap으로 들어간 val=35 INSERT는 real 0m0.477s, 거의 즉시 끝났다. 같은 테이블, 같은 순간, 같은 INSERT 문인데 하나는 5.8초를 멈추고 하나는 안 멈춘다. 락이 테이블 전체가 아니라 인덱스의 특정 범위에만 걸린다는 게 이 두 숫자로 증명된다.

val 인덱스 위에서 next-key lock 이 (10, 20) 구간에만 걸리고, (30, 40) 구간은 자유롭게 남는다

이때 B의 INSERT가 잠긴 gap 앞에서 멈추는 건 정확히는 Insert Intention Lock(삽입 의도 락) 때문이다. INSERT는 실행 직전에 삽입할 위치에 "내가 여기 뭔가 넣을 거야"라는 예약 표시를 거는데, 그 자리가 이미 다른 트랜잭션의 gap/next-key 락으로 덮여 있으면 그 락이 풀릴 때까지 기다린다. 서로 다른 위치에 삽입하는 트랜잭션끼리는 이 락 때문에 굳이 서로를 기다리지 않아도 되지만, 지금처럼 남이 잠근 gap 안으로 들어가려 하면 얄짤없이 대기한다.

범위를 잠그는 성질이 데드락으로 이어진다

락이 "범위"를 잠근다는 건 곧, 서로 다른 순서로 여러 자원에 접근하는 두 트랜잭션이 순환 대기에 빠질 여지가 그만큼 커진다는 뜻이다. 데드락은 트랜잭션 A가 B를 기다리고 동시에 B가 A를 기다리는, 스스로는 절대 풀리지 않는 순환 상태다.

이것도 직접 만들어봤다. 세션 A는 id=1id=2 순서로 UPDATE하고, 세션 B는 반대로 id=2id=1 순서로 UPDATE하게 짰다. SELECT SLEEP으로 타이밍을 조절해 A가 id=1을, B가 id=2를 각각 먼저 쥔 상태를 만든 다음, 서로 상대가 쥔 행을 요청하게 했다.

// 애플리케이션에서 이런 데드락이 나는 전형적인 코드
// 나쁨: 서비스마다 잠그는 순서가 제각각이면 순환이 생긴다
@Transactional
public void transfer(Long from, Long to, BigDecimal amount) {
    Account a = repo.findByIdForUpdate(from); // 여기서 from 을 먼저 잠근다
    Account b = repo.findByIdForUpdate(to);   // 다른 호출은 to 를 먼저 잠글 수 있다
    a.withdraw(amount);
    b.deposit(amount);
}
// 좋음: 항상 작은 id 를 먼저 잠그도록 순서를 통일한다
Long first = Math.min(from, to);
Long second = Math.max(from, to);

결과로 두 세션 중 하나가 ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction을 뱉으며 강제로 롤백됐다. InnoDB는 데드락 감지가 기본으로 켜져 있어서(innodb_deadlock_detect 기본값 ON), 락 대기가 생길 때마다 wait-for 그래프를 확인해 순환이 잡히면 롤백 비용이 더 적다고 판단되는 트랜잭션을 골라(그 크기는 INSERT/UPDATE/DELETE한 행 수로 매긴다) 즉시 되돌린다. 감지가 꺼져 있었다면 innodb_lock_wait_timeout(기본 50초)이 지나야 타임아웃으로 풀렸을 것이다.

SHOW ENGINE INNODB STATUSLATEST DETECTED DEADLOCK 섹션을 읽으면 이 순환이 그대로 박제돼 있다.

*** (1) TRANSACTION:
TRANSACTION 4224, ACTIVE 3 sec starting index read
...
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS ... index PRIMARY of table `til_lab`.`dl_demo` trx id 4224 lock_mode X locks rec but not gap
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... trx id 4224 lock_mode X locks rec but not gap waiting

*** (2) TRANSACTION:
TRANSACTION 4225, ACTIVE 2 sec starting index read
*** (2) HOLDS THE LOCK(S): ...
*** (2) WAITING FOR THIS LOCK TO BE GRANTED: ...

*** WE ROLL BACK TRANSACTION (2)

deadlock_demo.sh 로 유발한 실제 데드락, SHOW ENGINE INNODB STATUS 의 LATEST DETECTED DEADLOCK 섹션

읽어보면 TRX 4224(세션 A)가 id=1HOLDS한 채 id=2WAITING하고, TRX 4225(세션 B)가 정반대로 id=2를 쥐고 id=1을 기다린다. 서로가 서로를 물었고, 맨 아래 WE ROLL BACK TRANSACTION (2)에서 InnoDB가 4225를 희생자로 골라 순환을 끊었다.

TRX 4224 는 id=1 을 쥐고 id=2 를, TRX 4225 는 id=2 를 쥐고 id=1 을 기다려 순환이 완성된다

여기서 눈여겨볼 대목이 있다. 락 모드가 전부 lock_mode X locks rec but not gap, 즉 gap 없는 순수 Record Lock이다. WHERE id = ?는 PK(유니크 인덱스) 등가 조건이라 앞서 말한 예외에 걸려 gap을 안 잠갔다. 그러니까 데드락은 gap lock이 있어야만 생기는 게 아니다. 실제로 MySQL 공식 문서는 격리 수준은 읽기 동작의 방식을 바꾸는 반면 데드락은 쓰기 동작 때문에 발생하므로, 데드락 발생 가능성은 격리 수준의 영향을 받지 않는다고 못 박는다. gap lock은 데드락의 여지를 넓히는 요인이지 필요조건은 아니다. 순환을 만드는 본질은 "여러 자원을 서로 다른 순서로 잠그는 쓰기"다.

배운점

오늘 가장 크게 바뀐 건 "락은 행에 걸린다"는 머릿속 그림이다. 락은 행이 아니라 인덱스 위의 범위에 걸린다. 그래서 조건에 잡히지도 않은 값의 INSERT가 멈추고, 그래서 인덱스 설계가 곧 동시성 설계가 된다. 여기서 뽑을 한 줄 원칙은 이거다. 락의 단위는 행이 아니라 인덱스 범위다. 무엇을 잠그는지 알려면 먼저 어떤 인덱스로 스캔하는지를 봐야 한다.

FastCoupon을 다시 떠올리게 된다. 선착순 쿠폰 발급에서 동시성을 잡을 때 나는 Redis Lua로 원자적 카운터를 돌리는 쪽에 집중했고, DB 레벨의 락은 "그냥 비관적 락 걸면 되지" 정도로 얕게 넘겼다. 그런데 오늘 배운 걸로 보면, 만약 쿠폰 재고를 WHERE coupon_type = ? AND status = 'AVAILABLE' 같은 세컨더리 인덱스 범위 조건으로 FOR UPDATE 했다면, 등가 PK 조회와 달리 넓은 next-key lock이 걸려서 같은 타입 쿠폰을 건드리는 다른 발급 요청이 전부 그 gap 대기에 걸렸을 것이다. 초당 수천 건이 몰리는 플래시 세일에서 이건 그대로 처리량 붕괴다. 반대로 오늘 실습의 은행 이체 예시처럼 WHERE id = ? PK 등가 조회로 좁히면 gap 없는 record lock만 걸려 훨씬 유리하다. 그때는 왜 Redis로 우회했어야 했는지를 "DB가 느려서"라고만 뭉뚱그렸는데, 이제는 "어떤 조건으로 잠그느냐에 따라 락 범위가 달라지고, 넓은 범위 락이 동시성을 죽인다"까지 설명할 수 있게 됐다. 다음에 DB 비관적 락을 쓸 일이 생기면, 잠그는 쿼리가 어떤 인덱스로 어디까지 스캔하는지부터 EXPLAIN으로 확인하고 시작하겠다.

참고

profile
개발자가 되고 싶은 취준생

0개의 댓글