← 문제로

3. 플레이어 간 아이템 직거래(트레이드)

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

해설 — 플레이어 간 아이템 직거래(트레이드)

난이도: 상

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

요약

거래 체결(Commit)이 락 없이 두 인벤토리를 수정하고, 검증(Offer 시점)과 이전(Commit 시점)이 분리돼 있다. 그 결과 아이템 복제(dupe), 거래 도중 아이템 이동, 중복 거래(같은 아이템 두 거래에 등록), 확인-후-변경(confirm bypass), 부분 체결로 인한 정합성 붕괴가 모두 가능하다. 이 카테고리에서 가장 악명 높은 "복사 버그" 의 교과서적 집합이다.


문제점

(E)+(F) Commit 이 락 없이 두 인벤토리 수정 — 복제/증발 (정확성/동시성/보안)

  • 증상: 거래 체결 중간에 다른 스레드가 같은 아이템을 보거나 옮기면 같은 itemUid 가 양쪽에 모두 존재(복제) 하거나 사라진다(증발).
  • 재현 조건:
    • A의 아이템 이전 루프(E) 중간에 같은 트레이드의 Cancel/다른 Commit이 끼어들면 t.A.Inventory[uid] 가 이미 없어 KeyNotFoundException. 그때까지 옮긴 것만 반영 → 부분 체결(원자성 붕괴).
    • 같은 아이템을 A↔B, A↔C 두 거래에 올려두고 두 Commit이 동시에 돌면, 하나는 Remove 전에 다른 하나가 읽어 양쪽 모두 Inventory[uid] = item → 복제.
  • 근본 원인: Commit이 두 플레이어의 Inventory(공유 가변 컬렉션)를 어떤 락도 없이 수정한다. 또한 Confirm/OfferTrade 상태를 락 없이 건드린다. 전체 거래가 하나의 임계 구역 안에서 원자적으로 일어나야 하는데 그렇지 않다.

(B)→(E) 검증 시점과 이전 시점 분리 — TOCTOU (정확성/보안)

  • 증상: Offer 때는 인벤토리에 있던 아이템이 Commit 때는 없을 수 있다(그 사이 사용/판매/다른 거래). 그래도 Commit은 그 uid로 이전을 시도한다.
  • 재현 조건: A가 검을 Offer → 확인 전 그 검을 상점에 판매 → 그래도 B와 Commit이 진행되면 존재하지 않는 아이템을 B에게 "생성" 하거나 예외.
  • 근본 원인: "올릴 때 본인 것" 만 확인하고, 체결 시점에 소유권을 재확인하지 않는다. 동시성/시점 분리가 본질.

중복 거래: 같은 아이템을 두 거래에 등록 (정확성/보안)

  • 증상: 같은 itemUid를 동시에 여러 거래창에 올릴 수 있다. 둘 다 체결되면 복제.
  • 재현 조건: A↔B와 A↔C 거래창을 동시에 열고 같은 검을 양쪽에 Offer.
  • 근본 원인: 거래에 올린 아이템을 에스크로(잠금/예약) 하지 않는다. "올렸다" 는 상태가 인벤토리에 반영되지 않아 다른 거래/판매가 같은 아이템을 자유롭게 본다. 요구사항 "같은 아이템 두 거래에 올릴 수 없다" 가 전혀 구현되지 않음.

(C)+(D) Offer 후 Confirm 무효화 없음 — confirm bypass (정확성/보안)

  • 증상: B가 Confirm을 누른 뒤 A가 몰래 비싼 아이템을 빼고 쓰레기로 바꿔치기해도 B의 Confirm이 유지된다. 사기 거래.
  • 재현 조건: B Confirm → A가 추가 Offer/제거 → 양쪽 Confirm 플래그가 그대로 True → Commit. B는 동의하지 않은 구성으로 체결당함.
  • 근본 원인: Offer로 거래 내용이 바뀌면 양쪽 Confirm을 리셋해야 하는데 안 한다.

(H) Cancel 시 롤백이 "아무것도 안 함" — 에스크로 부재의 후폭풍 (정확성)

  • 현재는 아이템을 인벤토리에서 뺀 적이 없으니 "그냥 두면 된다" 는 주석이 맞다. 그러나 이는 에스크로 모델을 안 썼기 때문에 우연히 맞는 것이고, 중복 거래/복제를 허용한 대가다. 제대로 된 설계(에스크로)에선 Cancel이 반드시 원복 책임을 진다.

(A)+(G) 거래 키 (aId, bId) 의 방향성 / 다중 거래 (정확성)

  • _trades[(aId, bId)] 는 (B,A) 와 (A,B) 를 다른 키로 본다. 같은 두 사람의 거래가 방향에 따라 두 개 생길 수 있고, 한 플레이어가 동시에 여러 거래에 들어가는 것도 막지 못한다. 거래 상태 머신의 동일성/유일성 관리가 허술하다.

락 순서: 두 플레이어 락 동시 획득 시 데드락 (동시성)

  • 수정안에서 두 인벤토리를 동시에 잠가야 하므로 전역 락 순서(Id 오름차순) 필수.

수정안

