Published on

MySQL InnoDB 데드락 추적 - deadlock.log 읽기

Authors

운영에서 간헐적으로 터지는 데드락은 “한 번 발생하면 끝”이 아니라, 같은 트래픽 패턴이 반복되는 한 계속 재발할 가능성이 큽니다. 문제는 애플리케이션 로그에 남는 메시지가 보통 Deadlock found when trying to get lock; try restarting transaction 정도라서, 어떤 트랜잭션이 어떤 순서로 어떤 레코드를 잠갔는지 파악하기 어렵다는 점입니다.

InnoDB는 다행히도 데드락이 발생하면 가장 최근 데드락 1건에 대한 상세 정보를 로그로 남길 수 있습니다. 이 글에서는 흔히 deadlock.log 라고 부르는 InnoDB 데드락 덤프를 기준으로, 로그를 읽는 순서와 핵심 필드 해석법, 그리고 원인 쿼리를 찾아 수정하는 실전 접근을 정리합니다.

또한 장애 대응 관점에서 “증상이 DB에만 있는지, 혹은 Pod 재시작이나 타임아웃 연쇄로 증폭되는지”까지 함께 보는 습관이 중요합니다. 애플리케이션이 데드락 재시도 로직 없이 실패를 폭발시키면, 결국은 인프라 레벨 증상으로도 번집니다. 필요하다면 AWS ALB 502/504 급증 - 타임아웃 7곳 점검 같은 체크리스트로 연쇄 장애를 함께 확인해보세요.

데드락 로그를 남기는 방법

1) SHOW ENGINE INNODB STATUS 로 즉시 확인

가장 빠른 방법은 다음 명령으로 InnoDB 상태를 보는 것입니다.

SHOW ENGINE INNODB STATUS\G

출력 중 LATEST DETECTED DEADLOCK 섹션이 있으면, 거기에 가장 최근 데드락이 기록됩니다. 다만 이 정보는 새로운 데드락이 발생하면 덮어써지므로, 장애 순간에 바로 떠서 저장해야 합니다.

2) MySQL 에러 로그에 데드락을 자동 기록

운영에서 수동 캡처가 어렵다면 MySQL 설정으로 데드락 정보를 에러 로그로 남기게 할 수 있습니다.

SET GLOBAL innodb_print_all_deadlocks = ON;
  • 장점: 데드락이 발생할 때마다 에러 로그에 남습니다.
  • 단점: 트래픽이 많은 환경에서는 로그량이 증가합니다.

실제로 파일로 분리해 deadlock.log 로 관리하려면, MySQL 에러 로그를 파일로 두고 로그 수집 파이프라인에서 데드락 라인만 필터링해 별도 인덱싱하는 방식이 흔합니다.

데드락 로그를 읽는 “순서”가 중요하다

데드락 로그는 정보가 많아서 처음 보면 압도적입니다. 하지만 아래 순서로 읽으면 빠르게 원인을 좁힐 수 있습니다.

  1. 희생된 트랜잭션(ROLL BACK) 확인
  2. 각 트랜잭션의 실행 쿼리(또는 마지막 쿼리) 확인
  3. 각 트랜잭션이 잡고 있는 락과 기다리는 락의 대상 테이블·인덱스·레코드 확인
  4. 두 트랜잭션의 락 획득 순서가 엇갈렸는지 확인
  5. 해결책을 쿼리/인덱스/트랜잭션 설계 중 어디에 둘지 결정

예시 deadlock.log (축약)와 필드 해석

아래는 SHOW ENGINE INNODB STATUS 의 데드락 섹션을 운영에서 자주 보는 형태로 단순화한 예시입니다. 실제 로그는 더 길 수 있습니다.

