20. 즉사/부활(사망 상태) 처리 중 이동·아이템 사용 끼어듦
난이도 중해설 — 즉사/부활(사망 상태) 처리 중 이동·아이템 사용 끼어듦
난이도: 중하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
CombatActor 의 State/Hp 가 락 없이 여러 스레드에서 동시에 읽히고 쓰인다.
가장 치명적인 것은 (B) TakeDamage 의 비원자 RMW + 비멱등 Die() 로, 두 발의
치명타가 거의 동시에 들어오면 둘 다 Hp <= 0 을 보고 각각 Die() 를 호출 →
전리품 이중 드랍 / 부활 이중 예약이 난다. (A) Move 는 State 를 검사한 뒤
행동하지만 그 사이 다른 스레드가 사망 전이를 일으켜 죽은 직후에도 이동이 적용될 수
있고(TOCTOU), (C) UseHealPotion/Respawn 은 상태를 검사하지 않아 HP>0 인데 Dead
(물약으로 시체 회복) 또는 부활하자마자 직전 피격으로 즉사 같은 모순 상태를 만든다.
정답 한 줄: 플레이어 단위 임계구역(또는 단일 액터)으로 모든 상태 전이를 직렬화하고,
사망 전이는 "HP 를 0 이하로 끌어내린 단 한 호출만" 확정하며, 모든 행동을 현재 상태로
게이트한다.
문제점
(B) TakeDamage 의 비원자 차감 + 비멱등 Die — 이중 사망 처리 (동시성/버그) ★간판
- 증상:
Hp -= dmg와if (Hp <= 0) Die()가 한 임계구역이 아니다. HP=30 인 플레이어에게 25 데미지 2발이 동시에:- T1
Hp -= 25(→5) ... T2Hp -= 25(→-20) → 둘 다Hp <= 0참 →Die()2회. Die()가DropLoot·ScheduleRespawn을 무조건 실행 → 전리품 2배, 부활 타이머 2개 (부활 후 또 부활, 또는 즉시 재부활).
- T1
- 재현조건: 같은 대상에 대한 동시 피격(광역/다단/여러 공격자)이 서로 다른 워커에서 처리. 마지막 일격이 겹칠 때.
- 근본 원인: 차감·임계 판정·전이가 원자적이지 않고, 사망 전이에 "한 번만" 보장하는 가드(이전 상태 검사/CAS)가 없다.
(A) Move 의 상태 검사 TOCTOU — 죽은 뒤 이동 적용 (동시성)
- 증상:
if (State != Alive) return;로 통과한 직후, 다른 스레드의Die()가State = Dead로 바꿔도 T1 은 이미 통과해Pos를 갱신 → 죽은 캐릭터가 한 틱 더 움직인다. 반대로State가 비원자 가시성(메모리 배리어 없음)이라 stale 값을 읽을 수도. - 근본 원인: 검사와 행동이 같은 임계구역에 없다. 상태 전이와 행동이 직렬화되지 않음.
(C) Respawn / UseHealPotion 의 무가드 상태 변경 — 모순 상태 (동시성/정확성)
- 증상:
UseHealPotion이 상태를 검사하지 않아 죽은(Dead) 상태인데 HP 가 0 초과가 됨 → "HP 는 있는데 Dead" 또는 클라가 시체에 물약을 먹여 부활 비슷한 모순.Respawn이Hp=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으로 "부활 이전에 발사돼 늦게 도착한 데미지"를 무시(스폰 직후 즉사 방지). 실무에선 추가로 짧은 무적 시간을 둔다.
더 나은 설계 (+트레이드오프)
- 플레이어 액터(싱글스레드 메일박스): 한 플레이어의 모든 명령을 한 큐로 직렬 처리. 락이 사라지고 상태머신 추론이 쉬워진다. 트레이드오프: 광역 피격 등 다중 액터 교차 이벤트는 조정 필요, 핫 타깃 큐 지연.
- 명시적 생존 상태머신(Alive→Dying→Dead→Respawning→Alive) + 전이 가드: 각 전이는
현재 상태에서만 허용,
Dying진입은 CAS 로 1회 보장. 사망 사이드이펙트는 진입 시 1회. - 데미지 이벤트에 시퀀스/스냅샷 버전: 부활·페이즈 세대를 데미지에 실어 "옛 세대" 데미지를 거부 → 늦은 패킷/리플레이로 인한 즉사·유령 데미지 차단.
- 스폰 보호(무적) 윈도: 부활 직후 N ms 무적으로 동시 피격 레이스를 흡수.
면접 포인트 (예상 질문)
- 두 발의 치명타가 동시에 들어오면 왜
Die()가 두 번 불리는가?wasAlive가드가 왜 "정확히 1회"를 보장하는가? - "죽은 직후 한 틱 더 이동" 같은 TOCTOU 는 왜 생기며, 검사와 행동을 같은 락에 넣는 것이 왜 해법인가?
- 부활하자마자 즉사하는 레이스를 막는 방법(세대 토큰 / 무적 윈도)과 각각의 트레이드오프는?
해설 — 즉사/부활(사망 상태) 처리 중 이동·아이템 사용 끼어듦 (C++)
난이도: 중하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
C# 판과 같은 논리 결함에 더해, C++ 에서는 state/hp 를 동기화 없이 다중 스레드에서
읽고 쓰는 것 자체가 데이터 레이스(UB) 다. 가장 치명적인 것은 (B) takeDamage 의
비원자 RMW + 비멱등 die() — 동시 치명타 2발이 둘 다 hp <= 0 을 보고 die() 를
호출해 전리품 이중 드랍·부활 이중 예약. (A) move 의 상태 검사 TOCTOU, (C)
useHealPotion/respawn 의 무가드 전이로 HP>0 인데 Dead / 부활 직후 즉사 모순이 난다.
정답 한 줄: 플레이어 단위 std::mutex 로 모든 전이/행동을 직렬화하고, 사망은 HP 를
0 이하로 만든 호출만 1회 확정, 모든 행동을 현재 상태로 게이트한다.
문제점
(B) takeDamage 비원자 차감 + 비멱등 die — 이중 사망 처리 (동시성/UB) ★간판
- 증상:
hp -= dmg와if (hp <= 0) die()가 원자적이지 않다. HP=30 에 25 데미지 2발 동시 → 둘 다hp <= 0참 →die()2회 → dropLoot/scheduleRespawn 2배. - 추가(C++ 고유):
int hp동시 RMW 는 데이터 레이스로 정의되지 않은 동작. 찢긴 값/유실 차감이 가능하고, 컴파일러 최적화로 검사 순서가 바뀔 수 있다. - 근본 원인: 차감·임계 판정·전이가 한 임계구역이 아니고, 사망 전이 1회 가드가 없다.
(A) move 의 상태 검사 TOCTOU — 죽은 뒤 이동 (동시성)
- 증상:
if (state != Alive) return;통과 직후 다른 스레드의die()가state=Dead로 바꿔도 이미 통과한 T1 이pos를 갱신 → 죽은 캐릭터가 한 틱 더 이동. 동기화 없는enum state읽기는 가시성 보장도 없다(stale read, UB).
(C) respawn / useHealPotion 무가드 전이 — 모순 상태 (동시성/정확성)
- 증상:
useHealPotion이 상태 미검사 → Dead 인데hp>0.respawn의 비원자hp=maxHp; ...; state=Alive사이에 늦은takeDamage가 끼면 부활 직후 즉사 (스폰 보호 부재). rawthis포인터를scheduleRespawn에 넘기는 것도 수명 위험.
(공통) 데이터 레이스 — UB
- 락/atomic 없이
state·hp를 공유 → C++ 메모리 모델상 정의되지 않은 동작. C# 은 손상 없이 논리만 깨지지만, C++ 은 찢긴 읽기/재정렬/최적화 제거까지 가능.
수정안
#include <mutex>
#include <algorithm>
#include <cstdint>
class CombatActor {
public:
explicit CombatActor(IWorld* world) : world_(world) {}
bool move(float dx, float dy) {
std::lock_guard<std::mutex> lk(m_);
if (state_ != LifeState::Alive) return false;
pos_.x += dx; pos_.y += dy;
return true;
}
void takeDamage(int dmg, uint64_t spawnGen) {
std::lock_guard<std::mutex> lk(m_);
if (state_ != LifeState::Alive) return; // 시체에 데미지 금지
if (spawnGen != spawnGen_) return; // 옛 세대 데미지 무시
bool wasAlive = hp_ > 0;
hp_ -= dmg;
if (hp_ <= 0 && wasAlive) // 0 이하로 만든 단 한 호출만
die_locked();
}
void respawn() {
std::lock_guard<std::mutex> lk(m_);
if (state_ != LifeState::Dead) return; // 이중 부활 방지
hp_ = maxHp_;
pos_ = world_->getSpawnPoint();
state_ = LifeState::Alive;
++spawnGen_; // 늦은 데미지 무효화
}
bool useHealPotion(int amount) {
std::lock_guard<std::mutex> lk(m_);
if (state_ != LifeState::Alive) return false; // 죽은 상태 회복 금지
hp_ = std::min(maxHp_, hp_ + amount);
return true;
}
private:
void die_locked() {
state_ = LifeState::Dead;
hp_ = 0;
world_->dropLoot(this);
world_->scheduleRespawn(this, 10);
}
std::mutex m_;
LifeState state_ = LifeState::Alive;
int hp_ = 100, maxHp_ = 100;
Vec2 pos_{0, 0};
uint64_t spawnGen_ = 0;
IWorld* world_;
};
포인트
hp_ <= 0 && wasAlive로 사망 전이 정확히 1회 → 이중 드랍/부활 차단.- 모든 접근을
std::mutex로 직렬화 → 데이터 레이스 UB 제거 + TOCTOU 제거. spawnGen_으로 부활 이전에 발사된 늦은 데미지 무시(즉사 방지). 수명 안전을 위해scheduleRespawn은 rawthis대신std::shared_ptr<CombatActor>(또는 weak) 권장.
더 나은 설계 (+트레이드오프)
- 플레이어 액터(싱글스레드 메일박스): 명령을 한 큐로 직렬 처리 → 락·UB 소멸. 트레이드오프: 광역 피격 등 다중 액터 이벤트는 조정 필요.
- 생존 상태머신(Alive→Dying→Dead→Respawning) + CAS 1회 진입:
state_를std::atomic<LifeState>로 두고Dying진입을compare_exchange로 1회 보장, 사이드이펙트는 진입 성공자만. - 데미지에 세대/시퀀스: 부활·페이즈 세대를 실어 옛 세대 데미지 거부.
- 스폰 무적 윈도 +
shared_ptr수명 관리로 부활 콜백의 dangling 방지.
면접 포인트 (예상 질문)
- C++ 에서
int hp를 락 없이 동시 RMW 하면 왜 UB 인가? C# 과 무엇이 다른가? - 동시 치명타 2발에
die()가 두 번 불리는 인터리빙과,wasAlive가드가 1회를 보장하는 이유는? - 부활 콜백에 raw
this를 넘길 때의 수명 위험과shared_ptr/weak_ptr해법은?