1. 오류 설명: ERROR 1213
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction은 두 트랜잭션이 서로가 쥔 잠금을 기다리면서 영원히 풀리지 않는 상태가 됐을 때 발생합니다.
InnoDB는 이 상황을 감지하면 한쪽을 강제로 롤백시켜 교착을 깹니다. 그래서 오류를 받은 세션은 트랜잭션이 통째로 취소된 상태이고, 다른 한쪽은 정상적으로 진행됩니다.
여기서 중요한 성질이 하나 있습니다. 교착은 버그가 아니라 동시성의 정상적인 부산물입니다. 트래픽이 있는 서비스에서 교착을 0으로 만드는 것은 현실적이지 않습니다. 목표는 빈도를 낮추고, 났을 때 재시도로 복구하는 것입니다. 메시지가 try restarting transaction이라고 대놓고 알려주는 이유가 이것입니다.
아래 예시는 MySQL 8.0에서 두 세션을 실제로 붙여 재현한 결과입니다.
2. 오류 예시
① 잠금 순서가 엇갈릴 때
계좌 두 개(id=1, id=2)를 두 세션이 반대 순서로 잠급니다.
-- 세션 A
START TRANSACTION;
UPDATE acct SET bal = bal - 100 WHERE id = 1; -- 1번을 먼저 잠근다
SELECT SLEEP(2);
UPDATE acct SET bal = bal + 100 WHERE id = 2; -- 2번을 원한다
COMMIT;
-- 세션 B
START TRANSACTION;
UPDATE acct SET bal = bal - 100 WHERE id = 2; -- 2번을 먼저 잠근다
SELECT SLEEP(2);
UPDATE acct SET bal = bal + 100 WHERE id = 1; -- 1번을 원한다 -> 교착
COMMIT;
두 세션을 거의 동시에 실행하면 이렇게 됩니다.
ERROR 1213 (40001) at line 4: Deadlock found when trying to get lock; try restarting transaction

A는 1번을 쥔 채 2번을 기다리고, B는 2번을 쥔 채 1번을 기다립니다. 서로 상대가 놓기를 기다리므로 영원히 안 풀립니다. SLEEP(2)는 타이밍을 맞추려고 넣은 것이고, 실제 서비스에서는 그 자리에 다른 쿼리나 애플리케이션 로직이 들어갑니다.
② 무엇이 왜 걸렸는지 확인하기
교착이 나면 반드시 이 명령으로 상세를 봅니다.
SHOW ENGINE INNODB STATUS\G
LATEST DETECTED DEADLOCK 절에 마지막 교착의 전모가 남아 있습니다.
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 2009, ACTIVE 3 sec starting index read
MySQL thread id 37, query id 123 localhost root updating
UPDATE acct SET bal = bal + 100 WHERE id = 2
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 17 page no 4 index PRIMARY of table `blogdb`.`acct`
trx id 2009 lock_mode X locks rec but not gap
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 17 page no 4 index PRIMARY of table `blogdb`.`acct`
trx id 2009 lock_mode X locks rec but not gap waiting

읽는 순서는 이렇습니다.
TRANSACTION:뒤의 SQL — 그 트랜잭션이 마지막에 실행하려던 쿼리HOLDS THE LOCK(S)— 이미 쥐고 있던 잠금WAITING FOR THIS LOCK— 기다리다 막힌 잠금WE ROLL BACK TRANSACTION (N)— 희생된 쪽 (출력 맨 아래)
index PRIMARY of table blogdb.acct가 어느 테이블의 어느 인덱스인지 알려주고, lock_mode X는 배타 잠금(쓰기)입니다. 두 트랜잭션의 SQL 두 줄만 나란히 놓고 보면 잠금 순서가 엇갈렸는지 바로 보입니다. 이 예시에서는 한쪽이 id=1 → id=2, 다른 쪽이 id=2 → id=1 이었습니다.
3. 오류 해결책
(1) 잠금 순서를 통일합니다. 가장 근본적이고 효과적인 해결책입니다. 여러 행을 갱신해야 한다면 항상 같은 기준으로 정렬해서 접근합니다. 보통 기본키 오름차순이면 충분합니다. 계좌 이체처럼 두 행을 다루는 로직이라면, 코드에서 min(from, to) 먼저 잠그도록 강제합니다.
(2) 트랜잭션을 짧게 만듭니다. 잠금을 쥐고 있는 시간이 짧을수록 충돌 확률이 낮아집니다. 트랜잭션 안에서 외부 API 호출, 파일 IO, 사용자 입력 대기를 하지 마세요. 잠금을 쥔 채 네트워크를 기다리는 것이 교착의 흔한 원인입니다.
(3) 재시도 로직을 넣습니다. 교착은 완전히 없앨 수 없으므로 애플리케이션이 복구해야 합니다. 1213은 재시도해도 되는 오류입니다 — 트랜잭션 전체가 롤백된 상태라 부분 반영을 걱정할 필요가 없습니다.
(4) 인덱스를 확인합니다. 인덱스가 없어 풀 스캔이 일어나면 필요 이상으로 많은 행에 잠금이 걸려 충돌 확률이 급등합니다. UPDATE·DELETE의 WHERE 절이 인덱스를 타는지 EXPLAIN으로 확인하세요. 교착이 잦다면 잠금 순서보다 인덱스를 먼저 의심하는 편이 나을 때도 많습니다.
(5) 1213과 1205를 구분합니다. 둘 다 잠금 문제지만 성격이 다릅니다.
| ERROR 1213 | ERROR 1205 | |
|---|---|---|
| 메시지 | Deadlock found | Lock wait timeout exceeded |
| 상황 | 서로 물고 물림 (순환) | 한쪽이 오래 안 놓아줌 |
| 감지 | InnoDB가 즉시 감지 | innodb_lock_wait_timeout(기본 50초) 후 |
| 롤백 범위 | 트랜잭션 전체 | 해당 문장만 |
| 대응 | 재시도 | 긴 트랜잭션을 찾아 제거 |
1205는 롤백 범위가 문장 단위라 재시도가 더 까다롭습니다. 메시지를 보고 어느 쪽인지 먼저 확인하세요.
4. 오류 예제 코드 및 해결 코드
① 잠금 순서 통일
-- 양쪽 세션 모두 작은 id 부터 잠근다
START TRANSACTION;
UPDATE acct SET bal = bal - 100 WHERE id = 1;
UPDATE acct SET bal = bal + 100 WHERE id = 2;
COMMIT;
앞의 ①번과 똑같이 두 세션을 동시에 실행한 결과입니다.
교착 없음 — 두 세션 모두 커밋되었습니다.
id owner bal
1 kim 800
2 lee 1200

