반응형
Ant_U
DBA 개미
Ant_U
전체 방문자
오늘
어제
  • 분류 전체보기 (308) N
    • AWS (3)
    • C# (1)
    • SQL (282) N
      • MYSQL (228) N
      • MSSQL (54) N
    • 자격증 (20)
      • SQLD (12)
      • SQLP (8)

인기 글

최근 글

250x250
hELLO · Designed By 정상우.
Ant_U

DBA 개미

[MySQL] 데드락 로그 읽는 법: 피해 트랜잭션과 잠금 순서 복원하기
SQL/MYSQL

[MySQL] 데드락 로그 읽는 법: 피해 트랜잭션과 잠금 순서 복원하기

2026. 9. 26. 09:00
728x90
반응형

[MySQL] 데드락 로그 읽는 법: 피해 트랜잭션과 잠금 순서 복원하기

데이터베이스를 다루다 보면 어느 날 애플리케이션 로그에 Deadlock found when trying to get lock이라는 에러가 찍히는 순간을 만나게 됩니다. 개발자 입장에서는 당황스럽지만, DBA 입장에서는 오히려 반가운 신호이기도 합니다. MySQL의 InnoDB 스토리지 엔진은 데드락을 스스로 감지해서 둘 중 한 트랜잭션을 강제로 롤백시키기 때문에, 서비스가 완전히 멈추는 대신 한쪽만 실패로 끝나기 때문입니다. 문제는 그다음입니다. "어떤 SQL과 어떤 레코드가 충돌했는가", "왜 하필 이 트랜잭션이 롤백됐는가"를 설명하지 못하면 똑같은 데드락이 반복해서 터지게 됩니다. 이때 가장 먼저 확인해야 하는 것이 바로 SHOW ENGINE INNODB STATUS가 남기는 LATEST DETECTED DEADLOCK 로그입니다. 이 글에서는 이 로그의 구조를 줄 단위로 해부하고, 두 트랜잭션의 잠금 획득 순서를 표로 복원해서 순환 대기를 눈으로 확인하는 절차까지 정리해 드립니다.

1. 데드락 로그를 얻는 방법: SHOW ENGINE INNODB STATUS와 innodb_print_all_deadlocks

InnoDB는 기본적으로 가장 최근에 감지한 데드락 1건만 메모리에 보관합니다. 조회 방법은 다음과 같습니다.

SHOW ENGINE INNODB STATUS\G

출력 결과 중 LATEST DETECTED DEADLOCK 섹션을 찾으면 됩니다. 여기서 흔히 하는 실수가 하나 있는데, 이 명령을 여러 번 실행해서 예전 데드락을 다시 보려고 하는 경우입니다. InnoDB는 새로운 데드락이 발생하기 전까지는 같은 내용을 계속 보여주지만, 새 데드락이 감지되는 순간 이전 기록은 사라집니다. 즉 1건만 보관되는 휘발성 로그라는 점을 기억해야 합니다.

운영 환경에서 데드락이 자주 발생하는데 놓치고 싶지 않다면 다음 설정을 켜두는 것이 안전합니다.

SET GLOBAL innodb_print_all_deadlocks = ON;

이 옵션을 켜면 모든 데드락 내용이 MySQL 에러 로그(error log) 파일에 순서대로 계속 쌓입니다. 기본값은 OFF이므로, 데드락이 반복되는 서비스라면 반드시 켜두고 log_error 경로를 모니터링 대상에 포함시켜야 합니다. 다만 트래픽이 몰리는 시스템에서 데드락이 초 단위로 반복되면 에러 로그가 순식간에 커질 수 있으므로, 원인을 찾은 뒤에는 다시 끄는 것이 좋습니다.

2. LATEST DETECTED DEADLOCK 블록의 구조 해부

실제 로그는 대략 다음과 같은 형태로 출력됩니다(트랜잭션 2개, 각각 잠금 1건씩 관련된 가장 단순한 사례를 기준으로 구조만 보여드립니다).

