← 문제로

47. 펫 소환/소환해제와 스탯 버프 적용 경합 (C#)

난이도 중
내 리뷰 · C#
해설 · 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 를 뒤에" 두는 순서가 경합을 줄이는 이유.
  • 멱등/파생계산 설계가 방어적 코딩보다 근본적인 이유.