코드 리뷰시 클래스가 많고 호출 상황이 복잡하다면
- 시작점부터 본다
- 외부에서 처음 호출되는 함수가 뭔지 찾음
- 예: CombatWorld.ScheduleDamage(), CombatWorld.Tick()
- 상태를 실제로 들고 있는 클래스가 누군지 찾는다
- 이번 경우:
- 현재 엔티티 상태 → EntityRegistry
- 예약 피해 → CombatWorld._damageByTick
- 이번 경우:
- 값 하나를 끝까지 추적한다
- 예: EntityId target
- ScheduleDamage(target)
- Capture(target)
- EntityHandle
- ScheduledDamage.Target
- Tick()
- Resolve(handle)
- 실제 GameEntity
- 함수 이름이 아니라 “무슨 상태를 읽고/바꾸는가”를 본다
- Spawn() → slot 점유 + generation 증가
- Despawn() → slot 해제
- Capture() → 현재 lifecycle 스냅샷
- Resolve() → 저장된 lifecycle이 아직 유효한지 검사
- Tick() → 예약된 효과 실행
이번 문제를 이런 식으로만 그리면 사실 호출 흐름은 이 정도야.
[외부 코드]
|
v
CombatWorld.ScheduleDamage(EntityId)
|
v
EntityRegistry.Capture(EntityId)
|
v
EntityHandle(Id + Generation)
|
v
ScheduledDamage에 저장
|
| 몇 Tick 경과
v
CombatWorld.Tick()
|
v
EntityRegistry.Resolve(EntityHandle)
|
+-- generation 일치 --> GameEntity 반환 --> damage 적용
|
+-- generation 불일치 --> null --> damage DROP
이걸 호출 그래프(call graph) 라고 생각하면 돼.
그리고 Spawn/Despawn 흐름은 따로 떼서 보면 훨씬 단순해.
CombatWorld.Spawn()
|
v
EntityRegistry.Spawn()
|
+-- free slot 선택
+-- generation 증가
+-- GameEntity 생성
+-- _slots[slot]에 저장
CombatWorld.Despawn()
|
v
EntityRegistry.Despawn()
|
+-- _slots[slot] = null
+-- slot을 _freeSlots에 반환
이렇게 행동별로 흐름을 분리해서 보는 게 핵심이야.
특히 초보 때 많이 하는 실수가 클래스 파일을 위에서 아래까지 전부 읽는 거야. 그러면:
를 머릿속에 동시에 올려야 해서 금방 꼬여.
대신 이렇게 질문해.
“지금 내가 추적하는 값은 뭐지?”
이번에는 EntityId.
그다음:
“이 값이 어디서 다른 형태로 바뀌지?”
EntityId
→ Capture()
→ EntityHandle
그리고:
“이 값이 어디에 저장되지?”
ScheduledDamage.Target
마지막으로:
“언제 다시 꺼내 쓰지?”
Tick()
→ Resolve()
이 방식이 훨씬 편해.
<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<>>>>>>>>>>>>>>>>>>>>>>>
public Action<string>? TraceSink { get; set; }
private void Trace(string message) => TraceSink?.Invoke(message);
Action<string>
은 네가 아는 대로 string 하나를 매개변수로 받고 반환값은 없는(void) delegate야.
개념적으로 이런 함수를 담을 수 있어.
void PrintLog(string text)
{
Console.WriteLine(text);
}
그러면 외부에서:
world.TraceSink = PrintLog;
처럼 연결할 수 있어.
또는 람다로:
world.TraceSink = message =>
{
Console.WriteLine(message);
};
도 가능해.
그리고 여기의 ?:
Action<string>?
는 TraceSink에 delegate가 들어 있을 수도 있고 null일 수도 있다는 뜻이야.
즉 초기 상태에는:
TraceSink == null
일 수 있어.
get; set;은 말 그대로 외부에서 읽고 설정할 수 있다는 뜻이고.
var current = world.TraceSink; // get
world.TraceSink = Console.WriteLine; // set
이제 핵심인:
TraceSink?.Invoke(message);
를 풀어쓰면 사실상 이거야.
if (TraceSink is not null)
{
TraceSink.Invoke(message);
}
즉 TraceSink가 연결되어 있으면 message를 넘겨서 호출하고, 연결되어 있지 않으면 아무것도 하지 않는다.
예를 들어:
world.TraceSink = Console.WriteLine;
한 다음:
Trace("T3 APPLY DAMAGE");
를 호출하면:
T3 APPLY DAMAGE
가 출력돼.
반대로:
world.TraceSink = null;
인 상태에서:
Trace("T3 APPLY DAMAGE");
해도 오류가 나지 않고 그냥 아무 일도 안 일어나.
그래서 네 표현 중:
“message가 있으면 읽고 없으면 null”
은 조금 다르게 이해해야 해.
정확히는:
message가 있는지 검사하는 게 아님
TraceSink가 있는지 검사함
↓
있음 → TraceSink(message) 호출
없음 → 아무것도 하지 않음
이야.
그리고 Trace() 자체도:
private void Trace(string message)
이므로 반환값은 없어. null을 반환하는 함수도 아니야.
이번 프로젝트에서는 역할을 이렇게 보면 돼.
CombatWorld
|
| Trace("T1 SPAWN E0...")
v
Trace()
|
| TraceSink가 연결되어 있나?
|
+-- YES → 외부 로그 출력 함수에 message 전달
|
+-- NO → 그냥 넘어감
그래서 TraceSink는 쉽게 말하면 **“로그가 발생했을 때 그 로그를 어디로 보낼지 외부에서 꽂을 수 있게 만든 콜백”**이야.
Arena Delayed Damage Regression — 최종 분석 보고서
1. 문제 재현 및 불변식
제공된 run.cmd를 실행한 결과, Goblin-A(E0)를 대상으로 T0에 예약된 35 damage가 T1에 해당 엔티티가 despawn된 이후에도 남아 있었고, 같은 slot E0를 재사용하여 생성된 Goblin-B에게 T3에 적용되는 현상을 재현하였다. 그 결과 새로 생성된 Goblin-B의 HP가 공격받은 적이 없음에도 100에서 65로 감소하였다.
초기 test.cmd 실행 결과는 총 9개 테스트 중 7개가 통과하고, QA_1842_RespawnedEntityKeepsInitialHp와 QA_1911_RepeatedReuseDoesNotReceiveOldHit 2개가 실패하였다.
정상 동작이 지켜야 할 엔티티 생명주기 불변식은 다음과 같이 정의하였다.
새로 생성 또는 리스폰된 엔티티는 이전에 제거된 엔티티의 예약된 공격·피해 등 이전 생명주기의 상태를 상속받아서는 안 된다.
2. Root Cause
EntityRegistry는 고정 크기의 _slots 배열을 사용하며, despawn된 slot 번호를 _freeSlots에 반환한 후 이후 Spawn 시 다시 재사용한다. 따라서 Goblin-A가 사용하던 EntityId(0)은 despawn 이후 새로 생성된 Goblin-B에도 다시 할당될 수 있다.
문제는 기존 EntityId가 slot 번호만 가지고 있어 같은 slot을 사용했던 서로 다른 엔티티 생명주기를 구분하지 못한다는 점이다.
기존 ScheduledDamage 역시 다음과 같이 target을 EntityId만 저장하였다.
internal readonly record struct ScheduledDamage(
long DueTick,
long Sequence,
EntityId Target,
int Amount,
string SourceTag);
또한 ScheduleDamage()는 예약 시점에 target이 존재하는지만 확인한 뒤 해당 EntityId를 future damage에 저장하였다. Despawn()에서는 pending damage를 제거하거나 무효화하지 않았다.
마지막으로 Tick()에서는 damage 적용 시:
var target = _registry.Find(damage.Target);
을 수행하고 있었으며, Find(EntityId)는 과거 엔티티가 아니라 현재 해당 slot에 존재하는 엔티티를 반환한다.
따라서 실제 처리 흐름은 다음과 같았다.
Goblin-A = E0
→ E0에 delayed damage 예약
→ Goblin-A despawn
→ slot 0 반환
→ Goblin-B가 slot 0 재사용
→ Goblin-B 역시 E0
→ due tick에서 E0 조회
→ 현재 E0인 Goblin-B 반환
→ 과거 damage 적용
따라서 root cause는 다음과 같이 정의하였다.
재사용 가능한 slot 기반 EntityId가 서로 다른 entity lifecycle을 구분하지 못하는 상태에서, delayed damage가 lifecycle 정보 없이 해당 ID만 저장한 채 despawn 이후까지 남아 동일한 ID로 생성된 새 엔티티에 적용되는 stale-target 문제이다.
3. 수정 설계 및 구현
public EntityId와 compact slot 재사용 정책을 변경하지 않고, EntityRegistry가 각 slot의 lifecycle을 내부적으로 관리하도록 수정하였다.
EntityRegistry
기존 _slots와 별도로 slot별 generation을 저장하는 배열을 추가하였다.
private readonly ulong[] _generations;
새로운 Entity가 Spawn될 때 해당 slot의 generation을 증가시킨다.
_generations[slot]++;
따라서 같은 E0 slot이 반복 재사용되더라도 내부적으로는:
E0 / generation 1
E0 / generation 2
E0 / generation 3
과 같이 서로 다른 lifecycle로 식별할 수 있다.
EntityHandle
외부 API를 변경하지 않고 내부에서 논리적 엔티티 인스턴스를 식별하기 위해 다음 internal 타입을 추가하였다.
internal readonly record struct EntityHandle(
EntityId Id,
ulong Generation);
EntityId가 물리적인 slot 위치를 나타낸다면, EntityHandle은 slot과 해당 slot의 lifecycle을 함께 나타내는 내부 식별자 역할을 한다.
EntityRegistry.Capture()
현재 EntityId가 가리키는 lifecycle을 예약 시점에 저장하기 위해 Capture(EntityId)를 추가하였다.
internal EntityHandle? Capture(EntityId id)
현재 slot에 유효한 Entity가 존재하면 (EntityId, Generation)을 EntityHandle로 반환한다.
EntityRegistry.Resolve()
예약 당시 저장된 EntityHandle이 현재도 같은 lifecycle인지 확인하기 위해 Resolve(EntityHandle)를 추가하였다.
현재 slot의 generation과 handle에 저장된 generation이 일치하지 않으면 stale reference로 판단하고 null을 반환한다.
ScheduledDamage
기존:
EntityId Target
대신:
EntityHandle Target
을 저장하도록 변경하였다.
이에 따라 ScheduledDamage는 단순히 E0만 기억하는 것이 아니라 예약 당시의 E0 + Generation을 기억한다.
CombatWorld.ScheduleDamage()
public 시그니처는 기존과 동일하게 EntityId를 받도록 유지하였다.
다만 내부적으로:
var targetHandle = _registry.Capture(target);
를 호출하여 현재 lifecycle을 캡처하고, 해당 EntityHandle을 ScheduledDamage에 저장하도록 변경하였다.
CombatWorld.Tick()
damage 적용 시 단순 slot 조회 대신:
_registry.Resolve(damage.Target)
를 사용하도록 변경하였다.
예약 당시와 현재 generation이 같으면 기존처럼 damage를 적용하고, generation이 달라졌다면 대상이 이미 despawn되고 slot이 재사용된 것으로 판단하여 해당 stale damage를 버린다.
반면 Despawn(EntityId)와 TryGetEntity(EntityId)는 현재 slot의 엔티티를 즉시 처리하거나 조회하는 기능이므로 기존 EntityId 기반 동작을 유지하였다.
4. 검증 결과
수정 전 기존 테스트 결과는:
7 PASS / 2 FAIL
이었으며, 수정 후 기존 9개 테스트를 다시 실행한 결과 모두 통과하였다.
특히 기존에 실패했던:
QA_1842_RespawnedEntityKeepsInitialHp
QA_1911_RepeatedReuseDoesNotReceiveOldHit
가 모두 PASS로 변경되었으며, 기존 정상 동작을 검증하는 delayed damage, despawn 처리, EntityId 재사용, 입력 validation, determinism 및 unrelated entity damage 테스트도 계속 PASS하였다.
추가로 generation 기반 lifecycle 설계를 검증하기 위해 4개의 회귀 테스트를 추가하였다.
Regression_MinimumDelayStaleDamageIsDropped에서는 최소 정상 delay인 1 tick에서도 old lifecycle의 damage가 새 lifecycle로 전달되지 않는 것을 확인하였다.
Regression_OldAndNewDamageSameTickOnlyNewApplies에서는 old stale damage와 current valid damage가 같은 tick에 실행될 때 stale damage만 버려지고 새 lifecycle에 예약된 damage는 정상 적용되는 것을 확인하였다.
Regression_MultipleGenerationsOnlyCurrentDamageApplies에서는 동일 slot이 여러 차례 재사용되어 generation이 반복 증가하더라도 과거 generation에 예약된 damage는 모두 무효화되고 현재 generation의 damage만 적용되는 것을 확인하였다.
Regression_ScheduleAfterReuseBindsCurrentLifecycle에서는 public EntityId 값이 과거와 동일하더라도 ScheduleDamage()가 호출되는 현재 시점의 lifecycle을 정상적으로 Capture하는 것을 확인하였다.
최종 실행 결과는:
기존 회귀 테스트 9 / 9 PASS
추가 회귀 테스트 4 / 4 PASS
총 13 / 13 PASS
였다.
따라서 현재 테스트 범위에서는 기존 정상 동작과 EntityId 재사용 정책을 유지하면서 stale scheduled damage가 새 lifecycle에 적용되는 문제를 해결한 것을 확인하였다.
5. 시간 및 공간 복잡도
수정 후 Spawn()에는 해당 slot의 generation을 증가시키는 상수 시간 연산이 추가되었다.
ScheduleDamage()에는 현재 generation을 읽어 EntityHandle로 Capture하는 작업이 추가되었으며, Tick()에서는 damage 적용 전 현재 generation과 예약 generation을 비교하는 작업이 추가되었다.
모두 배열 직접 접근과 정수 비교로 수행되므로 각각 O(1)이며, 기존 연산의 점근적 시간 복잡도는 변경되지 않는다.
특히 Despawn 시 모든 pending damage를 순회하여 제거하는 방식을 사용하지 않았기 때문에 pending effect 수에 비례하는 O(P) scan은 추가되지 않았다.
공간 측면에서는 EntityRegistry에 capacity의 각 slot마다 하나의 ulong generation을 저장하고, 각 pending ScheduledDamage에 target generation 정보가 추가된다.
따라서 추가 공간은 registry capacity를 C, pending damage 수를 P라고 할 때 O(C + P)이다. 기존에도 registry와 pending queue가 각각 같은 차수의 공간을 사용하므로 전체 시스템의 점근적 공간 복잡도는 변경되지 않고 상수 크기의 메모리만 증가한다.
ulong generation은 이론적으로 overflow될 수 있으나 한 slot이 약 2^64번 재사용되어야 하므로 현재 시스템에서 현실적인 위험으로 판단하지 않았다. 별도의 overflow recovery를 추가하는 것은 현재 요구 범위에서는 과도한 복잡도라고 판단하였다.
6. 호환성 및 변경 영향
외부에 노출된 EntityId의 형태는 변경하지 않았으며 다음 public API의 시그니처도 유지하였다.
Spawn
Despawn
ScheduleDamage
Tick
TryGetEntity
또한 compact EntityId slot 재사용 정책 역시 그대로 유지하였다.
변경 범위는 EntityRegistry의 내부 generation 관리, internal EntityHandle, ScheduledDamage의 내부 target 표현, CombatWorld의 Capture/Resolve 처리로 제한하였다.
따라서 기존 외부 호출 코드는 계속 EntityId를 사용하며, lifecycle 구분은 내부 구현에서만 수행된다.
7. 최종 결론
이번 문제는 ID 재사용 자체가 잘못된 것이 아니라, 재사용 가능한 slot ID와 논리적인 엔티티 생명주기를 동일하게 취급한 것이 원인이었다.
이를 해결하기 위해 public EntityId와 slot 재사용 정책을 유지하면서 EntityRegistry가 slot별 generation을 관리하도록 하고, 예약 효과에는 (EntityId, Generation)으로 구성된 내부 EntityHandle을 저장하도록 수정하였다.
그 결과 과거 lifecycle의 delayed damage는 현재 generation과 일치하지 않을 경우 안전하게 무효화되고, 동일 엔티티가 계속 살아 있는 경우의 기존 delayed damage 동작은 그대로 유지된다.
수정 후 기존 9개 테스트와 추가 설계한 4개 테스트가 모두 통과하여 총 13/13 PASS를 확인하였다. 현재 검증 범위에서는 public API, deterministic behavior, compact ID reuse 및 기존 정상 동작을 유지하면서 QA에서 보고된 stale-target 문제를 해결하였다.
100점에 가깝게 만들 마지막 보완
최종 보고서에 아래 두 문장만 추가하면 돼.
Slot reuse가 없었다면 despawn된 EntityId의 slot에는 새 엔티티가 존재하지 않으므로 old scheduled damage는 Find/Resolve 단계에서 대상 없음으로 버려졌을 것이다. 따라서 slot reuse가 stale reference 문제를 실제 사용자 증상으로 노출시키는 조건이었다.
그리고:
Determinism과 correctness는 별개의 성질이다. 수정 전에도 동일 입력은 항상 동일한 잘못된 HP 결과를 만들 수 있으므로 deterministic test가 통과한다고 lifecycle 규칙의 정확성이 보장되는 것은 아니다.