두 트랜잭션이 서로가 쥔 락을 기다려 영원히 진행하지 못하는 상태.
전형적인 발생 — 락 순서가 엇갈릴 때
-- T1 -- T2
UPDATE acct SET bal=bal-1 UPDATE acct SET bal=bal-1
WHERE id='A'; -- A 잠금 WHERE id='B'; -- B 잠금
UPDATE acct SET bal=bal+1 UPDATE acct SET bal=bal+1
WHERE id='B'; -- B 대기(T2 보유) WHERE id='A'; -- A 대기(T1 보유)
T1 ── 보유 ──> A T2 ── 보유 ──> B
T1 ── 대기 ──> B T2 ── 대기 ──> A
순환이 만들어졌다 = 데드락
DB는 자동으로 탐지한다
- PostgreSQL — ERROR: deadlock detected
- MySQL — ERROR 1213 (40001): Deadlock found when trying to get lock
둘 다 한쪽을 희생자로 골라 롤백시킨다. 즉 데드락은 시스템이 멈추는 사고가 아니라 한 트랜잭션이 실패하는 사건이다.
그래서 애플리케이션의 정답은 재시도다.
// 데드락은 예외 상황이 아니라 정상 흐름으로 다룬다
for (int attempt = 0; attempt < 3; attempt++) {
try { return transfer(from, to, amount); }
catch (DeadlockLoserDataAccessException e) { /* 짧게 대기 후 재시도 */ }
}
빈도를 낮추는 네 가지
- ① 항상 같은 순서로 락을 잡는다
- A → B 로 통일하면 순환 자체가 안 생긴다 (가장 효과가 크다)
- ② 트랜잭션을 짧게 유지한다
- 락을 쥐는 시간이 짧으면 겹칠 확률이 준다
- ③ 필요한 행만 좁게 잠근다
- ④ 적절한 인덱스를 만든다
- 인덱스가 없으면 더 많은 행과 갭을 잠가 확률이 오른다
④가 자주 간과된다 — 인덱스는 조회 성능만이 아니라 락 범위를 결정한다.
진단
SHOW ENGINE INNODB STATUS\G -- LATEST DETECTED DEADLOCK 섹션에 양쪽 SQL 이 남는다
PostgreSQL은 로그에 어떤 프로세스가 무엇을 기다렸는지 남는다. 어떤 두 문장이 엇갈렸는지를 보고 ①의 순서 통일을 적용하는 것이 표준 절차다.
면접 함정
- ❌ "데드락은 없애야 하는 버그" → 완전히 없앨 수 없다. 자동 탐지 + 재시도로 다루고 빈도를 낮추는 문제다.
- ❌ "데드락이 나면 DB가 멈춘다" → 한쪽만 롤백된다. 멈추는 건 락 대기가 길어지는 경우다.
실제 로그를 읽는 법
SHOW ENGINE INNODB STATUS\G
LATEST DETECTED DEADLOCK
*** (1) TRANSACTION: UPDATE acct SET bal=bal+1 WHERE id='B'
- *** (1) HOLDS THE LOCK(S): — ... index PRIMARY of table acct ... lock_data: 'A' *** (1) WAITING FOR THIS LOCK: ... lock_data: 'B'
*** (2) TRANSACTION: UPDATE acct SET bal=bal+1 WHERE id='A' *** (2) HOLDS THE LOCK(S): ... lock_data: 'B' *** (2) WAITING FOR THIS LOCK: ... lock_data: 'A' *** WE ROLL BACK TRANSACTION (2)
읽는 순서는 HOLDS → WAITING FOR 다. 양쪽의 이 두 줄만 보면 어떤 두 자원이 엇갈렸는지 즉시 나온다. 위 경우 A와 B의 순서가 반대이므로, 처방은 항상 id 오름차순으로 잠그도록 코드를 고치는 것이다.
-- PostgreSQL: 로그에 남는다
-- ERROR: deadlock detected
-- DETAIL: Process 123 waits for ShareLock on transaction 456; blocked by process 789.
-- HINT: See server log for query details.
순서를 코드로 강제하기
// 두 계좌를 옮길 때 항상 작은 id 부터 잠근다 → 순환이 구조적으로 불가능
String first = from.compareTo(to) < 0 ? from : to;
String second = from.compareTo(to) < 0 ? to : from;
lockAccount(first);
lockAccount(second);
재시도할 때 주의
같은 순서로 바로 재시도하면 다시 부딪힐 수 있다
- 짧은 랜덤 지연(지터)을 넣는다
- 재시도 횟수 상한을 둔다 (보통 3회)