------------------------\nLATEST DETECTED DEADLOCK\n------------------------\n2026-09-07 14:12:03 0x1a2c\n*** (1) TRANSACTION:\nTRANSACTION 421589234, ACTIVE 3 sec starting index read\nmysql tables in use 1, locked 1\nLOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)\nMySQL thread id 87, OS thread handle ..., query id 552310 10.0.0.11 appuser updating\nUPDATE accounts SET balance = balance - 100 WHERE id = 1001\n*** (1) WAITING FOR THIS LOCK TO BE GRANTED:\nRECORD LOCKS space id 45 page no 3 n bits 80 index PRIMARY of table `bank`.`accounts` trx id 421589234 lock_mode X locks rec but not gap waiting\n*** (2) TRANSACTION:\nTRANSACTION 421589233, ACTIVE 5 sec starting index read\nmysql tables in use 1, locked 1\nMySQL thread id 86, OS thread handle ..., query id 552308 10.0.0.12 appuser updating\nUPDATE accounts SET balance = balance + 100 WHERE id = 1002\n*** (2) HOLDS THE LOCK(S):\nRECORD LOCKS space id 45 page no 3 n bits 80 index PRIMARY of table `bank`.`accounts` trx id 421589233 lock_mode X locks rec but not gap\n*** (2) WAITING FOR THIS LOCK TO BE GRANTED:\nRECORD LOCKS space id 45 page no 3 n bits 80 index PRIMARY of table `bank`.`accounts` trx id 421589233 lock_mode X locks rec but not gap waiting\n*** WE ROLL BACK TRANSACTION (1)

이 블록은 크게 세 부분으로 나뉩니다. 각 부분이 무엇을 의미하는지 정리하면 다음과 같습니다.

구간 표시 형식 의미
트랜잭션 헤더 *** (n) TRANSACTION: 데드락에 관련된 트랜잭션 번호. (1), (2)는 로그 안에서의 임의 순번일 뿐 실제 발생 순서가 아닙니다.
실행 중이던 SQL MySQL thread id ... query id ... 다음 줄 해당 트랜잭션이 데드락 시점에 실행하고 있던 실제 SQL문. 원인 추적의 출발점입니다.
HOLDS THE LOCK(S) *** (n) HOLDS THE LOCK(S): 이 트랜잭션이 이미 획득해 놓고 있는 잠금. 상대방이 이 잠금 때문에 대기 중이라는 뜻입니다.
WAITING FOR THIS LOCK *** (n) WAITING FOR THIS LOCK TO BE GRANTED: 이 트랜잭션이 지금 획득하려고 기다리고 있는 잠금. 이게 상대방이 들고 있는 것과 겹치면 데드락입니다.
결론 WE ROLL BACK TRANSACTION (n) InnoDB가 피해 트랜잭션으로 선택해 강제로 롤백시킨 쪽

2-1. 트랜잭션 헤더 읽기

TRANSACTION 421589234, ACTIVE 3 sec starting index read 줄에서 ACTIVE 3 sec는 이 트랜잭션이 시작된 지 3초가 지났다는 뜻입니다. 이 숫자가 유독 크게 나온다면, 트랜잭션을 오래 열어두는 습관(예를 들어 커넥션을 반환하지 않고 애플리케이션 로직에서 외부 API 호출을 기다리는 경우) 자체가 데드락의 근본 원인일 수 있습니다. mysql tables in use 1, locked 1은 몇 개의 테이블을 잠갔는지를 보여주는데, 예상보다 많은 테이블이 잠겨 있다면 트랜잭션 범위(scope)를 너무 크게 잡은 것이 아닌지 의심해야 합니다.

2-2. 잠금 대기와 보유 정보 읽기 (RECORD LOCKS)

