Published on

MySQL InnoDB 데드락 폭증 원인·튜닝 7단계

Authors

데드락은 “서로가 서로의 락을 기다리며 영원히 진행 못 하는 상태”이고, InnoDB는 이를 감지하면 한 트랜잭션을 롤백시켜 교착을 끊습니다. 문제는 데드락이 가끔 나는 수준이 아니라, 배포/트래픽 변화 이후 폭증하면 애플리케이션 레이어에서 재시도 폭탄, 지연 전파, 커넥션 고갈로 이어져 장애가 됩니다.

이 글은 “왜 갑자기 늘었는지”를 구조적으로 추적하고, 실무에서 효과가 큰 순서대로 튜닝을 진행하는 7단계를 제시합니다.

참고로 트랜잭션 경계가 의도대로 적용되지 않아 락이 길어지는 경우도 흔합니다. 프레임워크를 쓴다면 Spring Boot 3에서 @Transactional 무시되는 7가지도 함께 확인해 두는 것이 좋습니다.

0. 데드락 폭증의 전형적인 신호

  • Deadlock found when trying to get lock; try restarting transaction 에러가 급증
  • TPS는 비슷한데 p95/p99 지연이 급격히 상승
  • CPU는 낮은데 커넥션 수와 Threads_running이 증가
  • SHOW ENGINE INNODB STATUS에 동일 패턴의 데드락이 반복
  • 동일 테이블/인덱스에 UPDATE/DELETE 경합이 몰림

폭증은 대개 “락을 잡는 트랜잭션 수”가 늘었거나, “락 범위가 넓어졌거나”, “락 잡는 순서가 뒤섞였거나” 중 하나입니다.

1단계: 먼저 증거를 확보한다 (데드락 로그·메트릭)

1) InnoDB 데드락 상세 로그 확인

가장 먼저 아래를 실행해 마지막 데드락을 확보합니다.

SHOW ENGINE INNODB STATUS\G

출력의 LATEST DETECTED DEADLOCK 섹션에서 확인할 것:

  • 어떤 쿼리들이 교착을 만들었는지
  • 어떤 인덱스/레코드(또는 갭) 락인지
  • 트랜잭션이 어떤 순서로 락을 잡았는지

2) 데드락을 “항상” 남기게 설정

기본은 마지막 데드락만 남으니, 장애 분석 중이라면 로그를 남기도록 설정합니다.

SET GLOBAL innodb_print_all_deadlocks = ON;

운영에서는 로그 볼륨이 늘 수 있으니, 문제 구간에만 켜고 끄는 방식이 안전합니다.

3) Performance Schema로 락 대기 관측

MySQL 8.x라면 performance_schema의 락/대기 이벤트로 “데드락 직전 어떤 락 대기가 많았는지”를 볼 수 있습니다.

SELECT
  event_name,
  COUNT_STAR,
  SUM_TIMER_WAIT/1000000000000 AS wait_s
FROM performance_schema.events_waits_summary_global_by_event_name
WHERE event_name LIKE 'wait/lock/%'
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10;

여기서 wait/lock/innodb/%가 치솟으면, 쿼리 튜닝 이전에 락 경합 자체가 커졌다는 뜻입니다.

2단계: “폭증”의 트리거를 찾는다 (배포·트래픽·배치)

데드락이 갑자기 늘 때 흔한 트리거:

  • 신규 기능 배포로 동일 row를 갱신하는 경로 추가
  • 배치가 낮 시간에 돌기 시작하거나, 병렬도가 증가
  • 인덱스 변경으로 실행 계획이 바뀌어 더 많은 row를 스캔
  • 격리수준 변경(예: READ COMMITTED에서 REPEATABLE READ로)
  • 트랜잭션 범위가 길어짐(외부 API 호출을 트랜잭션 안에서 수행 등)

애플리케이션 레이어에서 트랜잭션이 예상보다 길어지는 패턴은 특히 치명적입니다. 트랜잭션이 “짧다”는 가정이 깨지면, 같은 락이라도 충돌 확률이 기하급수적으로 증가합니다.

3단계: 데드락 패턴을 분류한다 (레코드락 vs 갭락)

