Deadlock found when trying to get lock; try restarting transaction. 이 한 줄을 처음 제대로 붙잡고 씨름한 건 2020년 3월, 물류센터 창고관리시스템(WMS)의 재고 API를 맡고 있을 때였다. 데드락은 교과서에서 “두 트랜잭션이 서로의 락을 기다리는 상태”라고 한 줄로 끝난다. 실제로는 원인이 세 겹이었고, 하나를 걷어낼 때마다 다음 겹이 드러났다. 이 글은 하루 35건이던 데드락을 일주일에 한 건 안팎으로 줄이기까지 3주 동안의 기록이다.
오전 10시 웨이브마다 PDA에 뜬 “다시 시도”
그 센터는 하루 출고가 2만 건 남짓이었고, 오전 10시와 오후 2시에 주문을 묶어 한꺼번에 작업자에게 내려보내는 ‘웨이브’ 방식으로 돌았다. 작업자는 PDA로 상품 바코드를 찍고 “피킹 확정”을 누른다. 그 순간 서버는 로케이션별 재고를 차감하고 출고 대기 수량을 올린다. DB는 MySQL 5.7, 격리 수준은 기본값인 REPEATABLE READ였다.
3월 첫 주, 웨이브가 시작되고 20분쯤 지나면 PDA 화면에 “처리 실패, 다시 시도해 주세요”가 뜬다는 문의가 현장에서 올라왔다. 로그를 보니 범인은 위의 데드락 메시지였고, 하루 30~40건이었다. 이상한 점은 같은 버튼을 다시 누르면 멀쩡히 성공한다는 것이었다. 개발 서버에서 같은 요청을 아무리 보내도 재현되지 않았다. 코드를 몇 번을 읽어도 틀린 줄이 없었다.
나중에 알고 보니 각 요청은 정말로 아무 문제가 없었다. 문제는 두 요청이 같은 순간에 같은 행들을 서로 다른 순서로 잠글 때만 생겼다. 혼자 보내는 요청으로는 절대 재현되지 않는 종류의 버그였다.
첫 시도는 락 대기 시간을 늘린 것이었다
지금 생각하면 부끄러운데, 처음에는 “락을 못 잡아서 실패하니 더 오래 기다리게 하면 된다”고 생각했다. innodb_lock_wait_timeout을 기본 50초에서 120초로 올렸다. 다음 날 데드락 건수는 그대로였다. 대신 웨이브 피크에 피킹 확정 응답이 수십 초씩 걸리는 요청이 생겼고, PDA가 30초 타임아웃으로 먼저 끊어 버리는 일까지 겹쳤다. 하루 만에 원래 값으로 되돌렸다.
이때 데드락과 락 대기가 전혀 다른 문제라는 걸 몸으로 배웠다. InnoDB는 대기 그래프에서 순환을 발견하는 즉시 한쪽을 롤백한다. 타임아웃까지 기다려 주지 않으니 대기 시간을 늘려 봐야 아무 의미가 없다.
| 구분 | 락 대기(blocking) | 데드락(deadlock) |
|---|---|---|
| 상황 | 앞 트랜잭션이 락을 놓기를 한 방향으로 기다림 | 둘 이상이 서로의 락을 기다리는 순환 |
| 끝나는 방식 | 앞 트랜잭션이 끝나면 풀림, 안 끝나면 timeout | InnoDB가 순환을 감지하는 즉시 한쪽 롤백 |
| 에러 | 1205 Lock wait timeout exceeded | 1213 Deadlock found |
| 증상 | 느려진다 | 실패한다(재시도하면 대개 성공) |
| 대응 | 트랜잭션 단축, 인덱스로 락 범위 축소 | 잠금 순서 통일, 갭 락 회피, 재시도 |
| timeout 늘리기 | 증상 완화는 됨 | 효과 없음, 피크 지연만 늘어남 |
락 대기로 서비스가 통째로 멈췄던 경험은 서버 교체 후 1시간마다 죽는 서비스, 원인은 DB 락에 따로 적어 두었다. 그쪽은 줄이 길어지는 문제였고, 이번 것은 줄이 엉켜 버리는 문제였다.
SHOW ENGINE INNODB STATUS는 마지막 한 건만 보여 줬다
진단의 정석은 SHOW ENGINE INNODB STATUS의 LATEST DETECTED DEADLOCK 섹션이다. 두 트랜잭션이 각각 어떤 락을 쥐고 어떤 락을 기다렸는지, 누가 희생자로 롤백됐는지가 찍힌다. 문제는 이름 그대로 가장 최근 한 건만 남는다는 점이었다. 웨이브 피크에는 몇 분 사이에 여러 건이 터지니, 내가 명령을 칠 때쯤이면 이미 다른 건으로 덮여 있었다. 서로 다른 원인이 섞여 있다는 사실도 처음에는 몰랐다.
그래서 DBA에게 부탁해 innodb_print_all_deadlocks를 켰다. 감지된 데드락이 전부 MySQL 에러 로그에 쌓인다. 동시에 애플리케이션에서는 데드락 예외를 잡을 때 요청 ID, API 이름, 작업 지시 번호를 함께 남기게 했다. 두 로그를 시간으로 맞추면 “어떤 API 두 개가 엇갈렸는지”가 보인다. 로그에 요청 ID를 심는 방법은 운영 로깅 체크리스트에 정리했다.
-- 동적으로 켤 수 있다. 재시작 후에도 유지하려면 my.cnf에도 적는다
SET GLOBAL innodb_print_all_deadlocks = ON;
-- 에러 로그에서 본 것 (요약)
*** (1) TRANSACTION: UPDATE location_stock ... WHERE id = 81204
*** (1) HOLDS THE LOCK(S): ... index PRIMARY ... id = 77310 lock_mode X locks rec but not gap
*** (1) WAITING FOR THIS LOCK: ... id = 81204 lock_mode X locks rec but not gap waiting
*** (2) TRANSACTION: UPDATE location_stock ... WHERE id = 77310
*** (2) HOLDS THE LOCK(S): ... id = 81204
*** WE ROLL BACK TRANSACTION (2)
사흘치 로그를 모아 분류해 보니 데드락은 크게 세 무리였다. 로케이션 간 재고 이동이 서로 엇갈린 것이 약 60%, 피킹 확정에서 상품들을 다른 순서로 차감한 것이 약 30%, 나머지 10%는 그때까지 본 적 없는 갭 락 모양이었다.
첫 번째 겹: 보충과 반품 적치가 반대 방향으로 잠갔다
가장 큰 무리는 재고 이동 API였다. 피킹 구역 선반이 비면 보충 작업자가 상단 보관 로케이션에서 피킹 로케이션으로 재고를 옮긴다. 같은 시간에 반품 적치 작업자는 피킹 로케이션에 있던 재고를 보관 로케이션으로 되돌린다. 코드는 단순했다. 출발지를 잠그고, 도착지를 잠그고, 수량을 옮긴다.
@Transactional
public void move(Long fromLocStockId, Long toLocStockId, int qty) {
LocationStock from = stockRepo.findByIdForUpdate(fromLocStockId); // 출발지 먼저
LocationStock to = stockRepo.findByIdForUpdate(toLocStockId); // 그다음 도착지
from.decrease(qty);
to.increase(qty);
}
보충은 A→B, 반품 적치는 B→A다. 두 요청이 같은 상품의 같은 로케이션 쌍에 동시에 들어오면 한쪽은 A를 쥐고 B를, 다른 쪽은 B를 쥐고 A를 기다린다. 웨이브 직후에는 인기 상품 선반이 한꺼번에 비면서 보충이 몰렸고, 하필 그 시간이 반품 적치 시간과 겹쳐 있었다. 잠그는 순서가 인자에 따라 바뀐다는 것, 그게 함정의 전부였다.

