[MySQL] 갭 락은 REPEATABLE READ에서만 발생한다 — 이 말이 왜 절반만 맞는가

선상원·2026년 4월 11일

mysql

목록 보기
14/14

🎯 들어가며

제가 재직하고 있는 아임웹은 누구나 손쉽게 자신만의 웹사이트를 만들 수 있는
노코드 웹빌더 솔루션을 제공하는 기업입니다.

아임웹의 특정 서비스는 단일 요청에서 수십 개의 테이블에 걸쳐
INSERT / UPDATE / DELETE가 복합적으로 발생하는 대량 트랜잭션을 유발합니다.
이 트랜잭션은 하루에도 빈번하게 반복적으로 실행되며,
대규모 데이터를 보유한 사이트일수록 더욱 잦게 발생합니다.

DBA로서 실시간 모니터링을 진행하던 중, 저는 아래와 같은 패턴을 마주하게 됩니다.


대량의 INSERT 쿼리가 동시에 락에 걸리며,
innodb_lock_wait_timeout에 도달할 때까지 지연되다 자연스럽게 해소된다.


처음 이 패턴을 발견했을 때는 단순히 처리할 레코드 수가 많아 발생하는 지연이라고 생각했습니다.
그러나 동료 DBA의 추측과 MySQL 공식 문서를 교차 검토한 결과,
이는 처리 지연이 아닌 InnoDB의 잠금 메커니즘, 그중에서도 갭 락(Gap Lock) 이 원인임을 알 수 있었습니다.

더 흥미로웠던 점은, 저희 환경이 갭 락이 발생하지 않아야 하는 조건을 갖추고 있었다는 것입니다.

▶ 트랜잭션 격리 수준: READ COMMITTED
▶ 바이너리 로그 포맷: ROW (READ COMMITTED를 복제 환경에서 안전하게 사용하기 위한 전제 조건)

갭 락을 비활성화하는 직접적인 원인은 READ COMMITTED 격리 수준입니다.
ROW 포맷은 갭 락과 직접적인 관련은 없지만, RC를 안전하게 사용하기 위해 함께 설정됩니다.
그런데 RC 환경임에도 왜 갭 락이 발생한 것일까요?


이 글에서는 그 원인을 추적하는 과정과,
READ COMMITTED 환경에서도 갭 락이 사라지지 않는 두 가지 예외를 다룹니다.

Unique Key 가 만드는 잠금 구조
Foreign Key 까지 추가했을 때 어떤 일이 벌어지는가


📚 배경 지식: 넥스트 키 락과 갭 락은 왜 생기는가

이전 글 [생각정리 - 외래 키(foreign key) 그만 알아보자!]에서 FK의 전략적 판단 기준을 정리한 바 있습니다.
이번 글은 그 판단의 기술적 근거가 되는 잠금 메커니즘을 한 단계 더 파고듭니다.


🔒 넥스트 키 락(Next-Key Lock)이란?

InnoDB는 기본적으로 넥스트 키 락(Next-Key Lock) 을 잠금의 기본 단위로 사용합니다.
넥스트 키 락은 두 가지 잠금의 결합입니다.

레코드 락(Record Lock) : 인덱스 레코드 자체에 걸리는 잠금
갭 락(Gap Lock) : 인덱스 레코드 바로 앞 구간에 걸리는 잠금

-- 예시: id 컬럼에 10, 20, 30이 존재하는 테이블
-- id = 20에 넥스트 키 락이 걸리면 아래 범위가 잠김
 
(10, 20]  ← 갭 락(10~20 사이 구간) + 레코드 락(20 레코드)

즉, 넥스트 키 락은 레코드 자체그 앞의 구간을 동시에 보호하는 구조입니다.


🤔 REPEATABLE READ에서 갭 락이 필요한 이유

REPEATABLE READ 격리 수준은 팬텀 리드(Phantom Read) 를 방지해야 할 의무가 있습니다.

