51. 장비 젬 소켓 장착/제거의 동시 처리 (젬 복제·유실, C#)
난이도 중해설 — 장비 젬 소켓 장착/제거의 동시 처리 (젬 복제·유실, C#)
난이도: 중
요약
장비 소켓의 젬 장착(SocketGem)과 제거(UnsocketGem)가 각각 "검사 → 인벤토리 이동 → 슬롯 갱신"의 여러 단계로 나뉘어 있고, 이 전체 구간이 원자적으로 보호되지 않는다. 같은 슬롯에 두 요청이 겹치면 젬이 복제(인벤토리와 소켓 양쪽에 존재)되거나 유실(양쪽 모두 놓침)된다.
문제점
분류 태그: check-then-act 경쟁 / 복합 연산 비원자성 (TOCTOU)
증상: 클라이언트가 같은 슬롯에 장착·제거를 빠르게 반복하거나, 창을 두 개 열고 각각 요청하면 젬 수량이 늘거나 줄어든다. 로그상 "슬롯은 비었는데 인벤토리 젬 총량이 +1" 또는 "슬롯에 젬이 있는데 인벤토리에도 같은 젬 인스턴스가 있음".
재현 조건: 두 스레드가 같은 equipId/slot 을 동시에 처리한다.
근본 원인 — 두 가지 인터리빙:
- 이중 제거로 인한 복제: 스레드 T1·T2가 모두
UnsocketGem. 둘 다(A)에서 같은g(비어있지 않음)를 읽는다. 둘 다_inv.Add(g)를 호출 → 같은 젬 인스턴스가 인벤토리에 두 번 들어간다. 이후 각각(B)로 슬롯을 null 로 만든다. 결과: 젬 1개가 2개로 복제. - 장착/제거 교차로 인한 유실 또는 복제: T1
SocketGem, T2UnsocketGem이 교차. 예) 슬롯이 비어 있고 인벤토리에 젬 1개. T1(A)에서 빈 슬롯 확인 →Take로 젬을 인벤토리에서 꺼냄. 그 사이 T2UnsocketGem이(A)에서 아직 null 슬롯을 보고 false 반환(무해). 반대로 슬롯이 차 있는 상태에서 T1SocketGem(빈 줄 알고 진입 불가) 은 막히지만,Take성공 후 T1(B)직전에 T2 가UnsocketGem으로 그 슬롯을 null 로 비우고 인벤토리에 되돌리면, T1(B)가 방금 T2가 비운 슬롯을 자신이 꺼낸 젬으로 덮어써 T2가 되돌린 젬이 슬롯에서 사라진 것처럼 보이거나(순서에 따라) 인벤토리에 중복이 남는다.
Inventory 의 List 자체도 스레드 안전하지 않아 Add/RemoveAt 동시 호출은 인덱스 깨짐·항목 유실까지 유발한다.
수정안
같은 장비(또는 최소한 같은 슬롯)에 대한 장착/제거 전체 트랜잭션을 하나의 락으로 직렬화한다. 인벤토리 이동과 슬롯 갱신을 한 임계 구역에서 원자적으로 수행한다.
public sealed class SocketService
{
private readonly Dictionary<int, Equipment> _equips = new();
private readonly Inventory _inv;
private readonly object _gate = new(); // 장비/인벤토리 상태 보호
public SocketService(Inventory inv) { _inv = inv; }
public void Register(Equipment e) { lock (_gate) _equips[e.EquipId] = e; }
public bool SocketGem(int equipId, int slot, int gemType)
{
lock (_gate)
{
if (!_equips.TryGetValue(equipId, out var e)) return false;
if ((uint)slot >= (uint)e.Sockets.Length) return false;
if (e.Sockets[slot] != null) return false; // 검사
Gem g = _inv.Take(gemType);
if (g == null) return false;
e.Sockets[slot] = g; // 이동을 같은 임계구역에서
return true;
}
}
public bool UnsocketGem(int equipId, int slot)
{
lock (_gate)
{
if (!_equips.TryGetValue(equipId, out var e)) return false;
if ((uint)slot >= (uint)e.Sockets.Length) return false;
Gem g = e.Sockets[slot];
if (g == null) return false;
e.Sockets[slot] = null; // 슬롯을 먼저 비우고
_inv.Add(g); // 그 다음 인벤토리로 이동 (이중 Add 불가)
return true;
}
}
}
핵심은 "검사(빈/참) → 소유권 이동 → 슬롯 갱신"이 하나의 원자 단위가 되는 것. UnsocketGem 에서 슬롯을 먼저 null 로 만든 뒤 Add 하면, 설령 락을 잘게 쪼갠 설계여도 같은 젬을 두 번 Add 하는 창을 없앨 수 있다(이동의 방향을 "소유권을 먼저 회수").
더 나은 설계
- 슬롯 단위 상태 머신 + 버전: 각 슬롯에
(occupant, version)을 두고 CAS 로 교체하면 장비별 굵은 락 없이 슬롯 단위 동시성을 얻는다. 다만 인벤토리 이동과의 원자성을 위해선 결국 두 자원을 함께 커밋하는 경계가 필요하다. - 명령 직렬화(액터 모델): 한 플레이어의 인벤토리/장비 변경을 단일 큐로 직렬 처리하면 락 없이도 순서가 보장되고, 이런 TOCTOU 부류가 구조적으로 사라진다. MMO 서버에서 가장 흔한 선택.
- 소유권 단일화: 젬을 "인벤토리 항목 id" 로만 다루고 슬롯에는 id 참조만 저장, 이동은 "한 위치에서 지우고 다른 위치에 쓰기"를 단일 트랜잭션으로. 복제 자체가 표현 불가능해진다.
- 트레이드오프: 굵은 락은 단순·안전하지만 인벤토리 조작이 잦으면 경합. 액터/샤딩은 확장성이 좋지만 크로스-플레이어 거래 같은 교차 트랜잭션 처리가 복잡해진다.
면접 포인트
- "검사와 실행 사이"가 벌어지면 왜 복제/유실이 동시에 가능한지(이중 Add vs 덮어쓰기)를 한 문장으로 설명할 수 있는가.
- 되돌리는 이동에서 "슬롯 비우기 → Add" 순서가 "Add → 슬롯 비우기"보다 안전한 이유.
- 굵은 락 대신 액터/샤딩으로 갈 때 얻는 것과 잃는 것.
해설 — 장비 젬 소켓 장착/제거의 동시 처리 (소유권 이중화·UAF, C++)
난이도: 중상
요약
SocketService 는 젬의 소유권(소멸 책임)을 인벤토리 벡터와 소켓 포인터 사이에서 옮기는데, "검사 → 이동" 전체가 원자적이지 않다. 동시 요청 시 같은 Gem* 가 두 곳에서 소유되거나(이중 free 위험), 어디에서도 소유되지 않아(누수) 소유권 불변식이 깨진다. C++에서는 여기에 더해 이미 반납·해제된 젬을 가리키는 dangling 포인터가 남을 수 있다.
문제점
분류 태그: check-then-act 경쟁 / 소유권 이중화·use-after-free
증상: 젬 개수가 늘거나(같은 포인터가 인벤토리에 2회) 줄고, 스트레스 테스트에서 이중 해제로 인한 힙 손상/크래시가 산발적으로 발생. ASan 은 "double-free" 또는 "heap-use-after-free" 를 소켓 접근 지점에서 보고.
재현 조건: 두 워커 스레드가 같은 equipId/slot 에 대해 unsocketGem/unsocketGem 또는 socketGem/unsocketGem 을 동시에 실행.
근본 원인:
- 이중 제거 → 소유권 이중화: T1·T2 가
unsocketGem. 둘 다(A)에서 같은 비-nullg를 읽는다. 둘 다inv_->add(g)→ 같은Gem*가 인벤토리 벡터에 두 번 들어간다. 이후 이 젬을 소비/삭제하는 경로에서 double free 또는 dangling 참조. - 장착/제거 교차 → 유실/덮어쓰기: T1
socketGem이(A)에서 빈 슬롯을 확인하고take로 젬을 얻은 뒤(B)직전에 멈춘다. 그 사이 T2 가 같은 슬롯을 다른 젬으로 채우면(다른 요청), T1(B)가 그 포인터를 덮어써 T2 가 넣은 젬이 슬롯에서 사라진다(그 젬은 인벤토리에도 없으므로 누수 또는, 소멸자를 가진 소유 컨테이너였다면 UAF). Inventory::gems_벡터의 동시push_back/erase는 재할당·반복자 무효화로 그 자체가 UB.
C++ 특유의 심각성: C#은 GC가 있어 "복제"는 논리 버그에 그치지만, C++는 같은 객체를 두 소유자가 delete 하려 하면 즉시 힙 손상으로 이어진다.
수정안
장비/인벤토리 상태 전체를 하나의 뮤텍스로 보호하고, 검사·소유권 이동·슬롯 갱신을 한 임계 구역에서 수행한다. 소유권은 std::unique_ptr 로 표현해 "정확히 한 곳"을 타입으로 강제한다.
#include <memory>
#include <mutex>
#include <vector>
#include <unordered_map>
struct Equipment {
int equipId;
std::vector<std::unique_ptr<Gem>> sockets; // 소유권이 여기 또는 인벤토리
explicit Equipment(int slots) : equipId(0), sockets(slots) {}
};
class Inventory {
public:
void add(std::unique_ptr<Gem> g) { gems_.push_back(std::move(g)); }
std::unique_ptr<Gem> take(int type) {
for (std::size_t i = 0; i < gems_.size(); ++i)
if (gems_[i]->gemType == type) {
auto g = std::move(gems_[i]);
gems_.erase(gems_.begin() + i);
return g;
}
return nullptr;
}
private:
std::vector<std::unique_ptr<Gem>> gems_;
};
class SocketService {
public:
explicit SocketService(Inventory* inv) : inv_(inv) {}
void registerEquip(Equipment* e) { std::lock_guard<std::mutex> lk(m_); equips_[e->equipId] = e; }
bool socketGem(int equipId, int slot, int gemType) {
std::lock_guard<std::mutex> lk(m_);
auto it = equips_.find(equipId);
if (it == equips_.end()) return false;
Equipment* e = it->second;
if (slot < 0 || slot >= (int)e->sockets.size()) return false;
if (e->sockets[slot]) return false; // 검사
auto g = inv_->take(gemType);
if (!g) return false;
e->sockets[slot] = std::move(g); // 소유권 이동 (한 곳)
return true;
}
bool unsocketGem(int equipId, int slot) {
std::lock_guard<std::mutex> lk(m_);
auto it = equips_.find(equipId);
if (it == equips_.end()) return false;
Equipment* e = it->second;
if (slot < 0 || slot >= (int)e->sockets.size()) return false;
if (!e->sockets[slot]) return false;
inv_->add(std::move(e->sockets[slot])); // move 로 슬롯은 즉시 null
return true;
}
private:
std::mutex m_;
std::unordered_map<int, Equipment*> equips_;
Inventory* inv_;
};
std::move 로 슬롯에서 인벤토리로 옮기면 슬롯은 그 순간 nullptr 이 되어 "이중 add" 자체가 불가능하다. unique_ptr 는 두 소유자를 컴파일/런타임 어느 쪽으로도 허용하지 않아 이중화·UAF 불변식을 타입으로 못박는다.
더 나은 설계
- 소유권을 타입으로 강제: raw
Gem*를 소유 목적으로 쓰지 말고unique_ptr(단일 소유) 또는 명확한 아레나/풀 핸들(세대 검증 포함)로. "누가 지우는가"가 코드에 드러난다. - 플레이어 단위 직렬화: 한 플레이어의 인벤토리/장비 변경을 단일 스레드/액터가 처리하면 뮤텍스조차 불필요하고 이 부류가 사라진다.
- 아레나 + 세대 핸들: 젬을 슬롯맵에 저장하고
(index, generation)핸들로 참조하면, 재사용된 슬롯을 옛 핸들이 가리켜도 세대 불일치로 걸러져 ABA/UAF를 방지. - 트레이드오프: 굵은 뮤텍스는 안전하지만 경합 시 처리량 저하. 액터/샤딩은 확장성이 좋으나 교차 트랜잭션(거래) 구현 복잡도 증가.
unique_ptr는 안전하지만 소유권 경계 설계를 강제.
면접 포인트
- C#의 "복제"와 C++의 "이중 free"가 같은 경쟁에서 왜 심각도가 다른가.
std::move로 슬롯을 비우는 것이 왜 이중 반납을 구조적으로 막는가.- raw pointer 소유의 위험을
unique_ptr/세대 핸들로 어떻게 타입 수준에서 제거하는가.