Published on

MySQL InnoDB 데드락(1213) 재현과 해결 가이드

Authors

서버가 바쁜 시간대에만 간헐적으로 터지는 ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction은 운영자 입장에서는 가장 까다로운 장애 중 하나입니다. 로그에는 단서가 부족하고, 재현도 어려워 보이지만 InnoDB의 잠금 규칙을 이해하면 의도적으로 데드락을 만들 수 있고, 그 과정에서 해결책도 명확해집니다.

이 글에서는 (1) 데드락을 확실히 재현하는 최소 예제, (2) SHOW ENGINE INNODB STATUS로 원인을 읽는 방법, (3) 실무에서 가장 효과가 큰 해결 전략(쿼리 순서 통일, 인덱스 정비, 트랜잭션 축소, 재시도 설계)을 단계별로 정리합니다.

데드락(1213)의 본질: 서로가 가진 락을 서로가 기다리는 상태

InnoDB는 트랜잭션 격리 수준과 인덱스 접근 방식에 따라 다양한 락을 잡습니다. 대표적으로 다음이 자주 얽힙니다.

  • 레코드 락(record lock): 특정 인덱스 레코드에 대한 락
  • 갭 락(gap lock): 인덱스 레코드 사이 구간에 대한 락
  • 넥스트 키 락(next-key lock): 레코드 락 + 갭 락 조합(주로 REPEATABLE READ에서)

데드락은 단순히 “락 경합이 심하다”가 아니라, 트랜잭션 A가 락 1을 잡고 락 2를 기다리는 동안 트랜잭션 B는 락 2를 잡고 락 1을 기다리는 순환 대기가 성립할 때 발생합니다. InnoDB는 이를 감지하면 한 트랜잭션을 희생자로 골라 롤백시키고 1213을 반환합니다.

실습 준비: 데드락 재현용 테이블 만들기

아래 예제는 가장 흔한 패턴인 “서로 다른 순서로 같은 두 행을 업데이트”로 데드락을 만듭니다.

CREATE DATABASE IF NOT EXISTS deadlock_lab;
USE deadlock_lab;