RECORD LOCKS space id 45 page no 3 n bits 80 index PRIMARY of table `bank`.`accounts` trx id 421589233 lock_mode X locks rec but not gap 한 줄에 담긴 정보는 다음과 같습니다.

  • space id / page no: 실제 잠금이 걸린 물리적 페이지 위치. 같은 space id와 page no가 양쪽 트랜잭션에 반복해서 등장한다면 같은 페이지 안에서 충돌이 났다는 뜻입니다.
  • index: 잠금이 걸린 인덱스 이름. PRIMARY가 아니라 보조 인덱스(secondary index) 이름이 나온다면, WHERE 절이 어떤 인덱스를 태웠는지 실행 계획을 다시 확인해야 합니다. 이 부분은 [MySQL] WHERE 절 완전 정복: 기본 문법부터 8.0 최적화 팁까지에서 다룬 인덱스 선택 기준과 바로 이어집니다.
  • lock_mode: X(배타 잠금, exclusive)인지 S(공유 잠금, shared)인지. UPDATE·DELETE는 대부분 X 잠금을 요구합니다.
  • locks rec but not gap: 레코드 자체만 잠그고 갭(gap)은 잠그지 않는다는 뜻입니다. 만약 locks gap before rec이 보인다면 REPEATABLE READ 격리 수준에서 갭 락(gap lock)까지 걸린 것으로, 범위 조건(BETWEEN, IN, 부등호)을 쓴 쿼리에서 흔히 나타납니다.
  • waiting: 이 잠금을 아직 획득하지 못하고 대기 중이라는 표시. 이 표시가 없는 줄은 이미 보유한 잠금입니다.
가장 중요한 것은 두 트랜잭션의 RECORD LOCKS 줄에서 space id와 page no가 동일한지를 확인하는 일입니다. 다르다면 표면적으로는 같은 데드락처럼 보여도 실제로는 서로 다른 두 자원(resource)에서 각각 교차 대기가 발생한 것이므로, 원인 분석 방향이 완전히 달라집니다.

3. 피해 트랜잭션(victim)은 어떻게 선정될까

로그 마지막 줄의 WE ROLL BACK TRANSACTION (n)이 InnoDB가 강제 종료를 선택한 트랜잭션입니다. 이때 흔히 하는 오해가 "나중에 SQL을 실행한 쪽이 롤백된다"는 것인데, 사실이 아닙니다. InnoDB는 다음 기준으로 피해 트랜잭션을 고릅니다.

  1. 롤백에 필요한 작업량(undo log 양)이 더 적은 트랜잭션을 우선적으로 희생시킵니다. 즉 지금까지 변경한 행 수가 적은 쪽이 롤백 대상이 될 확률이 높습니다.
  2. 작업량이 비슷하면 더 나중에 잠금을 요청한 트랜잭션을 롤백하는 경향이 있지만, 이는 내부 휴리스틱(heuristic)이며 버전에 따라 미묘하게 달라질 수 있어 절대적인 규칙으로 신뢰하면 안 됩니다.

따라서 "먼저 시작한 트랜잭션이 항상 살아남는다"는 가정으로 애플리케이션 재시도 로직을 설계하면 실무에서 빗나가는 경우가 많습니다. 안전하게 대응하려면 어느 쪽이 롤백되든 모든 트랜잭션에 재시도(retry) 로직을 동일하게 적용하는 것이 더 안전합니다.

4. 잠금 획득 순서 복원하기: 4단계 절차