해결은 방향과 상관없이 항상 같은 순서로 잠그는 것이었다. 로케이션 재고 행의 PK가 작은 쪽을 먼저 잠그도록 바꿨다. 순환이 생기려면 순서가 엇갈려야 하니, 모두가 같은 순서를 따르면 순환의 조건 자체가 사라진다.
@Transactional
public void move(Long fromLocStockId, Long toLocStockId, int qty) {
// 방향과 무관하게 PK 오름차순으로 잠근다
List<LocationStock> locked = stockRepo.findAllByIdInForUpdateOrderById(
List.of(fromLocStockId, toLocStockId)); // ... WHERE id IN (?, ?) ORDER BY id FOR UPDATE
LocationStock from = pick(locked, fromLocStockId);
LocationStock to = pick(locked, toLocStockId);
from.decrease(qty);
to.increase(qty);
}
여기서 한 번 더 확인한 것이 있다. IN (...) ORDER BY id FOR UPDATE가 정말 PK 순서로 행을 잠그는지는 실행 계획에 달려 있다. PK 범위 스캔을 타는지 EXPLAIN으로 확인했고, 혹시 계획이 바뀌어도 순서가 보장되도록 테스트에서 두 스레드를 반대 방향으로 1만 번 돌려 데드락이 0건인 것까지 확인한 뒤 배포했다. 다음 날 데드락은 35건에서 9건으로 줄었다.
두 번째 겹: 장바구니 순서대로 차감한 피킹 확정
남은 9건 중 대부분은 피킹 확정이었다. 주문 하나에 상품이 여러 개 들어 있으면 서버는 주문 품목 순서대로 반복하면서 재고를 차감했다. 품목 순서는 고객이 장바구니에 담은 순서였다. 주문 1은 “생수, 휴지” 순, 주문 2는 “휴지, 생수” 순으로 같은 웨이브에 들어오면 이동 API와 똑같은 엇갈림이 생긴다. 생수와 휴지 같은 인기 상품 조합은 하루에도 수백 번 같이 팔렸다.
처방은 같았다. 차감할 로케이션 재고 ID를 먼저 모아 정렬한 뒤 한 번에 잠그고, 그다음에 품목별로 차감했다. 이 일을 겪고 나서 팀 규칙을 하나 만들었다. “한 트랜잭션에서 같은 테이블의 행을 두 개 이상 잠그면 반드시 PK 정렬 후 잠근다.” 코드 리뷰 체크리스트 맨 위에 올렸다. 두 번째 수정 후 데드락은 하루 2~3건이 됐다.
세 번째 겹: 없는 행을 잠근 갭 락
마지막 2~3건은 정말 이해가 안 됐다. 로그 속 두 트랜잭션은 같은 행을 하나씩만 건드렸다. 엇갈릴 순서가 아예 없었다. 에러 로그에는 lock_mode X locks gap before rec insert intention waiting이라는 낯선 문구가 있었다.
입고 적치 API였다. 입고된 상품을 로케이션에 올릴 때, 그 로케이션에 같은 상품 재고 행이 있으면 수량을 더하고 없으면 새로 만든다. 코드는 “있는지 잠그며 조회 → 없으면 INSERT”였다. (location_id, sku_id)에는 유니크 인덱스가 있었다.
-- 트랜잭션 1, 2가 같은 로케이션·같은 상품으로 거의 동시에 실행
SELECT * FROM location_stock WHERE location_id = 3021 AND sku_id = 55810 FOR UPDATE; -- 행 없음
INSERT INTO location_stock (location_id, sku_id, qty) VALUES (3021, 55810, 24);
REPEATABLE READ에서 없는 값을 FOR UPDATE로 조회하면 InnoDB는 그 값이 들어갈 자리, 즉 인덱스의 빈틈(gap)에 갭 락을 건다. 갭 락끼리는 서로 충돌하지 않아서 두 트랜잭션 모두 같은 빈틈에 갭 락을 잡는 데 성공한다. 그다음 각자 INSERT를 하려면 삽입 의도 락(insert intention lock)이 필요한데, 이건 상대가 쥔 갭 락과 충돌한다. 1은 2의 갭 락을, 2는 1의 갭 락을 기다리게 되고, 결국 순환이 만들어진다. 행이 하나뿐이어도 데드락이 나는 이유가 이것이었다. 같은 팔레트를 작업자 두 명이 나눠 적치할 때 정확히 이 모양이 됐다.
선택지는 두 가지였다. 하나는 격리 수준을 READ COMMITTED로 내리는 것이다. 그러면 이런 갭 락은 대부분 사라진다. 하지만 그 DB를 정산 배치와 다른 시스템이 같이 쓰고 있었고, 바이너리 로그 형식 변경까지 따라오는 일이라 재고 API 하나 때문에 바꾸기는 부담스러웠다. 그래서 다른 하나를 골랐다. 조회 후 INSERT를 INSERT ... ON DUPLICATE KEY UPDATE 한 문장으로 합치는 것이다.
INSERT INTO location_stock (location_id, sku_id, qty)
VALUES (3021, 55810, 24)
ON DUPLICATE KEY UPDATE qty = qty + VALUES(qty);
빈틈을 미리 잠그는 단계가 없어지니 갭 락끼리 엇갈릴 일이 없다. 이 문장도 아주 드물게 데드락이 날 수 있다고 알려져 있다. 그래도 이 패턴은 그 뒤로 하루 0건이 됐다. 인덱스 없는 조건으로 갱신하다 넓은 범위가 잠겨 엉뚱한 행끼리 얽히는 경우도 있는데, 인덱스로 락 범위를 좁힌 이야기는 MySQL 쿼리 지연 해결: 3개월 클라우드 DB 분투기에 있다.
재시도를 붙였더니 재고가 두 번 빠졌다
순서를 정리해도 데드락이 0이 된다는 보장은 없다. 그래서 남은 몇 건은 재시도로 흡수하기로 했다. 데드락으로 롤백된 트랜잭션은 잠시 후 다시 실행하면 대개 성공한다. 첫 버전은 피킹 확정 서비스에 @Retryable을 붙였는데, 재시도가 한 번도 성공하지 않고 UnexpectedRollbackException만 쌓였다. 바깥 퍼사드 클래스에 클래스 레벨 @Transactional이 붙어 있었던 것이다. 데드락이 나면 DB는 트랜잭션 전체를 이미 롤백했는데, 나는 그 바깥 트랜잭션 안에서 안쪽 메서드만 다시 돌리고 있었다. 트랜잭션 경계를 잘못 잡는 실수는 @Transactional 자주 틀리는 5가지 함정에도 적었다.
재시도는 트랜잭션 바깥에서 트랜잭션 전체를 다시 시작해야 의미가 있다. 퍼사드의 트랜잭션을 걷어내고 재시도 경계를 가장 바깥에 두었다. 2020년 당시에는 DeadlockLoserDataAccessException을 잡았다. 스프링 6부터는 이 예외가 사용 중단되어, 지금이라면 공통 부모인 PessimisticLockingFailureException을 잡는 편이 안전하다.
// 트랜잭션이 없는 바깥 계층에서 재시도 → 안쪽에서 새 트랜잭션
@Retryable(value = PessimisticLockingFailureException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 50, multiplier = 2, random = true))
public void confirmPicking(String taskNo, List<PickLine> lines) {
pickingTxService.confirm(taskNo, lines); // @Transactional은 여기 안쪽에만
}
재시도 간격에 random을 넣은 이유도 있다. 같은 순간에 부딪힌 두 요청이 같은 간격으로 다시 부딪히지 않게 하려는 것이다. 그런데 배포하고 2주 뒤, 재고 실사에서 상품 두 건의 수량이 장부보다 하나씩 적게 나왔다. 추적해 보니 서버가 재시도 끝에 두 번째 시도에서 성공했는데, 그사이 PDA가 30초 타임아웃으로 실패를 띄웠고, 작업자가 “다시 시도”를 눌러 같은 피킹을 한 번 더 확정한 것이었다. 서버 재시도와 사람 재시도가 겹쳐 차감이 두 번 된 셈이다.
재시도는 그 작업이 두 번 실행돼도 결과가 같을 때만 안전하다. 피킹 확정 이력 테이블에 작업 지시 번호 유니크 키를 걸었다. 같은 번호가 다시 들어오면 차감 없이 이전 결과를 돌려주게 했다. 그 뒤로 재시도는 더 이상 무섭지 않은 안전망이 됐다.
3주 뒤의 숫자와 다시 한다면
정리하면 이렇다. 잠금 순서 통일로 35건이 9건, 피킹 확정 정렬로 2~3건, 갭 락 제거로 웨이브 피크에도 하루 0~1건이 됐다. 남는 건 재시도가 조용히 흡수한다. 데드락 건수는 에러 로그에서 세어 대시보드에 올렸고, 하루 5건을 넘으면 알림이 오게 했다. 새 기능이 엇갈린 잠금을 다시 들여오면 숫자가 먼저 알려 준다.
지금 같은 일을 다시 한다면 순서를 이렇게 잡겠다.
- timeout부터 만지지 않는다. 에러 코드가 1213인지 1205인지 먼저 본다. 데드락이면 대기 시간은 아무 상관이 없다.
innodb_print_all_deadlocks를 첫날 켠다. 마지막 한 건만 보면 원인이 여러 개라는 사실조차 모른다. 애플리케이션 로그에는 요청 ID와 API 이름을 같이 남긴다.- 여러 행을 잠그면 정렬 후 잠근다. 이동 방향, 장바구니 순서처럼 입력에 따라 바뀌는 순서를 잠금 순서로 쓰지 않는다.
- “조회 후 없으면 INSERT”를 의심한다. REPEATABLE READ에서 없는 행을
FOR UPDATE로 잠그면 갭 락이 생긴다. 가능하면 한 문장 업서트로 바꾼다. - 재시도는 트랜잭션 바깥에, 멱등 키와 함께 붙인다. 서버 재시도와 사람의 재시도는 반드시 언젠가 겹친다.
데드락은 코드 한 줄의 버그가 아니라 동시에 실행될 때의 순서 문제였다. 그래서 코드를 아무리 읽어도 보이지 않았고, 로그를 모아 패턴을 세었을 때 비로소 보였다. 그 3월의 PDA “다시 시도” 화면 덕분에 나는 여러 행을 건드리는 코드를 볼 때마다 “이걸 어떤 순서로 잠그지?”부터 묻게 됐다. 분산 환경에서 동시성을 애플리케이션 쪽 락으로 다루는 방법은 Redis 분산락 구현하기에 정리해 두었다.
참고: MySQL 공식 문서 — Deadlocks in InnoDB, MySQL 공식 문서 — InnoDB Locking
“데드락은 왜 생기나: 원인 진단과 해결 실전”에 대한 2개의 생각