Published on

MySQL InnoDB 데드락 1213 재현·원인·해결

Authors

서버 로그에 ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction 이 찍히면, 많은 팀이 “가끔 나는 현상”으로만 취급하고 재시도 로직으로 덮어버립니다. 하지만 1213은 단순 운이 아니라 잠금 순서, 인덱스, 접근 패턴이 만들어낸 구조적 결과인 경우가 많습니다. 이 글에서는 InnoDB 데드락을 재현 가능한 형태로 만들고, SHOW ENGINE INNODB STATUS원인을 읽는 법, 그리고 재발을 줄이는 설계/쿼리/운영 대책을 정리합니다.

운영 트랜잭션 장애는 “재시도”만으로 끝내기보다, 분산 트랜잭션/보상 관점까지 고려하면 더 안전합니다. 트랜잭션 실패가 비즈니스에 어떤 보상 로직을 요구하는지에 대해서는 Saga 패턴 보상 트랜잭션 실패 원인 6가지도 함께 참고하면 좋습니다.

1) 1213 데드락이란: 교착 상태와 InnoDB의 선택

데드락은 두(또는 그 이상) 트랜잭션이 서로가 가진 락을 기다리며 순환 대기에 빠진 상태입니다.

  • 트랜잭션 A: 리소스 1 락 보유, 리소스 2 락 대기
  • 트랜잭션 B: 리소스 2 락 보유, 리소스 1 락 대기

InnoDB는 이런 상황을 감지하면 둘 중 하나를 희생자(victim)로 롤백시키고, 나머지는 진행시킵니다. 이때 희생자 쪽에서 흔히 보는 에러가 1213입니다.

중요 포인트는 다음입니다.

  • 1213은 “DB가 죽었다”가 아니라 “당신의 트랜잭션 하나가 롤백되었다”입니다.
  • 따라서 애플리케이션은 idempotent(멱등)한 재시도 또는 보상 트랜잭션을 고려해야 합니다.

2) 가장 흔한 재현 패턴: 같은 테이블을 다른 순서로 업데이트

데드락을 가장 쉽게 재현하는 방법은 두 행을 서로 반대 순서로 업데이트하는 것입니다.

2.1 재현용 테이블

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.2 세션 1

-- SESSION 1
SET autocommit = 0;
START TRANSACTION;

UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 일부러 타이밍을 벌려 교착을 만들기
DO SLEEP(2);
UPDATE accounts SET balance = balance + 100 WHERE id = 2;

COMMIT;

2.3 세션 2

-- SESSION 2
SET autocommit = 0;
START TRANSACTION;

UPDATE accounts SET balance = balance - 100 WHERE id = 2;
DO SLEEP(2);
UPDATE accounts SET balance = balance + 100 WHERE id = 1;

COMMIT;

두 세션을 거의 동시에 실행하면, 한쪽이 1213으로 롤백될 가능성이 큽니다.

핵심은 “업데이트 대상이 같아서”가 아니라 락 획득 순서가 서로 반대라는 점입니다.

3) SHOW ENGINE INNODB STATUS 로 데드락 읽기

데드락이 발생한 직후 아래를 실행합니다.

SHOW ENGINE INNODB STATUS\G

출력에서 LATEST DETECTED DEADLOCK 섹션을 찾으면 다음 정보가 나옵니다.

  • 어떤 트랜잭션들이 충돌했는지
  • 각 트랜잭션이 보유한 락과 기다리는 락
  • 어떤 인덱스/레코드(또는 갭)에서 락이 걸렸는지
  • 어떤 쿼리가 실행 중이었는지

실무 팁:

  • 데드락은 “마지막 1건”만 저장되는 경우가 많습니다. 운영에서는 performance_schema 기반의 events_transactions_* 또는 DB 로그 수집으로 보강하는 편이 안전합니다.
  • 재현 환경에서는 데드락 직후 곧바로 확인해야 정보가 남습니다.

4) 원인 해부: InnoDB 락의 3가지 함정

1213의 원인은 단순히 “동시성”이 아니라 InnoDB의 락 모델과 쿼리 설계가 결합된 결과입니다.

4.1 락 순서 불일치 (가장 흔함)

앞서 재현한 것처럼, 같은 리소스를 업데이트하더라도 항상 같은 순서로 락을 잡으면 데드락 확률이 크게 줄어듭니다.

  • 나쁜 예: id=1id=2id=2id=1 이 섞임
  • 좋은 예: 항상 작은 id부터 업데이트

4.2 인덱스 부재로 인한 “의도치 않은 범위 락”

WHERE 조건에 적절한 인덱스가 없으면, InnoDB는 더 많은 레코드를 스캔하며 락을 잡을 수 있습니다. 그 결과 데드락/락 대기 모두 증가합니다.

예를 들어 다음 쿼리를 생각해봅니다.

