← 문제로

20. 즉사/부활(사망 상태) 처리 중 이동·아이템 사용 끼어듦

난이도 중
내 리뷰 · C#
해설 · C#

해설 — 즉사/부활(사망 상태) 처리 중 이동·아이템 사용 끼어듦

난이도: 중하

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

CombatActorState/Hp락 없이 여러 스레드에서 동시에 읽히고 쓰인다. 가장 치명적인 것은 (B) TakeDamage비원자 RMW + 비멱등 Die() 로, 두 발의 치명타가 거의 동시에 들어오면 둘 다 Hp <= 0 을 보고 각각 Die() 를 호출 → 전리품 이중 드랍 / 부활 이중 예약이 난다. (A) MoveState 를 검사한 뒤 행동하지만 그 사이 다른 스레드가 사망 전이를 일으켜 죽은 직후에도 이동이 적용될 수 있고(TOCTOU), (C) UseHealPotion/Respawn 은 상태를 검사하지 않아 HP>0 인데 Dead (물약으로 시체 회복) 또는 부활하자마자 직전 피격으로 즉사 같은 모순 상태를 만든다. 정답 한 줄: 플레이어 단위 임계구역(또는 단일 액터)으로 모든 상태 전이를 직렬화하고, 사망 전이는 "HP 를 0 이하로 끌어내린 단 한 호출만" 확정하며, 모든 행동을 현재 상태로 게이트한다.


문제점

(B) TakeDamage 의 비원자 차감 + 비멱등 Die — 이중 사망 처리 (동시성/버그) ★간판

  • 증상: Hp -= dmgif (Hp <= 0) Die() 가 한 임계구역이 아니다. HP=30 인 플레이어에게 25 데미지 2발이 동시에:
    • T1 Hp -= 25 (→5) ... T2 Hp -= 25 (→-20) → 둘 다 Hp <= 0 참 → Die() 2회.
    • Die()DropLoot·ScheduleRespawn 을 무조건 실행 → 전리품 2배, 부활 타이머 2개 (부활 후 또 부활, 또는 즉시 재부활).
  • 재현조건: 같은 대상에 대한 동시 피격(광역/다단/여러 공격자)이 서로 다른 워커에서 처리. 마지막 일격이 겹칠 때.
  • 근본 원인: 차감·임계 판정·전이가 원자적이지 않고, 사망 전이에 "한 번만" 보장하는 가드(이전 상태 검사/CAS)가 없다.

(A) Move 의 상태 검사 TOCTOU — 죽은 뒤 이동 적용 (동시성)

  • 증상: if (State != Alive) return; 로 통과한 직후, 다른 스레드의 Die()State = Dead 로 바꿔도 T1 은 이미 통과해 Pos 를 갱신 → 죽은 캐릭터가 한 틱 더 움직인다. 반대로 State 가 비원자 가시성(메모리 배리어 없음)이라 stale 값을 읽을 수도.
  • 근본 원인: 검사와 행동이 같은 임계구역에 없다. 상태 전이와 행동이 직렬화되지 않음.

(C) Respawn / UseHealPotion 의 무가드 상태 변경 — 모순 상태 (동시성/정확성)

  • 증상:
    • UseHealPotion 이 상태를 검사하지 않아 죽은(Dead) 상태인데 HP 가 0 초과가 됨 → "HP 는 있는데 Dead" 또는 클라가 시체에 물약을 먹여 부활 비슷한 모순.
    • RespawnHp=MaxHp; ...; State=Alive 를 비원자로 수행. 부활 직전/직후에 도착한 TakeDamage 가 끼어들면 부활하자마자 이전 전투의 데미지로 즉사(스폰 보호 부재), 혹은 부활이 Hp=MaxHp 한 뒤 늦게 도착한 차감이 적용돼 HP 가 어긋난다.
  • 근본 원인: 행동·전이가 현재 상태로 게이트되지 않고, 부활 시 무적/시퀀스 경계가 없다.

(공통) 동기화·메모리 가시성 부재

  • State/Hp 모두 동기화 없이 다중 스레드 공유 → 가시성 보장 없음(stale read), 복합 연산 원자성 없음. C# 에서 데이터 구조 손상은 없지만 논리적 깨짐은 위와 같다.

수정안

