---
title: "하나를 위해 ECS 를 지불할 이유는 없다"
description: "적 1만은 ECS 이고 플레이어는 GameObject 입니다. 무엇을 옮기고 무엇을 두는지는 개수와 이터레이션 빈도가 정합니다."
author: "유니얼"
pubDate: 2026-09-09
category: "game-dev/unity/dots"
tags: ["Unity", "DOTS", "최적화"]
canonical: https://blog.unialgames.com/posts/dots-hybrid-boundary/
---
<x-repo>
<script type="application/json">
{
  "repo": "SeoBYP/ecs-warriors",
  "text": "적 1만 마리를 돌리는 3D 무쌍 액션. 아래 경계가 실제로 이 저장소에 그어져 있는 선입니다.",
  "chips": ["Unity 6000.5.4f1", "Entities 6.5.0", "Burst 1.8.29", "Collections 6.5.0"],
  "links": [
    { "text": "week3-combat-ab.csv (측정 원본)", "path": "docs/benchmarks/week3-combat-ab.csv" }
  ],
  "note": "플레이어와 UI 는 GameObject, 적 1만은 ECS 입니다. 두 세계를 잇는 코드도 같은 저장소에 있습니다."
}
</script>
</x-repo>

이 프로젝트는 전부 ECS 가 아닙니다. 경계가 이렇습니다.

| | 어디 | 무엇 |
| --- | --- | --- |
| **GameObject** | 플레이어 | 콤보, 타격감, 입력 버퍼, 카메라 |
| | UI · VFX | 킬 카운터, 사망 연출, 화면 흔들림 |
| **ECS** | 적 1만 | 스폰, 이동, 이웃 회피, 타겟팅 |
| | | 데미지, 사망, 넉백, 경직, 렌더 |

## 플레이어는 왜 ECS 가 아닌가

성능 때문이 아닙니다. 하나짜리를 ECS 로 옮겨도 얻는 게 없습니다.

**이터레이션 비용 때문입니다.**
애니메이션 캔슬 타이밍, 입력 버퍼 길이, 타격 프레임, 카메라 추적 감도.
이런 것은 숫자를 바꾸고 플레이해 보고 다시 바꾸기를 수십 번 합니다.

ECS 에서는 그 사이클이 비쌉니다.
값 하나를 인스펙터에 노출하려면 authoring 과 baker 를 거쳐야 하고,
로그 하나 찍으려면 잡을 `Run` 으로 바꿔야 하고,
그러면 Burst 도 병렬도 꺼져서 성능이 다른 코드가 됩니다.

**객체 하나를 위해 그 오버헤드를 낼 이유가 없습니다.**

## 무엇을 옮길지 고르는 기준

네 가지를 봤습니다.

| 질문 | ECS 로 | GameObject 로 |
| --- | --- | --- |
| 같은 일을 하는 것이 몇 개인가 | 수천 개 이상 | 한둘 |
| 얼마나 자주 고칠 것인가 | 규칙이 굳었다 | 계속 만진다 |
| 매 프레임 전부 도는가 | 그렇다 | 이벤트로 충분하다 |
| 데이터가 독립적인가 | 서로 안 본다 | 여기저기 참조한다 |

넷 다 같은 방향이면 쉽습니다. 갈리면 **개수가 이깁니다.**
1만 개짜리는 이터레이션이 불편해도 ECS 로 가야 하고, 하나짜리는 반대입니다.

## 전투를 얹어도 기울기는 0이었다

적 1만에 전투 시스템 다섯 개를 얹으면 무너지는지 재 봤습니다.
같은 세션 안에서 시스템을 런타임에 켜고 끄며 측정했습니다.

| 적 수 | 전투 전부 끔 | 적 공격만 끔 | 전부 켬 |
| --- | --- | --- | --- |
| 1,000 | 8.08 | 8.00 | 8.53 |
| 10,000 | 7.76 | 8.16 | 9.37 |
| **기울기 (1천에서 1만)** | **-0.32** | **+0.16** | **+0.84** |

**세 조건 모두 평평합니다.** 적이 열 배가 돼도 프레임타임이 안 늘어납니다.
1만 마리 거리 판정과 데미지 적용을 통째로 얹어도 그렇습니다.