팬텀 리드란, 동일한 트랜잭션 안에서 같은 범위를 두 번 조회했을 때
처음에는 없던 레코드가 두 번째 조회에서 나타나는 현상입니다.

-- 팬텀 리드 예시
 
트랜잭션 A: SELECT * FROM t WHERE id BETWEEN 10 AND 20;  → 1건 조회
트랜잭션 B: INSERT INTO t (id) VALUES (15);  COMMIT;
트랜잭션 A: SELECT * FROM t WHERE id BETWEEN 10 AND 20;  → 2건 조회 (팬텀!)

이를 방지하기 위해 InnoDB는 REPEATABLE READ에서
조회 범위 사이의 구간에 갭 락을 설정하여 다른 트랜잭션의 INSERT를 차단합니다.

즉, 갭 락은 REPEATABLE READ가 팬텀 리드를 막기 위해 치르는 비용입니다.


✅ READ COMMITTED에서 갭 락이 사라지는 이유

READ COMMITTED 격리 수준은 팬텀 리드 방지 의무가 없습니다.
각 쿼리가 실행되는 시점의 커밋된 데이터를 읽으면 되기 때문에,
굳이 구간을 잠가둘 필요가 없습니다.

그 결과, InnoDB는 READ COMMITTED에서 갭 락을 비활성화합니다.
잠금은 레코드 자체에만 걸리고, 구간은 열려 있습니다.


📌 참고: STATEMENT 포맷과 REPEATABLE READ

종종 "STATEMENT 포맷이기 때문에 넥스트 키 락이 발생한다"는 설명을 볼 수 있는데, 이는 인과관계가 역전된 표현입니다.
넥스트 키 락의 원인은 REPEATABLE READ 격리 수준이며, STATEMENT 포맷은 그 결과적 산물입니다.
MySQL은 STATEMENT 포맷 + READ COMMITTED 조합에서 복제 안전성 문제가 발생할 수 있어, STATEMENT를 사용하려면 REPEATABLE READ를 유지해야 하고 그 결과로 넥스트 키 락이 수반됩니다.


⚠️ RC + ROW 포맷에서도 갭 락이 사라지지 않는 두 가지 예외

READ COMMITTED 격리 수준에서 갭 락이 비활성화된다는 것은 사실입니다.
그러나 MySQL 공식 문서는 이 원칙에 두 가지 명확한 예외를 명시하고 있습니다.

"Gap locking is disabled for searches and index scans,
except for foreign-key constraint checking and duplicate-key checking."
📎 MySQL 8.0 Reference Manual - InnoDB Locking

해석하면 아래와 같습니다.

▶ 일반적인 검색과 인덱스 스캔에서는 갭 락이 비활성화됨
▶ 단, FK 제약 검사중복 키 감지 시에는 예외적으로 갭 락이 유지됨

하나씩 살펴보겠습니다.


1️⃣ Unique Key — 중복 키 감지

INSERT 실행 시 InnoDB는 해당 컬럼에 Unique Key가 설정되어 있다면
중복 여부를 반드시 확인해야 합니다.

이 중복 감지 과정에서 삽입하려는 키가 존재하지 않으면 Exclusive Record Lock을 직접 획득하고,
중복 키가 발견되면 Shared Next-Key Lock 을 획득합니다.

-- 예시: email 컬럼에 Unique Key가 설정된 테이블
 
INSERT INTO users (email) VALUES ('test@example.com');
 
  ├─► [중복 없음] InnoDB: 인덱스 레코드 탐색
  │         └─► Exclusive Record Lock 직접 획득 → INSERT 진행
  │
  └─► [중복 있음] InnoDB: 중복 키 에러 발생
              └─► Shared Next-Key Lock 획득 → 대기 또는 데드락

단, 이 Shared Next-Key Lock이 걸리는 조건에는 한 가지 중요한 전제가 있습니다.

삽입하려는 레코드가 존재하지 않는 경우 → Exclusive Record Lock 직접 획득 (갭 락 없음)
삽입하려는 레코드가 이미 존재하는 경우 → 중복 키 에러 발생 → Shared Next-Key Lock 획득