핵심: ① 플레이어 단위 락으로 모든 전이/행동을 직렬화, ② 사망은 "HP 를 0 이하로 만든 호출만" 확정(멱등), ③ 모든 행동을 상태로 게이트, ④ 부활에 시퀀스/무적 경계.

public class CombatActor
{
    private readonly object _gate = new();
    public LifeState State { get; private set; } = LifeState.Alive;
    public int Hp { get; private set; } = 100;
    public int MaxHp = 100;
    public (float x, float y) Pos;
    private long _spawnGen = 0;   // 부활 세대(스폰 보호/늦은 데미지 무시용)

    private readonly IWorld _world;
    public CombatActor(IWorld world) { _world = world; }

    public bool Move(float dx, float dy)
    {
        lock (_gate)
        {
            if (State != LifeState.Alive) return false;
            Pos = (Pos.x + dx, Pos.y + dy);
            return true;
        }
    }

    public void TakeDamage(int dmg, long spawnGen)
    {
        lock (_gate)
        {
            if (State != LifeState.Alive) return;       // 시체에 데미지 금지
            if (spawnGen != _spawnGen) return;          // 부활 전(옛 세대) 데미지 무시
            bool wasAlive = Hp > 0;
            Hp -= dmg;
            if (Hp <= 0 && wasAlive)                     // 살아있다가 이번에 0 이하로 만든 호출만
                Die_locked();                            //  → 사망 전이 정확히 1회
        }
    }

    private void Die_locked()
    {
        State = LifeState.Dead;
        Hp = 0;
        _world.DropLoot(this);
        _world.ScheduleRespawn(this, 10);
    }

    public void Respawn()
    {
        lock (_gate)
        {
            if (State != LifeState.Dead) return;        // 살아있으면 무시(이중 부활 방지)
            Hp = MaxHp;
            Pos = _world.GetSpawnPoint();
            State = LifeState.Alive;
            _spawnGen++;                                 // 이전 세대의 늦은 데미지 무효화
        }
    }

    public bool UseHealPotion(int amount)
    {
        lock (_gate)
        {
            if (State != LifeState.Alive) return false; // 죽은 상태 회복 금지
            Hp = Math.Min(MaxHp, Hp + amount);
            return true;
        }
    }
}

포인트

  • Hp <= 0 && wasAlive 가드로 사망 전이를 정확히 1회만 → 이중 드랍/이중 부활 차단.
  • 모든 행동을 State 로 게이트하고 같은 락 안에서 검사+적용(TOCTOU 제거).
  • _spawnGen 으로 "부활 이전에 발사돼 늦게 도착한 데미지"를 무시(스폰 직후 즉사 방지). 실무에선 추가로 짧은 무적 시간을 둔다.

더 나은 설계 (+트레이드오프)

  1. 플레이어 액터(싱글스레드 메일박스): 한 플레이어의 모든 명령을 한 큐로 직렬 처리. 락이 사라지고 상태머신 추론이 쉬워진다. 트레이드오프: 광역 피격 등 다중 액터 교차 이벤트는 조정 필요, 핫 타깃 큐 지연.
  2. 명시적 생존 상태머신(Alive→Dying→Dead→Respawning→Alive) + 전이 가드: 각 전이는 현재 상태에서만 허용, Dying 진입은 CAS 로 1회 보장. 사망 사이드이펙트는 진입 시 1회.
  3. 데미지 이벤트에 시퀀스/스냅샷 버전: 부활·페이즈 세대를 데미지에 실어 "옛 세대" 데미지를 거부 → 늦은 패킷/리플레이로 인한 즉사·유령 데미지 차단.
  4. 스폰 보호(무적) 윈도: 부활 직후 N ms 무적으로 동시 피격 레이스를 흡수.

면접 포인트 (예상 질문)

  1. 두 발의 치명타가 동시에 들어오면 왜 Die() 가 두 번 불리는가? wasAlive 가드가 왜 "정확히 1회"를 보장하는가?
  2. "죽은 직후 한 틱 더 이동" 같은 TOCTOU 는 왜 생기며, 검사와 행동을 같은 락에 넣는 것이 왜 해법인가?
  3. 부활하자마자 즉사하는 레이스를 막는 방법(세대 토큰 / 무적 윈도)과 각각의 트레이드오프는?