InnoDB 데드락은 크게 두 부류가 많습니다.

1) 동일 row를 서로 다른 순서로 갱신

예를 들어 주문 아이템을 두 트랜잭션이 서로 다른 정렬로 업데이트하면 교착이 발생합니다.

  • Tx A: item 1 락 후 item 2 락
  • Tx B: item 2 락 후 item 1 락

이 경우 해결은 비교적 명확합니다. 항상 같은 순서로 락을 잡게 만들면 됩니다.

2) 갭락(Next-key lock)로 인한 범위 경합

REPEATABLE READ에서 범위 조건을 걸고 UPDATE/SELECT ... FOR UPDATE를 하면, 단일 row가 아니라 **범위(갭 포함)**를 잠글 수 있습니다.

예:

-- 인덱스가 (user_id, created_at)에 있고,
-- created_at 범위로 잠금이 넓게 잡히는 상황
SELECT id
FROM orders
WHERE user_id = 10
  AND created_at BETWEEN '2026-02-01' AND '2026-02-25'
FOR UPDATE;

범위가 넓을수록, 그리고 동시성이 높을수록 데드락/락대기가 폭증합니다.

4단계: 쿼리와 인덱스로 “락 범위”부터 줄인다

데드락 튜닝의 핵심은 잠그는 row 수를 줄이는 것입니다. 그 다음이 “락 순서 통일”입니다.

1) 조건절이 인덱스를 제대로 타는지 확인

EXPLAIN ANALYZE
UPDATE orders
SET status = 'PAID'
WHERE user_id = 10
  AND status = 'PENDING'
  AND created_at >= '2026-02-01';
  • rows가 과도하게 크면 잠금 후보 row가 많습니다.
  • Using where만 있고 적절한 키를 못 타면 테이블 스캔에 가까워져 락 경합이 커집니다.

2) “갱신 조건”을 커버하는 복합 인덱스 설계

예를 들어 위 쿼리에서 자주 갱신하는 조건이 (user_id, status, created_at)라면 다음이 도움이 될 수 있습니다.

CREATE INDEX idx_orders_user_status_created
ON orders(user_id, status, created_at);

인덱스는 만능이 아니지만, 데드락 폭증 국면에서는 “스캔 범위 축소”가 즉효가 많습니다.

3) 불필요한 범위 잠금을 만드는 쿼리 패턴 제거

  • LIKE '%keyword%' 같은 비SARG 조건
  • 함수로 감싼 컬럼 조건: WHERE DATE(created_at) = ...
  • 암묵적 형 변환으로 인덱스 미사용

이런 패턴은 “읽기 느림”을 넘어 “락 범위 확대”로 데드락을 부릅니다.

5단계: 트랜잭션 경계와 락 획득 순서를 표준화한다

1) 트랜잭션을 짧게

  • 트랜잭션 안에서 외부 API 호출, 파일 I/O, 대기성 로직 제거
  • 여러 테이블을 갱신할 때 불필요한 조회를 트랜잭션 밖으로 이동

2) 락 획득 순서를 고정

서로 다른 코드 경로에서 같은 리소스를 갱신한다면, 항상 동일한 순서로 접근하도록 규칙을 만듭니다.

예:

  • 항상 accounts를 먼저 업데이트하고 orders를 나중에 업데이트
  • 다건 업데이트 시 항상 id ASC로 정렬 후 처리
-- 예: 다건 갱신 전에 대상 id를 정렬해 잠금 순서를 고정
SELECT id
FROM orders
WHERE user_id = 10 AND status = 'PENDING'
ORDER BY id
FOR UPDATE;

UPDATE orders
SET status = 'PAID'
WHERE id IN ( ... );

3) 애플리케이션에서 “같은 row에 대한 중복 갱신” 제거

예: 상태 전이 로직이 여러 워커에서 동시에 실행되면 데드락이 아니라도 충돌이 잦습니다.

  • 단일 소비자(파티셔닝)로 동일 키가 한 워커로만 가게 하기
  • 작업 큐에서 키 기반 샤딩

6단계: 격리수준·락 옵션을 상황에 맞게 조정한다

