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

난이도 상 해설 보기 →

결함을 모두 찾고 원인·수정안·더 나은 설계를 제시하라. 마커 (A)(B) 는 주목 위치 힌트다.

결함 코드 · C#
// ============================================================================
// [코드리뷰 문제] C# - 거래 도중 한쪽이 로그아웃/접속종료되어 롤백이 필요한 상황
// ----------------------------------------------------------------------------
// 시나리오 (세션/네트워크 · 서버-클라):
//   - 두 플레이어가 직거래 창을 열고 각자 아이템/골드를 올린 뒤, 양쪽이 "확정(confirm)" 하면
//     서버가 교환을 commit 한다(A의 물건은 B에게, B의 물건은 A에게).
//   - commit 직전/도중에 한쪽 세션이 끊길 수 있다(자발적 로그아웃 또는 연결 끊김 -> OnDisconnect).
//     이 경우 거래는 안전하게 취소(롤백)되어야 하고, 이미 일부가 적용됐다면 원자적으로 되돌려야 한다.
//   - OnConfirm 과 OnDisconnect 는 서로 다른 스레드(요청 처리 / 네트워크 콜백)에서 들어온다.
//
// 요구사항:
//   - 교환은 원자적이어야 한다: "A의 아이템만 넘어가고 B의 골드는 안 넘어온" 부분 적용 금지
//     (아이템 증발/복제 금지).
//   - commit 과 disconnect-취소가 동시에 일어나도 거래는 정확히 한 번만 commit 되거나
//     정확히 한 번만 취소되어야 한다(이중 commit/이중 롤백/commit+취소 동시 금지).
//   - 접속종료 시 진행 중 거래가 방치되면 안 된다(세션만 정리하고 거래를 남기면 누수/복제).
//
// 과제:
//   이 코드의 잠재적 문제를 모두 찾고, 왜/어떻게 부분 적용(복제/증발)·이중 처리·좀비 거래가
//   동시 인터리빙으로 발생하는지 설명하고, 수정안과 더 나은 설계를 제시하라.
//   (먼저 직접 리뷰를 적은 뒤 answer.md 와 대조할 것)
// ============================================================================

using System;
using System.Collections.Generic;

public interface IInventory
{
    bool MoveItem(long fromPlayer, long toPlayer, long itemUid);   // 성공 시 true
    bool MoveGold(long fromPlayer, long toPlayer, long amount);    // 성공 시 true
}

public class TradeOffer
{
    public long       PlayerId;
    public List<long> ItemUids = new();
    public long       Gold;
    public bool       Confirmed;
}

public class TradeSession
{
    public long        TradeId;
    public TradeOffer  A;
    public TradeOffer  B;
    public bool        Committed;
    public bool        Cancelled;
}

public class TradeService
{
    private readonly Dictionary<long, TradeSession> _trades = new();      // tradeId -> 세션
    private readonly Dictionary<long, long>         _playerToTrade = new(); // playerId -> tradeId
    private readonly IInventory _inv;

    public TradeService(IInventory inv) { _inv = inv; }

    // 양쪽이 모두 확정하면 commit
    public void OnConfirm(long tradeId, long playerId)
    {
        if (!_trades.TryGetValue(tradeId, out var t)) return;

        if (playerId == t.A.PlayerId) t.A.Confirmed = true;
        else if (playerId == t.B.PlayerId) t.B.Confirmed = true;
        else return;

        // (A) commit 진입 가드를 검토하라
        if (t.A.Confirmed && t.B.Confirmed && !t.Committed)
            Commit(t);
    }

    private void Commit(TradeSession t)
    {
        t.Committed = true;

        // (B) 이 교환 절차(단계별 이동/반환값)를 검토하라
        foreach (var uid in t.A.ItemUids) _inv.MoveItem(t.A.PlayerId, t.B.PlayerId, uid);
        foreach (var uid in t.B.ItemUids) _inv.MoveItem(t.B.PlayerId, t.A.PlayerId, uid);
        _inv.MoveGold(t.A.PlayerId, t.B.PlayerId, t.A.Gold);
        _inv.MoveGold(t.B.PlayerId, t.A.PlayerId, t.B.Gold);

        _trades.Remove(t.TradeId);
        _playerToTrade.Remove(t.A.PlayerId);
        _playerToTrade.Remove(t.B.PlayerId);
    }

    // 세션 종료(네트워크 콜백, 다른 스레드)
    public void OnDisconnect(long playerId)
    {
        if (!_playerToTrade.TryGetValue(playerId, out var tradeId)) return;
        if (!_trades.TryGetValue(tradeId, out var t)) return;

        // (C) 이 정리 경로와 commit 의 관계를 검토하라
        if (!t.Committed)
        {
            t.Cancelled = true;
            _trades.Remove(tradeId);            // 이미 일부 적용됐을 수도 있는데 그냥 제거
            _playerToTrade.Remove(t.A.PlayerId);
            _playerToTrade.Remove(t.B.PlayerId);
        }
    }
}
내 리뷰 · C#
내 답안 · 자동 저장

작성 후 위 해설 보기에서 모범 해설과 대조하세요.