로그 한 건만 봐서는 "왜" 순환 대기가 생겼는지 바로 보이지 않습니다. 다음 4단계를 따라가면 원인이 명확해집니다.

  1. 1단계 — 각 트랜잭션이 실행한 SQL을 시간순으로 나열: 로그에는 데드락 시점의 SQL 한 줄만 나오므로, 애플리케이션 로그나 general log, 혹은 slow query log를 대조해서 각 트랜잭션이 그 이전에 실행한 UPDATE/SELECT ... FOR UPDATE 순서까지 함께 확보해야 합니다.
  2. 2단계 — 트랜잭션별 보유·대기 잠금을 표로 정리: HOLDS와 WAITING FOR를 트랜잭션별로 분리해 표로 그립니다.
  3. 3단계 — 순환 여부 확인: 트랜잭션 A가 대기 중인 잠금을 B가 들고 있고, 동시에 B가 대기 중인 잠금을 A가 들고 있으면 순환(cycle)이 성립합니다. 이것이 데드락의 정의입니다.
  4. 4단계 — 공통 접근 순서 도출: 두 트랜잭션이 같은 자원(테이블·행)들을 서로 다른 순서로 접근했는지 확인합니다. 대부분의 데드락 원인은 여기서 드러납니다.

5. 실전 예시로 순환 대기 그려보기

앞서 예시로 든 로그를 2단계 표로 정리하면 다음과 같습니다.

트랜잭션 실행 SQL 보유(HOLDS) 중인 잠금 대기(WAITING) 중인 잠금
(1) trx 421589234 UPDATE accounts SET balance=balance-100 WHERE id=1001 id=1001 행의 X 잠금 id=1002 행의 X 잠금
(2) trx 421589233 UPDATE accounts SET balance=balance+100 WHERE id=1002 id=1002 행의 X 잠금 id=1001 행의 X 잠금

표로 그려보면 (1)은 1001을 들고 1002를 기다리고, (2)는 1002를 들고 1001을 기다리는 정확한 순환이 보입니다. 이 경우 원인은 명확합니다. 한쪽 트랜잭션은 계좌 1001 → 1002 순서로 UPDATE했고, 다른 쪽은 1002 → 1001 순서로 UPDATE한 것입니다. 애플리케이션 코드에서 계좌 이체 로직을 짤 때 두 개 이상의 행을 갱신하는 순서를 통일하지 않은 것이 근본 원인이며, 이것이 데드락의 가장 흔한 발생 패턴입니다.

실무에서 접근 순서가 자연스럽게 갈리는 대표 상황도 짚어두겠습니다.

  • 이체·정산처럼 두 행을 함께 갱신하는데 호출하는 쪽에 따라 파라미터 순서가 뒤바뀌는 경우
  • 부모-자식 테이블을 갱신할 때 한쪽은 부모→자식, 다른 쪽은 자식→부모 순서로 잠그는 경우
  • 배치(batch) 작업과 온라인 트랜잭션이 같은 테이블을 서로 다른 정렬 기준(인덱스)으로 순회하며 갱신하는 경우

6. 8.0 이상에서 쓸 수 있는 보조 도구: performance_schema.data_lock_waits

MySQL 5.7까지는 information_schema.innodb_lock_waits를 주로 썼지만, 8.0부터는 이 테이블이 제거되고 performance_schema 쪽으로 이관되었습니다. 이는 버전별 동작 차이가 뚜렷한 부분이라 반드시 짚고 넘어가야 합니다.

-- MySQL 8.0 이상\nSELECT * FROM performance_schema.data_lock_waits;\nSELECT * FROM performance_schema.data_locks;

다만 이 두 테이블은 데드락이 해소되고 난 뒤에는 조회할 수 없다는 점에 주의해야 합니다. InnoDB가 순환을 감지하는 즉시 한쪽을 롤백시켜 대기 상태 자체가 사라지기 때문입니다. 따라서 이 뷰는 "현재 진행 중인 잠금 대기"를 살펴보는 용도이지, 이미 지나간 데드락을 사후 분석하는 용도로는 쓸 수 없습니다. 사후 분석의 유일한 근거는 결국 LATEST DETECTED DEADLOCK 로그(또는 innodb_print_all_deadlocks로 쌓은 에러 로그)뿐이라는 점이 초보 개발자가 가장 흔히 놓치는 부분입니다.