------------------------
LATEST DETECTED DEADLOCK
------------------------
2026-02-24 12:34:56 0x7f...
*** (1) TRANSACTION:
TRANSACTION 123456, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1136, 3 row lock(s)
MySQL thread id 111, OS thread handle 0x7f..., query id 98765 app_user updating
UPDATE orders SET status='PAID' WHERE id=100;
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 42 page no 123 n bits 72 index PRIMARY of table `shop`.`orders`
trx id 123456 lock_mode X locks rec but not gap waiting
Record lock, heap no 5 PHYSICAL RECORD: ...

*** (2) TRANSACTION:
TRANSACTION 123457, ACTIVE 3 sec starting index read
mysql tables in use 1, locked 1
6 lock struct(s), heap size 1136, 4 row lock(s)
MySQL thread id 112, OS thread handle 0x7f..., query id 98766 app_user updating
UPDATE order_items SET qty=qty+1 WHERE order_id=100 AND sku='A';
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 77 page no 88 n bits 96 index idx_order_id of table `shop`.`order_items`
trx id 123457 lock_mode X locks rec but not gap

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 42 page no 123 n bits 72 index PRIMARY of table `shop`.`orders`
trx id 123457 lock_mode X locks rec but not gap waiting

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 42 page no 123 n bits 72 index PRIMARY of table `shop`.`orders`
trx id 123456 lock_mode X locks rec but not gap

*** WE ROLL BACK TRANSACTION (1)

1) *** WE ROLL BACK TRANSACTION (1) 부터 본다

InnoDB는 데드락을 감지하면 둘 중 하나를 희생시켜 롤백합니다. 위 예시에서는 (1)이 롤백 대상입니다.

  • 애플리케이션 관점: (1)을 실행한 요청이 실패했을 가능성이 큽니다.
  • 대응 관점: (1) 쿼리에 재시도 로직이 없다면 사용자 오류로 보일 수 있습니다.

2) 각 트랜잭션의 “실행 쿼리”는 마지막 단서다

query id ... updating 다음 줄에 나오는 SQL이 핵심입니다.

  • (1)orders 의 PK id=100 을 업데이트
  • (2)order_items 를 업데이트하면서, 동시에 orders 로도 락을 기다리는 상황

주의할 점은, 로그에 찍힌 SQL이 “트랜잭션 전체에서 마지막으로 실행한 문장”일 수 있다는 것입니다. 즉, 트랜잭션 내부에서 앞서 실행한 쿼리들이 락을 이미 잡고 있을 수 있습니다.

3) WAITING FOR THIS LOCKHOLDS THE LOCK 를 짝지어 본다

데드락의 본질은 “서로가 가진 락을 서로가 기다림”입니다.

  • (1)orders.PRIMARY 레코드 락을 기다리는 중
  • (2)orders.PRIMARY 레코드 락을 기다리는 중
  • 동시에 (2)order_items.idx_order_id 에 대한 X 락을 보유 중
  • (1)orders.PRIMARY 에 대한 X 락을 보유 중

실전에서는 보통 다음과 같은 형태가 더 흔합니다.

  • 트랜잭션 A: 테이블 X의 레코드를 먼저 잠그고, 이후 테이블 Y를 잠금
  • 트랜잭션 B: 테이블 Y의 레코드를 먼저 잠그고, 이후 테이블 X를 잠금

즉, 락 획득 순서가 서로 반대일 때 데드락 확률이 급증합니다.

4) lock_mode X locks rec but not gap 는 무엇인가

  • X 는 배타 락(쓰기 락)
  • locks rec 는 레코드 락
  • but not gap 은 갭 락이 아니라는 뜻

반대로 locks gap 혹은 next-key lock 같은 표현이 보이면 범위 조건에서 인덱스 스캔 중 갭까지 잠그는 상황일 수 있습니다. 이 경우 인덱스 설계나 조건절이 데드락의 핵심 원인이 되는 경우가 많습니다.

로그에서 “어떤 레코드”가 문제인지 찾는 법

