3. 플레이어 간 아이템 직거래(트레이드)
난이도 상해설 — 플레이어 간 아이템 직거래(트레이드)
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
거래 체결(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→ 복제.
- A의 아이템 이전 루프(E) 중간에 같은 트레이드의 Cancel/다른 Commit이 끼어들면
- 근본 원인: Commit이 두 플레이어의
Inventory(공유 가변 컬렉션)를 어떤 락도 없이 수정한다. 또한Confirm/Offer도Trade상태를 락 없이 건드린다. 전체 거래가 하나의 임계 구역 안에서 원자적으로 일어나야 하는데 그렇지 않다.
(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 리셋 을 하나의 거래 트랜잭션으로 엮는 능력. "복사 버그" 가 왜 생기는지를 메커니즘으로 설명.
- 예상 질문:
- "같은 검을 두 거래에 올리는 걸 어떻게 막나?"
→ Offer 시 인벤토리에서 빼 에스크로로(소유권 이전). 또는 DB
locked_by_trade. - "B가 확인했는데 A가 내용을 바꾸면?" → Offer 변경 시 양쪽 Confirm 리셋. FSM으로 강제.
- "거래 도중 서버가 죽으면 에스크로 아이템은?" → 인메모리면 유실 위험. 영속 상태 머신/DB 트랜잭션으로 복구 가능하게.
- "같은 검을 두 거래에 올리는 걸 어떻게 막나?"
→ Offer 시 인벤토리에서 빼 에스크로로(소유권 이전). 또는 DB
해설 — 플레이어 간 아이템 직거래(트레이드)
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
거래 체결(Commit)이 락 없이 두 인벤토리(std::unordered_map)를 수정하고, 검증
(Offer 시점)과 이전(Commit 시점)이 분리돼 있다. 그 결과 아이템 복제(dupe), 거래 도중
아이템 이동, 중복 거래(같은 아이템 두 거래에 등록), 확인-후-변경(confirm bypass),
부분 체결로 인한 정합성 붕괴가 모두 가능하다. 게다가 C++ 특유의 컨테이너 동시 변경
UB 와 operator[] 자동 삽입 까지 겹친다. 이 카테고리에서 가장 악명 높은 "복사 버그"
의 교과서적 집합이다.
문제점
(E)+(F) Commit 이 락 없이 두 인벤토리 수정 — 복제/증발/UB (정확성/동시성/보안)
- 증상: 거래 체결 중간에 다른 스레드가 같은 아이템을 보거나 옮기면 같은 itemUid
가 양쪽에 모두 존재(복제) 하거나 사라진다(증발).
unordered_map동시 변경은 UB. - 재현 조건:
- A의 아이템 이전 루프(E) 중간에 같은 트레이드의 Cancel/다른 Commit이 끼어들면
t->a->inventory[uid]가 자동 삽입으로 빈 Item 을 만들거나, 이미 erase 된 키를 다시 읽어 쓰레기 값을 옮긴다. 그때까지 옮긴 것만 반영 → 부분 체결(원자성 붕괴). - 같은 아이템을 A↔B, A↔C 두 거래에 올려두고 두 Commit이 동시에 돌면, 하나가 erase
하기 전에 다른 하나가 읽어 양쪽 모두
inventory[uid] = item→ 복제. - 두 스레드가 같은
unordered_map에 동시insert/erase→ rehash 중 버킷 손상 → 미정의 동작(크래시/무한 루프).
- A의 아이템 이전 루프(E) 중간에 같은 트레이드의 Cancel/다른 Commit이 끼어들면
- 근본 원인: Commit이 두 플레이어의
inventory(공유 가변 컨테이너)를 어떤 락도 없이 수정한다.Confirm/Offer도Trade상태를 락 없이 건드린다. 전체 거래가 하나의 임계 구역 안에서 원자적으로 일어나야 하는데 그렇지 않다. C++ 표준 컨테이너는 같은 객체에 대한 동시 쓰기를 보장하지 않는다.
(E)/(F) operator[] 자동 삽입 — 유령 아이템 (정확성/보안)
t->a->inventory[uid]는 키가 없으면 기본값Item{0,0}을 삽입한다. Offer 후 그 아이템이 사라진 상태(판매 등)에서 Commit 하면 빈 아이템이 상대에게 "생성" 된다.find/at로 존재를 확인하지 않은 결과다.
(B)→(E) 검증 시점과 이전 시점 분리 — TOCTOU (정확성/보안)
- 증상: Offer 때는 인벤토리에 있던 아이템이 Commit 때는 없을 수 있다(그 사이 사용/판매/다른 거래). 그래도 Commit은 그 uid로 이전을 시도한다.
- 재현 조건: A가 검을 Offer → 확인 전 그 검을 상점에 판매 → 그래도 B와 Commit이 진행되면 존재하지 않는 아이템을 B에게 "생성"(operator[]) 하거나 쓰레기 이전.
- 근본 원인: "올릴 때 본인 것" 만 확인하고, 체결 시점에 소유권을 재확인하지 않는다.
중복 거래: 같은 아이템을 두 거래에 등록 (정확성/보안)
- 증상: 같은 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) 를 다른 키로 본다. 같은 두 사람의 거래가 방향에 따라 두 개 생길 수 있고, 한 플레이어가 동시에 여러 거래에 들어가는 것도 막지 못한다. 또한Cancel에서delete t하는데 다른 스레드가 같은t포인터를 쓰고 있으면 use-after-free. 거래 객체 수명 관리가 위험하다.
락 순서: 두 플레이어 락 동시 획득 시 데드락 (동시성)
- 수정안에서 두 인벤토리를 동시에 잠가야 하므로 데드락 회피(
std::scoped_lock/std::lock) 필수.
수정안
핵심 아이디어 — 에스크로(escrow) 모델:
Offer 시 아이템을 인벤토리에서 빼서 거래 객체로 옮긴다(예약). 그러면 중복 거래/판매가
원천 차단된다. Commit/Cancel은 거래 객체에서 상대/원주인에게 넘긴다. 모든 상태 전이는
두 플레이어 락(데드락 회피 동시 획득) 안에서 원자적으로. 거래 객체는 shared_ptr 로
수명을 관리해 use-after-free 를 막는다.
struct Trade {
Player* a; Player* b;
std::vector<int64_t> offerA, offerB;
std::unordered_map<int64_t, Item> escrow; // 예약된 아이템 보관
bool confirmA = false, confirmB = false, committed = false;
};
bool Offer(Trade* t, int64_t playerId, int64_t itemUid) {
std::scoped_lock lk(t->a->lock, t->b->lock); // 두 락 동시 획득(교착 없음)
if (t->committed) return false;
Player* p = (t->a->id == playerId) ? t->a : t->b;
auto it = p->inventory.find(itemUid); // find: 자동 삽입 금지
if (it == p->inventory.end()) return false;
// 본인 인벤토리에서 빼서 에스크로로(소유권을 거래가 가져감 → 중복 거래 불가)
t->escrow[itemUid] = it->second;
p->inventory.erase(it);
if (t->a->id == playerId) t->offerA.push_back(itemUid);
else t->offerB.push_back(itemUid);
// 거래 내용 변경 → 양쪽 확인 리셋(confirm bypass 차단)
t->confirmA = t->confirmB = false;
return true;
}
void Confirm(Trade* t, int64_t playerId) {
std::scoped_lock lk(t->a->lock, t->b->lock);
if (t->committed) return;
if (t->a->id == playerId) t->confirmA = true; else t->confirmB = true;
if (t->confirmA && t->confirmB) Commit(t); // 같은 임계 구역에서 체결
}
void Commit(Trade* t) { // 호출자가 두 락 보유 중
if (t->committed) return;
t->committed = true;
for (int64_t uid : t->offerA) t->b->inventory[uid] = t->escrow.at(uid);
for (int64_t uid : t->offerB) t->a->inventory[uid] = t->escrow.at(uid);
t->escrow.clear();
}
void Cancel(Trade* t) { // 로그아웃/취소 → 원주인에게 원복
std::scoped_lock lk(t->a->lock, t->b->lock);
if (!t->committed) {
for (int64_t uid : t->offerA) t->a->inventory[uid] = t->escrow.at(uid);
for (int64_t uid : t->offerB) t->b->inventory[uid] = t->escrow.at(uid);
t->escrow.clear();
}
std::lock_guard<std::mutex> g(lock_);
trades_.erase(Key(t->a->id, t->b->id));
}
static std::pair<int64_t,int64_t> Key(int64_t a, int64_t b) {
return a < b ? std::make_pair(a,b) : std::make_pair(b,a); // 방향 무관 정규화
}
추가로 한 플레이어가 동시에 여러 거래에 들어가지 못하도록 Player::activeTradeId
같은 상태를 두고 OpenTrade에서 검사한다. Trade 는 std::shared_ptr<Trade> 로
관리해 어떤 스레드가 참조 중이면 delete 되지 않게 한다.
더 나은 설계
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 단일 거래 액터
- 두 플레이어 락(
scoped_lock동시 획득)은 단순하지만 락 자체가 늘 추론 부담이다. - 대안: 거래를 단일 스레드(거래 액터/큐) 가 직렬 처리. 락이 사라지고 추론이 쉬워진다. 대신 처리량 한계와 크로스 샤드 거래 시 메시지 패싱 복잡도가 생긴다.
면접 포인트
- 면접관이 듣고 싶은 핵심: "에스크로(예약) 없이는 중복 거래/복제를 막을 수 없다" 는 통찰과, 검증-체결 시점 분리(TOCTOU) + 원자적 커밋 + confirm 리셋 을 하나의 거래 트랜잭션으로 엮는 능력. "복사 버그" 가 왜 생기는지를 메커니즘으로 설명.
- 예상 질문:
- "같은 검을 두 거래에 올리는 걸 어떻게 막나?"
→ Offer 시 인벤토리에서 빼 에스크로로(소유권 이전). 또는 DB
locked_by_trade. - "
inventory[uid]와inventory.at(uid)의 차이가 왜 중요한가?" →operator[]는 없는 키를 자동 삽입해 유령 아이템을 만든다. 존재 확인은find, 존재 보장 후 접근은at. 거래는 소유권이 생명이라 자동 삽입은 금물. - "거래 도중 서버가 죽으면 에스크로 아이템은?" → 인메모리면 유실 위험. 영속 상태 머신/DB 트랜잭션으로 복구 가능하게.
- "같은 검을 두 거래에 올리는 걸 어떻게 막나?"
→ Offer 시 인벤토리에서 빼 에스크로로(소유권 이전). 또는 DB