← 문제로

34. 거래 정산과 거래 취소가 경합하는 상황 (C#)

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

해설 — 거래 정산과 거래 취소가 경합하는 상황 (C#)

난이도: 중

요약

정산(SettleTrade)과 취소(CancelTrade)가 같은 거래에 대해 서로 다른 스레드에서 동시에 호출되는데, 거래 상태 확인과 상태 전이 사이가 보호되지 않는다. 두 경로가 모두 Pending을 보고 통과하면 한 거래가 정산도 되고 취소도 되어, 판매자 지급 + 구매자 환불이 동시에 일어나 에스크로 한 몫에서 두 몫의 골드가 만들어진다.

문제점

  • [원자성 위반: check-then-act] (A)if (t.State != Pending) return; 으로 상태를 검사하지만, 검사와 (B)의 상태 전이가 한 임계영역으로 묶여 있지 않다. 두 스레드가 거의 동시에 검사하면 둘 다 Pending을 보고 통과한다.
    • 재현조건: SettleTrade와 CancelTrade가 같은 tradeId로 동시 진입. 스케줄러가 두 (A) 검사를 모두 통과시킨 뒤 각자 (B)를 실행.
    • 근본원인: 거래의 "종결" 결정은 비교-후-쓰기(compare-and-set)인데, 락이나 원자적 CAS 없이 평범한 읽기/쓰기로 구현했다.
  • [lost update: 비원자 누적] (B)Wallet.Gold += ..., SystemVault += ... 는 읽기-가산-쓰기로 비원자다. 서로 다른 거래라도 같은 지갑/금고를 동시에 갱신하면 갱신이 유실된다(ConcurrentDictionary는 버킷 접근만 안전할 뿐 값 필드의 +=는 보호하지 않는다).
  • 결과(돈 보존 붕괴): 에스크로에 1몫(price)만 잠겨 있는데, 정산은 판매자에게 payout을, 취소는 구매자에게 price를 각각 지급 → 총량이 (price - fee) + price 만큼 늘어난다. 무에서 골드가 생성된다(인플레이션·아이템 복제와 같은 등급의 사고).

수정안

거래 상태 전이를 단일 임계영역으로 묶고, "Pending → Settled/Canceled" 전이에 성공한 스레드만 잔액을 만진다. 거래별 락(또는 거래 객체 자체를 락 대상으로)으로 직렬화한다.

public bool SettleTrade(long tradeId)
{
    if (!_trades.TryGetValue(tradeId, out var t)) return false;
    lock (t)                                  // 거래 단위 직렬화
    {
        if (t.State != TradeState.Pending) return false;  // 검사+전이를 한 임계영역에서
        long fee = t.Price * t.FeeRate / 10000;
        t.State = TradeState.Settled;         // 먼저 상태를 확정해 경쟁자를 배제
        Credit(t.SellerId, t.Price - fee);
        Interlocked.Add(ref SystemVault, fee);
        return true;
    }
}

public bool CancelTrade(long tradeId)
{
    if (!_trades.TryGetValue(tradeId, out var t)) return false;
    lock (t)
    {
        if (t.State != TradeState.Pending) return false;
        t.State = TradeState.Canceled;
        Credit(t.BuyerId, t.Price);
        ReturnItem(t.SellerId, t.ItemId);
        return true;
    }
}

// 지갑 갱신도 직렬화(지갑별 lock 또는 Interlocked)
private void Credit(long id, long amount)
{
    var w = W(id);
    lock (w) { w.Gold += amount; }
}
  • 상태 전이를 CAS로도 표현 가능: Interlocked.CompareExchange(ref t.StateInt, Settled, Pending) == Pending 인 스레드만 후속 진행. 단 enum은 int로 바꿔야 한다.
  • 진짜 자산 변경(지갑/금고)은 영속 계층에서는 DB 트랜잭션 + 거래행 WHERE state='Pending' 조건부 UPDATE의 영향 행 수로 승자를 가린다.

더 나은 설계

  • 상태 기계 + 단일 종결 지점: 모든 종결을 Finalize(tradeId, outcome) 한 메서드로 모으고 그 안에서만 상태 전이·정산을 수행. 진입점이 하나라 락 범위·불변식 검증이 쉽다.
    • 트레이드오프: 거래별 락은 경합이 거래 단위로 쪼개져 확장성이 좋지만, 락 객체 수명(거래 객체 GC/풀링)과 잠금 순서(지갑 락과 함께 잡을 때 ABBA)를 관리해야 한다.
  • 영속 일관성: 인메모리 락만으로는 다중 서버에서 부족하다. 거래 종결은 DB의 조건부 UPDATE(낙관적) 또는 분산 락으로 단일 승자를 보장하고, 골드 이동은 같은 트랜잭션에 묶는다.
  • 멱등성: 운영 취소가 재시도될 수 있으므로 종결은 멱등이어야 한다(이미 Canceled면 true 반환, 재환불 금지).

면접 포인트

  • "ConcurrentDictionary를 썼는데 왜 안전하지 않나?" — 컬렉션의 구조적 동시성만 보장하지, 그 안에 담긴 가변 객체 필드의 read-modify-write까지 보호하지 않는다.
  • check-then-act를 원자화하는 두 방법: 임계영역(lock)으로 검사+전이 묶기 vs CompareExchange로 상태를 락프리 전이. 자산 이동까지 있으면 보통 전자가 단순·안전.