← 문제로

20. 거래 도중 한쪽이 로그아웃/접속종료되어 롤백이 필요한 상황

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

해설 — 거래 도중 한쪽이 로그아웃/접속종료되어 롤백이 필요한 상황

난이도: 상

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

OnConfirm(commit)과 OnDisconnect(취소)가 공유 거래 상태를 락 없이 플래그만으로 조율한다. (C) 둘 다 !t.Committed 를 검사하고 행동하는 check-then-act 라, commit 이 Committed=true 직후 아직 자산 이동을 진행하는 사이 OnDisconnect 가 (이전에 읽은) !Committed 로 취소에 들어가거나, 그 반대 인터리빙이 생겨 commit 과 취소가 동시에 일어난다. (B) Commit 의 교환이 "아이템들 → 골드" 를 여러 단계로 비원자 적용하므로, 중간에 한쪽 인벤토리가 세션 정리로 사라지거나 MoveItem/MoveGold 가 실패하면 부분 적용(A의 아이템은 갔는데 B의 골드는 안 옴 → 복제/증발)으로 끝난다 — 반환값도 무시한다. (A) commit 진입 가드가 단순 플래그라 이중 confirm· 재진입 시 이중 commit 가능. 정답 한 줄: 거래에 상태기계(Pending→Committing→Committed| Cancelled)를 두고 락/CAS 로 commit↔취소를 상호배제하며, 자산 교환은 검증→all-or-nothing(에스크로 또는 단일 트랜잭션+롤백)으로 원자화하라.

변별: content_gamelogic/problem3(두 거래자의 교환 경합/원자성 자체)와 달리, 본 문제의 간판은 세션 종료(접속 끊김)가 거래 commit 과 경합할 때의 "정리 vs commit" 조율과 부분 commit 롤백이다(세션 생명주기 축). session_network/problem11(강제종료 vs 정상진행)과도 도메인은 가깝지만, 여기서는 "2인 원자 교환의 부분 적용/이중 처리" 가 초점.


문제점

(C) commit vs 취소 비원자 가드 — 이중 처리/commit+취소 동시 (동시성·정합) ★간판

  • 분류 태그: check-then-act / race / TOCTOU.
  • 증상: OnConfirm 스레드가 !t.Committed 통과 → Commit 진입 → Committed=true 후 교환 진행. 같은 순간 OnDisconnect 스레드가 (조금 전 읽은) !t.Committed 로 취소 분기에 들어가 _trades.Remove/세션 맵 정리. 결과: 교환은 절반 적용됐는데 거래는 "취소" 로 지워지고 양쪽 _playerToTrade 가 제거돼 어디서도 롤백을 못 한다. 반대로 취소가 먼저 플래그를 세워도 commit 은 Cancelled 를 보지 않아 그대로 진행 → 취소된 거래가 commit.
  • 재현조건: confirm 으로 commit 진입과 동시에 한쪽 disconnect.
  • 근본 원인: commit/취소가 단일 임계구역·단일 상태 전이가 아니라 독립 플래그 검사.

(B) 비원자 자산 교환 — 부분 적용(복제/증발) (정합) ★간판

  • 분류 태그: non-atomic multi-step / missing rollback / ignored return value.
  • 증상: MoveItem 들 → MoveGold 들을 순차로 적용하고 반환값을 무시한다. 중간에
    • B 가 disconnect 되어 B 인벤토리가 정리됐다면 MoveItem(A→B) 는 사라진 대상에 넣어 유실,
    • MoveGold 가 잔액 부족/실패로 false 를 반환해도 앞서 옮긴 아이템은 그대로 → A는 아이템을 잃고 B는 골드를 안 줌(전형적 거래 복제/증발 버그).
  • 근본 원인: 교환이 트랜잭션이 아니다(선검증·all-or-nothing·실패 시 롤백 없음).

(A) 이중 commit 가드 부실 — 이중 지급 (정합/동시성)

  • 분류 태그: check-then-act / idempotency.
  • 증상: !t.Committed 만 보고 Commit. 두 스레드가 동시에 마지막 confirm 을 넣으면 둘 다 통과해 Commit 이 두 번 호출될 수 있다(아이템/골드 이중 이동).
  • 근본 원인: commit 전이가 원자적 1회(CAS/락)로 보장되지 않음.

(참고) 비스레드세이프 Dictionary + 세션 정리 경합

  • _trades/_playerToTrade 동시 접근은 자료구조 손상. 또한 인벤토리 모듈의 세션 정리와 거래 commit 사이의 순서(거래를 먼저 freeze 했는지)가 정의돼 있지 않다.

수정안

핵심: 거래 단위 락 + 상태기계로 commit↔취소 상호배제, 교환은 선검증 후 all-or-nothing. 가장 견고한 형태는 확정 시 에스크로(임시 보관) 로 옮겨두고 commit 은 "에스크로→상대" 이동, 취소는 "에스크로→원주인" 반환으로 만드는 것.

