Published on

MySQL 8 Deadlock 1213 원인추적·재시도 패턴

Authors

운영 중인 MySQL 8에서 ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction 를 만나면 대부분 “일시적이니 재시도하면 되지”로 끝나기 쉽습니다. 하지만 재시도만 걸어두면 원인(락 순서, 인덱스, 격리수준, 쿼리 패턴) 은 그대로 남고, 트래픽이 커질수록 데드락 빈도와 지연이 함께 증가합니다.

이 글은 MySQL 8(InnoDB) 기준으로 다음을 목표로 합니다.

  • 데드락 1213의 전형적인 발생 패턴을 락 관점으로 이해
  • MySQL 8에서 데드락 로그를 확보하고 “무슨 락이 어떤 순서로 걸렸는지” 추적
  • 애플리케이션에서 안전하게 적용할 수 있는 재시도 패턴(백오프, 지터, 멱등성)
  • 구조적으로 데드락을 줄이는 쿼리/인덱스/트랜잭션 설계 체크리스트

참고로, 트랜잭션/롤백 동작 자체가 프레임워크에서 기대와 다르게 보일 때는 Spring Boot 3에서 @Transactional 롤백이 안될 때 도 함께 점검하면 원인 분리가 쉬워집니다.

1213 데드락이 의미하는 것

MySQL의 데드락은 “서로가 서로가 가진 락을 기다리는 순환 대기”입니다. InnoDB는 이를 감지하면 한 트랜잭션을 희생(victim)으로 골라 롤백 하고, 나머지 트랜잭션이 진행되도록 합니다. 이때 애플리케이션이 받는 에러가 1213이고 SQLSTATE는 보통 40001 입니다.

핵심은 두 가지입니다.

  1. 데드락은 “잠깐 기다리면 풀리는” 단순 대기가 아니라, 절대 풀리지 않는 대기 사이클 이라서 강제 롤백이 필요합니다.
  2. 데드락의 “피해자”는 매번 동일하지 않을 수 있고, 트래픽/실행계획/락 범위에 따라 달라집니다.

따라서 운영 관점에서는 다음이 중요합니다.

  • 원인 추적: 어떤 테이블/인덱스/레코드 범위에서 어떤 락이 충돌했는가
  • 재시도 설계: 롤백된 트랜잭션을 어떻게 안전하게 재수행할 것인가(멱등성 포함)

MySQL 8에서 데드락 로그 확보하기

1) SHOW ENGINE INNODB STATUS 로 즉시 확인

데드락이 발생한 직후 가장 빠른 확인 방법입니다.

SHOW ENGINE INNODB STATUS\G

출력에서 LATEST DETECTED DEADLOCK 섹션을 찾습니다. 여기에는 보통 다음이 포함됩니다.

  • 각 트랜잭션의 실행 중 SQL
  • 어떤 인덱스의 어떤 레코드(또는 갭)에 어떤 락을 요청/보유 중인지
  • victim으로 롤백된 트랜잭션

단점은 “마지막 데드락 1건”만 보관된다는 점입니다. 반복 발생하는 문제라면 지속 수집이 필요합니다.

2) MySQL 8의 performance_schema 활용(권장)

MySQL 8에서는 performance_schema 에서 락/대기 정보를 더 체계적으로 볼 수 있습니다. 환경에 따라 옵션과 테이블 구성이 다를 수 있지만, 운영 진단에서 자주 보는 축은 다음입니다.

  • 현재 대기: performance_schema.data_lock_waits
  • 현재 락: performance_schema.data_locks
  • 실행 중 쿼리: performance_schema.threads, performance_schema.events_statements_current

예시(현재 락 대기 관계 확인):

SELECT
  w.REQUESTING_ENGINE_TRANSACTION_ID AS waiting_trx_id,
  w.BLOCKING_ENGINE_TRANSACTION_ID   AS blocking_trx_id,
  dlw.OBJECT_SCHEMA,
  dlw.OBJECT_NAME,
  dlw.INDEX_NAME,
  dlw.LOCK_TYPE,
  dlw.LOCK_MODE
FROM performance_schema.data_lock_waits w
JOIN performance_schema.data_locks dlw
  ON w.REQUESTING_ENGINE_LOCK_ID = dlw.ENGINE_LOCK_ID;