핵심 아이디어 — 에스크로(escrow) 모델: Offer 시 아이템을 인벤토리에서 빼서 거래 객체로 옮긴다(예약). 그러면 중복 거래/판매가 원천 차단된다. Commit/Cancel은 거래 객체에서 상대/원주인에게 넘긴다. 모든 상태 전이는 두 플레이어 락(순서 고정) 안에서 원자적으로.

public bool Offer(Trade t, long playerId, long itemUid)
{
    var (first, second) = Ordered(t.A, t.B);
    lock (first.Lock) lock (second.Lock)
    {
        if (t.Committed) return false;
        Player p = (t.A.Id == playerId) ? t.A : t.B;

        // 본인 인벤토리에서 빼서 에스크로로 (소유권을 거래가 가져감 → 중복 거래 불가)
        if (!p.Inventory.TryGetValue(itemUid, out var item)) return false;
        p.Inventory.Remove(itemUid);

        if (t.A.Id == playerId) t.OfferA.Add(itemUid);
        else                    t.OfferB.Add(itemUid);
        t.EscrowItems[itemUid] = item;

        // 거래 내용 변경 → 양쪽 확인 리셋(confirm bypass 차단)
        t.ConfirmA = t.ConfirmB = false;
        return true;
    }
}

public void Confirm(Trade t, long playerId)
{
    var (first, second) = Ordered(t.A, t.B);
    lock (first.Lock) lock (second.Lock)
    {
        if (t.Committed) return;
        if (t.A.Id == playerId) t.ConfirmA = true; else t.ConfirmB = true;
        if (t.ConfirmA && t.ConfirmB) Commit(t);   // 같은 임계 구역에서 체결
    }
}

private void Commit(Trade t)   // 호출자가 두 락 보유 중
{
    if (t.Committed) return;
    t.Committed = true;
    foreach (var uid in t.OfferA) t.B.Inventory[uid] = t.EscrowItems[uid];
    foreach (var uid in t.OfferB) t.A.Inventory[uid] = t.EscrowItems[uid];
    t.EscrowItems.Clear();
}

public void Cancel(Trade t)    // 로그아웃/취소 → 원주인에게 원복
{
    var (first, second) = Ordered(t.A, t.B);
    lock (first.Lock) lock (second.Lock)
    {
        if (t.Committed) return;
        foreach (var uid in t.OfferA) t.A.Inventory[uid] = t.EscrowItems[uid];
        foreach (var uid in t.OfferB) t.B.Inventory[uid] = t.EscrowItems[uid];
        t.EscrowItems.Clear();
        lock (_lock) _trades.Remove(Key(t.A.Id, t.B.Id));
    }
}

private (Player, Player) Ordered(Player a, Player b)
    => a.Id < b.Id ? (a, b) : (b, a);
private static (long, long) Key(long a, long b)
    => a < b ? (a, b) : (b, a);     // 방향 무관 정규화 키

추가로 한 플레이어가 동시에 여러 거래에 들어가지 못하도록 Player.ActiveTradeId 같은 상태를 두고 OpenTrade에서 검사한다.


더 나은 설계

1) 2-페이즈 커밋 + 영속 로그

인메모리 에스크로는 서버 크래시에 취약하다(거래 중 죽으면 에스크로 아이템 행방).

  • Offer/Confirm/Commit을 DB 트랜잭션 + 상태 머신(OPEN→CONFIRMED→COMMITTED)으로 영속화. 아이템 소유권을 owner_id + locked_by_trade 컬럼으로 관리하면 복제/중복이 DB 제약 수준에서 막힌다.
  • 트레이드오프: 거래마다 DB 왕복. 거래는 빈도가 낮고 정합성이 생명이라 정당한 비용.

2) 상태 머신을 명시적으로

거래는 전형적 FSM이다. OPEN/OFFER_CHANGED/BOTH_CONFIRMED/COMMITTED/CANCELLED 를 명시하고 잘못된 전이를 거부하면 confirm bypass 류 버그가 구조적으로 사라진다.

3) 락 입도 vs 단일 거래 액터

  • 두 플레이어 락(순서 고정)은 단순하지만 데드락 위험을 항상 안고 간다.
  • 대안: 거래를 단일 스레드(거래 액터/큐) 가 직렬 처리. 락이 사라지고 추론이 쉬워진다. 대신 처리량 한계와 크로스 샤드 거래 시 메시지 패싱 복잡도가 생긴다.

면접 포인트

  • 면접관이 듣고 싶은 핵심: "에스크로(예약) 없이는 중복 거래/복제를 막을 수 없다" 는 통찰과, 검증-체결 시점 분리(TOCTOU) + 원자적 커밋 + confirm 리셋 을 하나의 거래 트랜잭션으로 엮는 능력. "복사 버그" 가 왜 생기는지를 메커니즘으로 설명.
  • 예상 질문:
    1. "같은 검을 두 거래에 올리는 걸 어떻게 막나?" → Offer 시 인벤토리에서 빼 에스크로로(소유권 이전). 또는 DB locked_by_trade.
    2. "B가 확인했는데 A가 내용을 바꾸면?" → Offer 변경 시 양쪽 Confirm 리셋. FSM으로 강제.
    3. "거래 도중 서버가 죽으면 에스크로 아이템은?" → 인메모리면 유실 위험. 영속 상태 머신/DB 트랜잭션으로 복구 가능하게.