---
title: "엔티티는 물건만이 아니다"
description: "공격을 요청 엔티티로 모델링해 뒀더니, 나중에 광역기를 붙일 때 숫자 두 개만 바꾸면 됐습니다. 시스템 코드는 한 줄도 안 고쳤습니다."
author: "유니얼"
pubDate: 2026-09-09
category: "game-dev/unity/dots"
tags: ["Unity", "DOTS"]
canonical: https://blog.unialgames.com/posts/dots-entity-as-event/
---
플레이어가 공격하면 범위 안의 적이 맞아야 합니다.
`MonoBehaviour` 로 짜면 플레이어가 직접 주변을 훑어서 때리겠지만, ECS 에서는 그럴 수 없습니다.
GameObject 는 잡 안에서 엔티티를 만질 수 없으니까요.

그래서 **공격을 엔티티로 만들어 던졌습니다.**

```csharp
public struct AttackRequest : IComponentData
{
    public float3 Center;
    public float Radius;
    public int Damage;
    public float KnockbackScale;
    public float StunDuration;
}
```

플레이어가 공격 키를 누르면 이 컴포넌트만 붙은 엔티티를 하나 만듭니다.
시스템이 그걸 보고 판정하고, 처리한 뒤 `DestroyEntity` 합니다. **수명 1프레임입니다.**

엔티티는 캐릭터나 총알 같은 "물건" 만이 아닙니다.
**한 번 일어난 사건**도 엔티티가 됩니다. 이게 데이터 지향에서 개념적으로 제일 낯선 부분이었습니다.

## 판정은 앞에서 만든 격자를 재사용한다

`AttackResolveSystem` 이 요청을 받아 반경 안의 적을 찾는데,
이때 이동 회피에 쓰던 공간 해시를 그대로 씁니다. 새로 만들 게 없습니다.

```csharp
int range = (int)math.ceil(r.Radius / SpatialHash.CellSize);
// 중심 셀에서 range 만큼 사방으로 훑습니다
```

**셀은 사각형이고 반경은 원입니다.** 그래서 셀 안의 엔티티를 다 때리면 안 되고,
거리로 한 번 더 걸러야 합니다. 모서리 셀에 있는 놈들이 그 차이입니다.

### 잡 안전성 예외 2,531개

여기서 크게 막혔습니다. 실행하자마자 예외가 2,531개 쏟아졌습니다.

원인은 **싱글톤에 담아 넘긴 raw 네이티브 컨테이너가 ECS 의 자동 의존성 추적을 우회한다**는 것이었습니다.

한 시스템 안에서 잡을 연달아 스케줄하면 `state.Dependency` 가 알아서 체인을 겁니다.
그런데 해시맵을 싱글톤 컴포넌트에 실어 다른 시스템으로 넘기면, ECS 입장에서는
그냥 필드일 뿐이라 "이 맵을 쓰는 잡이 아직 안 끝났다" 를 모릅니다.

해법은 `JobHandle` 을 같이 실어 보내는 것이었습니다.

```csharp
public struct SpatialHashMap : IComponentData
{
    public NativeParallelMultiHashMap<int, float3> Map;
    public JobHandle BuildHandle;      // 빌드 잡이 끝났는지를 같이 넘깁니다
}
```

읽기 전에 `BuildHandle.Complete()` 를 부르면 됩니다.
**추적을 우회했으면 추적을 손으로 해 줘야 합니다.**

### 검증

공격 한 번에 **129마리가 맞고 총 1,290 데미지** 가 들어갔습니다.
`Damage` 를 10으로 뒀으니 129 × 10 입니다. 딱 떨어집니다.

숫자가 딱 떨어지는지를 보는 이유는, 중복 적용이나 누락이 여기서 드러나기 때문입니다.
1,300이 나오면 누군가 두 번 맞은 것이고 1,280이면 하나가 빠진 것입니다.

## 그리고 광역기가 공짜로 붙었다

몇 주 뒤에 광역기를 넣게 됐습니다. 화면의 적을 한 방에 쓸어버리는 스킬입니다.

**시스템 코드를 한 줄도 안 고쳤습니다.**

```csharp
// 평타
new AttackRequest { Radius = 5f,  Damage = 10,  KnockbackScale = 2.5f, StunDuration = 0.2f }

// 광역기
new AttackRequest { Radius = 25f, Damage = 100, KnockbackScale = 5.0f, StunDuration = 0.5f }
```

숫자만 다릅니다. 별도 `AoeRequest` 타입도, 시스템 안의 분기도 없습니다.
반경이 하드코딩이 아니라 요청에 실려 오니까 25를 넣으면 그게 광역기입니다.

만약 판정 로직을 `PlayerAttackBridge` 안에 박아 뒀다면 지금 갈아엎어야 했습니다.
**경계를 데이터로 그어 둔 것이 나중에 값을 했습니다.**

`KnockbackScale` 과 `StunDuration` 도 나중에 같은 자리에 얹혔습니다.
시스템은 그 값을 **흘려보내기만** 하고, 실제 값은 인스펙터에서 튜닝됩니다.
평타는 2.5, 광역기는 5.0. 코드 수정 없이 감을 잡을 수 있습니다.

> **좋은 경계는 나중에 공짜를 만듭니다.**
> 이건 미리 설계를 잘해서가 아니라, "공격" 을 함수 호출이 아니라 데이터로 표현했기 때문입니다.
> 데이터는 값을 바꾸는 게 자유롭고, 함수 호출은 그렇지 않습니다.

## 예측이 3배 틀렸다

광역기를 넣기 전에 예상을 적어 뒀습니다.

> 반경 5 평타가 129마리였으니, 반경 25면 면적이 25배 → **수천 마리** 쯤 맞겠다

실제로는 **10,000 마리 전부** 였습니다. 킬 카운터가 정확히 +10,000 을 찍었습니다.

밀도를 역산하면 `10000 / (π × 25²) ≈ 5.1마리/unit²` 인데
어림잡았던 값은 1.6이었습니다. **3배 이상 조밀했습니다.**

원인은 회피에 대한 오해였습니다.
셀 크기가 1이니까 적들이 대략 1칸 간격으로 벌어져 있을 거라고 생각했는데,
**회피는 딱딱한 제약이 아니라 무른 힘** 입니다.
1만 마리가 안쪽으로 미는 압력이 회피력을 이기면 그냥 압축됩니다.

밀도에 대한 직관은 틀립니다. 이건 재 보기 전에는 알 수 없었습니다.