이 결과를 기반으로 “어떤 트랜잭션이 누구에게 막혔는지”를 빠르게 파악할 수 있습니다.

3) 애플리케이션 로그에 반드시 남겨야 할 것

데드락은 DB에서만 보면 “왜 이 SQL이 이런 순서로 호출됐는지”가 비어 있습니다. 최소한 아래는 애플리케이션에서 남겨야 합니다.

  • 트랜잭션 경계(시작/커밋/롤백)
  • 비즈니스 키(주문번호, 사용자ID 등)
  • SQL 템플릿 ID(또는 ORM 쿼리 키)
  • 재시도 횟수, 대기 시간

DB 커넥션/풀 이슈가 섞이면 증상이 더 복잡해질 수 있으니, 커넥션 관련 징후가 있다면 Spring Boot DB 커넥션 누수? HikariCP 원인 9가지 도 함께 확인하는 것을 권합니다.

데드락이 자주 생기는 전형적인 패턴 6가지

1) 서로 다른 순서로 같은 자원을 업데이트

가장 교과서적인 케이스입니다.

  • 트랜잭션 A: row1 업데이트 후 row2 업데이트
  • 트랜잭션 B: row2 업데이트 후 row1 업데이트

예시:

-- TX A
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- TX B
START TRANSACTION;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
COMMIT;

해결의 1순위는 “항상 같은 순서로 잠그기”입니다. 예를 들어 id 오름차순으로 업데이트하도록 정렬해 락 순서를 고정합니다.

2) 인덱스 부재로 인한 광범위 잠금(예상 밖의 락 범위)

WHERE 조건에 적절한 인덱스가 없으면 InnoDB는 더 많은 레코드를 스캔/잠금할 수 있습니다. 이때 데드락 확률이 급격히 올라갑니다.

예시:

-- status에 인덱스가 없을 때
UPDATE orders
SET status = 'PAID'
WHERE user_id = 123 AND status = 'CREATED';

(user_id, status) 복합 인덱스가 없다면, 많은 레코드를 훑으면서 불필요한 락을 잡을 수 있습니다.

진단은 EXPLAIN 으로 시작합니다.

EXPLAIN UPDATE orders
SET status = 'PAID'
WHERE user_id = 123 AND status = 'CREATED';

여기서 typeALL 이거나, rows 추정치가 과도하게 크면 데드락뿐 아니라 전체 지연의 원인이 됩니다.

3) SELECT ... FOR UPDATE 와 범위 조건(넥스트키 락)

격리수준이 REPEATABLE READ 인 경우, 범위 조건에서 넥스트키 락(next-key lock) 이 걸리며 갭까지 잠글 수 있습니다. “존재하지 않는 키”를 두고 경쟁하는 형태로 데드락이 발생하기도 합니다.

예시:

START TRANSACTION;
SELECT *
FROM coupons
WHERE user_id = 123 AND expires_at >= NOW()
FOR UPDATE;
-- 이후 쿠폰 사용 처리
COMMIT;

user_idexpires_at 조합 인덱스가 없거나, 범위가 넓으면 잠금 범위가 커집니다.

4) 유니크 인덱스 충돌 + 동시 INSERT/UPDATE

유니크 키를 두고 동시에 upsert가 발생할 때도 데드락이 나올 수 있습니다.

INSERT INTO user_settings(user_id, k, v)
VALUES (123, 'theme', 'dark')
ON DUPLICATE KEY UPDATE v = VALUES(v);

동일 (user_id, k) 에 대해 여러 트랜잭션이 동시에 들어오면 충돌이 빈번해집니다. 이 경우는 재시도 패턴이 특히 중요합니다.

5) 외래키(FK) 연쇄 잠금

부모/자식 테이블에 FK가 있으면, 자식 테이블 업데이트/삭제 시 부모 키에 대한 잠금이 추가로 필요할 수 있습니다. 트랜잭션이 여러 테이블을 섞어 다루면 락 순서가 어긋나 데드락이 생깁니다.

6) 트랜잭션이 너무 길어지는 구조

트랜잭션이 길면 락 보유 시간이 늘고, 교착 가능성이 커집니다. 특히 다음이 흔한 원인입니다.

  • 트랜잭션 내부에서 외부 API 호출
  • 대량 배치 처리에서 한 번에 너무 많은 row를 갱신
  • 사용자 입력 대기 같은 인터랙션을 트랜잭션에 포함

