본문으로 건너뛰기
개발 머꼬
개발 노트SQL
hohyeon.dev23

동시에 두 트랜잭션을 실행했더니 하나가 Deadlock found로 실패한 이유

  • #Concurrency
  • #Database
  • #Engineering Note

문제 발생

계좌 이체 로직에서 동시에 두 건의 이체(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. 트랜잭션 1이 row A를 잠급니다(UPDATE로 인한 행 락).
  2. 트랜잭션 2가 row B를 잠급니다.
  3. 트랜잭션 1이 이제 row B를 잠그려 하지만, 트랜잭션 2가 이미 잠그고 있어서 대기합니다.
  4. 트랜잭션 2가 이제 row A를 잠그려 하지만, 트랜잭션 1이 이미 잠그고 있어서 대기합니다.

둘 다 서로가 가진 락을 기다리며 영원히 멈춰있는 상태 — 이게 데드락입니다. MySQL/MariaDB의 InnoDB 스토리지 엔진은 이런 순환 대기를 자동으로 감지하고, 둘 중 하나(보통 롤백 비용이 더 적은 쪽)의 트랜잭션을 강제로 롤백시켜 나머지 하나가 진행되게 합니다 — 그래서 둘 중 하나가 Deadlock found 에러로 실패하는 것입니다. 이건 DB가 고장 난 게 아니라, 데드락 상황을 정상적으로 감지하고 해소한 것입니다.

해결 방안

  1. 애플리케이션은 데드락으로 인한 실패를 재시도 가능한 실패로 취급해야 합니다 — 데드락은 동시성이 있는 시스템에서 구조적으로 발생할 수 있는 정상적인 현상이므로, 그 에러 코드(MySQL의 1213)를 잡아 트랜잭션을 짧은 지연 후 다시 시도하는 로직을 갖추는 것이 표준적인 대응입니다.
for attempt in range(3):
    try:
        run_transfer()
        break
    except DeadlockError:
        if attempt == 2:
            raise
        time.sleep(0.1 * (attempt + 1))
  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);
  1. 트랜잭션을 짧게 유지하고, 트랜잭션 안에서 불필요하게 많은 row를 건드리거나 오래 걸리는 로직(외부 API 호출 등)을 함께 넣지 않는 것도 락을 쥐고 있는 시간을 줄여 데드락 확률을 낮추는 데 도움이 됩니다.
  2. 데드락이 반복적으로 발생한다면 SHOW ENGINE INNODB STATUS(MySQL/MariaDB)로 최근 데드락의 상세 정보(어떤 쿼리들이 얽혔는지)를 확인해 근본 원인을 분석할 수 있습니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.