11. DB 트랜잭션 데드락: 일관된 락 순서 부재와 재시도 누락 (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→ 상위에서 요청 실패로 처리.
- T1:
- 근본 원인:
- (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 / 저장 프로시저: 두 행을 한 문장(또는 서버측 프로시저)에서 정렬된 순서로 갱신하면 왕복과 잠금 창이 준다. 이식성·가독성 저하.
- 계정 샤드 직렬화/큐: 계정별로 이체를 직렬 처리(액터)하면 데드락 자체가 사라진다. 대신 크로스-샤드 이체 조정이 필요.
면접 포인트
- DB 데드락의 발생 조건(순환 대기)과 정규 락 순서(작은 키 먼저) 로 예방하는 법 — 인프로세스 뮤텍스 ABBA 와 같은 원리, 무대만 DB 행 잠금.
- 데드락/직렬화 실패는 일시적 오류 → 오류 코드로 식별해 트랜잭션 전체를 재시도(백오프+지터, 상한). 재시도가 안전하려면 트랜잭션이 멱등해야 함.
- 격리수준·
SELECT ... FOR UPDATE·조건부 UPDATE(음수 방지), 그리고 트랜잭션을 짧게 유지해 잠금 경합·데드락 빈도를 낮추는 원칙.
해설 — DB 트랜잭션 데드락: 일관된 락 순서 부재와 재시도 누락 (C++)
난이도: 상
요약
두 계정 행을 인자 순서대로 갱신(잠금)하므로, A→B 이체와 B→A 이체가 동시에 실행되면 순환 대기가 성립해 DB 데드락이 난다. DBMS 는 한쪽을 희생자로 강제 중단(DbError)하는데, 코드는 이 오류를 일시적(재시도 가능) 으로 구분하지 않고 그대로 실패로 던진다. 정상 이체가 사용자 오류로 튄다.
문제점
- 분류 태그: DB 데드락(락 획득 순서 불일치) · 일시적 오류 재시도 부재 · (부수) 잔액 음수 방지 조건 부재.
- 증상: 부하 시 산발적 "이체 실패". 드라이버가 데드락 코드(예: MySQL 1213, PostgreSQL SQLSTATE 40P01)의
DbError를 던지지만 애플리케이션은 재시도 없이 실패시킨다. - 재현 조건:
- T1:
transfer(A, B)→ 행 A 잠금 후 행 B 잠금 시도. - T2:
transfer(B, A)→ 행 B 잠금 후 행 A 잠금 시도. - 순환 대기 → DBMS 데드락 감지 → 한쪽 강제 중단 →
(B)에서 롤백 후throw.
- T1:
- 근본 원인:
- (A) 잠금 순서가 이체 방향(인자 순서)에 종속적 → 반대 방향과 겹치면 락 순서 불일치(ABBA), 무대는 DB 행 잠금.
- (B) 데드락은 재시도로 흡수할 일시적 오류인데, 오류 코드를 구분하지 않고 모든
DbError를 똑같이 전파. 한 번 재시도면 성공할 작업이 사용자 오류가 된다. (음수 잔액 방지 조건도 없음.)
수정안
(1) 정규 락 순서(작은 id 먼저)로 순환 대기 제거, (2) 데드락 코드 감지 시 트랜잭션 전체 재시도, (3) RAII 로 롤백/반납 보장.
#include <algorithm>
#include <thread>
#include <chrono>
// RAII: 커밋 안 됐으면 소멸 시 롤백. 예외/조기 반환에도 누수 없음.
class TxGuard {
Connection& c_; bool done_ = false;
public:
explicit TxGuard(Connection& c) : c_(c) { c_.begin(); }
void commit() { c_.commit(); done_ = true; }
~TxGuard() { if (!done_) { try { c_.rollback(); } catch (...) {} } }
};
static bool isDeadlock(const DbError& e) {
// 드라이버별: MySQL 1213(deadlock)/1205(lock wait timeout), PostgreSQL 40P01/40001 매핑값 등
return e.code == 1213 || e.code == 1205 || e.code == 40001 || e.code == 40807;
}
void TransferService::transfer(int fromAccount, int toAccount, int64_t amount) {
constexpr int kMaxRetries = 5;
for (int attempt = 0; ; ++attempt) {
Connection& conn = pool_.acquire();
try {
TxGuard tx(conn);
// 정규 순서: 항상 작은 id 먼저 잠근다(방향 무관)
int lo = std::min(fromAccount, toAccount);
int hi = std::max(fromAccount, toAccount);
conn.exec("SELECT gold FROM accounts WHERE id = ? FOR UPDATE", 0, lo);
conn.exec("SELECT gold FROM accounts WHERE id = ? FOR UPDATE", 0, hi);
// 조건부 차감으로 음수 방지(영향 행 0이면 잔액 부족)
conn.exec("UPDATE accounts SET gold = gold - ? WHERE id = ? AND gold >= ?", amount, fromAccount);
conn.exec("UPDATE accounts SET gold = gold + ? WHERE id = ?", amount, toAccount);
tx.commit();
pool_.release(conn);
return;
} catch (const DbError& e) {
pool_.release(conn); // TxGuard 소멸로 이미 롤백됨
if (!isDeadlock(e) || attempt >= kMaxRetries) throw;
std::this_thread::sleep_for(std::chrono::milliseconds(5 * (1 << attempt))); // 백오프(+지터 권장)
}
}
}
핵심: 정규 순서로 이 데드락은 사라지지만 인덱스·락 승급 등으로 데드락을 완전 제거할 수는 없으므로, 오류 코드 기반 재시도가 안전망으로 필요하다. TxGuard 로 예외 경로의 롤백·반납을 RAII 로 보장한다.
더 나은 설계 (트레이드오프)
- 정규 락 순서 + 재시도: 순서로 빈도↓, 재시도로 잔여 흡수(실무 표준). 재시도는 트랜잭션이 멱등/재실행 안전할 때만 안전 — 외부 부수효과는 트랜잭션 커밋 이후로.
- 서버측 프로시저/단일 문장: 두 행 갱신을 정렬된 순서로 서버에서 처리해 왕복·잠금 창 축소. 이식성 저하.
- 계정 샤드 직렬화(액터): 계정별 직렬 처리로 데드락 원천 제거. 크로스-샤드 이체는 saga/2PC 로.
면접 포인트
- DB 데드락의 순환 대기 조건과 정규 락 순서(작은 키 먼저) 예방 — 인프로세스 뮤텍스 ABBA 와 동형, 무대만 DB.
- 데드락/직렬화 실패 = 일시적 오류 → 코드 식별 후 트랜잭션 전체 재시도(백오프·상한), 그리고 재시도 안전성(멱등) 요건.
- RAII(
TxGuard)로 예외 경로 롤백/반납 보장,FOR UPDATE·조건부 UPDATE, 짧은 트랜잭션으로 경합 최소화.