즉, RC 환경에서 UK로 인한 Shared Next-Key Lock은 중복 키 에러가 발생했을 때 걸립니다.
그러나 여러 트랜잭션이 동시에 동일한 키 범위로 INSERT를 시도하는 순간,
이 Shared Next-Key Lock이 데드락의 씨앗이 됩니다.
해당 시나리오는 다음 섹션에서 상세히 다룹니다.


2️⃣ Foreign Key — 참조 무결성 검사

FK가 설정된 테이블에서 INSERT 또는 UPDATE가 발생하면,
InnoDB는 참조 대상인 부모 테이블에 해당 레코드가 존재하는지 검사합니다.

이 과정에서도 동일하게 Shared Next-Key Lock 이 획득됩니다.

-- 예시: orders 테이블이 users 테이블을 FK로 참조
 
INSERT INTO orders (user_id, product) VALUES (42, 'item_A');
 
  └─► InnoDB: users 테이블에서 user_id = 42 존재 여부 검사
        └─► users의 해당 인덱스 레코드에 Shared Next-Key Lock 획득
              └─► 존재 확인 후 INSERT 진행

여기서 주목할 점은 child 테이블을 쓸 때 parent 테이블에 락이 걸린다는 것입니다.
이는 FK가 설정되는 순간, 잠금의 영향 범위가 단일 테이블을 넘어
참조 체인 전체로 확장된다는 것을 의미합니다.


📌 두 예외의 공통점

구분발생 시점잠금 종류잠금 대상
Unique KeyINSERT 시 중복 감지Shared Next-Key Lock해당 테이블 인덱스 레코드
Foreign KeyINSERT/UPDATE 시 참조 검사Shared Next-Key Lock부모 테이블 인덱스 레코드

두 예외 모두 Shared Next-Key Lock 이라는 동일한 잠금을 사용합니다.
그리고 이 공유 잠금이 여러 트랜잭션 사이에서 충돌할 때,
READ COMMITTED와 ROW 포맷을 사용하더라도 피할 수 없는 데드락 으로 이어질 수 있습니다.


🔒 Unique Key가 만드는 잠금 구조

📌 정상 케이스: 중복이 없는 경우

단일 트랜잭션에서 UK 컬럼으로 INSERT가 발생하면 아래 순서로 처리됩니다.

INSERT INTO users (email) VALUES ('test@example.com');
 
Step 1. InnoDB: 'test@example.com' 인덱스 레코드 탐색
Step 2. 중복 없음 확인 → Exclusive Record Lock 직접 획득
Step 3. INSERT 완료 후 커밋 시 잠금 해제

중복이 없는 경우 S-lock 없이 X-lock을 직접 획득하여 INSERT를 처리합니다.
Shared Next-Key Lock은 다음에 설명할 중복 키 충돌이 발생했을 때 등장합니다.


💀 데드락 시나리오: 동시 INSERT

아래 테이블을 기준으로 살펴보겠습니다.

create table users (
    id    int          not null auto_increment
  , email varchar(255) not null
  , primary key (id)
  , unique key uk_email (email)
);

현재 테이블에는 test@example.com 레코드가 존재하지 않는 상태입니다.
세션 A, B, C가 동시에 동일한 email로 INSERT를 시도합니다.

Step 1.
  세션 A: INSERT INTO users (email) VALUES ('test@example.com');
          → 중복 없음 확인 → X-lock 획득 → INSERT (미커밋 상태) ✅
 
  세션 B: INSERT INTO users (email) VALUES ('test@example.com');
          → 세션 A의 미커밋 레코드로 인해 중복 키 에러 발생
          → Shared Next-Key Lock 요청 → 세션 A의 X-lock 때문에 대기 ⏳
 
  세션 C: INSERT INTO users (email) VALUES ('test@example.com');
          → 세션 B와 동일하게 Shared Next-Key Lock 대기 ⏳
 
