---
title: "에러 0개로 틀리는 법"
description: "이 프로젝트에서 가장 오래 잡은 버그들은 전부 컴파일을 통과했습니다. 하나는 런타임 데모까지 통과했습니다."
author: "유니얼"
pubDate: 2026-09-09
category: "game-dev/unity/dots"
tags: ["Unity", "DOTS", "프로파일링"]
canonical: https://blog.unialgames.com/posts/dots-silent-failures/
---
DOTS 로 넘어오면서 새로 생긴 버그의 종류가 있습니다.
**컴파일이 통과하고, 예외도 안 나고, 로그도 깨끗한데 동작이 틀린 것** 입니다.

소스 제너레이터가 쿼리를 만들어 주고, 잡이 워커에서 돌고, 베이킹이 별도 단계라
"내가 쓴 코드" 와 "실제로 실행되는 것" 사이에 층이 여럿 끼어 있어서 그렇습니다.

한 프로젝트에서 나온 것들을 모았습니다.

| 사건 | 증상 | 원인 |
| --- | --- | --- |
| `WithPresent` 누락 | 다친 적 **0마리**, 에러 0개 | 쿼리 기본 규칙이 "켜진 것만" 이라 살아 있는 적이 통째로 빠졌습니다 |
| 해시가 두 개 | 이웃을 하나도 못 찾음 | 빌드와 조회가 다른 키를 씀 |
| 잡 미스케줄 | 회피가 아예 안 돎 | 잡을 정의만 하고 `OnUpdate` 에서 스케줄을 안 함 |
| `CreateSingleton` 누락 | 큐는 있는데 아무도 못 찾음 | 소비자의 `if (IsEmpty) return;` 가드가 조용히 삼킴 |
| `Range` 와 `StopDistance` 불일치 | 적이 사거리 밖에 서서 멀뚱히 봄 | 두 파일에 걸친 모순 |
| 부분 갱신 누락 | 죽은 플레이어를 계속 때림 | `new` 로 덮어써서 `IsDead` 가 매 프레임 리셋 |
| 가드 순서 | **런타임 데모까지 통과** | 경직 체크가 거리 체크 뒤에 있어 붙어 있는 적은 타이머가 안 깎임 |
| `normalize(0)` | 엔티티가 렌더에서 사라짐 | NaN 이 `Position` 에 들어가면 영구 복구 불가 |

몇 개는 따로 볼 만합니다.

## `WithPresent` 누락

데미지를 적용하는 잡을 다 짜고 실행했는데 **다친 적이 0마리** 였습니다.

```csharp
partial struct DamageApplyJob : IJobEntity
{
    void Execute(ref Health health, EnabledRefRW<DeadTag> dead,
                 ref DynamicBuffer<DamageEvent> buffer) { ... }
}
```

`EnabledRefRW<DeadTag>` 를 시그니처에 넣으면 `DeadTag` 가 쿼리에 들어갑니다.
그리고 **쿼리 기본 규칙은 "켜진 것만 매칭"** 입니다.

적은 전부 `DeadTag` 가 꺼진 채로 시작합니다.
그러니 **살아 있는 적이 통째로 쿼리에서 빠졌습니다.** 죽은 적만 데미지를 받고 있었습니다.

```csharp
[WithPresent(typeof(DeadTag))]      // 켜짐 여부를 무시하고 존재만 봅니다
partial struct DamageApplyJob : IJobEntity { ... }
```

한 줄입니다. 그런데 이걸 찾기까지 오래 걸린 이유는,
**"아무 일도 안 일어난다" 가 증상의 전부** 였기 때문입니다.
잡이 도는지, 쿼리가 비었는지, 데미지가 0인지를 하나씩 찍어 봐야 했습니다.

## 가드 순서

가장 고약했던 것입니다. **화면에서는 정확히 맞아 보였습니다.**

경직을 넣으면서 이동 잡을 이렇게 썼습니다.

```csharp
if (distance < StopDistance) return;                        // 거리 체크가 먼저
if (stun.Remaining > 0f) { stun.Remaining -= dt; return; }  // 여기 도달을 못 함
```

