← 문제로

11. DB 트랜잭션 데드락: 일관된 락 순서 부재와 재시도 누락 (C#)

난이도 상
내 리뷰 · C#
해설 · C#

해설 — DB 트랜잭션 데드락: 일관된 락 순서 부재와 재시도 누락 (C#)

난이도: 상

요약

두 계정 행을 인자 순서대로 잠그기 때문에, A→B 이체와 B→A 이체가 동시에 실행되면 서로 상대가 쥔 행 잠금을 기다리는 DB 데드락이 발생한다. DBMS 는 한쪽 트랜잭션을 데드락 희생자로 강제 중단(rollback)하는데, 코드는 이 오류를 일시적(재시도하면 성공) 으로 구분하지 않고 그대로 실패로 상위에 던진다. 결과적으로 정상 이체가 사용자에게 오류로 튄다.

문제점

  • 분류 태그: DB 데드락(락 획득 순서 불일치) · 일시적 오류(transient) 재시도 부재 · (부수) 잔액 음수 방지 조건 부재.
  • 증상: 부하가 오르면 "이체 실패" 가 산발적으로 발생. DB 로그에 deadlock victim 오류(예: SQL Server 1205)가 찍히지만 애플리케이션은 재시도 없이 사용자 요청을 실패시킨다.
  • 재현 조건:
    • T1: Transfer(A, B) → 행 A 잠금 성공, 이어서 행 B 잠금 시도.
    • T2: Transfer(B, A) → 행 B 잠금 성공, 이어서 행 A 잠금 시도.
    • 서로 상대의 행을 기다림 → 순환 대기 → DBMS 데드락 감지 → 한쪽 강제 중단.
    • (B)catch (DbException) 이 이를 롤백 후 throw → 상위에서 요청 실패로 처리.
  • 근본 원인:
    • (A) 잠금 순서가 이체 방향(인자 순서)에 종속적이다. 반대 방향 이체가 겹치면 락 순서가 어긋나 순환 대기가 성립한다(전형적 ABBA, 단 여기선 DB 행 잠금).
    • (B) 데드락은 예외적 실패가 아니라 재시도로 흡수해야 하는 일시적 오류다. 그런데 모든 DbException 을 똑같이 실패로 전파해, 한 번만 다시 시도하면 성공할 작업이 사용자 오류가 된다. (또한 잔액 검사 없이 무조건 차감해 음수 잔액이 될 여지도 있다.)

수정안

(1) 정규 락 순서(항상 작은 id 먼저)로 순환 대기를 없애고, (2) 데드락/직렬화 실패 오류 코드를 감지해 트랜잭션 전체를 유한 횟수 재시도한다.

private const int MaxRetries = 5;

public void Transfer(int fromAccount, int toAccount, long amount)
{
    for (int attempt = 0; ; attempt++)
    {
        try
        {
            using DbConnection conn = _factory.CreateConnection();
            conn.ConnectionString = _connString;
            conn.Open();
            using DbTransaction tx = conn.BeginTransaction();

            // 정규 순서: 항상 작은 id 를 먼저 잠근다(방향 무관)
            int lo = Math.Min(fromAccount, toAccount);
            int hi = Math.Max(fromAccount, toAccount);
            LockRow(conn, tx, lo);   // SELECT ... FOR UPDATE
            LockRow(conn, tx, hi);

            // 조건부 차감으로 음수 잔액 방지
            int affected = ExecReturning(conn, tx,
                "UPDATE accounts SET gold = gold - @a WHERE id = @id AND gold >= @a", amount, fromAccount);
            if (affected == 0) { tx.Rollback(); throw new InsufficientFundsException(); }

            Exec(conn, tx, "UPDATE accounts SET gold = gold + @a WHERE id = @id", amount, toAccount);
            tx.Commit();
            return;
        }
        catch (DbException ex) when (IsTransient(ex) && attempt < MaxRetries)
        {
            // 트랜잭션은 이미 롤백됨. 백오프 후 재시도.
            Thread.Sleep(BackoffWithJitter(attempt));
        }
    }
}

// SQL Server 1205(deadlock), 1222(lock timeout); Postgres 40P01/40001; MySQL 1213/1205 등
private static bool IsTransient(DbException ex) => ex switch
{
    SqlException s => s.Number is 1205 or 1222 or -2 /*timeout*/,
    _ => false // 드라이버별 SQLSTATE/Number 로 확장
};

핵심: 정규 락 순서만으로도 이 특정 데드락은 사라지지만, 인덱스·다른 트랜잭션·락 승급 등으로 데드락은 완전히 없앨 수 없으므로 재시도가 안전망으로 반드시 필요하다. 트랜잭션은 짧게(잠금 보유 시간 최소화) 유지한다.

더 나은 설계 (트레이드오프)

  • 정규 락 순서 + 재시도 병행: 순서로 빈도를 줄이고, 재시도로 잔여 데드락을 흡수. 실무 표준. 재시도는 트랜잭션이 멱등/재실행 안전해야 함(외부 부수효과를 트랜잭션 밖에서 하면 안 됨).
  • 단일 원자 UPDATE / 저장 프로시저: 두 행을 한 문장(또는 서버측 프로시저)에서 정렬된 순서로 갱신하면 왕복과 잠금 창이 준다. 이식성·가독성 저하.
  • 계정 샤드 직렬화/큐: 계정별로 이체를 직렬 처리(액터)하면 데드락 자체가 사라진다. 대신 크로스-샤드 이체 조정이 필요.

면접 포인트

  1. DB 데드락의 발생 조건(순환 대기)과 정규 락 순서(작은 키 먼저) 로 예방하는 법 — 인프로세스 뮤텍스 ABBA 와 같은 원리, 무대만 DB 행 잠금.
  2. 데드락/직렬화 실패는 일시적 오류 → 오류 코드로 식별해 트랜잭션 전체를 재시도(백오프+지터, 상한). 재시도가 안전하려면 트랜잭션이 멱등해야 함.
  3. 격리수준·SELECT ... FOR UPDATE·조건부 UPDATE(음수 방지), 그리고 트랜잭션을 짧게 유지해 잠금 경합·데드락 빈도를 낮추는 원칙.