이 글은 2025년 9월에 발생한 이슈를 기록한 글입니다.
매일 22시, 출입 서버에서 출입 카드의 권한을 초기화 하는 배치가 자동으로 실행됩니다. 카드마다 기존 card_access_level(카드별 출입 권한) 레코드를 삭제하고 새 권한을 삽입하는 단순한 로직이었습니다.
그러던 어느 날 에러 알림이 들어왔습니다. 출입 서버에서 에러가 반복되고 있었고, 로그를 열어보니 MySQL 데드락이었습니다.
카드마다 독립적으로 처리되는 로직인데, 왜 데드락이 발생할까요?
show engine innodb status로 가장 최근 감지된 데드락을 확인했습니다.
LATEST DETECTED DEADLOCK
2025-09-01 13:12:37
*** (1) TRANSACTION: {trx_id_1}, ACTIVE 0 sec inserting
INSERT INTO `card_access_level`(`id`, `card_id`, `access_level_id`, ...)
VALUES (DEFAULT, 1001, 201, ...), (DEFAULT, 1001, 202, ...)
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS index idx_card_id of table `출입 서버 DB`.`card_access_level`
lock_mode X locks gap before rec
heap no N → card_id: 1003 (다음 레코드 기준의 gap)
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS index idx_card_id
lock_mode X locks gap before rec insert intention waiting
heap no N → card_id: 1003 (동일한 gap)
*** (2) TRANSACTION: {trx_id_2}, ACTIVE 0 sec inserting
INSERT INTO `card_access_level`(`id`, `card_id`, `access_level_id`, ...)
VALUES (DEFAULT, 1002, 202, ...), (DEFAULT, 1002, 201, ...)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS index idx_card_id
lock_mode X locks gap before rec
heap no N → card_id: 1003 (동일한 gap)
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS index idx_card_id
lock_mode X locks gap before rec insert intention waiting
heap no N → card_id: 1003
*** WE ROLL BACK TRANSACTION (2)
서로 다른 카드(1001, 1002)를 처리하는 두 트랜잭션이, 인덱스 상의 같은 gap(card_id 1003 앞)에 락을 잡고 서로를 기다리다가 데드락에 빠졌습니다.
초기화 로직은 다음 두 단계로 구성되어 있습니다.
-- 1. 기존 권한 삭제
DELETE FROM card_access_level WHERE card_id = :cardId;
-- 2. 새 권한 삽입
INSERT INTO card_access_level (id, card_id, access_level_id, ...)
VALUES (DEFAULT, :cardId, :accessLevelId1, ...),
(DEFAULT, :cardId, :accessLevelId2, ...);
card_access_level 테이블에는 card_id 컬럼에 보조 인덱스 idx_card_id이 걸려 있습니다.
REPEATABLE READ 격리 수준에서 WHERE card_id = :cardId 조건으로 DELETE를 실행하면, InnoDB는 보조 인덱스를 통해 해당 레코드를 찾고 Next-Key Lock을 획득합니다.
Next-Key Lock = Record Lock + Gap Lock
gap lock은 "인덱스 상 다음 레코드 바로 앞까지의 범위"를 잠급니다. 삭제 대상 레코드가 없거나 삭제 후 해당 card_id 범위에 레코드가 사라지면, 다음으로 큰 card_id 앞의 gap이 잠기게 됩니다.
22시 초기화 시점에, 두 카드에 대한 트랜잭션이 동시에 실행됩니다.
| 순서 | Tx1 (card_id=1001) | Tx2 (card_id=1002) |
|---|---|---|
| 1 | DELETE WHERE card_id=1001 → idx_card_id에서 gap lock 획득 (before card_id=1003) | |
| 2 | DELETE WHERE card_id=1002 → 동일 gap에 gap lock 획득 (before card_id=1003) | |
| 3 | INSERT (1001, ...) → insert intention lock 요청 → Tx2의 gap lock에 막힘 | |
| 4 | INSERT (1002, ...) → insert intention lock 요청 → Tx1의 gap lock에 막힘 | |
| 5 | 데드락 감지 → Tx2 롤백 |
두 카드의 삭제 시점에 각자의 card_id 이후 구간에 존재하는 "다음 레코드"가 같은 물리 위치였기 때문에, 두 트랜잭션의 gap이 겹쳤습니다.
gap lock끼리는 서로를 막지 않습니다. 하지만 insert intention lock은 gap lock과 충돌하기 때문에 교착 상태가 발생합니다.
WHERE card_id = :cardId 조건으로 삭제하면 보조 인덱스를 경유하면서 Next-Key Lock이 발생합니다. WHERE id IN (...) 조건으로 삭제하면 클러스터드 인덱스(PK)를 사용하기 때문에 record lock만 발생하고 gap lock이 생기지 않습니다.
// 수정 전: card_id 조건으로 바로 삭제 → 보조 인덱스 Next-Key Lock 발생
await queryRunner.query(
`DELETE FROM card_access_level WHERE card_id = ?`,
[cardId]
);
// 수정 후: id(PK)로 먼저 조회 후 PK 기반 삭제 → record lock만 발생
const rows = await queryRunner.query(
`SELECT id FROM card_access_level WHERE card_id = ?`,
[cardId]
);
const ids = rows.map((r: { id: number }) => r.id);
if (ids.length > 0) {
await queryRunner.query(
`DELETE FROM card_access_level WHERE id IN (?)`,
[ids]
);
}
팀원이 로컬에서 직접 락을 걸어두고 테스트한 결과, PK 기반 삭제 시 보조 인덱스에 gap lock이 발생하지 않고 클러스터드 인덱스에 record lock만 걸림을 확인했습니다.
이 초기화 로직은 카드별로 독립적으로 실행됩니다. 동일한 card_id를 동시에 처리하는 트랜잭션이 존재하지 않기 때문에, SELECT와 DELETE 사이에 해당 card_id의 레코드가 외부에서 변경될 여지가 없습니다. SELECT → DELETE가 원자적이지 않더라도 데이터 정합성 문제는 발생하지 않습니다.
락 메커니즘을 개선하더라도 예상치 못한 경합이 생길 수 있습니다. MySQL은 데드락을 감지하면 자동으로 한 트랜잭션을 롤백합니다(error code 1213). 이때 롤백된 쪽이 요청을 재시도하면 대부분 정상 처리됩니다.
데드락으로 롤백된 트랜잭션은 처음부터 다시 실행해야 합니다. withDeadlockRetry는 SELECT부터 INSERT까지 전체 트랜잭션을 감싸도록 적용했습니다.
const MAX_RETRY = 3;
async function withDeadlockRetry<T>(fn: () => Promise<T>): Promise<T> {
for (let attempt = 1; attempt <= MAX_RETRY; attempt++) {
try {
return await fn();
} catch (err) {
const isDeadlock = (err as any)?.errno === 1213; // ER_LOCK_DEADLOCK
if (isDeadlock && attempt < MAX_RETRY) {
continue;
}
throw err;
}
}
throw new Error('unreachable');
}
| 항목 | 내용 |
|---|---|
| 테이블 | card_access_level |
| 문제 인덱스 | idx_card_id (card_id 보조 인덱스) |
| 락 종류 | Next-Key Lock (gap lock + record lock) |
| 충돌 구조 | 두 트랜잭션이 동일 gap에 gap lock 획득 후, 각자 insert intention lock 대기 |
| 해결 1 | DELETE 조건을 id(PK)로 변경 → gap lock 제거 |
| 해결 2 | 데드락 발생 시 재시도 로직 추가 |
보조 인덱스 기반 DELETE는 Next-Key Lock을 유발합니다. 같은 인덱스 범위에 동시에 DELETE + INSERT가 발생하면 데드락 위험이 있습니다.
카드마다 독립적으로 처리된다고 생각했지만, 인덱스 구조상 서로 다른 카드의 삭제 gap이 겹칠 수 있었습니다. 비슷한 패턴을 사용한다면: