---
title: "1만 개를 한 번 도는 것과 1개를 1만 번 부르는 것"
description: "적 1만 마리를 움직이는 코드가 느린 이유는 계산이 무거워서가 아니라 데이터가 흩어져 있어서입니다. ECS 는 그걸 붙여 놓는 것에서 시작합니다."
author: "유니얼"
pubDate: 2026-09-09
category: "game-dev/unity/dots"
tags: ["Unity", "DOTS", "최적화", "메모리"]
canonical: https://blog.unialgames.com/posts/dots-why-ecs/
---
적 1만 마리가 플레이어를 향해 걸어오는 무쌍 게임을 만들려고 합니다.
각자 위치를 갱신하고, 옆에 있는 놈을 피하고, 사거리에 들어오면 때립니다.

`MonoBehaviour` 로 만들면 `Update` 가 1만 개 돕니다. 그리고 느립니다.
그런데 **계산 자체는 무겁지 않습니다.** 위치에 방향 곱해서 더하는 게 전부입니다.

느린 이유는 다른 데 있습니다.

## 값을 가지러 가는 시간이 계산하는 시간보다 길다

CPU 가 메모리에서 값을 하나 가져올 때, 그 값 하나만 가져오지 않습니다.
**캐시 라인**이라는 덩어리 단위로 주변까지 통째로 끌고 옵니다.
다음에 필요한 값이 그 안에 있으면 공짜로 읽고, 없으면 다시 메모리까지 다녀와야 합니다.

<figure class="diagram">
<svg viewBox="0 0 680 250" role="img" aria-label="힙 여기저기 흩어진 컴포넌트를 순회할 때와 청크에 연속으로 붙어 있는 컴포넌트를 순회할 때의 접근 경로 비교">
<text x="20" y="24" font-size="13" fill="var(--text-muted)">GameObject 1만 개</text>
<g stroke="var(--border)" fill="none">
<rect x="20" y="36" width="290" height="86" rx="4"></rect>
</g>
<g fill="var(--warn)">
<rect x="36" y="50" width="14" height="14"></rect>
<rect x="118" y="94" width="14" height="14"></rect>
<rect x="212" y="56" width="14" height="14"></rect>
<rect x="70" y="100" width="14" height="14"></rect>
<rect x="266" y="98" width="14" height="14"></rect>
<rect x="164" y="72" width="14" height="14"></rect>
</g>
<g stroke="var(--warn)" fill="none" stroke-dasharray="3 3">
<path d="M43 57 L125 101 L219 63 L77 107 L273 105 L171 79"></path>
</g>
<text x="20" y="142" font-size="12" fill="var(--warn)">읽을 때마다 딴 데로 뜁니다. 끌고 온 주변 값은 대부분 못 씁니다.</text>
<text x="20" y="182" font-size="13" fill="var(--text-muted)">ECS 청크</text>
<g stroke="var(--accent)" fill="var(--accent-soft)">
<rect x="20" y="194" width="24" height="18"></rect>
<rect x="46" y="194" width="24" height="18"></rect>
<rect x="72" y="194" width="24" height="18"></rect>
<rect x="98" y="194" width="24" height="18"></rect>
<rect x="124" y="194" width="24" height="18"></rect>
<rect x="150" y="194" width="24" height="18"></rect>
<rect x="176" y="194" width="24" height="18"></rect>
<rect x="202" y="194" width="24" height="18"></rect>
<rect x="228" y="194" width="24" height="18"></rect>
<rect x="254" y="194" width="24" height="18"></rect>
<rect x="280" y="194" width="24" height="18"></rect>
</g>
<g stroke="var(--accent)" fill="none">
<path d="M20 224 L304 224"></path>
</g>
<text x="20" y="242" font-size="12" fill="var(--accent)">앞에서 뒤로 한 방향입니다. 한 번 끌고 오면 여러 개가 같이 옵니다.</text>
<text x="340" y="60" font-size="13" fill="var(--text-muted)">같은 계산인데 속도가 갈리는 이유는</text>
<text x="340" y="80" font-size="13" fill="var(--text-muted)">계산이 아니라 값을 가지러 가는 시간입니다.</text>
<text x="340" y="118" font-size="13" fill="var(--text-muted)">그리고 연속된 배열이라는 사실이</text>
<text x="340" y="138" font-size="13" fill="var(--text-muted)">두 가지를 더 열어 줍니다.</text>
<text x="340" y="172" font-size="12" fill="var(--accent)">SIMD 로 한 번에 여러 개를 계산할 수 있고</text>
<text x="340" y="194" font-size="12" fill="var(--accent)">앞뒤가 독립적이라 워커 스레드에 쪼갤 수 있습니다</text>
</svg>
<figcaption>ECS 가 파는 것은 빠른 계산이 아니라 예측 가능한 메모리 접근입니다.</figcaption>
</figure>