Step 2.
  세션 A: 롤백 (또는 에러)
          → 세션 B, C: Shared Next-Key Lock 동시 획득 ✅
          (Shared 잠금은 서로 호환되므로 두 세션 모두 획득 가능)
 
Step 3.
  세션 B: Exclusive Lock 전환 시도
          → 세션 C의 Shared Lock 때문에 대기 ⏳
 
  세션 C: Exclusive Lock 전환 시도
          → 세션 B의 Shared Lock 때문에 대기 ⏳
 
Step 4.
  세션 B: 세션 C를 기다리는 중
  세션 C: 세션 B를 기다리는 중
  → 교착 상태 → DEADLOCK 🔴

핵심은 두 가지입니다.
첫째, Shared Next-Key Lock은 중복 키 에러가 발생했을 때 걸립니다.
둘째, Shared Lock은 서로 호환되므로 여러 세션이 동시에 획득할 수 있고,
이후 X-lock으로 전환하려는 순간 서로를 기다리며 데드락이 발생합니다.


⚠️ 실무에서 이 패턴이 위험한 이유

단순히 "같은 email로 두 요청이 동시에 들어오는 경우"만의 문제가 아닙니다.
앞서 설명한 아임웹의 특정 서비스처럼, 단일 트랜잭션 내에서 수십 개의 테이블에 대량 INSERT가 발생하는 구조에서는

▶ 여러 트랜잭션이 같은 테이블의 인접한 키 범위에 동시에 접근하고
▶ 각각이 Shared Next-Key Lock을 획득한 채로 Exclusive Lock 전환을 시도하며
innodb_lock_wait_timeout에 도달할 때까지 대기하다 롤백되는 패턴이 반복됩니다.

이는 데드락으로 즉시 종료되지 않더라도,
수많은 트랜잭션이 동시에 락 대기 상태에 빠지는 것만으로도 서비스에 충분한 영향을 줍니다.


🔗 Foreign Key까지 추가하면 어떻게 되는가

UK만으로도 이미 RC 환경에서 갭 락이 발생한다는 것을 확인했습니다.
여기에 FK까지 추가하면 어떤 일이 벌어질까요?


📌 FK가 만드는 잠금 전파 구조

FK가 설정된 환경에서 child 테이블에 INSERT가 발생하면,
InnoDB는 parent 테이블에 참조 레코드가 존재하는지 검사합니다.
이 과정에서 parent 테이블의 해당 인덱스 레코드에 Shared Next-Key Lock이 걸립니다.

-- 구조: orders(child) → users(parent) FK 참조
 
INSERT INTO orders (user_id, amount) VALUES (42, 10000);
 
  └─► InnoDB: users 테이블에서 user_id = 42 존재 여부 검사
        └─► users의 uk_user_id 인덱스에 Shared Next-Key Lock 획득
              └─► 존재 확인 후 orders에 INSERT 진행

중요한 것은 이 잠금이 child를 쓰는 트랜잭션이 parent에 걸어두는 잠금이라는 점입니다.

즉, FK가 설정되는 순간 잠금의 영향 범위가 단일 테이블을 벗어납니다.


💀 데드락 시나리오: parent ↔ child 교차 잠금

아래 구조를 기준으로 살펴보겠습니다.
시나리오는 세션 A와 B가 각각 동일한 트랜잭션 안에서 child INSERT 후 parent UPDATE를 순차적으로 실행하는 상황을 가정합니다.

-- parent 테이블
create table users (
    id    int not null auto_increment
  , email varchar(255) not null
  , primary key (id)
  , unique key uk_email (email)
);
 
-- child 테이블 (users를 FK로 참조)
create table orders (
    id      int not null auto_increment
  , user_id int not null
  , amount  int not null
  , primary key (id)
  , constraint fk_orders_user foreign key (user_id) references users (id)
);
Step 1.
  세션 A: INSERT INTO orders (user_id, amount) VALUES (42, 10000);
          → users의 id = 42 인덱스에 Shared Next-Key Lock 획득 ✅
 
  세션 B: INSERT INTO orders (user_id, amount) VALUES (42, 20000);
          → users의 id = 42 인덱스에 Shared Next-Key Lock 획득 ✅
          (Shared 잠금은 서로 호환)
 