UPDATE orders
SET status = 'PAID'
WHERE user_id = 10 AND status = 'PENDING';

여기서 (user_id, status) 복합 인덱스가 없으면, 불필요한 스캔이 늘고 락 범위가 커질 수 있습니다.

권장:

CREATE INDEX idx_orders_user_status ON orders (user_id, status);

4.3 REPEATABLE READ에서의 갭 락/넥스트키 락

MySQL InnoDB 기본 격리수준인 REPEATABLE READ 에서는 팬텀 리드를 막기 위해 갭 락(gap lock), 넥스트키 락(next-key lock) 이 등장합니다. 이 락은 “존재하지 않는 범위”까지 잠그기 때문에, 개발자가 예상한 것보다 충돌면적이 커집니다.

특히 다음 패턴이 위험합니다.

  • 범위 조건: WHERE created_at BETWEEN ...
  • 정렬 + 제한: ORDER BY ... LIMIT ... 로 특정 행을 “선점”하려는 패턴
  • 유니크하지 않은 인덱스 조건으로 SELECT ... FOR UPDATE

예시:

START TRANSACTION;
SELECT id FROM coupons
WHERE user_id = 10 AND used = 0
ORDER BY id
LIMIT 1
FOR UPDATE;

여기서 인덱스 설계가 애매하면, “딱 1장만 잠그는” 의도와 달리 더 넓은 범위를 락으로 감쌀 수 있습니다.

5) 해결 전략: 재시도만 하지 말고 구조를 바꾸기

해결책은 크게 4계층으로 나눌 수 있습니다.

  1. 쿼리/트랜잭션 설계
  2. 인덱스/스키마
  3. 격리수준/락 힌트
  4. 애플리케이션 재시도/보상

5.1 락 획득 순서를 강제하라

여러 행/여러 테이블을 업데이트한다면 “항상 같은 순서”를 팀 규칙으로 만드세요.

  • 동일 테이블 다건 업데이트: PK 오름차순으로
  • 여러 테이블 업데이트: 예를 들어 usersorderspayments 처럼 고정

다건 업데이트의 경우, 애플리케이션에서 정렬 후 순서대로 처리하거나, SQL에서 정렬을 활용합니다.

-- 예: 두 계좌를 항상 작은 id부터 잠그기
START TRANSACTION;
SELECT id 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;

SELECT ... FOR UPDATE 로 “잠금 순서”를 먼저 맞춰두면 데드락 확률이 크게 줄어듭니다.

5.2 트랜잭션을 짧게: 락 보유 시간을 줄여라

데드락은 락을 오래 잡을수록 유리(?)해집니다. 다음을 점검하세요.

  • 트랜잭션 안에서 외부 API 호출, 파일 I/O, 긴 계산을 수행하지 않는지
  • 불필요한 사용자 입력 대기(웹 요청 처리) 구간이 트랜잭션 안에 들어가 있지 않은지
  • ORM이 의도치 않게 트랜잭션 범위를 넓히지 않는지

5.3 인덱스를 “락을 줄이는 도구”로 바라보기

인덱스는 단지 성능만이 아니라 락 범위를 축소합니다.

체크리스트:

  • 업데이트/삭제 쿼리의 WHERE 조건이 인덱스를 타는가 (EXPLAIN 확인)
  • SELECT ... FOR UPDATE 조건 컬럼이 적절히 인덱스화되어 있는가
  • 복합 인덱스의 컬럼 순서가 실제 조건과 맞는가
EXPLAIN UPDATE orders
SET status = 'PAID'
WHERE user_id = 10 AND status = 'PENDING';

typeALL 이거나 rows 가 과도하게 크면, 락 범위도 커질 가능성이 높습니다.

5.4 격리수준 조정: 필요한 곳에만 적용

갭 락이 문제의 핵심이라면, 일부 트랜잭션에 한해 READ COMMITTED 로 낮춰 충돌을 줄이는 선택지가 있습니다.

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
-- 작업 수행
COMMIT;

주의:

  • 격리수준 변경은 “정합성 요구사항”과 맞교환입니다.
  • 팬텀 리드가 비즈니스적으로 문제가 되는지 먼저 합의해야 합니다.

5.5 애플리케이션 레벨: 지수 백오프 재시도 + 멱등성

InnoDB가 희생자를 롤백시키는 것은 정상 동작이므로, 애플리케이션은 1213을 재시도 가능한 오류로 분류하는 경우가 많습니다. 다만 재시도는 반드시 멱등성을 갖춰야 합니다.

Node.js 예시(의사 코드):

async function withRetry(txFn, max = 5) {
  let delay = 30;
  for (let i = 0; i < max; i++) {
    try {
      return await txFn();
    } catch (e) {
      const code = e?.errno || e?.code;
      const msg = String(e?.message || "");
      const deadlock = code === 1213 || msg.includes("Deadlock found");
      if (!deadlock || i === max - 1) throw e;
      await new Promise(r => setTimeout(r, delay));
      delay *= 2;
    }
  }
}

