동시 접속자가 몰리는 이벤트나 결제 처리 시스템을 운영하다가 ERROR 1213 (40001): Deadlock found when trying to get lock 에러를 마주친 적이 있을 것이다. 단순한 쿼리 오류인 줄 알고 사용자가 다시 시도하도록 유도해보지만, 트래픽이 폭증할 때마다 서비스 전체로 퍼지며 데이터베이스 트랜잭션이 롤백되는 장애를 겪곤 한다. 다만 대부분의 개발자들은 교착 상태가 정확히 어떤 원인으로 발생하는지 모른 채 DB 커넥션 수만 늘리거나 무작정 서비스 재시작으로 대응하는 경우가 많다. 이번에는 데드락이 정확히 무엇인지, 왜 발생하는지, 그리고 트랜잭션 순서 정렬과 자동 재시도 로직으로 어떻게 해결하는지 완벽하게 정리해서 소개하겠다.

 

1단계: MySQL 데드락(Deadlock) 이해하기

데드락(Deadlock, 교착 상태)은 두 개 이상의 트랜잭션이 각각 소유하고 있는 레코드의 잠금(Lock)이 해제되기를 기다리며 서로 무한 대기 상태에 빠지는 현상이다. MySQL InnoDB 엔진은 이러한 무한 대기 상황을 감지하면 데드락 에러(1213)를 즉시 발생시키고, 작업 비용이 더 적은 트랜잭션 하나를 강제로 롤백(Rollback)시켜 시스템을 보호한다.

많은 개발자가 Lock Wait Timeout(ERROR 1205)과 데드락을 혼동하는데, 두 에러는 원인과 동작 방식에서 분명한 차이가 있다.

구분ERROR 1205 (Lock Wait Timeout)ERROR 1213 (Deadlock Found)
발생 원인한 트랜잭션이 다른 트랜잭션의 잠금 해제를 너무 오래 기다림두 개 이상의 트랜잭션이 서로 상대방의 잠금을 교차 대기함
감지 시점innodb_lock_wait_timeout 시간(기본 50초) 초과 시InnoDB 교착 상태 감지 마이크로스레드가 즉시 감지
처리 방식대기 중이던 쿼리만 취소되거나 롤백됨여파가 적은 트랜잭션 하나를 즉시 선택하여 전체 롤백

 

2단계: 데드락 발생하는 대표적인 원인과 패턴

데드락은 크게 두 가지 상황에서 주로 발생한다. 첫째는 하나의 트랜잭션 내에서 여러 테이블이나 레코드를 수정할 때 접근하는 순서가 서로 다른 경우다. 둘째는 UPDATE/DELETE 문 조건절 컬럼에 인덱스가 제대로 설정되지 않아 행 단위 잠금(Row Lock) 대신 테이블 전체나 넓은 범위의 갭 잠금(Gap Lock)이 걸리는 경우다.

 

교차 잠금 순서에 의한 데드락 시나리오

예를 들어 A 유저와 B 유저 사이에 포인트를 이체하는 작업을 처리할 때 다음과 같은 순서로 쿼리가 실행된다고 가정해 보자.

1. 트랜잭션 A: 회원 1번의 포인트를 차감 (회원 1 레코드 Exclusive Lock 획득)
2. 트랜잭션 B: 회원 2번의 포인트를 차감 (회원 2 레코드 Exclusive Lock 획득)
3. 트랜잭션 A: 회원 2번의 포인트를 증가 시도 (트랜잭션 B의 Lock 해제 대기)
4. 트랜잭션 B: 회원 1번의 포인트를 증가 시도 (트랜잭션 A의 Lock 해제 대기)

이 순간 두 트랜잭션은 상대방의 잠금이 풀리기만을 기다리는 교차 대기 상태가 되며, MySQL 엔진은 즉시 1213 에러를 반환하고 트랜잭션 중 하나를 강제 취소한다.

 

3단계: 실전 예제와 데드락 해결책

데드락을 해결하는 가장 근본적인 방법은 트랜잭션 내의 자원 접근 순서를 동일하게 통일하는 것이다. 또한 동시성이 아주 높은 환경에서는 어쩌다 발생하는 데드락에 대비해 애플리케이션 레벨에서 안전하게 재시도(Retry)하는 로직을 구축해야 한다.

 

✗ 잘못된 코드: 잠금 순서 불일치 및 예외 처리 누락

서로 다른 프로세스에서 레코드 수정 순서가 일정하지 않아 데드락 위험에 그대로 노출되는 예시다.

// 트랜잭션 A (회원 1 -> 회원 2 순서 업데이트)
$pdo->beginTransaction();
$pdo->exec("UPDATE users SET point = point - 100 WHERE id = 1"); // id 1 잠금
$pdo->exec("UPDATE users SET point = point + 100 WHERE id = 2"); // id 2 대기
$pdo->commit();

