- Published on
MySQL InnoDB 데드락 1213 로그로 재현·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction가 터지면, 대부분은 “운이 나빴다”가 아니라 잠금 순서·인덱스·격리수준·트랜잭션 범위가 함께 만든 구조적 문제입니다. InnoDB는 교착 상태를 감지하면 한 트랜잭션을 희생(victim)시켜 풀어주는데, 그 희생이 애플리케이션 레벨에서는 재시도 폭주, 결제/재고 불일치, 커넥션 고갈로 이어질 수 있습니다.
이 글에서는 (1) InnoDB 데드락 로그를 제대로 읽는 법, (2) 가장 흔한 패턴을 SQL로 재현하는 법, (3) 재현 결과를 바탕으로 해결하는 실전 접근(쿼리/인덱스/트랜잭션/재시도)을 정리합니다.
또한 데드락이 연쇄적으로 커넥션 풀을 말려버리는 경우가 많으니, 운영 환경에서는 Spring Boot DB 커넥션 고갈 - HikariCP 튜닝 가이드도 함께 점검하는 것을 권합니다.
1) 데드락 1213의 본질: “잠금 순서 충돌”
InnoDB에서 데드락은 보통 아래 조건이 겹칠 때 발생합니다.
- 서로 다른 트랜잭션이 서로가 가진 잠금을 기다림
- 잠금 대상이 명확히 한 행이 아니라, 인덱스 범위/갭까지 포함(특히
REPEATABLE READ) - 인덱스가 부실해서 필요 이상으로 많은 레코드/범위를 잠금
- 트랜잭션이 길고, 한 트랜잭션 안에서 여러 테이블/여러 행을 다른 순서로 갱신
핵심은 “동일한 자원 집합을 서로 다른 순서로 잠그는 상황”입니다.
2) 로그로 원인 추적: SHOW ENGINE INNODB STATUS
데드락을 해결하려면 먼저 InnoDB가 어떤 잠금을 잡고 있었는지 확인해야 합니다.
2.1 즉시 확인 (최근 데드락 1건)
데드락이 발생한 직후 아래를 실행합니다.
SHOW ENGINE INNODB STATUS\G
출력에서 LATEST DETECTED DEADLOCK 섹션을 찾습니다. 여기에는 보통 다음 정보가 들어 있습니다.
- 어떤 트랜잭션이 어떤 SQL을 실행 중이었는지
- 어떤 인덱스/레코드(또는 갭)를 잠그려 했는지
- 어떤 트랜잭션이 희생되었는지
주의: 이 명령은 “가장 최근 데드락 1건”만 보여줍니다. 빈번하게 발생하면 금방 덮어씌워집니다.
2.2 운영에서 지속 수집: innodb_print_all_deadlocks
운영에서 재현이 어려운 데드락은 MySQL 에러 로그에 남겨야 합니다.
SET GLOBAL innodb_print_all_deadlocks = 1;
이 설정을 켜면 데드락이 발생할 때마다 에러 로그에 상세 정보가 기록됩니다(환경에 따라 로그 볼륨 증가 주의).
2.3 로그에서 꼭 봐야 하는 포인트
로그를 읽을 때는 다음을 체크합니다.
- 두 트랜잭션이 같은 테이블인지, 아니면 서로 다른 테이블을 교차로 잠그는지
WAITING FOR THIS LOCK TO BE GRANTED에 나온 인덱스가 무엇인지lock_mode X(쓰기 잠금),lock_mode S(읽기 잠금) 등 모드- “record lock”인지 “gap lock/next-key lock”인지
특히 REPEATABLE READ에서 SELECT ... FOR UPDATE나 범위 조건이 있으면 갭/넥스트키 락이 개입해 데드락이 훨씬 쉽게 생깁니다.
3) SQL로 데드락을 “의도적으로” 재현하기
재현이 되면 해결이 빨라집니다. 아래는 가장 흔한 패턴(행 2개를 서로 다른 순서로 업데이트)으로, 로컬에서 쉽게 재현됩니다.
3.1 테스트 테이블 준비
DROP TABLE IF EXISTS accounts;
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);
3.2 세션 A / 세션 B로 데드락 만들기
세션 A:
SET autocommit = 0;
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 여기서 잠깐 대기(수동으로)
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
세션 B:
SET autocommit = 0;
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 2;
-- 세션 A가 두 번째 UPDATE를 치기 전에 실행
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
COMMIT;
두 트랜잭션이 서로 반대 순서로 id=1, id=2를 잡으면서 교착이 발생하고, 둘 중 하나가 1213으로 롤백됩니다.
3.3 재현 후 데드락 로그 확인
SHOW ENGINE INNODB STATUS\G
여기서 “어떤 인덱스의 어떤 레코드를 기다렸는지”가 나옵니다. 이 예시는 PK 기반이라 비교적 단순하지만, 실무에서는 보조 인덱스/범위 조건이 끼면서 훨씬 복잡해집니다.
4) 실무에서 더 자주 터지는 패턴 4가지
4.1 동일 자원을 다른 순서로 잠그는 로직
- 주문 생성에서
inventory먼저 잠그고orders갱신 - 다른 API에서는
orders먼저 잠그고inventory갱신
해결의 1순위는 “잠금 순서 통일”입니다.
4.2 인덱스 부재로 인한 과도한 잠금(스캔 잠금)
예를 들어 아래 쿼리가 인덱스를 못 타면, 조건에 맞는 행을 찾기 위해 더 넓은 범위를 스캔하며 잠금 경합이 커집니다.
SELECT *
FROM coupons
WHERE user_id = 123 AND status = 'READY'
FOR UPDATE;
해결: (user_id, status) 복합 인덱스가 필요할 수 있습니다.
CREATE INDEX idx_coupons_user_status ON coupons (user_id, status);
4.3 범위 조건 + FOR UPDATE로 인한 갭/넥스트키 락
SELECT *
FROM events
WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'
FOR UPDATE;
이런 형태는 범위 잠금이 커져서 데드락/락 대기가 늘 수 있습니다. 가능하면 “정확한 PK 조회로 잠금 범위를 줄이기”가 좋습니다.
4.4 유니크 인덱스 충돌 + 동시 INSERT/UPDATE
INSERT ... ON DUPLICATE KEY UPDATE가 섞이면, 유니크 인덱스 레코드 잠금 경쟁이 생기고 데드락이 발생할 수 있습니다. 이 경우 로그에서 유니크 인덱스 이름이 자주 등장합니다.
5) 해결 전략: 재현 결과를 바탕으로 “잠금 범위를 줄이기”
5.1 트랜잭션에서 잠금 순서를 강제로 통일
위 재현 예제는 “항상 작은 id부터 업데이트”로 해결할 수 있습니다.
-- 두 계좌를 항상 id 오름차순으로 잠금
START TRANSACTION;
SELECT id
FROM accounts
WHERE id IN (1, 2)
ORDER BY id
FOR UPDATE;
-- 잠금이 잡힌 뒤 실제 업데이트
UPDATE accounts SET balance = CASE
WHEN id = 1 THEN balance - 100
WHEN id = 2 THEN balance + 100
ELSE balance
END
WHERE id IN (1, 2);
COMMIT;
포인트는 “업데이트 전에 FOR UPDATE로 잠금 대상과 순서를 고정”하는 것입니다.
5.2 인덱스로 스캔을 줄여 잠금 범위를 축소
데드락 로그에 특정 보조 인덱스가 반복적으로 등장하거나, 반대로 “적절한 인덱스가 없어” PK까지 도달하기 위해 많은 레코드를 훑는 흔적이 보이면 인덱스가 우선입니다.
EXPLAIN으로 실제 사용 인덱스 확인- 조건 컬럼의 선택도/정렬/조인 패턴에 맞춘 복합 인덱스 설계
EXPLAIN SELECT *
FROM coupons
WHERE user_id = 123 AND status = 'READY'
FOR UPDATE;
5.3 격리수준 조정은 “마지막 카드”로
REPEATABLE READ에서 갭락이 문제라면, 워크로드에 따라 READ COMMITTED로 낮추는 것이 도움이 될 수 있습니다. 다만 이는 애플리케이션의 일관성 가정에 영향을 줄 수 있어, 단순히 데드락만 보고 내리면 부작용이 생깁니다.
세션 단위로만 낮춰서 영향 범위를 제한할 수도 있습니다.
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
-- ...
COMMIT;
5.4 트랜잭션을 짧게: 외부 호출/긴 로직을 트랜잭션 밖으로
- 트랜잭션 안에서 API 호출, 파일 업로드, 복잡한 계산을 하면 락 유지 시간이 길어집니다.
- “DB 잠금이 필요한 구간”만 최소화하고 나머지는 밖으로 빼야 합니다.
5.5 애플리케이션 레벨 재시도(필수) + 백오프(권장)
InnoDB는 데드락을 정상적인 동시성 비용으로 간주합니다. 따라서 재시도는 필수입니다. 다만 무한 재시도는 장애를 키웁니다.
- 최대 재시도 횟수 제한
- 지수 백오프 + 지터
- 멱등성 보장(특히 결제/차감)
예: 의사코드
for attempt in 1..3:
try:
begin
do business updates
commit
return success
catch deadlock(1213) or lock_wait_timeout(1205):
rollback
sleep(backoff_with_jitter)
raise error
재시도가 많아지면 커넥션 점유 시간이 늘어 커넥션 풀이 고갈될 수 있으니, 운영에서는 커넥션 풀/타임아웃/대기열도 같이 봐야 합니다. 관련해서는 Spring Boot DB 커넥션 고갈 - HikariCP 튜닝 가이드가 직접적인 도움이 됩니다.
6) 운영 점검 체크리스트
6.1 “데드락 vs 락 대기 타임아웃” 구분
- 데드락:
1213 - 락 대기 타임아웃:
1205
둘 다 재시도 대상이 될 수 있지만, 원인과 해결 방향이 다릅니다. 데드락은 “순서/경합”, 타임아웃은 “긴 트랜잭션/핫스팟/인덱스 미스”가 더 흔합니다.
6.2 핫스팟 행(단일 row)에 트래픽이 몰리는지
예:
- 특정 사용자/특정 상품/특정 재고 row
- “전역 카운터” 테이블
이 경우는 잠금 순서를 맞춰도 경합 자체가 너무 커서 병목이 됩니다. 샤딩, 카운터 분산, 비동기 적재 같은 구조 개선이 필요할 수 있습니다.
6.3 배포/마이그레이션 시점과 상관관계
데드락은 배포로 쿼리 패턴이 바뀌거나 인덱스가 누락될 때 급증합니다. GitOps로 운영한다면 배포 상태 꼬임도 함께 확인해야 합니다. 예를 들어 Argo CD를 쓰는 환경에서는 Argo CD Sync Failed/OutOfSync 원인 10가지처럼 배포 불일치가 원인으로 남아 “특정 버전에서만 데드락이 증가”하는 현상이 생길 수 있습니다.
7) 결론: “로그로 잠금 대상을 확정하고, 재현으로 해결을 검증”
데드락 1213은 피할 수 없는 동시성 이벤트가 아니라, 대부분 잠금 범위를 줄이고 잠금 순서를 통일하면 크게 줄일 수 있습니다.
실전에서의 우선순위는 다음이 효율적입니다.
SHOW ENGINE INNODB STATUS\G와 에러 로그로 데드락 패턴 수집- 동일 패턴을 로컬/스테이징에서 SQL로 재현
- 잠금 순서 통일(가장 효과 큼)
- 인덱스 보강으로 스캔/락 범위 축소
- 트랜잭션 짧게, 재시도는 제한+백오프
- 필요 시 격리수준/구조 개선(핫스팟 제거)
이 흐름대로 접근하면 “가끔 터지는 1213”이 아니라, “왜 터지는지 설명 가능한 1213”이 되고, 해결도 검증 가능해집니다.