제가 재직하고 있는 아임웹은 누구나 손쉽게 자신만의 웹사이트를 만들 수 있는
노코드 웹빌더 솔루션을 제공하는 기업입니다.
아임웹의 특정 서비스는 단일 요청에서 수십 개의 테이블에 걸쳐
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의 전략적 판단 기준을 정리한 바 있습니다.
이번 글은 그 판단의 기술적 근거가 되는 잠금 메커니즘을 한 단계 더 파고듭니다.
InnoDB는 기본적으로 넥스트 키 락(Next-Key Lock) 을 잠금의 기본 단위로 사용합니다.
넥스트 키 락은 두 가지 잠금의 결합입니다.
▶ 레코드 락(Record Lock) : 인덱스 레코드 자체에 걸리는 잠금
▶ 갭 락(Gap Lock) : 인덱스 레코드 바로 앞 구간에 걸리는 잠금
-- 예시: id 컬럼에 10, 20, 30이 존재하는 테이블
-- id = 20에 넥스트 키 락이 걸리면 아래 범위가 잠김
(10, 20] ← 갭 락(10~20 사이 구간) + 레코드 락(20 레코드)
즉, 넥스트 키 락은 레코드 자체와 그 앞의 구간을 동시에 보호하는 구조입니다.
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 격리 수준은 팬텀 리드 방지 의무가 없습니다.
각 쿼리가 실행되는 시점의 커밋된 데이터를 읽으면 되기 때문에,
굳이 구간을 잠가둘 필요가 없습니다.
그 결과, InnoDB는 READ COMMITTED에서 갭 락을 비활성화합니다.
잠금은 레코드 자체에만 걸리고, 구간은 열려 있습니다.
종종 "STATEMENT 포맷이기 때문에 넥스트 키 락이 발생한다"는 설명을 볼 수 있는데, 이는 인과관계가 역전된 표현입니다.
넥스트 키 락의 원인은 REPEATABLE READ 격리 수준이며, STATEMENT 포맷은 그 결과적 산물입니다.
MySQL은STATEMENT 포맷 + READ COMMITTED조합에서 복제 안전성 문제가 발생할 수 있어, STATEMENT를 사용하려면 REPEATABLE READ를 유지해야 하고 그 결과로 넥스트 키 락이 수반됩니다.
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 제약 검사와 중복 키 감지 시에는 예외적으로 갭 락이 유지됨
하나씩 살펴보겠습니다.
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이 데드락의 씨앗이 됩니다.
해당 시나리오는 다음 섹션에서 상세히 다룹니다.
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 Key | INSERT 시 중복 감지 | Shared Next-Key Lock | 해당 테이블 인덱스 레코드 |
| Foreign Key | INSERT/UPDATE 시 참조 검사 | Shared Next-Key Lock | 부모 테이블 인덱스 레코드 |
두 예외 모두 Shared Next-Key Lock 이라는 동일한 잠금을 사용합니다.
그리고 이 공유 잠금이 여러 트랜잭션 사이에서 충돌할 때,
READ COMMITTED와 ROW 포맷을 사용하더라도 피할 수 없는 데드락 으로 이어질 수 있습니다.
단일 트랜잭션에서 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은 다음에 설명할 중복 키 충돌이 발생했을 때 등장합니다.
아래 테이블을 기준으로 살펴보겠습니다.
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에 도달할 때까지 대기하다 롤백되는 패턴이 반복됩니다.
이는 데드락으로 즉시 종료되지 않더라도,
수많은 트랜잭션이 동시에 락 대기 상태에 빠지는 것만으로도 서비스에 충분한 영향을 줍니다.
UK만으로도 이미 RC 환경에서 갭 락이 발생한다는 것을 확인했습니다.
여기에 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가 설정되는 순간 잠금의 영향 범위가 단일 테이블을 벗어납니다.
아래 구조를 기준으로 살펴보겠습니다.
시나리오는 세션 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 단독 | 해당 테이블 1개 | 동일 키 범위 동시 INSERT |
| UK + FK | 해당 테이블 + parent 테이블 | 위 조건 + parent 동시 UPDATE/DELETE |
FK가 추가될수록 하나의 트랜잭션이 잠금을 획득해야 하는 테이블이 늘어납니다.
참조 체인이 길어질수록 이 구조는 더욱 복잡해집니다.
단순히 데드락 발생 가능성의 문제가 아닙니다.
▶ parent 테이블이 핵심 테이블일수록 child의 모든 쓰기가 parent에 공유 잠금을 걸어둠
▶ 동시에 수십 개의 트랜잭션이 같은 parent 레코드를 참조하면 공유 잠금이 누적
▶ parent를 수정하려는 트랜잭션은 모든 공유 잠금이 해제될 때까지 대기
▶ Write QPS가 높을수록 대기 트랜잭션이 쌓이는 속도 > 해소되는 속도
데드락이 발생하지 않더라도,
이 구조에서는 락 경합 누적 자체가 레이턴시 저하로 직결됩니다.
지금까지의 내용을 정리하면 아래와 같습니다.
▶ READ COMMITTED + ROW 포맷으로 전환해도 갭 락이 완전히 사라지지는 않는다
▶ UK가 있는 테이블에 동시 INSERT가 발생하면 Shared Next-Key Lock 충돌로 데드락이 발생할 수 있다
▶ FK까지 추가하면 잠금이 parent 테이블로 전파되며 데드락 패턴이 더 복잡해진다
그렇다면 UK와 FK 각각에 대해 어떻게 판단해야 할까요?
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은 전역 설정입니다. 문제가 되는 트랜잭션만 선택적으로 적용할 수 없으며,
값을 낮출수록 정상적인 장시간 트랜잭션도 영향을 받으므로 충분한 테스트 후 적용해야 합니다.
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 환경에서는 데드락이 없더라도 락 경합 누적 자체가 레이턴시 저하로 직결