### 이 표에서 말할 수 없는 것

같은 세션 안의 차이(전부 켬 빼기 전부 끔)는 1ms 안팎입니다.
그런데 **그 숫자는 못 믿습니다.**

근거는 `전투 전부 끔` 의 기울기가 **-0.32, 즉 음수** 라는 점입니다.
적이 열 배인데 빨라졌다는 것은 물리적으로 불가능하니,
**측정 노이즈가 최소 ±0.5ms** 라는 뜻입니다. 1ms 신호가 그 안에 들어앉아 있습니다.

> **신호가 노이즈보다 작으면 "작다" 는 것 말고는 할 수 있는 말이 없습니다.**

### 그리고 다른 세션과 비교하면 안 된다

하마터면 이렇게 쓸 뻔했습니다.

```text
전투 있음 9.37 - 이전 측정의 6.26 = 전투 비용 3ms
```

**틀렸습니다.** 전투를 **전부 끈** 조건도 7.76ms 가 나왔습니다.
이전 세션의 6.26이 재현되지 않습니다.
차이의 절반 이상이 코드와 무관한 **세션 간 베이스라인 드리프트** 였습니다.
에디터 상태, 도메인 리로드 누적 같은 것들입니다.

유효한 비교는 **같은 세션 안의 A/B** 와 **기울기** 뿐입니다.

## 안 쓰는 것도 설계다

적이 플레이어를 때리는 판정에는 공간 해시를 **안 썼습니다.**

쓰고 싶어집니다. 이미 만들어 뒀고, 이웃 탐색에 잘 듣는 것도 확인했으니까요.
그런데 필요가 없습니다.

> 그리드가 푼 문제는 **1만 × 1만 = 1억** 이었습니다.
> 적이 플레이어를 때리는 것은 **1만 × 1** 입니다.

적 1만이 각자 플레이어 하나와 거리를 재면 끝입니다.
Burst 가 1억 번을 19ms 에 처리했으니 1만 번은 그 만분의 일입니다.
여기에 그리드를 끼우면 얻는 것 없이 `JobHandle` 얽힘만 늘어납니다.

**비쌀 이유가 없을 때 안 쓰는 것도 설계입니다.**

## 이 프로젝트에서 남은 것

정직하게 적어 둡니다. 아직 못 한 것이 셋 있습니다.

- **전수 검사 대비 시스템 마커 격리.** 기준선 브랜치가 아트 에셋 이전의 옛 스냅샷이라
  라이브 에디터를 그쪽으로 전환하면 대용량 리임포트가 일어납니다.
  `git worktree` 로 별도 인스턴스를 띄워서 같은 하니스를 돌려야 합니다
- **GPU 프레임 타임 실측.** 에디터에서는 0으로 나오므로 빌드에서 따로 잡아야 합니다
- **반경 스윕.** 셀 스캔이 `(2R+1)²` 로 늘어나는데, 반경 25면 2,601칸이고 50이면 10,201칸입니다.
  그런데 결과는 똑같이 1만 킬입니다. **그리드가 손해로 뒤집히는 지점**이 어딘가에 있습니다

## 정리

- 데이터를 붙여 놓으면 캐시가 맞고, 그 결과로 SIMD 와 병렬이 열린다
- 셀 크기를 질의 반경에 맞추면 검사 수가 상수가 되고, 기울기가 0이 된다
- 배수는 잰 지점에서만 유효하다. 기울기가 구조에 대한 답이다
- 최적화에는 손익분기점이 있다. 1,000 마리에서는 그리드가 손해였다
- 브릿지는 방향과 건수가 모양을 정한다. 표면적은 좁을수록 좋다
- 값이냐 참조냐가 반복해서 갈린다. 대상이 사라질 수 있으면 값을 복사한다
- 구조 변경은 잡에서 못 한다. 그래서 비트 토글이 이긴다
- 지표 설계와 표본 수가 결론을 만든다. 뺄셈으로 만든 지표는 노이즈가 더해진다
- 격리하지 않은 관측은 증명이 아니다. 경쟁하는 시스템을 끄면 오차가 0이 된다
- 개수가 적으면 ECS 를 지불할 이유가 없다