47. 펫 소환/소환해제와 스탯 버프 적용 경합 (C#)
난이도 중해설 — 펫 소환/소환해제와 스탯 버프 적용 경합 (C#)
난이도: 중
요약
소환 여부 검사 (A) 와 실제 등록·버프 적용 (B) 사이에 await(펫 데이터 로드)가
끼어 있어, 검사와 처리가 원자적이지 않다. 같은 플레이어의 소환 요청이 두 번
겹치면 둘 다 "펫 없음"으로 통과해 펫 버프가 두 번 붙고(영구 스탯 인플레),
유령 펫이 스폰된다. 게다가 Dictionary 자체가 여러 스레드에서 무보호로
동시 접근된다.
문제점
- [분류: check-then-act / TOCTOU · await 경계 원자성 붕괴]
- 증상: 소환 연타 시 공격력 버프가 1중이 아니라 2중·3중으로 적용되고, 필드에 펫 엔티티가 여러 마리 남거나(고스트), 반대로 소환/해제가 겹치면 "펫은 없는데 버프만 남는" 불일치가 생긴다.
- 재현: 스레드 T1·T2가 거의 동시에
SummonPet(p, petId)호출 → 둘 다(A)에서ContainsKey==false→ 둘 다await LoadPet→ 둘 다(B)실행 →AddModifier두 번,_world.Spawn두 번,_activePets[p]는 뒤가 앞을 덮어 한 마리만 추적됨(다른 한 마리는 추적 불가한 고스트). - 근본 원인:
(A)~(B)가 하나의 원자 구간이 아니며, 그 사이await로 실행이 중단되어 다른 요청이 끼어든다.Dictionary는 스레드 세이프하지 않아 동시 쓰기 자체가 자료구조를 손상시킬 수 있다.
수정안
플레이어 단위 상태 전이를 원자화한다. 비동기 구간을 포함하므로 lock 대신
플레이어별 SemaphoreSlim 로 직렬화하고, "예약 → 로드 → 확정" 순으로 상태를
먼저 점유한다.
private readonly ConcurrentDictionary<long, SemaphoreSlim> _gate = new();
private readonly ConcurrentDictionary<long, PetEntity> _activePets = new();
private SemaphoreSlim GateFor(long id) =>
_gate.GetOrAdd(id, _ => new SemaphoreSlim(1, 1));
public async Task SummonPet(long playerId, int petId)
{
var gate = GateFor(playerId);
await gate.WaitAsync();
try
{
if (_activePets.ContainsKey(playerId)) return; // 검사
var data = await _repo.LoadPet(playerId, petId); // 로드는 게이트 안
var pet = _world.Spawn(data);
_activePets[playerId] = pet; // 확정
_stats.AddModifier(playerId, data.BuffModifier);
}
finally { gate.Release(); }
}
DismissPet 도 같은 게이트로 감싸 소환↔해제 교차를 직렬화한다. 로드 비용이
크다면 "예약 슬롯"(pending 상태)을 먼저 점유해 게이트 유지 시간을 줄이는
2단계(reserve→load→commit)로 분리할 수 있다.
더 나은 설계
- 세션/플레이어를 단일 스레드 액터로 처리하면 한 플레이어의 모든 명령이 순차 실행되어 이런 경합 자체가 사라진다(락 불필요). 게임서버의 흔한 패턴.
- 버프 수와 펫 수를 불변식으로 강제: 소환은 멱등(이미 있으면 no-op), 버프는 "펫 존재"라는 상태에서 파생 계산(가산 대신 재계산)하면 이중 가산이 원천 차단된다.
- 트레이드오프: 액터 모델은 크로스 플레이어 상호작용에서 메시지 패싱이 필요해 복잡도가 올라간다. 파생 계산은 매 변경 시 재계산 비용이 있다.
면접 포인트
await를 사이에 둔 check-then-act 가 왜lock으로 못 막히는지(락은 await 를 넘겨 유지할 수 없다 →SemaphoreSlim).- "상태를 먼저 점유(예약)하고 느린 I/O 를 뒤에" 두는 순서가 경합을 줄이는 이유.
- 멱등/파생계산 설계가 방어적 코딩보다 근본적인 이유.
해설 — 펫 소환/소환해제와 스탯 버프 경합 (C++)
난이도: 중상
요약
activePets_ 맵을 뮤텍스 없이 여러 스레드가 동시에 읽고 쓴다. 소환 검사 (A) 와
등록 (B) 가 비원자라 동시 소환이 버프 이중 적용·유령 펫을 만들고, 무엇보다
unordered_map 동시 수정은 미정의 동작(rehash 중 자료구조 손상)이다. 소환↔해제가
겹치면 한 스레드가 Despawn+delete 한 PetEntity* 를 다른 스레드가 (B) 뒤에
역참조/재등록해 use-after-free 가 난다.
문제점
- [분류: 데이터 레이스 · check-then-act · use-after-free]
- 증상: 소환 연타 시 스탯 버프가 2중 적용, 추적되지 않는 고스트 펫 스폰,
간헐적 크래시/힙 손상. 소환↔해제 교차 시 이미
delete된 펫 포인터 접근. - 재현:
- 이중 소환 — T1·T2가
(A)를 둘 다 통과 → 둘 다Spawn→(B)에서activePets_[playerId]를 뒤 스레드가 덮어써 앞 펫은 맵에서 사라진 채 필드에 남고(고스트, 나중에 leak), 버프는 두 번 가산. - 맵 손상 — 두 스레드의 동시
insert가 rehash 를 유발하면 버킷/노드 포인터가 무효화되어 UB. - UAF — T1
DismissPet가delete pet직후,(A)를 이미 통과해pet을 들고 있던 다른 경로나, 재소환 T2가 stale 참조를 사용.
- 이중 소환 — T1·T2가
- 근본 원인: 공유 가변 상태(맵·펫 객체)에 대한 동기화 부재 + 검사/처리 비원자 + 수명 관리(소유권) 부재.
- 증상: 소환 연타 시 스탯 버프가 2중 적용, 추적되지 않는 고스트 펫 스폰,
간헐적 크래시/힙 손상. 소환↔해제 교차 시 이미
수정안
플레이어 단위로 상태 전이를 락으로 원자화하고, 펫 수명을 shared_ptr 로 관리해
경합 중 조기 파괴를 막는다.
#include <mutex>
#include <shared_mutex>
class PetService {
std::mutex mtx_; // 맵 + 전이 보호
std::unordered_map<int64_t, std::shared_ptr<PetEntity>> activePets_;
// ...
public:
void SummonPet(int64_t playerId, int petId) {
{
std::lock_guard<std::mutex> lk(mtx_);
if (activePets_.count(playerId)) return; // 검사
}
PetData data = repo_.LoadPet(playerId, petId); // 느린 I/O 는 락 밖
auto pet = std::shared_ptr<PetEntity>(world_.Spawn(data),
[w=&world_](PetEntity* p){ w->Despawn(p); delete p; });
std::lock_guard<std::mutex> lk(mtx_);
// 재검사(더블체크): 로드 사이 다른 소환이 확정됐을 수 있음
if (!activePets_.emplace(playerId, pet).second) return; // 이미 있으면 폐기
stats_.AddMod(playerId, data.buffMod);
}
void DismissPet(int64_t playerId) {
std::shared_ptr<PetEntity> pet;
{
std::lock_guard<std::mutex> lk(mtx_);
auto it = activePets_.find(playerId);
if (it == activePets_.end()) return;
pet = std::move(it->second);
activePets_.erase(it);
stats_.RemoveMod(playerId, pet->buffMod);
}
// pet 의 마지막 참조가 사라질 때 deleter 가 Despawn+delete → UAF 없음
}
};
핵심: (1) 맵 접근을 항상 락으로 보호, (2) 로드 사이를 재검사(더블체크)해 이중
확정 차단, (3) shared_ptr + 커스텀 deleter 로 다른 스레드가 참조 중인 펫을
안전하게 회수(마지막 참조 소멸 시점에 파괴).
더 나은 설계
- 플레이어를 단일 스레드 액터로 처리하면 락·재검사 없이 순차성으로 해결된다.
- 펫 수명은 slot map + generation 핸들로 관리하면 raw 포인터 UAF 를 구조적으로 제거.
- 트레이드오프:
shared_ptr는 원자 refcount 비용이 있고, 커스텀 deleter 로 Despawn 시점이 "마지막 참조 소멸"에 묶여 결정적 시점 제어가 약해진다(필요 시 명시적 회수 큐 사용).
면접 포인트
unordered_map동시 수정이 왜 UB 인지(rehash·노드 재배치, 반복자/포인터 무효화).- raw
deletevsshared_ptr커스텀 deleter 로 경합 중 안전 회수(수명 연장) 하는 법. - "느린 I/O 는 락 밖, 상태 전이는 락 안 + 더블체크" 패턴.