로그에는 space id, page no, heap no 같은 물리 정보가 나오는데, 이것만으로는 사람이 바로 어떤 PK인지 알기 어렵습니다. 접근 방법은 다음 중 하나를 씁니다.

1) 쿼리의 조건절로 역추적

대부분은 WHERE id=... 같은 조건이 로그에 그대로 남으므로, 해당 레코드가 락 대상입니다. 위 예시라면 orders.id=100 이 거의 확실합니다.

2) 인덱스 이름으로 범위를 추정

index idx_order_id 처럼 인덱스명이 나오면, 해당 인덱스를 타고 스캔한 범위가 잠겼을 가능성이 큽니다.

  • WHERE order_id=100 은 특정 주문의 아이템들(여러 행)을 잠글 수 있음
  • 동시에 다른 트랜잭션이 orders 를 잠그고 아이템을 갱신하면 충돌 가능

3) performance_schema 로 보완(가능한 경우)

MySQL 8에서는 performance_schema 의 락 관련 테이블로 “현재” 락 대기를 볼 수 있습니다. 데드락은 순간적으로 끝나지만, 유사 상황이 지속될 때는 재현 중에 도움이 됩니다.

SELECT *
FROM performance_schema.data_locks
WHERE OBJECT_SCHEMA = 'shop';

환경에 따라 활성화 여부와 권한이 다르므로, 운영 적용 전 확인이 필요합니다.

데드락을 자주 만드는 패턴 6가지

1) 동일 리소스를 서로 다른 순서로 업데이트

가장 흔한 원인입니다.

  • 주문 상태 업데이트 후 아이템 업데이트
  • 다른 API는 아이템 업데이트 후 주문 상태 업데이트

해결의 1순위는 “항상 같은 순서로 잠그기”입니다.

2) 인덱스 미스로 인한 범위 잠금 확대

인덱스가 없거나 조건이 인덱스를 못 타면, InnoDB는 더 많은 레코드를 스캔하며 락을 잡을 수 있습니다.

예를 들어 다음 쿼리가 인덱스를 못 타면, 의도치 않게 대량 레코드 락을 유발합니다.

UPDATE order_items
SET qty = qty + 1
WHERE order_id = 100 AND sku = 'A';

권장 인덱스 예시는 아래와 같습니다.

CREATE INDEX idx_order_items_order_id_sku
ON order_items(order_id, sku);

3) 긴 트랜잭션(외부 API 호출, 대기 포함)

트랜잭션 안에서 외부 API 호출, 메시지 발행 대기, 파일 I/O 같은 작업이 들어가면 락 보유 시간이 길어져 데드락 확률이 증가합니다.

  • 트랜잭션은 DB 작업만 포함
  • 외부 작업은 커밋 이후로 분리

MSA 환경이라면 트랜잭션 경계를 분리하면서 정합성을 유지하기 위해 Outbox 패턴을 고려할 수 있습니다. 참고로 Kafka MSA 중복처리 막는 Outbox 패턴 구현 글이 설계 감을 잡는 데 도움이 됩니다.

4) SELECT ... FOR UPDATE 남용

읽기라도 FOR UPDATE 를 쓰면 쓰기 락과 동일한 수준의 충돌이 생깁니다. 정말 필요한 행만 잠그도록 조건과 인덱스를 점검하세요.

SELECT *
FROM orders
WHERE id = 100
FOR UPDATE;

5) 갭 락이 걸리는 격리수준과 범위 조건

REPEATABLE READ 에서는 팬텀 방지를 위해 넥스트키 락이 걸 수 있습니다. 범위 조건이 있고, 인덱스 설계가 애매하면 갭까지 잠그며 충돌이 늘어납니다.

6) 유니크 키 경합(동일 키로 동시 INSERT)

동일한 유니크 키에 동시 삽입이 발생하면 잠금 경합이 커지고, 다른 업데이트와 섞이면 데드락으로 번질 수 있습니다.