DROP TABLE IF EXISTS accounts;
CREATE TABLE accounts (
  id BIGINT PRIMARY KEY,
  balance INT NOT NULL,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;

INSERT INTO accounts (id, balance) VALUES (1, 1000), (2, 1000);

2세션으로 1213 데드락 재현하기

터미널을 2개 열어 MySQL에 각각 접속합니다. 아래에서 세션 A와 세션 B는 서로 다른 커넥션입니다.

세션 A

USE deadlock_lab;
SET autocommit = 0;

START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 여기서 멈춘 상태로 유지

세션 B

USE deadlock_lab;
SET autocommit = 0;

START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 2;
-- 여기서 멈춘 상태로 유지

이제 서로 반대 행을 업데이트하도록 교차합니다.

세션 A (두 번째 업데이트)

UPDATE accounts SET balance = balance + 100 WHERE id = 2;

세션 B (두 번째 업데이트)

UPDATE accounts SET balance = balance + 100 WHERE id = 1;

둘 중 하나는 다음과 같은 오류로 실패합니다.

ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

여기서 핵심은 “같은 자원(행 1, 행 2)을 서로 다른 순서로 잠근다”입니다. 이 패턴은 계좌 이체, 재고 차감, 포인트 이동, 양방향 관계 업데이트 등에서 매우 자주 등장합니다.

데드락 원인 읽기: SHOW ENGINE INNODB STATUS

데드락이 발생한 직후, 아무 세션에서나 아래를 실행합니다.

SHOW ENGINE INNODB STATUS\G

출력에서 LATEST DETECTED DEADLOCK 섹션을 찾으면, 대략 다음 정보가 보입니다.

  • 어떤 트랜잭션들이 관련됐는지
  • 각 트랜잭션이 이미 보유한 락(locks held)
  • 각 트랜잭션이 기다리는 락(waiting for)
  • 데드락 희생자(rolled back)

운영에서는 이 섹션을 “그대로 복사해 이슈에 붙이는 것”만으로도 해결 속도가 크게 올라갑니다. 다만 이 정보는 마지막 데드락 1건만 유지되므로, 발생 직후 확보가 중요합니다.

해결 전략 1: 락 획득 순서 통일(가장 효과적)

위 재현 예제는 두 행을 서로 다른 순서로 업데이트해서 생겼습니다. 해결은 간단하게 “항상 작은 키부터 잠그기”처럼 순서를 통일하는 것입니다.

예: 계좌 이체에서 from_idto_id 두 행을 업데이트해야 한다면, 먼저 두 id를 정렬한 뒤 그 순서로 SELECT ... FOR UPDATE로 잠그고 업데이트합니다.

-- 예시: 두 계좌를 항상 id 오름차순으로 먼저 잠금
START TRANSACTION;

SELECT id, balance
FROM accounts
WHERE id IN (1, 2)
ORDER BY id
FOR UPDATE;

-- 이후 비즈니스 로직에 따라 업데이트
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;

COMMIT;

이 방식의 장점은 다음과 같습니다.

  • 데드락 가능성을 구조적으로 제거(또는 크게 감소)
  • 트랜잭션이 어떤 락을 언제 잡는지 예측 가능

단, FOR UPDATE는 읽기에도 락을 걸기 때문에 트래픽이 큰 테이블에서는 락 범위를 최소화하도록 조건과 인덱스를 함께 점검해야 합니다.

해결 전략 2: 인덱스 정비로 락 범위 줄이기

데드락은 “락을 오래 잡는다”보다 “불필요하게 넓게 잡는다”에서 폭발적으로 증가합니다. 특히 조건 컬럼에 적절한 인덱스가 없으면, InnoDB는 더 많은 레코드를 스캔하면서 더 많은 락을 잡을 수 있습니다.

예를 들어 아래처럼 인덱스가 없는 컬럼으로 업데이트하면, 범위 스캔과 잠금이 커질 수 있습니다.

-- 예시: status에 인덱스가 없다면 더 넓은 잠금을 유발할 수 있음
UPDATE orders
SET status = 'DONE'
WHERE status = 'READY' AND user_id = 123;

이럴 때는 실행 계획과 실제 수행을 확인해 “인덱스를 타는지”부터 확인해야 합니다. 인덱스 미사용을 잡는 방법은 아래 글이 함께 도움이 됩니다.

실무 팁:

  • WHERE에 쓰는 컬럼 조합으로 복합 인덱스를 고려
  • 업데이트/삭제 쿼리는 특히 인덱스가 중요(락이 걸리기 때문)
  • “정확히 1행”을 겨냥하는 조건을 만들고, 그 조건이 인덱스를 타게 만들기

해결 전략 3: 트랜잭션을 짧게, 일관되게

데드락은 확률 게임입니다. 락을 잡고 있는 시간이 길수록 다른 트랜잭션이 끼어들 여지가 커집니다.

체크리스트:

  • 트랜잭션 안에서 외부 API 호출, 파일 IO, 느린 계산을 하지 않기
  • 사용자 입력 대기, 큐 대기 같은 블로킹 작업을 트랜잭션 밖으로 빼기
  • 같은 트랜잭션에서 여러 테이블을 업데이트한다면 항상 같은 순서로 접근

특히 “테이블 A 업데이트 후 테이블 B 업데이트”를 어떤 코드 경로에서는 반대로 하고 있다면, 그 자체가 데드락 공장입니다.

해결 전략 4: 격리 수준과 잠금 모드 이해하기

기본 격리 수준이 REPEATABLE READ인 환경에서는 갭 락과 넥스트 키 락으로 인해, 단순한 범위 조회도 예상보다 넓게 잠길 수 있습니다.

예를 들어 다음 쿼리는 인덱스와 조건에 따라 갭 락을 동반할 수 있습니다.

START TRANSACTION;
SELECT * FROM accounts
WHERE id BETWEEN 1 AND 10
FOR UPDATE;

가능한 대응:

  • 정말 필요한 경우에만 FOR UPDATE 또는 LOCK IN SHARE MODE 사용
  • 범위 잠금이 문제라면 쿼리를 “정확한 키 기반”으로 바꾸기
  • 서비스 특성상 허용된다면 격리 수준을 READ COMMITTED로 조정 검토(갭 락 영향 감소)

격리 수준 변경은 정합성 요구사항과 직결되므로, 단순히 데드락 회피만을 이유로 성급히 바꾸면 안 됩니다.

해결 전략 5: 애플리케이션 레벨 재시도(필수 안전장치)

InnoDB는 데드락을 완전히 “0”으로 만들기 어렵습니다. 따라서 1213을 “재시도 가능한 오류”로 취급하는 것이 일반적입니다.

권장 패턴:

  • 데드락(1213) 또는 락 대기 타임아웃(1205)은 짧은 지수 백오프 후 재시도
  • 재시도 횟수 상한 설정(예: 3회)
  • 멱등성 보장(특히 결제, 포인트 적립 같은 작업)

Node.js 예시(의사코드)

async function withRetry(txFn, maxRetry = 3) {
  for (let i = 0; i <= maxRetry; i++) {
    try {
      return await txFn();
    } catch (e) {
      const mysqlErrno = e?.errno;
      const sqlState = e?.sqlState;
      const isDeadlock = mysqlErrno === 1213 || sqlState === '40001';

      if (!isDeadlock || i === maxRetry) throw e;

      const backoffMs = 50 * Math.pow(2, i);
      await new Promise(r => setTimeout(r, backoffMs));
    }
  }
}

재시도는 “근본 해결”은 아니지만, 운영 안정성 측면에서는 거의 필수입니다. 특히 락 순서 통일과 함께 적용하면 체감 장애율이 크게 줄어듭니다.

자주 나오는 데드락 패턴 4가지

1) 서로 다른 순서로 같은 행 집합 업데이트

  • 가장 흔함
  • 해결: 정렬 후 잠금, 접근 순서 통일

