18. 장비 착용/해제 중 강화·분해 끼어듦 (아이템 상태 소유권 경합)
난이도 중해설 — 장비 착용/해제 중 강화·분해 끼어듦 (아이템 상태 소유권 경합)
난이도: 중상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
Equip/Enchant/Dismantle 가 같은 _inventory/_equipped 맵을 락 없이 동시에
읽고 쓴다. 가장 치명적인 것은 (A)+(C) 의 check-then-act 경합으로, 같은 UID 에 대해
Equip 과 Dismantle 이 둘 다 TryGetValue 를 통과하면 아이템이 장비 슬롯에 남으면서
분해 재료도 환급되는 아이템 복제(dupe) 가 난다. 반대 인터리빙에서는 이미 분해된
아이템이 슬롯에 박혀 유령 장비가 된다. (B) 의 Enchant 는 조회한 아이템 객체를 락 없이
수정해 강화 수치 유실/사라진 아이템 강화가 가능하고, 락이 전혀 없어 Dictionary 자체가
동시 구조변경으로 손상/예외가 날 수 있다. 정답 한 줄: 플레이어 단위로 인벤토리/장비
변경을 단일 임계구역(또는 단일 액터)으로 직렬화하고, "조회→이동/제거" 를 원자적으로
수행하며, 아이템은 한 곳에서만 소유하도록 상태를 한 번에 전이시킨다.
문제점
(A)+(C) Equip ↔ Dismantle 의 비원자 경합 — 아이템 복제 / 유령 장비 (동시성/버그) ★간판
- 증상: 같은 UID 에 대해 거의 동시에 착용·분해 요청이 오면:
- 인터리빙 1: T1
Equip이TryGetValue성공 → (선점 전) T2Dismantle이TryGetValue성공 →Remove+RefundMaterials실행(재료 환급 완료) → T1 이_equipped[slot] = item; _inventory.Remove(uid)실행. 결과: 아이템은 장비 슬롯에 살아 있고, 분해 재료도 이미 받았다 → 복제. - 인터리빙 2: T2
Dismantle이 먼저Remove완료 → T1Equip의_inventory.Remove는 false 지만 그 전에 잡아둔item참조를_equipped에 넣음 → 이미 분해된(환급된) 아이템을 착용한 유령 장비.
- 인터리빙 1: T1
- 재현조건: 같은 플레이어의 Equip/Dismantle 패킷이 서로 다른 IO 스레드에서 거의 동시 도착(매크로/더블요청). 부하가 높을수록 빈번.
- 근본 원인: "조회 → 슬롯 배치 → 인벤토리 제거" 와 "조회 → 인벤토리 제거 → 환급" 이 각각 하나의 원자적 트랜잭션이 아니다. 아이템의 소유 위치 전이가 직렬화되지 않았다.
(B) Enchant 의 락 없는 객체 수정 — 유실 갱신 / 사라진 아이템 강화 (동시성)
- 증상:
Enchant가item.EnchantLevel = cur + 1을 비원자로 수행. 두 강화가 겹치면 read-modify-write 가 섞여 +2 가 될 것이 +1 로 끝난다(유실 갱신). 또Dismantle과 겹치면 방금 제거(환급)된 아이템 객체의 필드를 올리는 무의미/모순 연산이 된다. - 근본 원인: 조회와 수정이 같은 임계구역 안에 있지 않고, 아이템 소유 여부가 변할 수 있다는 점을 무시했다.
(공통) 락 부재 — Dictionary 동시 구조변경 손상/예외 (동시성)
- 증상: 여러 스레드가
_inventory/_equipped에 동시Remove/인덱서 대입을 하면 비스레드세이프Dictionary의 내부 버킷이 손상되어InvalidOperationException, 무한 루프, 또는 조용한 데이터 오염이 발생할 수 있다. - 근본 원인: 공유 가변 컬렉션에 동기화가 전혀 없다.
(설계) Unequip 시 슬롯 점유/가방 가득참 미검사 (정확성, 경미)
- 증상:
Unequip이 인벤토리 용량/중복 UID 를 검사하지 않고_inventory[uid]=item. 같은 UID 가 두 경로로 들어오면 덮어쓰기. 본질 문제는 아니나 트랜잭션 경계에 포함해야 한다.
수정안
핵심: ① 플레이어 단위 락으로 인벤토리/장비 변경을 직렬화, ② 각 동작을 "검사 후 한 번에 전이" 하는 원자적 블록으로, ③ 아이템은 정확히 한 컨테이너만 소유.
public class PlayerEquipment
{
private readonly object _gate = new();
private readonly Dictionary<long, Item> _inventory = new();
private readonly Dictionary<EquipSlot, Item> _equipped = new();
public bool Equip(long uid, EquipSlot slot)
{
lock (_gate)
{
if (!_inventory.TryGetValue(uid, out var item)) return false;
if (!item.Equippable) return false;
// 슬롯에 이미 다른 장비가 있으면 먼저 인벤토리로 회수(원자적)
if (_equipped.TryGetValue(slot, out var prev))
_inventory[prev.Uid] = prev;
_inventory.Remove(uid); // 먼저 소유 해제
_equipped[slot] = item; // 그 다음 새 소유처에 등록
return true;
}
}
public bool Unequip(EquipSlot slot)
{
lock (_gate)
{
if (!_equipped.Remove(slot, out var item)) return false;
_inventory[item.Uid] = item;
return true;
}
}
public bool Enchant(long uid)
{
lock (_gate)
{
if (!_inventory.TryGetValue(uid, out var item)) return false;
item.EnchantLevel += 1; // 임계구역 안에서 RMW
return true;
}
}
public bool Dismantle(long uid)
{
lock (_gate)
{
if (!_inventory.Remove(uid, out var item)) return false; // 제거 성공한 자만 환급
RefundMaterials(item);
return true;
}
}
}
포인트
- "제거에 성공한 스레드만" 후속 작업(환급/배치)을 수행 → 단일 소유권. 두 스레드가 같은
UID 를 동시에 처리해도
Remove는 정확히 한 번만 true. - 강화 RMW 와 슬롯 이동을 같은 락 안에서 → 유실 갱신/복제 차단.
Dictionary.Remove(key, out value)(netstandard2.1+) 로 조회+제거를 원자화.
더 나은 설계 (+트레이드오프)
- 플레이어 액터 모델(싱글스레드 메일박스): 한 플레이어의 모든 인벤토리/장비 명령을 하나의 큐로 직렬 처리. 락이 사라지고 추론이 쉬워진다. 트레이드오프: 액터 간 교차 작업 (거래)은 별도 조정 필요, 핫 플레이어의 큐 지연.
- 아이템 상태에 명시적 location enum (
InBag/Equipped/Destroyed) + 버전: 전이를 상태머신으로 강제하고,Destroyed는 어떤 동작도 거부. CAS/버전으로 ABA 방지. - 영속 계층은 DB 트랜잭션으로 동일 불변식 보장: 메모리 락이 깨지거나 다중 서버일 때를 대비해 "한 row 의 owner_slot 갱신" 을 단일 UPDATE 로. 트레이드오프: DB 왕복 비용.
- 분해/강화는 멱등 요청 ID: 중복 요청이 와도 한 번만 적용(연타 방어).
면접 포인트 (예상 질문)
- Equip 과 Dismantle 이 같은 아이템에 동시에 들어올 때 왜 복제가 나는가? 두 인터리빙을 각각 설명하라. (TryGetValue 통과 후 둘 다 진행 / Remove 순서 차이)
- 락 하나로 묶는 것과 플레이어 액터(싱글스레드)로 가는 것의 차이와 장단점은?
- "제거에 성공한 스레드만 환급" 패턴이 왜 단일 소유권을 보장하는가?
Remove의 반환값이 왜 핵심인가?
해설 (C++) — 장비 착용/해제 중 강화·분해 끼어듦 (아이템 상태 소유권 경합)
난이도: 중상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
C++ 판은 C# 의 복제/유령 장비 경합에 더해 수명(lifetime) 문제가 치명적이다. equipped_
는 Item* raw 포인터로 참조하는데 소유는 inventory_ 의 unique_ptr 가 한다. (A) Equip
이 슬롯에 raw 포인터를 넣고 inventory_.erase(uid) 를 호출하면 그 포인터가 가리키던 객체가
즉시 파괴되어 슬롯이 댕글링 포인터(use-after-free) 가 된다. (C) Dismantle 이
Enchant/Equip 과 겹치면 한 스레드가 erase 로 객체를 파괴하는 사이 다른 스레드가 그
포인터를 역참조해 UAF. 게다가 두 unordered_map 에 락이 없어 동시 erase/삽입이 리해시와
겹치면 컨테이너 자체가 UB 다. 정답 한 줄: 플레이어 단위 뮤텍스로 직렬화하고, 소유를
shared_ptr 로 통일하거나 "한 컨테이너만 소유" 하도록 객체를 이동시키며, "조회→전이" 를
원자적으로 수행한다.
문제점
(A) Equip 의 즉시 use-after-free — 슬롯이 파괴된 객체를 가리킴 (수명/UB) ★간판
- 증상:
equipped_[slot] = item;(raw) 직후inventory_.erase(uid);가 그unique_ptr를 파괴 →item이 가리키던Item이 즉시 해제. 이후 슬롯 포인터 역참조(스탯 계산 등)는 UAF. 단일 스레드에서도 이미 깨진다. - 근본 원인: 소유와 참조가 분리되어 있고, 참조를 남긴 채 소유자를 파괴했다.
(C) Dismantle ↔ Equip/Enchant 동시 — UAF + 복제/유령 장비 (수명/동시성) ★간판
- 증상: T1
Dismantle이erase(it)로 객체를 파괴하는 사이 T2Enchant/Equip이 같은 UID 의it->second.get()으로 얻은 포인터를 역참조 → UAF. 인터리빙에 따라 복제 (분해 환급 + 슬롯 잔존) 또는 유령 장비도 C# 과 동일하게 발생. - 근본 원인: 소유 전이가 직렬화되지 않았고, 파괴와 접근이 동기화되지 않았다.
(B) Enchant 의 락 없는 RMW (동시성)
- 증상:
item->enchantLevel = cur + 1비원자 → 동시 강화 시 유실 갱신. 분해와 겹치면 파괴 직전/직후 객체 수정.
(공통) unordered_map 무동기 동시 변경 — 컨테이너 UB (동시성)
- 증상: 동시
erase/operator[]삽입 중 리해시가 일어나면 노드/버킷 포인터가 무효화돼 자료구조 자체가 손상(UB). C# 의 예외와 달리 C++ 는 조용한 메모리 오염일 수 있다.
수정안
핵심: ① 플레이어 뮤텍스로 모든 변경 직렬화, ② 소유를 shared_ptr 로 통일(참조 안전) 또는
unique_ptr 를 컨테이너 간 이동(한 곳만 소유), ③ "제거 성공한 자만 후속 처리".
#include <mutex>
class PlayerEquipment {
public:
bool Equip(std::int64_t uid, EquipSlot slot) {
std::lock_guard<std::mutex> lk(m_);
auto it = inventory_.find(uid);
if (it == inventory_.end()) return false;
if (!it->second->equippable) return false;
// 기존 착용분 회수
if (auto e = equipped_.find(slot); e != equipped_.end()) {
inventory_[e->second->uid] = e->second; // shared_ptr 복귀
equipped_.erase(e);
}
equipped_[slot] = it->second; // 소유권 공유(shared_ptr)
inventory_.erase(it); // 인벤토리에서만 빠짐, 객체는 살아 있음
return true;
}
bool Unequip(EquipSlot slot) {
std::lock_guard<std::mutex> lk(m_);
auto e = equipped_.find(slot);
if (e == equipped_.end()) return false;
inventory_[e->second->uid] = e->second;
equipped_.erase(e);
return true;
}
bool Enchant(std::int64_t uid) {
std::lock_guard<std::mutex> lk(m_);
auto it = inventory_.find(uid);
if (it == inventory_.end()) return false;
it->second->enchantLevel += 1; // 임계구역 안 RMW
return true;
}
bool Dismantle(std::int64_t uid) {
std::lock_guard<std::mutex> lk(m_);
auto it = inventory_.find(uid);
if (it == inventory_.end()) return false;
auto sp = it->second; // 환급에 쓸 사본(shared_ptr)
inventory_.erase(it); // 단일 소유 해제 — 성공한 자만 진행
RefundMaterials(sp.get());
return true;
}
private:
void RefundMaterials(Item*) {}
std::mutex m_;
std::unordered_map<std::int64_t, std::shared_ptr<Item>> inventory_;
std::unordered_map<EquipSlot, std::shared_ptr<Item>> equipped_;
};
포인트
unique_ptrraw 노출 대신shared_ptr로 소유 공유 → 슬롯/인벤 어느 쪽이 먼저 지워져도 객체는 마지막 참조까지 산다(UAF 제거). 단일 소유를 고집한다면std::move로 컨테이너 간 이동(이 경우 슬롯은unique_ptr를 소유).- 뮤텍스로 리해시-동시접근 UB 제거.
더 나은 설계 (+트레이드오프)
- 플레이어 액터(싱글스레드 커맨드 큐): 락·shared_ptr 경합 제거, 추론 단순. 트레이드오프: 교차 거래는 별도 조정, 큐 지연.
shared_ptr비용 vs 단일 소유 이동: shared_ptr 는 atomic refcount 비용/캐시 영향. 핫패스라면unique_ptr이동 + 락으로 단일 소유가 더 가볍다. 상황에 맞춰 선택.- location 상태머신(
InBag/Equipped/Destroyed) + 멱등 요청 ID: 잘못된 전이/중복 요청 원천 차단. - 영속 계층 트랜잭션: 다중 서버/재기동 시에도 단일 소유 불변식 유지.
면접 포인트 (예상 질문)
equipped_[slot]=item; inventory_.erase(uid);가 단일 스레드에서도 왜 위험한가? (raw 포인터 + 소유자 파괴 → 즉시 댕글링)- shared_ptr 로 소유를 공유하는 방법과 unique_ptr 를 move 하는 방법의 장단점은?
- C# 은 동시 Dictionary 변경 시 예외/오염, C++ 은 UB. 운영 관점에서 어느 쪽이 더 위험하고 왜인가? (조용한 손상 vs 즉시 예외)
구문 검증:
g++ -std=c++17 -fsyntax-only problem.cpp통과(문제 코드/수정안 모두).