격리수준을 낮추면 갭락이 줄어드는 경우가 있습니다. 다만 정합성 요구사항을 먼저 확인해야 합니다.

1) READ COMMITTED 고려

REPEATABLE READ는 InnoDB 기본이며 안정적이지만, 범위 잠금이 커질 수 있습니다. 특정 워크로드(특히 큐성 테이블, 상태 전이 테이블)에서는 세션 또는 트랜잭션 단위로 READ COMMITTED가 도움이 되기도 합니다.

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
-- ...
COMMIT;

2) 잠금 강도를 줄이는 쿼리로 변경 가능 여부

  • 정말로 FOR UPDATE가 필요한지 재검토
  • “존재 확인” 수준이면 LOCK IN SHARE MODE 또는 일반 SELECT로 충분한지 검토

단, 애플리케이션 로직이 “읽고 나서 쓰기”에 의존한다면, 락을 약하게 바꾸는 것은 레이스 컨디션을 만들 수 있습니다.

7단계: 운영 튜닝과 재시도 전략으로 피해를 제한한다

구조적 원인을 고치는 동안에도, 운영 설정과 재시도 정책이 폭증을 완화할 수 있습니다.

1) 데드락 재시도는 “지수 백오프 + 지터”로

즉시 재시도는 같은 락 경합을 재현해 2차 폭주를 만듭니다.

  • 재시도 횟수: 2~3회 내
  • 대기: 50ms, 100ms, 200ms 같은 지수 백오프
  • 지터: 랜덤 분산

2) 긴 트랜잭션을 빨리 끊기

innodb_lock_wait_timeout은 “락 대기 타임아웃”이라 데드락 자체와 다르지만, 락 경합이 심한 구간에서 대기열을 줄이는 데 도움이 됩니다.

SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';

무작정 낮추면 정상 트랜잭션도 실패가 늘 수 있으니, 데드락과 함께 “락 대기 실패” 비율을 관찰하며 조정합니다.

3) 커넥션 풀과 타임아웃 정렬

데드락이 늘면 트랜잭션이 롤백되고 재시도되며 커넥션 점유가 길어집니다.

  • DB 타임아웃, 애플리케이션 타임아웃, LB 타임아웃을 일관되게
  • 풀 사이즈를 늘리는 것만으로 해결하려 하지 말 것(경합만 증폭)

4) 장애 시 관측 포인트 체크리스트

  • SHOW PROCESSLIST로 대기 쿼리 패턴 확인
  • information_schema.innodb_trx로 오래 열린 트랜잭션 탐지
SELECT
  trx_id,
  trx_started,
  trx_mysql_thread_id,
  trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;

오래 열린 트랜잭션이 있으면 “데드락 폭증”이라기보다 “락을 오래 쥔 범인”일 가능성이 큽니다.

자주 나오는 원인 5가지 (실무 요약)

  1. 인덱스 미스/실행계획 변경으로 스캔 row 수 증가
  2. 범위 조건 FOR UPDATE로 갭락이 넓게 발생
  3. 다건 업데이트에서 락 획득 순서가 뒤섞임
  4. 트랜잭션이 길어짐(특히 애플리케이션 레이어 변경)
  5. 배치 병렬도 증가로 동일 리소스 경합 폭증

결론: 데드락은 “증상”, 락 설계가 “원인”

InnoDB 데드락은 피할 수 없는 경우도 있지만, 폭증은 대부분 구조적 변화의 결과입니다. 해결 우선순위를 정리하면 다음이 가장 효율적입니다.

  1. 데드락 로그를 상시 수집해 패턴을 고정하고,
  2. 쿼리/인덱스로 잠금 범위를 줄인 뒤,
  3. 트랜잭션을 짧게 만들고 락 순서를 표준화하며,
  4. 필요할 때만 격리수준/락 옵션을 조정하고,
  5. 재시도는 백오프로 피해를 제한합니다.

트랜잭션 경계가 의도대로 동작하지 않으면 2)와 3) 튜닝이 전부 무력화될 수 있습니다. 프레임워크 기반 서비스라면 앞서 언급한 Spring Boot 3에서 @Transactional이 안 먹는 6가지도 함께 점검해 보세요.