2) 인덱스 미사용으로 인한 대량 잠금

  • 업데이트/삭제에서 폭발
  • 해결: 인덱스 설계, EXPLAIN ANALYZE로 검증

3) 범위 조건 + FOR UPDATE로 인한 갭 락 충돌

  • 특히 핫한 범위 키에서 자주 발생
  • 해결: 키 기반 조회로 변경, 격리 수준 검토

4) 유니크 키 경쟁(동시 INSERT 또는 UPSERT)

  • INSERT ... ON DUPLICATE KEY UPDATE에서 경합
  • 해결: 키 분산, 핫키 완화, 트랜잭션 축소, 재시도

운영에서의 진단 루틴

데드락이 보고되면 아래 순서로 접근하면 빠릅니다.

  1. SHOW ENGINE INNODB STATUS에서 LATEST DETECTED DEADLOCK 확보
  2. 두 트랜잭션의 쿼리를 비교해 “잠금 순서가 뒤집힌 지점” 찾기
  3. 해당 쿼리의 조건이 인덱스를 타는지 EXPLAIN ANALYZE로 확인
  4. 트랜잭션 범위에서 불필요한 작업 제거
  5. 애플리케이션에 1213 재시도 적용

마무리: 데드락은 버그가 아니라 설계 신호다

InnoDB 데드락(1213)은 MySQL이 불안정해서가 아니라, 동시성 하에서 잠금 순서와 잠금 범위가 충돌한다는 신호입니다. 재현 가능한 최소 케이스를 만들고, SHOW ENGINE INNODB STATUS로 실제 락 경로를 읽은 다음, 다음 3가지를 우선 적용하면 대부분의 데드락은 빠르게 줄일 수 있습니다.

  • 락 획득 순서 통일
  • 인덱스로 락 범위 최소화
  • 1213 재시도(백오프 포함)

특히 인덱스 점검은 데드락뿐 아니라 전반적인 성능 문제를 함께 해결하는 경우가 많으니, 데드락을 계기로 쿼리 플랜까지 같이 정리하는 것을 권장합니다.