`MonoBehaviour` 는 힙에 있는 객체입니다.
1만 개를 만들면 힙 여기저기에 흩어져 앉고, `Update` 를 부를 때마다 그리로 뛰어갑니다.
게다가 `Update` 는 가상 호출이라 어디로 가는지 매번 확인해야 합니다.

## ECS 가 데이터를 두는 방식

ECS 는 세 가지로 나눕니다.

- **엔티티**: 번호표입니다. 그 자체로는 아무 데이터도 없습니다
- **컴포넌트**: 순수한 데이터입니다. 위치, 속도, 체력. 메서드가 없습니다
- **시스템**: 로직입니다. "이런 컴포넌트를 가진 것들" 을 훑으면서 일합니다

그리고 **같은 컴포넌트 구성을 가진 엔티티끼리 한 덩어리에 모아 둡니다.**
그 구성을 아키타입이라 하고, 그 덩어리를 청크라 합니다.

<figure class="diagram">
<svg viewBox="0 0 680 250" role="img" aria-label="구성이 제각각인 엔티티들을 같은 구성끼리 모으면 아키타입이 되고 그 덩어리가 청크가 된다는 그림">
<text x="20" y="24" font-size="13" fill="var(--text-muted)">구성이 제각각인 엔티티</text>
<g stroke="var(--border)" fill="var(--bg)">
<rect x="20" y="38" width="110" height="22" rx="3"></rect>
<rect x="20" y="66" width="110" height="22" rx="3"></rect>
<rect x="20" y="94" width="110" height="22" rx="3"></rect>
<rect x="20" y="122" width="110" height="22" rx="3"></rect>
<rect x="20" y="150" width="110" height="22" rx="3"></rect>
<rect x="20" y="178" width="110" height="22" rx="3"></rect>
</g>
<text x="30" y="53" font-size="11" fill="var(--text-muted)">위치 이동 체력</text>
<text x="30" y="81" font-size="11" fill="var(--text-muted)">위치 체력</text>
<text x="30" y="109" font-size="11" fill="var(--text-muted)">위치 이동 체력 사망</text>
<text x="30" y="137" font-size="11" fill="var(--text-muted)">위치 이동 체력</text>
<text x="30" y="165" font-size="11" fill="var(--text-muted)">위치 체력</text>
<text x="30" y="193" font-size="11" fill="var(--text-muted)">위치 이동 체력</text>
<g stroke="var(--border)" fill="none">
<path d="M140 118 L190 118"></path>
<path d="M180 113 L190 118 L180 123"></path>
</g>
<text x="136" y="108" font-size="11" fill="var(--text-muted)">같은 구성끼리</text>
<text x="200" y="24" font-size="13" fill="var(--accent)">아키타입 = 구성이 같은 것들의 묶음</text>
<g stroke="var(--accent)" fill="none">
<rect x="200" y="34" width="230" height="80" rx="4"></rect>
</g>
<text x="212" y="52" font-size="11" fill="var(--accent)">A · 위치 이동 체력</text>
<g stroke="var(--accent)" fill="var(--accent-soft)">
<rect x="212" y="60" width="206" height="14"></rect>
<rect x="212" y="78" width="206" height="14"></rect>
<rect x="212" y="96" width="206" height="14"></rect>
</g>
<g stroke="var(--border)" fill="none">
<rect x="200" y="122" width="230" height="42" rx="4"></rect>
</g>
<text x="212" y="140" font-size="11" fill="var(--text-muted)">B · 위치 이동 체력 사망</text>
<g stroke="var(--border)" fill="var(--bg)">
<rect x="212" y="146" width="206" height="14"></rect>
</g>
<g stroke="var(--border)" fill="none">
<rect x="200" y="172" width="230" height="60" rx="4"></rect>
</g>
<text x="212" y="190" font-size="11" fill="var(--text-muted)">C · 위치 체력</text>
<g stroke="var(--border)" fill="var(--bg)">
<rect x="212" y="196" width="206" height="14"></rect>
<rect x="212" y="214" width="206" height="14"></rect>
</g>
<text x="446" y="60" font-size="12" fill="var(--text-muted)">묶음 하나가 메모리 블록 하나입니다.</text>
<text x="446" y="80" font-size="12" fill="var(--text-muted)">이 블록을 청크라 부릅니다.</text>
<text x="446" y="114" font-size="12" fill="var(--accent)">"이동할 것 전부" 는</text>
<text x="446" y="134" font-size="12" fill="var(--accent)">A 와 B 블록을 통째로 훑으면 됩니다.</text>
<text x="446" y="168" font-size="12" fill="var(--text-muted)">C 는 이동 컴포넌트가 없으니</text>
<text x="446" y="188" font-size="12" fill="var(--text-muted)">열어 보지도 않습니다.</text>
<text x="446" y="222" font-size="12" fill="var(--text-muted)">구성이 바뀌면 다른 묶음으로 옮겨 갑니다.</text>
</svg>
<figcaption>엔티티를 종류로 나눈 것이 아니라 데이터의 모양으로 나눈 것입니다.</figcaption>
</figure>