원인 추적 절차: “재현 가능한 단위”로 쪼개기

데드락은 로그 한 줄만 보고는 해결이 어렵습니다. 다음 순서로 접근하면 빠릅니다.

  1. SHOW ENGINE INNODB STATUS 또는 수집된 데드락 로그에서 두 트랜잭션의 SQL 을 확보
  2. 각 SQL에 대해 EXPLAIN 으로 접근 경로와 사용 인덱스를 확인
  3. 충돌 테이블의 인덱스 구성 확인: SHOW INDEX FROM table_name;
  4. 트랜잭션 경계와 호출 순서 확인(서비스 코드/ORM 로그)
  5. 가능하면 스테이징에서 동시성 테스트로 재현

동시성 재현은 MySQL 클라이언트 2개 세션으로도 가능합니다.

세션 1:

START TRANSACTION;
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
-- 커밋하지 않고 대기

세션 2:

START TRANSACTION;
UPDATE accounts SET balance = balance - 20 WHERE id = 2;

이후 서로 반대 row를 업데이트하도록 진행하면 데드락/락대기를 유도할 수 있습니다.

해결 전략: 데드락을 “줄이는” 설계 체크리스트

1) 락 획득 순서를 고정한다

  • 여러 row를 업데이트해야 한다면, 항상 PK 기준으로 정렬해서 처리
  • 여러 테이블을 건드린다면, 서비스 전반에서 테이블 접근 순서를 통일

예시(애플리케이션에서 정렬 후 업데이트):

-- id 오름차순으로 업데이트한다고 가정
UPDATE accounts SET balance = balance + ? WHERE id = ?;

2) 인덱스를 “락 범위를 줄이는 방향”으로 설계한다

  • WHERE 조건에 맞는 인덱스가 없으면 불필요한 레코드까지 잠글 수 있음
  • 특히 SELECT ... FOR UPDATE 는 인덱스 설계에 매우 민감

권장: 자주 쓰는 조건을 복합 인덱스로 묶되, 카디널리티와 정렬/범위 조건을 고려합니다.

3) 트랜잭션을 짧게 유지한다

  • DB 락이 필요한 구간만 트랜잭션으로 감싸고, 나머지는 밖으로 빼기
  • 대량 처리라면 청크로 나누고 커밋을 자주 수행

4) 격리수준을 의도적으로 선택한다

REPEATABLE READ 가 기본인 환경에서 넥스트키 락이 문제라면, 일부 워크로드는 READ COMMITTED 로 바꾸는 것이 데드락/락대기를 줄일 수 있습니다. 다만 팬텀 리드 등 일관성 요구사항과 함께 검토해야 합니다.

5) “같은 키에 대한 동시성”을 애초에 줄인다

  • 사용자별/주문별로 단일 워커로 직렬화
  • 애플리케이션 레벨에서 키 기반 뮤텍스(분산락 포함)

단, 분산락은 장애/타임아웃/해제 실패 등 또 다른 복잡성을 만들 수 있으니 신중히 적용합니다.

재시도 패턴: 1213을 안전하게 다루는 방법

데드락은 InnoDB가 한 트랜잭션을 롤백시키기 때문에, 애플리케이션은 “실패했으니 다시 시도”가 가능합니다. 하지만 무작정 반복하면 다음 문제가 생깁니다.

  • 동시 재시도로 인해 다시 데드락(폭풍 재시도)
  • 중복 실행으로 인한 부작용(멱등성 위반)
  • 장기적으로 p99 지연 악화

따라서 재시도는 다음 원칙을 따라야 합니다.

  1. 대상 에러를 좁게: 1213(데드락), 경우에 따라 1205(lock wait timeout)만
  2. 최대 횟수 제한: 예를 들어 3회
  3. 지수 백오프 + 지터: 동시 재시도 타이밍 분산
  4. 멱등성 보장: 동일 요청을 여러 번 실행해도 결과가 동일하거나, 중복이 감지되어야 함

SQLSTATE와 에러코드로 분기

  • MySQL 에러코드: 1213
  • SQLSTATE: 40001