적들은 플레이어에 붙어서 `StopDistance` 안에 뭉쳐 있습니다.
그 적들은 첫 줄에서 나가 버리니 **경직 타이머가 안 깎이고 얼어붙습니다.**

그런데 넉백이 이걸 가려 줍니다.
넉백이 적을 `StopDistance` 밖으로 밀어내면 다음 프레임에는 거리 체크를 통과하고,
그때부터 경직이 정상으로 돕니다.

그래서 광역기 데모가 **"밀려났다 잠깐 멈췄다 다시 온다"** 로 정확히 보입니다.
로직은 틀렸는데요.

```csharp
if (stun.Remaining > 0f) { stun.Remaining -= dt; return; }  // 위치와 무관하게 벽시계처럼
if (distance < StopDistance) return;
```

두 시스템이 `Stun` 이라는 데이터로 계약을 맺은 것인데,
**무엇을 공유하느냐뿐 아니라 "언제 확인하느냐" 까지 맞아야** 계약이 성립합니다.

## 격리하지 않으면 검증이 아니다

위의 가드 순서 버그가 알려 준 것이 하나 더 있습니다.
**관측값이 다른 원인으로도 설명되면, 그 관측은 아무것도 증명하지 않습니다.**

넉백을 검증할 때 같은 문제를 만났습니다.
시스템을 다 켜 둔 채로 재니 넉백이 `+1.24` 만큼만 움직인 것으로 나왔습니다.
설정한 값은 5인데요.

원인은 넉백과 이동이 **같은 `Position` 을 두고 싸우고 있어서** 였습니다.
그래서 `MovementSystem` 과 `SpatialHashSystem` 을 런타임에 정지시키고 다시 쟀습니다.

```text
정지 확인:  3회 샘플이 소수점까지 동일 (8.08 / 0.03 / 15.39)
발사 전:    평균  8.08   최소 0.03   최대 15.39
발사 후:    평균 13.08   최소 0.03   최대 20.39
              ↑ +5.00           ↑ +5.00
```

**평균도 최대도 소수점까지 `+5.00`** 입니다. 설정값과 정확히 일치합니다.
1만 마리 전원이 자기 방향으로 정확히 5씩 밀렸다는 뜻입니다.

그리고 **최소가 `0.03` 에서 안 변한 것**이 NaN 가드가 실제로 일했다는 증거입니다.
중심에 0.03만큼 붙어 있던 적이 실제로 있었고, 가드가 없었으면 그 하나가 NaN 이 됐을 것입니다.

경직도 같은 식으로 격리했습니다. `KnockbackScale = 0` 으로 놓고 쐈습니다.
넉백이 안 밀어 주니 가드 순서가 틀렸으면 경직 걸린 적이 1만이 안 나옵니다.
고친 뒤에 재니 **전원 10,000 이 경직에 걸렸고, 같은 프레임에 동시에 풀렸습니다.**

## 테스트 설계도 틀린다

넉백만 떼어내려고 처음에는 `Damage = 0` 으로 쐈습니다.
그런데 아무 일도 안 일어났습니다.

`total > 0` 가드에 막힌 것이었습니다. "안 맞았으면 안 민다" 는 정상 동작입니다.
**가드가 옳았고 테스트가 틀렸습니다.** `Damage = 1` 로 바꾸니 됐습니다.

버그를 찾을 때 의심하는 순서가 코드, 데이터, 그리고 **실험 설계** 입니다.
세 번째를 안 의심하면 멀쩡한 코드를 계속 뜯게 됩니다.

## 무엇으로 잡나

여덟 건 전부 컴파일러가 못 잡았고, 대부분 예외도 안 났습니다.
잡은 방법은 하나뿐이었습니다. **숫자로 확인할 수 있는 형태를 만들어 두는 것.**

- 점유 셀 개수를 세서 해시 분산을 확인
- 죽인 수와 킬 카운터를 대조해서 누락과 중복을 확인
- 공격 한 번의 총 데미지가 `대상 수 × 데미지` 와 맞는지 확인
- 경직 걸린 적 수가 전체와 같은지 확인
- 경쟁하는 시스템을 꺼서 오차를 0으로 만든 뒤 확인

"돌아가는 것 같다" 와 "정확히 몇 개" 사이의 거리가 이 프로젝트에서 제일 컸습니다.