enum TradeState { Pending, Committing, Committed, Cancelled }

public class TradeSession
{
    public long TradeId;
    public TradeOffer A, B;
    public TradeState State = TradeState.Pending;
    public readonly object Gate = new();
}

public void OnConfirm(long tradeId, long playerId)
{
    TradeSession t;
    lock (_mapGate) { if (!_trades.TryGetValue(tradeId, out t)) return; }

    bool doCommit = false;
    lock (t.Gate)
    {
        if (t.State != TradeState.Pending) return;          // 취소/완료된 거래엔 무시
        if (playerId == t.A.PlayerId) t.A.Confirmed = true;
        else if (playerId == t.B.PlayerId) t.B.Confirmed = true;
        else return;

        if (t.A.Confirmed && t.B.Confirmed)
        {
            t.State = TradeState.Committing;                // 단일 전이 → 취소와 상호배제
            doCommit = true;
        }
    }
    if (doCommit) Commit(t);
}

private void Commit(TradeSession t)
{
    // 선검증: 두 플레이어를 고정 순서로 락(데드락 회피)하고 모든 전제 확인
    long lo = Math.Min(t.A.PlayerId, t.B.PlayerId), hi = Math.Max(t.A.PlayerId, t.B.PlayerId);
    lock (_inv.LockFor(lo)) lock (_inv.LockFor(hi))
    {
        if (!_inv.CanMoveAll(t.A) || !_inv.CanMoveAll(t.B))
        {
            Cancel(t, "precondition");                      // 하나라도 불가 → 통째로 취소
            return;
        }
        // 모두 가능 확인 후에야 실제 이동(원자적으로) — 실패하면 즉시 롤백
        if (!_inv.ApplyAtomic(t.A, t.B)) { Cancel(t, "apply"); return; }
    }
    lock (t.Gate) { t.State = TradeState.Committed; }
    Cleanup(t);
}

public void OnDisconnect(long playerId)
{
    long tradeId;
    lock (_mapGate) { if (!_playerToTrade.TryGetValue(playerId, out tradeId)) return; }
    if (!_trades.TryGetValue(tradeId, out var t)) return;

    lock (t.Gate)
    {
        if (t.State == TradeState.Committing || t.State == TradeState.Committed)
            return;                                         // 이미 commit 경로 — 취소 금지
        t.State = TradeState.Cancelled;                     // Pending 만 취소 가능
    }
    // 에스크로 모델이면 에스크로 반환. 단계 적용 모델이면 아무것도 안 옮겼으므로 정리만.
    Cleanup(t);
}

포인트

  • State 전이를 거래 락 안에서 단일화 → commit(Committing)과 취소(Cancelled)는 둘 중 하나만 일어난다(이중 commit·commit+취소 동시 제거).
  • 교환은 두 플레이어를 고정 순서로 락 → 전제 검증(CanMoveAll) → ApplyAtomic(실패 시 롤백) 로 all-or-nothing. 부분 적용/증발/복제 제거.
  • 더 견고하게는 에스크로: 확정 시 양쪽 자산을 거래 에스크로로 이동 → commit 은 에스크로→ 상대, 취소/접속종료는 에스크로→원주인. 어느 시점에 끊겨도 자산이 항상 어딘가에 정확히 존재.
  • 맵은 락(또는 ConcurrentDictionary)으로 보호. 인벤토리 락 순서를 id 오름차순으로 고정해 데드락 회피.

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

  1. 에스크로 + 2단계(준비/확정): 확정=에스크로 입고(준비), commit=배분, 취소=반납. 크래시에도 에스크로 잔존분으로 복구. 트레이드오프: 에스크로 저장/정리 잡 필요.
  2. 영속 트랜잭션(DB): 교환을 단일 DB 트랜잭션으로(또는 사가+보상). 다중 서버/크래시 안전. 트레이드오프: 지연·DB 부하.
  3. 접속종료 grace 윈도우: 끊김 직후 짧게 거래를 freeze 했다가 미복귀 시 취소 → 순간 끊김에 거래가 날아가는 UX 완화. 트레이드오프: 그동안 자산 잠금/타임아웃.
  4. 멱등 commit 키: (tradeId)로 commit 을 멱등화해 재시도/중복 호출에도 1회만 적용.

면접 포인트 (예상 질문)

  1. commit 과 disconnect-취소가 각각 !Committed 만 보고 행동하면 어떤 인터리빙에서 "교환은 절반 적용됐는데 거래는 취소" 가 되는가? 상태기계로 어떻게 막는가?
  2. 교환을 "아이템들 옮기고 골드 옮기기" 로 순차 처리하면 어디서 복제/증발이 나는가? 에스크로 모델이 왜 어느 시점 끊김에도 안전한가?
  3. 두 플레이어 인벤토리를 락으로 보호할 때 데드락을 어떻게 피하는가(락 순서)?