데드락이 발생한 이유 — InnoDB Next-Key Lock과 PK 기반 삭제

dasong·2026년 2월 22일

Postmortem

목록 보기
3/3
post-thumbnail

이 글은 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이 걸려 있습니다.

InnoDB의 Next-Key Lock

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)
1DELETE WHERE card_id=1001 → idx_card_id에서 gap lock 획득 (before card_id=1003)
2DELETE WHERE card_id=1002 → 동일 gap에 gap lock 획득 (before card_id=1003)
3INSERT (1001, ...) → insert intention lock 요청 → Tx2의 gap lock에 막힘
4INSERT (1002, ...) → insert intention lock 요청 → Tx1의 gap lock에 막힘
5데드락 감지 → Tx2 롤백

두 카드의 삭제 시점에 각자의 card_id 이후 구간에 존재하는 "다음 레코드"가 같은 물리 위치였기 때문에, 두 트랜잭션의 gap이 겹쳤습니다.

gap lock끼리는 서로를 막지 않습니다. 하지만 insert intention lock은 gap lock과 충돌하기 때문에 교착 상태가 발생합니다.


해결 방안

1. DELETE 조건을 PK(id)로 변경

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가 원자적이지 않더라도 데이터 정합성 문제는 발생하지 않습니다.

2. 데드락 발생 시 재시도 로직 추가

락 메커니즘을 개선하더라도 예상치 못한 경합이 생길 수 있습니다. 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 대기
해결 1DELETE 조건을 id(PK)로 변경 → gap lock 제거
해결 2데드락 발생 시 재시도 로직 추가

교훈

보조 인덱스 기반 DELETE는 Next-Key Lock을 유발합니다. 같은 인덱스 범위에 동시에 DELETE + INSERT가 발생하면 데드락 위험이 있습니다.

카드마다 독립적으로 처리된다고 생각했지만, 인덱스 구조상 서로 다른 카드의 삭제 gap이 겹칠 수 있었습니다. 비슷한 패턴을 사용한다면:

  • 가능하면 PK 기반으로 삭제 조건을 구성하거나
  • 삭제와 삽입 순서를 재검토하거나
  • 최소한 데드락 재시도 전략을 안전망으로 두는 것이 안전합니다.
profile
소비자에서 생산자로

0개의 댓글