재현을 위한 최소 실험 스크립트

데드락은 재현이 어렵지만, “락 순서 뒤집기”로 비교적 쉽게 만들 수 있습니다. 아래는 두 세션에서 순서만 다르게 업데이트하는 예입니다.

사전 테이블 예시:

CREATE TABLE accounts (
  id BIGINT PRIMARY KEY,
  balance INT NOT NULL
) ENGINE=InnoDB;

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

세션 A:

START TRANSACTION;
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
-- 여기서 잠깐 대기
UPDATE accounts SET balance = balance + 10 WHERE id = 2;
COMMIT;

세션 B:

START TRANSACTION;
UPDATE accounts SET balance = balance - 10 WHERE id = 2;
-- 여기서 잠깐 대기
UPDATE accounts SET balance = balance + 10 WHERE id = 1;
COMMIT;

둘이 서로 반대 순서로 id=1id=2 를 잡으려 하므로 데드락이 발생할 수 있고, 발생 시 LATEST DETECTED DEADLOCK 에 기록됩니다.

해결 전략: 무엇을 바꿀지 결정하는 체크리스트

1) 트랜잭션 내 락 획득 순서를 통일

가장 효과적이고 부작용이 적습니다.

  • 항상 orders 를 먼저 잠그고 order_items 를 잠그기
  • 혹은 그 반대로, 단 반드시 전 API에서 일관되게

2) 필요한 행만 잠그도록 인덱스와 조건절을 정리

  • 복합 인덱스 추가
  • 조건절에서 함수 적용 등으로 인덱스 무력화 제거

3) 트랜잭션을 짧게 만들기

  • 트랜잭션 시작을 최대한 늦추고
  • 커밋을 최대한 빨리

4) 데드락은 “정상적인 이벤트”로 보고 재시도 설계

InnoDB는 데드락을 피할 수 없을 때가 있습니다. 따라서 애플리케이션은 데드락 에러 코드를 감지해 짧은 지수 백오프 후 재시도하는 것이 일반적입니다.

예: MySQL 에러 코드 1213 또는 SQLSTATE 40001 를 기준으로 재시도.

for attempt in 1..3:
  try:
    begin
    do db work
    commit
    return success
  catch deadlock:
    rollback
    sleep(backoff)
raise

재시도는 만능이 아니며, 근본 원인(락 순서, 인덱스, 긴 트랜잭션)을 먼저 줄여야 합니다.

운영 팁: 데드락과 장애 징후를 함께 보기

데드락이 늘면 보통 다음이 같이 따라옵니다.

  • 응답 지연 증가, 타임아웃 증가
  • 커넥션 풀 고갈
  • 재시도 폭증으로 QPS 상승

이때는 DB만 보지 말고, 상위 레이어의 타임아웃과 재시도 정책도 같이 점검해야 합니다. 특히 타임아웃이 짧고 재시도가 공격적이면 데드락이 더 자주 재현됩니다. 앞서 언급한 AWS ALB 502/504 급증 - 타임아웃 7곳 점검 같은 관점이 실제 장애 대응에서 도움이 됩니다.

마무리: deadlock.log에서 뽑아야 할 3가지 결론

데드락 로그를 읽고 나면 최종적으로 아래 3가지를 문서화할 수 있어야 합니다.

  1. 충돌한 트랜잭션 2개가 무엇인지: 어떤 API 요청, 어떤 비즈니스 흐름인지
  2. 경합한 락이 무엇인지: 어떤 테이블의 어떤 인덱스(가능하면 어떤 키 범위)인지
  3. 재발 방지 액션: 락 순서 통일, 인덱스 추가, 트랜잭션 축소, 재시도 정책 조정 중 무엇을 적용할지

이 3가지만 명확히 정리되면, 데드락은 “원인 불명 장애”가 아니라 “재현 가능한 경쟁 조건”으로 바뀌고, 수정도 훨씬 빨라집니다.