Step 2.
  세션 A: users의 id = 42 레코드를 UPDATE 시도
          → Exclusive Lock 필요
          → 세션 B의 Shared Lock 때문에 대기 ⏳
 
  세션 B: users의 id = 42 레코드를 UPDATE 시도
          → Exclusive Lock 필요
          → 세션 A의 Shared Lock 때문에 대기 ⏳
 
Step 3.
  세션 A: 세션 B를 기다리는 중
  세션 B: 세션 A를 기다리는 중
  → DEADLOCK 🔴

UK 데드락은 동일 테이블 내에서 충돌이 완결됩니다.
FK 데드락은 child를 쓰면서 parent에 잠금이 전파되고,
parent를 수정하려는 다른 트랜잭션과 교차하며 충돌합니다.
잠금의 전파 범위 자체가 다릅니다.


📊 UK 단독 vs UK + FK 잠금 구조 비교

구분잠금 발생 테이블데드락 발생 조건
UK 단독해당 테이블 1개동일 키 범위 동시 INSERT
UK + FK해당 테이블 + parent 테이블위 조건 + parent 동시 UPDATE/DELETE

FK가 추가될수록 하나의 트랜잭션이 잠금을 획득해야 하는 테이블이 늘어납니다.
참조 체인이 길어질수록 이 구조는 더욱 복잡해집니다.


⚠️ 고QPS 환경에서 이 구조가 위험한 이유

단순히 데드락 발생 가능성의 문제가 아닙니다.

parent 테이블이 핵심 테이블일수록 child의 모든 쓰기가 parent에 공유 잠금을 걸어둠
▶ 동시에 수십 개의 트랜잭션이 같은 parent 레코드를 참조하면 공유 잠금이 누적
▶ parent를 수정하려는 트랜잭션은 모든 공유 잠금이 해제될 때까지 대기
▶ Write QPS가 높을수록 대기 트랜잭션이 쌓이는 속도 > 해소되는 속도

데드락이 발생하지 않더라도,
이 구조에서는 락 경합 누적 자체가 레이턴시 저하로 직결됩니다.


🛠️ 그렇다면 실무에서 어떻게 판단하는가

지금까지의 내용을 정리하면 아래와 같습니다.

READ COMMITTED + ROW 포맷으로 전환해도 갭 락이 완전히 사라지지는 않는다
▶ UK가 있는 테이블에 동시 INSERT가 발생하면 Shared Next-Key Lock 충돌로 데드락이 발생할 수 있다
▶ FK까지 추가하면 잠금이 parent 테이블로 전파되며 데드락 패턴이 더 복잡해진다

그렇다면 UK와 FK 각각에 대해 어떻게 판단해야 할까요?


📌 Unique Key에 대한 판단

UK는 데이터 무결성의 핵심 제약조건입니다.
갭 락 예외가 발생한다는 이유만으로 UK를 제거하는 것은 현실적인 선택이 아닙니다.

대신 아래 방향으로 접근하는 것이 실무적입니다.

근본적 해결 방안

트랜잭션을 짧고 단순하게 유지한다
Shared Next-Key Lock의 보유 시간을 최소화하는 것이 핵심입니다.
트랜잭션이 길어질수록 잠금 충돌 가능성이 높아집니다.

동일한 UK 범위로의 동시 INSERT 가능성을 애플리케이션 레벨에서 줄인다
요청 직렬화, 분산 락 등을 통해 동일 키로의 동시 접근 자체를 줄이는 것이 근본적인 해결책입니다.

데드락 발생 시 재시도 로직을 구현한다
UK 충돌로 인한 데드락은 일시적인 경합에서 비롯되는 경우가 많습니다.
애플리케이션 레벨의 재시도 로직으로 상당 부분 흡수할 수 있습니다.