참고로 실행 계획이 예상과 다른 인덱스를 태워서 불필요하게 넓은 범위에 잠금을 걸고 있는 것은 아닌지 확인할 때는 [MySQL] EXPLAIN ANALYZE 해석법: 추정 행 수와 actual rows 오차 찾기에서 다룬 actual rows 비교 방법이 함께 도움이 됩니다. 스캔 범위가 넓을수록 갭 락 범위도 넓어져 데드락 확률이 높아지기 때문입니다.

7. 재현과 재발 방지: 잠금 순서를 코드 레벨에서 고정하기

로그 해석으로 원인을 찾았다면, 재발 방지는 다음 원칙을 따르는 것이 효율적으로 데드락을 줄이는 방법입니다.

  1. 다중 행을 갱신하는 로직은 항상 같은 정렬 기준(예: 기본키 오름차순)으로 접근하도록 애플리케이션 코드를 통일합니다. 계좌 이체라면 두 계좌 ID를 비교해서 항상 작은 ID부터 갱신하는 식입니다.
  2. 트랜잭션 격리 수준이 REPEATABLE READ라면 갭 락 범위를 줄이기 위해 조건절에 명확한 인덱스를 태우도록 쿼리를 점검합니다.
  3. 동시성이 높은 구간에서는 SELECT ... FOR UPDATE의 대상 행 수를 최소화하고, 트랜잭션을 열어둔 채 외부 I/O를 기다리지 않도록 구조를 분리합니다.
  4. 재발 여부를 확인하려면 수정 전후로 innodb_print_all_deadlocks를 켠 상태에서 동일한 동시성 시나리오를 재현해, 에러 로그에 새로운 LATEST DETECTED DEADLOCK 블록이 더 이상 쌓이지 않는지 검증해야 합니다.

정리해 드리면, 데드락 로그를 읽는 핵심은 결국 "HOLDS는 상대가 기다리는 것, WAITING FOR는 내가 기다리는 것"이라는 두 문구를 트랜잭션별로 표로 나눠 그려보는 것 하나로 압축됩니다. 이 표만 정확히 그릴 수 있으면 어떤 SQL과 어떤 레코드가 충돌했는지, 왜 특정 트랜잭션이 피해자로 선택됐는지는 자연스럽게 드러납니다.

728x90
반응형

'SQL > MYSQL' 카테고리의 다른 글

[MySQL] NOWAIT와 SKIP LOCKED 활용: 대기 없는 작업 큐 구현과 한계  (0) 2026.09.30
[MySQL] SELECT FOR UPDATE와 FOR SHARE 비교: 예약·재고 처리 잠금 설계  (0) 2026.09.29
[MySQL] 긴 트랜잭션이 Undo와 Purge를 지연시키는 과정 측정하기  (0) 2026.09.28
[MySQL] performance_schema로 현재 잠금 대기와 차단 세션 찾기  (0) 2026.09.27
[MySQL] 옵티마이저 힌트 사용 기준: JOIN_ORDER와 INDEX 힌트의 선택·제거 원칙  (0) 2026.09.25
[MySQL] 중복 인덱스 찾기: 좌측 접두사 관계와 삭제 전 검증 절차  (1) 2026.09.24
[MySQL] 혼합 정렬 ORDER BY 최적화: 내림차순 인덱스로 filesort 피하기  (0) 2026.09.23
[MySQL] 접두사 인덱스 길이 정하기: 카디널리티와 충돌률로 계산하는 실전 기준  (0) 2026.09.22
    'SQL/MYSQL' 카테고리의 다른 글
    • [MySQL] 긴 트랜잭션이 Undo와 Purge를 지연시키는 과정 측정하기
    • [MySQL] performance_schema로 현재 잠금 대기와 차단 세션 찾기
    • [MySQL] 옵티마이저 힌트 사용 기준: JOIN_ORDER와 INDEX 힌트의 선택·제거 원칙
    • [MySQL] 중복 인덱스 찾기: 좌측 접두사 관계와 삭제 전 검증 절차
    Ant_U
    Ant_U

    티스토리툴바