실무 팁:

  • 재시도는 “즉시 무한 재시도”가 아니라 최대 횟수 + 지수 백오프로 제한하세요.
  • 결제/포인트/쿠폰 같은 작업은 요청 키(예: request_id) 기반으로 중복 실행 방지 테이블을 두는 방식이 안전합니다.

6) 운영에서의 진단 루틴: 재현이 안 될 때 무엇을 볼까

운영에서는 데드락이 “가끔”만 나고 재현이 어려운 경우가 많습니다. 아래 순서로 접근하면 원인에 빨리 닿습니다.

6.1 데드락 로그 수집과 상관관계

  • SHOW ENGINE INNODB STATUS 의 데드락 섹션을 주기적으로 수집(또는 발생 시점에 자동 수집)
  • 애플리케이션 로그에 트랜잭션 경계(시작/커밋/롤백), 요청 ID, 핵심 파라미터를 남김

분산 환경에서 “한 요청이 여러 컴포넌트를 건드리며 실패”하는 유형은 장애가 연쇄로 커질 수 있습니다. 이런 연쇄 장애를 차단하는 관점은 systemd 서비스 재시작 무한루프 - 원인과 차단법처럼 “실패를 제어 가능한 형태로 만들기”와 결이 같습니다.

6.2 핫스팟 탐지

데드락은 보통 다음 중 하나의 핫스팟에서 반복됩니다.

  • 특정 사용자/특정 상품 등 동일 키에 트래픽 집중
  • “마지막 1개 가져오기” 같은 선점 로직
  • 상태 전이 테이블(예: 주문 상태)에서 같은 행을 여러 워커가 경쟁

핫스팟이 확인되면, DB만이 아니라 작업 큐/샤딩/락 전략까지 확장해서 봐야 합니다.

6.3 대기 락 관측

가능하면 performance_schema 의 락/대기 이벤트를 켜고, 어떤 SQL이 오래 기다리는지 봅니다. (환경에 따라 오버헤드가 있어 운영 정책에 맞게 조정)

7) 자주 쓰는 해결 패턴 3가지

패턴 A: “선점 후 처리”를 명시적으로 만들기

여러 워커가 같은 작업을 가져가 처리한다면 다음처럼 “가져갈 행을 잠그고” 처리합니다.

START TRANSACTION;
SELECT id
FROM jobs
WHERE status = 'READY'
ORDER BY id
LIMIT 1
FOR UPDATE;

UPDATE jobs SET status = 'RUNNING' WHERE id = ?;
COMMIT;

여기서도 인덱스가 중요합니다.

CREATE INDEX idx_jobs_status_id ON jobs (status, id);

패턴 B: 상태 전이는 단일 행에 모으되, 조건 업데이트로 경쟁을 줄이기

낙관적 형태로, 조건을 만족할 때만 업데이트되게 만들고 영향 행 수로 성공 여부를 판단합니다.

UPDATE orders
SET status = 'PAID'
WHERE id = 123 AND status = 'PENDING';

이 방식은 데드락 자체를 “0”으로 만들지는 못해도, 불필요한 락 확장을 줄이고 애플리케이션 로직을 단순화합니다.

패턴 C: 유니크 키로 멱등성 확보

예: 결제 요청마다 request_id 를 발급하고 유니크로 강제합니다.

CREATE TABLE payment_requests (
  request_id CHAR(36) PRIMARY KEY,
  order_id BIGINT NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;

요청 처리 시 먼저 삽입을 시도하고, 중복이면 이미 처리된 것으로 간주합니다.

8) 정리: 1213을 줄이는 체크리스트

  • 여러 행/테이블을 업데이트한다면 락 순서를 표준화했다
  • 트랜잭션 내부에서 느린 작업(외부 호출, 대기, 대량 처리)을 제거했다
  • WHERE 조건과 SELECT ... FOR UPDATE 에 필요한 인덱스를 갖췄다
  • EXPLAIN 으로 스캔 범위를 확인했다
  • 갭 락이 문제면 일부 트랜잭션에 READ COMMITTED 적용을 검토했다
  • 1213은 제한된 재시도(지수 백오프)로 처리하고, 멱등성 키로 중복 실행을 막았다
  • 운영에서 데드락 정보를 수집하고 요청 ID로 상관관계를 추적한다

데드락은 “없애는” 것보다 “충돌면적을 줄이고, 실패를 통제 가능한 형태로 만드는” 것이 현실적인 목표입니다. 재현 스크립트로 패턴을 고정한 뒤, 인덱스와 락 순서부터 하나씩 바꾸어 보면 1213은 눈에 띄게 줄어듭니다.