// 동시에 실행되는 트랜잭션 B (회원 2 -> 회원 1 역순 업데이트)
$pdo->beginTransaction();
$pdo->exec("UPDATE users SET point = point - 100 WHERE id = 2"); // id 2 잠금
$pdo->exec("UPDATE users SET point = point + 100 WHERE id = 1"); // ERROR 1213 발생!
$pdo->commit();

✗ 설명: 트랜잭션 A는 id 1 -> id 2 순서로 접근하고, 트랜잭션 B는 id 2 -> id 1 순서로 접근하면서 교차 잠금이 발생하여 데드락으로 실패한다.

 

✓ 올바른 코드: 정렬된 접근 순서와 PDO 트랜잭션 재시도 로직

접근할 PK ID를 항상 오름차순으로 정렬하여 동일한 순서로 잠금을 획득하고, 데드락 발생 시 자동 재시도하는 안전한 코드다.

function transferPoints(PDO $pdo, int $fromId, int $toId, int $amount): bool {
    $maxRetries = 3;
    $retryCount = 0;

    while ($retryCount < $maxRetries) {
        try {
            $pdo->beginTransaction();

            // 1. 잠금 순서 일치: ID가 작은 쪽을 먼저 잠그도록 정렬
            $ids = [$fromId, $toId];
            sort($ids);

            // SELECT ... FOR UPDATE로 순서대로 명시적 잠금 획득
            $stmt = $pdo->prepare("SELECT id FROM users WHERE id IN (?, ?) ORDER BY id ASC FOR UPDATE");
            $stmt->execute($ids);

            // 2. 실제 포인트 이동 처리
            $stmt1 = $pdo->prepare("UPDATE users SET point = point - ? WHERE id = ?");
            $stmt1->execute([$amount, $fromId]);

            $stmt2 = $pdo->prepare("UPDATE users SET point = point + ? WHERE id = ?");
            $stmt2->execute([$amount, $toId]);

            $pdo->commit();
            return true;

        } catch (PDOException $e) {
            if ($pdo->inTransaction()) {
                $pdo->rollBack();
            }

            // MySQL ERROR 1213 (SQLSTATE 40001) 데드락 감지 시 재시도
            if ($e->getCode() === '40001' || (isset($e->errorInfo[1]) && $e->errorInfo[1] === 1213)) {
                $retryCount++;
                usleep(50000 * $retryCount); // 50ms, 100ms 지연 후 재시도
                continue;
            }

            throw $e; // 다른 에러는 그대로 던짐
        }
    }

    return false;
}

✓ 설명: ID를 오름차순으로 정렬한 뒤 FOR UPDATE로 접근 순서를 고정했다. 동시성이 매우 극심해 일시적으로 데드락이 발생하더라도 SQLSTATE 40001 예외를 잡아 최대 3회까지 지연 후 재시도하므로 작업이 실패하지 않고 정상 완료된다.

 

4단계: 데드락 방지를 위한 실무 주의사항

트랜잭션 순서 정렬 외에도 실무에서 놓치기 쉬운 주요 체크포인트를 정리했다.

✗ 잘못된 방식: WHERE 조건절 컬럼에 인덱스가 없어 테이블 풀 스캔이 발생하는 쿼리를 트랜잭션 내에서 실행하는 경우
✓ 올바른 방식: UPDATE 및 DELETE 조건에 사용되는 컬럼은 반드시 인덱스를 생성하여 정확히 필요한 레코드에만 Row Lock이 걸리도록 최적화한다. 인덱스가 없으면 InnoDB는 테이블 전체 레코드 및 스캔 범위에 잠금을 걸게 되어 데드락 확률이 급증한다.

✗ 잘못된 방식: 트랜잭션 내부에서 외부 API 호출이나 긴 파일 읽기/쓰기 작업을 수행하는 경우
✓ 올바른 방식: 트랜잭션의 범위는 가능한 최소한으로 유지해야 한다. 외부 API 호출이나 네트워크 I/O 작업은 트랜잭션 시작 전이나 커밋 후에 실행하여 DB 잠금 유지 시간을 극단적으로 줄여야 한다.

 

5단계: 마무리 및 핵심 요약

MySQL 데드락 ERROR 1213은 고성능 동시성 시스템을 개발할 때 반드시 대처해야 하는 필수 과제다. 올바른 인덱스 설계와 잠금 순서 정렬, 그리고 애플리케이션 재시도 패턴이 모여서 서비스의 무중단 안정성을 만드는 법이다. 이 글의 트랜잭션 재시도 로직과 정렬 패턴을 실무 코드에 적용하면, 트래픽 폭주 상황에서도 데드락으로 인한 결제 실패나 데이터 오류를 완벽하게 방지할 수 있을 것이다.