드라이버/ORM에 따라 노출 방식이 다르므로, 애플리케이션에서는 “에러코드 기반”으로 분기하는 편이 안전합니다.

Java(Spring) 예시: 재시도 템플릿(의사코드)

아래 코드는 개념을 보여주는 예시입니다. 실제로는 사용하는 드라이버 예외 타입과 에러코드 접근 방식에 맞게 조정하세요.

int maxAttempts = 3;
long baseDelayMs = 30;

for (int attempt = 1; attempt <= maxAttempts; attempt++) {
  try {
    transactionTemplate.execute(status -> {
      // 1) 필요한 row를 일관된 순서로 잠그거나 업데이트
      // 2) 외부 I/O는 트랜잭션 밖에서 처리
      // 3) 멱등성 키(예: request_id)로 중복 실행 방지
      return doBusiness();
    });
    break; // 성공
  } catch (SQLException e) {
    boolean isDeadlock = (e.getErrorCode() == 1213);
    if (!isDeadlock || attempt == maxAttempts) throw e;

    long jitter = ThreadLocalRandom.current().nextLong(0, baseDelayMs);
    long delay = (baseDelayMs * (1L << (attempt - 1))) + jitter;
    Thread.sleep(delay);
  }
}

Go 예시: 지수 백오프 + 지터

maxAttempts := 3
base := 30 * time.Millisecond

for attempt := 1; attempt <= maxAttempts; attempt++ {
  err := withTx(db, func(tx *sql.Tx) error {
    return doBusiness(tx)
  })
  if err == nil {
    return nil
  }

  if !isMySQLDeadlock1213(err) || attempt == maxAttempts {
    return err
  }

  jitter := time.Duration(rand.Int63n(int64(base)))
  delay := base*time.Duration(1<<(attempt-1)) + jitter
  time.Sleep(delay)
}
return nil

여기서 isMySQLDeadlock1213 는 드라이버가 제공하는 에러 타입에서 번호 1213 을 추출해 판별합니다.

멱등성 구현: request_id 유니크 키 패턴

재시도를 안전하게 만드는 가장 현실적인 방법은 “이미 처리된 요청인지”를 DB에 기록하는 것입니다.

CREATE TABLE payment_requests (
  request_id VARCHAR(64) PRIMARY KEY,
  created_at DATETIME NOT NULL,
  status VARCHAR(20) NOT NULL
) ENGINE=InnoDB;

업무 트랜잭션 시작 시 다음을 먼저 시도합니다.

INSERT INTO payment_requests(request_id, created_at, status)
VALUES(?, NOW(), 'STARTED');
  • 성공하면 처음 처리
  • PK 중복이면 이미 처리 중이거나 처리 완료된 요청이므로, 결과 조회로 전환

이 패턴은 데드락뿐 아니라 네트워크 재시도, 타임아웃 재호출에도 강합니다.

“재시도만”으로 끝내면 안 되는 신호들

다음 중 하나라도 보이면 원인 제거 작업이 필요합니다.

  • 특정 테이블/인덱스에서 데드락이 집중
  • 재시도 횟수가 늘면서 p95/p99 지연이 상승
  • lock wait timeout 1205 도 함께 증가
  • CPU는 낮은데 DB 응답이 느려짐(락 대기 누적)

이 경우는 보통 인덱스/쿼리/락 순서 문제가 숨어 있습니다.

운영 체크리스트(요약)

  • 데드락 발생 시점의 LATEST DETECTED DEADLOCK 내용을 확보하고 저장
  • performance_schema 로 “누가 누구를 막는지”를 상시 관측 가능하게 구성
  • EXPLAIN 으로 인덱스 미스/풀스캔 여부 확인
  • 여러 row/테이블 업데이트는 락 순서를 고정
  • 트랜잭션을 짧게, 외부 I/O는 밖으로
  • 재시도는 1213 에만 제한하고, 지수 백오프 + 지터 + 최대 횟수 제한
  • 재시도 안전장치로 멱등성 키(유니크 request_id) 도입

데드락은 완전히 “0”으로 만들기 어렵지만, 발생 빈도와 영향도를 설계로 낮추고, 불가피한 경우에는 안전한 재시도로 사용자 경험을 지키는 것이 현실적인 목표입니다.