36. 거래 확정(confirm) 토글과 제안 변경 경합 (미끼 교환 / bait-and-switch, C#)
난이도 하내 리뷰 · 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(). - 근본원인: 어느 쪽이든 제안이 바뀌면 양쪽 확정을 모두 리셋해야 한다는 불변식이 없음. 확정은 "지금 화면의 그 내용"에 대한 동의여야 한다.
- 증상: A가 "전설검 → 골드 1000" 제안을 보고 확정한다. 그 뒤 B가
- [검사-실행 비원자 / 재검증 부재] (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).
- 검사-실행을 원자화하고 실행 직전 소유를 재검증해야 하는 이유(그 사이 아이템이 사라질 수 있다).
해설 · C++
해설 — 거래 확정(confirm) 토글과 제안 변경 경합 (미끼 교환 / bait-and-switch, C++)
난이도: 하
요약
(1) 한쪽이 제안을 바꿔도(setOffer) 상대의 확정 상태가 리셋되지 않아 상대가 "본 것과 다른" 거래를 확정한 채 성사되고(미끼 교환), (2) tryExecute 가 확정 검사와 실제 이전 사이 목록·소유를 재검증하지 않는다. C++ 은 여기에 더해 여러 워커가 같은 std::unordered_set/std::vector 를 동기화 없이 동시 변경해 데이터 레이스(UB) 와 컨테이너 손상까지 난다.
문제점
- [확정 상태 미초기화 / TOCTOU] (A)
s.offer = items;- 증상: A가 "전설검↔골드" 를 보고
confirm한다. 이후 B가setOffer로 전설검을 잡템으로 교체해도 A의confirmed==true가 그대로라, B가 확정만 누르면 양쪽 확정이 되어 A는 잡템을 받고 전설검을 넘긴다. - 재현:
A.confirm()→B.setOffer({잡템})→B.confirm()→tryExecute(). - 근본원인: 제안이 바뀌면 양쪽 확정을 리셋한다는 불변식 부재. 확정은 "현재 내용에 대한 동의"여야 한다.
- 증상: A가 "전설검↔골드" 를 보고
- [검사-실행 비원자 / 재검증 부재] (B)
if (!a_.confirmed || !b_.confirmed) return false;- 증상: 확정 검사 통과 후 이전까지 사이에
setOffer/confirm/인벤토리 변경이 끼면 다른 목록으로 이전되거나,offer의 ID가 이미 인벤토리에 없어도erase는 조용히 0을 반환하고 상대에insert해 소유하지 않은 아이템을 복제 지급한다(erase 반환값 미확인). - 근본원인: 세션 직렬화(락)와 실행 직전 소유 재검증 부재.
- 증상: 확정 검사 통과 후 이전까지 사이에
- [데이터 레이스 / UB] 공통 서로 다른 워커가 같은
TradeSide::offer(vector)·confirmed·Player::inventory(unordered_set)를 동시 read/write. C++ 표준상 데이터 레이스는 UB — rehash 중 순회, 찢긴 bool, 반복자 무효화로 크래시·힙 손상 가능.
수정안
세션 뮤텍스로 전 연산을 직렬화하고, 제안 변경 시 양쪽 확정 리셋, 실행 직전 소유·목록 재검증. 확정에는 버전을 실어 무엇에 대한 동의인지 못박는다.
#include <mutex>
class TradeSession {
public:
void setOffer(uint64_t playerId, const std::vector<uint64_t>& items) {
std::lock_guard<std::mutex> lk(m_);
TradeSide& s = sideOf(playerId);
s.offer = items;
a_.confirmed = b_.confirmed = false; // (A) 내용이 바뀌면 이전 동의 무효
++version_;
}
bool confirm(uint64_t playerId, uint64_t seenVersion) {
std::lock_guard<std::mutex> lk(m_);
if (seenVersion != version_) return false; // 그새 내용이 바뀜 → 재확인
sideOf(playerId).confirmed = true;
return true;
}
bool tryExecute() {
std::lock_guard<std::mutex> lk(m_);
if (!a_.confirmed || !b_.confirmed) return false; // (B)
if (!ownsAll(a_) || !ownsAll(b_)) { a_.confirmed = b_.confirmed = false; return false; }
for (uint64_t id : a_.offer) { a_.owner->inventory.erase(id); b_.owner->inventory.insert(id); }
for (uint64_t id : b_.offer) { b_.owner->inventory.erase(id); a_.owner->inventory.insert(id); }
return true;
}
private:
static bool ownsAll(const TradeSide& s) {
for (uint64_t id : s.offer) if (!s.owner->inventory.count(id)) return false;
return true;
}
TradeSide& sideOf(uint64_t pid) { return a_.owner->id == pid ? a_ : b_; }
std::mutex m_;
TradeSide a_, b_;
uint64_t version_ = 0;
};
더 나은 설계
- 확정=버전/해시 동의: 클라 확정에 자기가 본 제안 버전을 실어 서버가 대조. 불일치 시 재확인. (위
seenVersion.) - 2단계 lock-in: 1차 양쪽 확정 시 창을 잠가
setOffer를 금지하고 짧은 최종 확인 단계. 변경 시 lock 해제·재확정(상용 MMO 표준). - 에스크로: 확정 즉시 아이템을 세션 에스크로로 이동해 판매·정렬 등 다른 경로 접근을 차단(
content_gamelogic/problem28). - 소유는 값/ID로:
Player*원시 포인터 대신 세션 수명 동안 유효한 핸들·ID 로 다뤄, 한쪽 로그아웃 시 UAF 를 피한다(session_network/problem20롤백과 연계).
면접 포인트
- 미끼 교환이 "확정 리셋 누락"만으로 성립하는 이유(고전적 거래 사기).
- C++ 에서 공유 컨테이너 동시 변경이 UB 인 점과, erase/insert 가 조용히 실패(반환값)해 복제가 생기는 경로.
- 검사-실행 원자화 + 실행 직전 소유 재검증의 필요성.