단기 적용 가능 방안

innodb_lock_wait_timeout 조정을 검토한다
위 세 가지를 단기간에 적용하기 어려운 상황이라면,
innodb_lock_wait_timeout 값을 낮춰 대기 트랜잭션이 빠르게 실패하도록 유도하는 방법을 검토할 수 있습니다.

-- 기본값: 50초
-- 세션 레벨 조정 예시
set session innodb_lock_wait_timeout = 5;
 
-- 전역 레벨 조정 예시
set global innodb_lock_wait_timeout = 5;

대기 시간을 줄이면 락이 누적되는 속도를 억제할 수 있습니다.
그러나 이 방안은 단독으로는 의미가 없으며, 반드시 아래 두 가지 조건이 충족되어야 합니다.

전제 조건: 빠른 실패는 빠른 에러입니다. 애플리케이션 레벨의 재시도 로직이 없으면 에러율만 높아집니다.
한계: innodb_lock_wait_timeout은 전역 설정입니다. 문제가 되는 트랜잭션만 선택적으로 적용할 수 없으며,
값을 낮출수록 정상적인 장시간 트랜잭션도 영향을 받으므로 충분한 테스트 후 적용해야 합니다.


📌 Foreign Key에 대한 판단

FK는 UK와 다르게 DB 레벨 설정을 대체할 수 있는 수단이 존재합니다.

상황FK DB 레벨 설정판단 근거
소규모 / 저QPS 서비스✅ 가능무결성 보장 이점이 락 비용보다 큼
고QPS / 핵심 테이블❌ 비권장parent 테이블 락 전파로 레이턴시 저하
pt-osc 운영 환경❌ 비권장--alter-foreign-keys-method 이슈
대규모 DDL 작업 빈번❌ 비권장FK 순서 의존성으로 운영 복잡도 증가

고QPS 환경에서 FK를 DB 레벨에서 제거하기로 결정했다면,
참조 무결성은 아래 방식으로 대체할 수 있습니다.

애플리케이션 레벨 보장 — BE에서 참조 무결성 검사 로직을 직접 구현
주기적 배치 탐지 — orphan row를 감지하는 쿼리를 정기적으로 실행해 정합성 모니터링
ERD 문서화 — 논리적 관계는 ERD에 명시해 개발자가 참조 관계를 인지할 수 있도록 유지

DB가 무결성을 강제하지 않는다고 해서 무결성 관리를 포기하는 것이 아닙니다.
책임의 위치를 DB에서 애플리케이션으로 이동하는 것입니다.


🎯 마치며

이 글을 한 줄로 요약하면 아래와 같습니다.

"갭 락은 READ COMMITTED로 없앨 수 있다 — 이 말은 절반만 맞습니다."

READ COMMITTED와 ROW 포맷은 분명히 대부분의 갭 락을 제거합니다.
그러나 Unique Key의 중복 감지Foreign Key의 참조 무결성 검사
MySQL 공식 문서가 명시한 예외이며, 격리 수준과 무관하게 동작합니다.

DBA로서 이 예외를 인지하고 있는 것과 그렇지 않은 것은
장애 상황에서 원인을 찾는 속도에 큰 차이를 만들어냅니다.


💡 핵심 포인트

▶ 넥스트 키 락 = 레코드 락 + 갭 락, InnoDB의 기본 잠금 단위
▶ 갭 락의 원인은 격리 수준(REPEATABLE READ)이며, STATEMENT 포맷은 결과적 산물
READ COMMITTED에서도 UK 중복 감지, FK 참조 검사 시에는 Shared Next-Key Lock 발생
▶ UK 데드락은 단일 테이블 내에서, FK 데드락은 parent 테이블로 전파되어 발생
▶ 고QPS 환경에서는 데드락이 없더라도 락 경합 누적 자체가 레이턴시 저하로 직결

profile
쉼 없는 고민과 학습을 통해 가장 효율적인 데이터베이스 관리 방안을 찾고자 노력하는 DBA 입니다.

0개의 댓글