34. 거래 정산과 거래 취소가 경합하는 상황 (C#)
난이도 중내 리뷰 · 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 없이 평범한 읽기/쓰기로 구현했다.
- 재현조건: SettleTrade와 CancelTrade가 같은
- [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로 상태를 락프리 전이. 자산 이동까지 있으면 보통 전자가 단순·안전.
해설 · C++
해설 — 거래 정산과 거래 취소가 경합하는 상황 (C++)
난이도: 중
요약
settleTrade와 cancelTrade가 다른 스레드에서 같은 거래에 동시 호출되는데, 상태 검사 (A)와 상태 전이 (B)가 한 임계영역으로 묶여 있지 않다. 게다가 두 메서드는 공유 std::unordered_map(trades_, wallets_)을 락 없이 읽고 쓴다. 그 결과 한 거래가 정산·취소 양쪽으로 종결되어 골드가 복제되고, 맵 동시 변경으로 미정의 동작(컨테이너 내부 자료구조 손상)까지 발생한다.
문제점
- [원자성 위반: check-then-act] (A) —
if (t.state != Pending) return false;와 (B)의t.state = ...사이가 보호되지 않는다. 두 스레드가 모두Pending을 읽고 통과 → 둘 다 종결을 수행.- 재현조건: settle/cancel 동시 진입, 두 (A) 검사가 전이보다 먼저 일어남.
- 근본원인: 비교-후-쓰기를 락/원자 연산 없이 평범한 멤버 읽기·쓰기로 구현.
- [데이터 레이스: 비보호 공유 컨테이너] (B) —
wallets_[id]는 키가 없으면 삽입(쓰기)이고settle/cancel이 동시에wallets_·systemVault_를 변경한다.std::unordered_map은 동시 쓰기에 안전하지 않다(리해시/노드 삽입 중 자료구조 손상, 반복자/참조 무효화로 인한 UAF성 충돌).+=누적도 비원자. - 결과(돈 보존 붕괴): 에스크로 1몫에서 판매자 payout + 구매자 환불 2몫이 지급 → 골드 인플레이션. 더 나아가 맵 동시 변경으로 크래시/메모리 손상 위험.
수정안
거래별로 상태 전이와 자산 이동을 한 뮤텍스 아래 직렬화하고, 공유 맵 접근도 보호한다. 거래마다 별도 뮤텍스를 두면 거래 단위로 경합이 쪼개진다.
struct Trade {
/* ... */ TradeState state = TradeState::Pending;
std::mutex mtx; // 거래 단위 직렬화(주: Trade는 더 이상 복사 불가)
};
bool settleTrade(int64_t tradeId) {
Trade* t = find(tradeId); // trades_ 접근은 별도 락/읽기전용 보장 하에서
if (!t) return false;
std::lock_guard<std::mutex> lk(t->mtx);
if (t->state != TradeState::Pending) return false;
int64_t fee = t->price * t->feeRate / 10000;
t->state = TradeState::Settled; // 먼저 종결 확정
credit(t->sellerId, t->price - fee); // credit 내부에서 지갑 락
addVault(fee); // systemVault_ 는 atomic 또는 전용 락
return true;
}
- 거래 객체에 뮤텍스를 넣으면 복사·이동이 막히므로, 맵은
std::unordered_map<int64_t, std::unique_ptr<Trade>>로 보관해 포인터 안정성을 확보한다. wallets_/trades_자체의 구조 변경(삽입)은 전역(또는 샤딩된) 뮤텍스로 보호하거나, 등록 단계에서 미리 채워 런타임 삽입을 없앤다.- 상태 전이만 락프리로 하려면
std::atomic<TradeState>+compare_exchange_strong(expected=Pending, Settled)로 승자를 가린 뒤 자산 이동.
더 나은 설계
- 포인터 안정 컨테이너 + 거래별 락:
unordered_map<id, unique_ptr<Trade>>로 거래 객체 주소를 고정하고, 거래별 뮤텍스로 종결을 직렬화. 경합이 거래 단위로 분산돼 확장성이 좋다.- 트레이드오프: 거래 객체 수명 관리(완료된 거래의 회수 시점)와 지갑 락과의 잠금 순서(항상 trade→wallet 순)가 필요.
- 단일 종결 함수:
finalize(id, outcome)하나로 모아 불변식(이미 종결이면 멱등)을 한 곳에서 검증. - 영속 일관성: 인메모리 락은 단일 프로세스 한정. 멀티 노드면 DB 조건부 UPDATE(
WHERE state='Pending')나 분산 락으로 단일 승자 보장, 자산 이동은 동일 트랜잭션.
면접 포인트
unordered_map에 동시 접근이 왜 위험한가:operator[]는 키가 없으면 삽입(쓰기)이며, 동시 삽입은 리해시·버킷 재배치 중 자료구조를 깨뜨려 미정의 동작이 된다. 읽기 전용이 아닌 한 보호가 필요.- 거래 객체에 뮤텍스를 넣을 때 컨테이너 선택(복사 불가 → unique_ptr/포인터 안정성)까지 함께 설명할 수 있어야 한다.
- 락 직렬화 vs
atomic<state>CAS의 선택 기준: 자산 이동까지 한 단위로 묶어야 하면 락이 단순·안전, 단순 플래그 전이면 CAS.