← 문제로

36. 거래 확정(confirm) 토글과 제안 변경 경합 (미끼 교환 / bait-and-switch, C#)

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

해설 — 거래 확정(confirm) 토글과 제안 변경 경합 (미끼 교환 / bait-and-switch, C#)

난이도: 하

요약

거래 창에서 (1) 한쪽이 제안 내용을 바꿔도(SetOffer) 상대의 확정 상태가 초기화되지 않아, 상대가 "본 것과 다른" 거래를 확정한 채로 성사되고(미끼 교환), (2) TryExecute 가 확정 검사와 실제 이전 사이에 제안·소유를 다시 검증하지 않아 확정 후 바뀐 목록/이미 없는 아이템으로 거래가 나간다. 스레드가 갈리면 검사-실행이 비원자라 더 벌어진다.

문제점

  • [확정 상태 미초기화 / TOCTOU] (A) side.Offer = items;
    • 증상: A가 "전설검 → 골드 1000" 제안을 보고 확정한다. 그 뒤 B가 SetOffer 로 전설검을 잡템으로 교체한다. B의 변경이 A의 Confirmed=true건드리지 않으므로, 이제 B가 확정만 누르면 양쪽 확정이 되어 A는 잡템을 받고 전설검을 넘긴다.
    • 재현: A.Confirm()B.SetOffer(잡템)B.Confirm()TryExecute().
    • 근본원인: 어느 쪽이든 제안이 바뀌면 양쪽 확정을 모두 리셋해야 한다는 불변식이 없음. 확정은 "지금 화면의 그 내용"에 대한 동의여야 한다.
  • [검사-실행 비원자 / 재검증 부재] (B) if (!_a.Confirmed || !_b.Confirmed) return false;
    • 증상: 확정 검사 통과 후 실제 이전까지 사이에 다른 스레드가 SetOffer/Confirm/인벤토리 변경 을 끼워 넣으면, 확정 시점과 다른 목록으로 이전되거나, Offer 에 든 ID가 이미 인벤토리에 없어(다른 경로로 사용/판매됨) 소유하지 않은 아이템을 상대에게 지급(복제)한다. Remove 반환값을 확인하지 않는다.
    • 근본원인: 세션에 대한 직렬화(락) 와, 실행 직전 "제안한 아이템을 실제로 지금 소유하는가" 재검증이 없음.
  • [데이터 레이스] 공통 서로 다른 처리 스레드가 같은 TradeSide.Offer(참조 필드)·Confirmed·Inventory(HashSet) 를 동기화 없이 읽고 쓴다. HashSet 동시 변경은 손상/예외로 이어진다.

수정안

세션 단위 락으로 모든 연산을 직렬화하고, 제안이 바뀌면 양쪽 확정을 리셋, 실행 직전 소유·목록을 재검증한다. 확정에는 내용 해시(버전)를 실어 "무엇에 대한 확정인지"를 못박는다.

public sealed class TradeSession
{
    private readonly object _gate = new();
    private readonly TradeSide _a, _b;
    private long _version = 0;   // 제안이 바뀔 때마다 증가

    public void SetOffer(long playerId, List<long> items)
    {
        lock (_gate)
        {
            var side = SideOf(playerId);
            side.Offer = new List<long>(items);   // 방어적 복사
            // (A) 내용이 바뀌면 "이전 내용에 대한 동의"는 무효 → 양쪽 확정 리셋
            _a.Confirmed = _b.Confirmed = false;
            _version++;
        }
    }

    // 확정은 자기가 본 버전에 대해서만 유효
    public bool Confirm(long playerId, long seenVersion)
    {
        lock (_gate)
        {
            if (seenVersion != _version) return false;   // 그새 내용이 바뀜 → 재확인 필요
            SideOf(playerId).Confirmed = true;
            return true;
        }
    }

    public bool TryExecute()
    {
        lock (_gate)
        {
            if (!_a.Confirmed || !_b.Confirmed) return false;   // (B)
            // 실행 직전 재검증: 제안한 것을 실제로 지금 소유하는가
            if (!OwnsAll(_a) || !OwnsAll(_b)) { _a.Confirmed = _b.Confirmed = false; return false; }

            foreach (var id in _a.Offer) { _a.Owner.Inventory.Remove(id); _b.Owner.Inventory.Add(id); }
            foreach (var id in _b.Offer) { _b.Owner.Inventory.Remove(id); _a.Owner.Inventory.Add(id); }
            return true;
        }
    }

    private static bool OwnsAll(TradeSide s)
    {
        foreach (var id in s.Offer) if (!s.Owner.Inventory.Contains(id)) return false;
        return true;
    }
}

더 나은 설계

  • 확정=내용 서명: 클라가 확정 시 자기가 본 제안의 해시/버전을 함께 보내고 서버가 현재 버전과 대조. 불일치면 "내용이 바뀌었습니다" UI 재확인. 위 seenVersion 이 그 최소형.
  • 2단계 확정(lock-in): 양쪽이 1차 확정하면 창을 잠가(더 이상 SetOffer 불가) 짧은 최종 확인 단계를 둔다. 대부분 상용 MMO 가 이 방식(변경 시 lock 해제·재확정).
  • 원자적 이전 + 감사 로그: 실제 교환은 한 트랜잭션(또는 세션 락 안)에서 remove/add 를 수행하고, 실패 시 전량 롤백. 거래 결과를 거래ID로 로그(사기 신고·복구 대비).
  • 에스크로 모델: 확정 즉시 아이템을 세션 에스크로로 옮겨 다른 경로 사용/판매를 원천 차단(가방 정렬·판매와의 경합 회피, content_gamelogic/problem28 과 연결).

면접 포인트

  • "미끼 교환"이 왜 확정 상태를 리셋하지 않는 것만으로 성립하는지 — 실제 상용 게임 거래 사기의 고전.
  • 확정을 "버전/해시에 대한 동의"로 모델링하는 이유(무엇에 동의했는지 없으면 TOCTOU).
  • 검사-실행을 원자화하고 실행 직전 소유를 재검증해야 하는 이유(그 사이 아이템이 사라질 수 있다).