같은 동시 실행인데 오류가 나지 않았습니다. 두 세션이 같은 순서로 줄을 서니, 뒤에 온 쪽은 잠시 기다렸다가 순서대로 처리됩니다. 잔액도 1000/1000에서 800/1200으로 두 번의 이체가 모두 정상 반영됐습니다.
② 이체 로직에서 순서 강제 (Java)
public void transfer(long fromId, long toId, int amount) {
// 잠글 순서를 id 기준으로 고정한다
long first = Math.min(fromId, toId);
long second = Math.max(fromId, toId);
jdbc.update("SELECT bal FROM acct WHERE id = ? FOR UPDATE", first);
jdbc.update("SELECT bal FROM acct WHERE id = ? FOR UPDATE", second);
jdbc.update("UPDATE acct SET bal = bal - ? WHERE id = ?", amount, fromId);
jdbc.update("UPDATE acct SET bal = bal + ? WHERE id = ?", amount, toId);
}
from과 to가 어느 쪽이든 잠그는 순서는 항상 id 오름차순이 됩니다. 이 몇 줄이 교착의 대부분을 없앱니다.
③ 재시도 (Spring)
@Retryable(
retryFor = DeadlockLoserDataAccessException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 50, multiplier = 2, random = true)
)
@Transactional
public void transfer(long fromId, long toId, int amount) { ... }
세 가지가 중요합니다.
@Transactional보다 재시도가 바깥에 있어야 합니다. 트랜잭션 안에서 재시도하면 이미 롤백된 트랜잭션을 재사용하게 됩니다.random = true(지터) 를 넣습니다. 두 세션이 같은 간격으로 재시도하면 또 부딪힙니다.- Spring은 1213을
DeadlockLoserDataAccessException으로 변환해 줍니다.SQLException을 직접 잡고 에러 코드를 비교할 필요가 없습니다.
④ 교착 통계 확인
SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks';
수치가 꾸준히 오르면 재시도로 버티는 중이라는 뜻입니다. 재시도가 동작하더라도 빈도가 높으면 잠금 순서나 인덱스를 손봐야 합니다.
운영 서버라면 innodb_print_all_deadlocks = ON으로 모든 교착을 에러 로그에 남기게 해두세요. SHOW ENGINE INNODB STATUS는 마지막 한 건만 보여주므로, 그 사이에 다른 교착이 나면 원본을 놓칩니다.
요약
ERROR 1213은 두 트랜잭션이 서로의 잠금을 기다려 순환이 생겼을 때 발생하며, InnoDB가 한쪽을 롤백해 교착을 깹니다. 트랜잭션 전체가 취소된 상태이므로 그대로 재시도해도 안전합니다.
원인 파악은 SHOW ENGINE INNODB STATUS의 LATEST DETECTED DEADLOCK 절에서 시작합니다. 두 트랜잭션이 각각 무엇을 쥐고(HOLDS THE LOCK) 무엇을 기다렸는지(WAITING FOR THIS LOCK)를 나란히 놓고 보면 잠금 순서가 엇갈렸는지 바로 드러납니다.
해결의 핵심은 잠금 순서 통일 + 짧은 트랜잭션 + 지터를 넣은 재시도 세 가지입니다. 교착을 0으로 만들려 하지 말고, 빈도를 낮추고 났을 때 복구되게 만드세요. 그리고 교착이 유독 잦다면 잠금 순서만 보지 말고 WHERE 절이 인덱스를 타는지 함께 확인하는 것이 좋습니다.
'[DataBase] > MySql' 카테고리의 다른 글
| [MySQL] ERROR 1146 Table doesn't exist — 테이블은 분명히 있는데 (0) | 2026.09.14 |
|---|---|
| [MySQL] ERROR 1045 Access denied for user — 비밀번호가 맞는데도 막힐 때 (0) | 2026.09.13 |
| [MySQL] ERROR 1366 Incorrect string value — 이모지와 utf8mb4 (0) | 2026.09.02 |
| LocalDateTime의 데이터가 9시간 차이남 (0) | 2020.12.19 |
| Unblock with 'mysqladmin flush-hosts' (0) | 2018.02.19 |
댓글