동시에 두 트랜잭션을 실행했더니 하나가 Deadlock found로 실패한 이유
문제 발생
계좌 이체 로직에서 동시에 두 건의 이체(A→B, B→A)가 겹쳐 실행되자, 그중 하나가 Deadlock found when trying to get lock 에러로 실패했습니다.
-- 트랜잭션 1: A → B
UPDATE accounts SET balance = balance - 100 WHERE id = 'A';
UPDATE accounts SET balance = balance + 100 WHERE id = 'B';
-- 트랜잭션 2 (동시 실행): B → A
UPDATE accounts SET balance = balance - 50 WHERE id = 'B';
UPDATE accounts SET balance = balance + 50 WHERE id = 'A';원인 분석
두 트랜잭션이 서로 다른 순서로 같은 두 row에 락을 걸려고 하면 데드락이 발생할 수 있습니다.
- 트랜잭션 1이 row A를 잠급니다(UPDATE로 인한 행 락).
- 트랜잭션 2가 row B를 잠급니다.
- 트랜잭션 1이 이제 row B를 잠그려 하지만, 트랜잭션 2가 이미 잠그고 있어서 대기합니다.
- 트랜잭션 2가 이제 row A를 잠그려 하지만, 트랜잭션 1이 이미 잠그고 있어서 대기합니다.
둘 다 서로가 가진 락을 기다리며 영원히 멈춰있는 상태 — 이게 데드락입니다. MySQL/MariaDB의 InnoDB 스토리지 엔진은 이런 순환 대기를 자동으로 감지하고, 둘 중 하나(보통 롤백 비용이 더 적은 쪽)의 트랜잭션을 강제로 롤백시켜 나머지 하나가 진행되게 합니다 — 그래서 둘 중 하나가 Deadlock found 에러로 실패하는 것입니다. 이건 DB가 고장 난 게 아니라, 데드락 상황을 정상적으로 감지하고 해소한 것입니다.
해결 방안
- 애플리케이션은 데드락으로 인한 실패를 재시도 가능한 실패로 취급해야 합니다 — 데드락은 동시성이 있는 시스템에서 구조적으로 발생할 수 있는 정상적인 현상이므로, 그 에러 코드(MySQL의
1213)를 잡아 트랜잭션을 짧은 지연 후 다시 시도하는 로직을 갖추는 것이 표준적인 대응입니다.
for attempt in range(3):
try:
run_transfer()
break
except DeadlockError:
if attempt == 2:
raise
time.sleep(0.1 * (attempt + 1))- 가능하다면 항상 같은 순서로 row를 잠그도록 코드를 설계하면 데드락 발생 자체를 줄일 수 있습니다 — 예를 들어 계좌 id를 정렬해서 항상 더 작은 id를 먼저 UPDATE하도록 강제하면, 위 예시의 순환 대기 조건 자체가 성립하지 않습니다.
-- 항상 id가 작은 계좌부터 잠그도록 순서를 고정
UPDATE accounts SET balance = balance - :amount WHERE id = LEAST(:from, :to);
UPDATE accounts SET balance = balance + :amount WHERE id = GREATEST(:from, :to);- 트랜잭션을 짧게 유지하고, 트랜잭션 안에서 불필요하게 많은 row를 건드리거나 오래 걸리는 로직(외부 API 호출 등)을 함께 넣지 않는 것도 락을 쥐고 있는 시간을 줄여 데드락 확률을 낮추는 데 도움이 됩니다.
- 데드락이 반복적으로 발생한다면
SHOW ENGINE INNODB STATUS(MySQL/MariaDB)로 최근 데드락의 상세 정보(어떤 쿼리들이 얽혔는지)를 확인해 근본 원인을 분석할 수 있습니다.
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.