청크 안에서 위치는 위치끼리, 속도는 속도끼리 배열로 붙어 있습니다.
그러니 "모든 적의 위치를 갱신하라" 는 그 배열을 앞에서 뒤로 훑는 일이 됩니다.

## 여기서 두 가지가 더 열린다

이게 이 방식의 진짜 값어치입니다. 캐시 효율은 시작일 뿐입니다.

**하나. SIMD.** 값이 연속으로 붙어 있으니 CPU 가 한 명령으로 여러 개를 동시에 계산할 수 있습니다.
Burst 컴파일러가 이걸 자동으로 해 줍니다.

**둘. 병렬.** 배열의 앞부분과 뒷부분이 서로를 안 건드립니다.
그러면 앞 절반은 워커 1번, 뒤 절반은 워커 2번에 맡겨도 됩니다.
`MonoBehaviour` 는 이게 안 됩니다. 다른 객체를 언제 참조할지 모르기 때문입니다.

**데이터를 붙여 놓은 결과로 병렬화가 가능해진 것**이지, 병렬화를 위해 붙인 게 아닙니다.
순서가 이쪽입니다.

## Burst 는 CoreCLR 과 다른 축이다

여기서 헷갈리기 쉬운 게 있어서 미리 정리합니다.

유니티가 Mono 를 걷어내고 CoreCLR 로 가는 중이라는 이야기와,
Burst 로 잡을 컴파일한다는 이야기는 **다른 층** 입니다.

| | 무엇 |
| --- | --- |
| Mono / IL2CPP / CoreCLR | 일반 C# 코드를 실행하는 런타임입니다. GC 도 여기 붙습니다 |
| **Burst** | 잡과 시스템에 붙는 **별도 컴파일러**입니다. LLVM 으로 네이티브 코드를 뽑습니다 |

Burst 로 컴파일되는 코드는 **관리 힙을 안 씁니다.** 그래서 GC 대상이 아닙니다.
런타임이 무엇으로 바뀌든 이쪽은 영향을 안 받습니다.
반대로 잡 안에서 `class` 나 `string` 을 쓰면 Burst 가 컴파일을 거부합니다.
**같은 C# 문법인데 규칙이 다른 구역**이 하나 더 생기는 셈입니다.

## 원리는 재 보기 전까지 원리다

여기까지가 ECS 를 쓰는 이유입니다. 다만 전부 원리입니다.
캐시가 어떻고 청크가 어떻고 하는 말은 맞지만, 내 프로젝트에서 몇 밀리초인지는 아무것도 말해 주지 않습니다.

**손익분기점이 있다는 것만 미리 적어 둡니다.** 대상이 적으면 ECS 의 고정 비용이 이득보다 큽니다.
적 100마리짜리 게임에 ECS 를 얹으면 느려지지는 않아도 얻는 것도 없습니다.
그 분기점이 어디인지는 원리로는 안 나오고 재야 나옵니다.

여기에 적힌 숫자와 코드는 적 1만 마리짜리 무쌍 게임을 실제로 만들면서 잰 것입니다.

<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": "벤치마크 CSV 폴더", "path": "docs/benchmarks" }
  ],
  "note": "재는 방식은 매번 같습니다. 워밍업 프레임을 버리고, 정해진 구간의 평균을 쓰고, 에디터가 백그라운드였던 프레임은 제외합니다."
}
</script>
</x-repo>