---
title: "엔티티는 번호표이고 데이터는 청크에 줄지어 있다"
description: "ECS 의 세 조각이 각각 무엇인지, 아키타입과 청크가 왜 필요한지, 쿼리가 엔티티를 하나씩 뒤지지 않는 이유까지."
author: "유니얼"
pubDate: 2026-09-10
category: "game-dev/unity/dots"
tags: ["Unity", "DOTS", "메모리"]
canonical: https://blog.unialgames.com/posts/dots-ecs-theory/
---
ECS 는 게임 오브젝트를 이루던 것을 셋으로 쪼갭니다.
그런데 "쪼갠다" 는 말만으로는 왜 빨라지는지가 안 나옵니다.
빨라지는 이유는 쪼갠 뒤에 **어떻게 다시 모아 두느냐**에 있습니다.

## 셋의 역할

| | 무엇 | 갖지 않는 것 |
| --- | --- | --- |
| **엔티티** | 번호표. `Index` 와 `Version` 두 정수뿐입니다 | 데이터도 로직도 없습니다 |
| **컴포넌트** | 순수한 데이터. `IComponentData` 를 구현한 struct | **메서드가 없습니다** |
| **시스템** | 로직. 조건에 맞는 엔티티를 훑으며 컴포넌트를 고칩니다 | 상태를 안 갖습니다 |

`MonoBehaviour` 는 셋이 한 덩어리입니다.
`Enemy` 클래스 하나가 식별자이면서 데이터이면서 로직입니다.
ECS 는 그걸 갈라 놓고 **데이터만 따로 모읍니다.**

```csharp
public struct Enemy : IComponentData { }               // 태그. 필드가 없어도 됩니다
public struct Health : IComponentData { public int Value; }
public struct MoveStats : IComponentData { public float Speed, StopDistance; }
```

엔티티는 이 컴포넌트들을 **가리키지 않습니다.**
참조로 들고 있으면 결국 힙 여기저기를 가리키게 되니까요.
반대입니다. **데이터가 모여 있는 자리에 엔티티의 번호가 적혀 있습니다.**

## 아키타입

컴포넌트 구성이 **정확히 같은** 엔티티들을 한 묶음으로 봅니다. 그 구성이 아키타입입니다.

```text
아키타입 A : Enemy + Health + MoveStats + LocalTransform
아키타입 B : Enemy + Health + MoveStats + LocalTransform + DeadTag
아키타입 C : Player + Health + LocalTransform
```

A 와 B 는 `DeadTag` 하나 차이지만 **다른 아키타입** 입니다.
컴포넌트를 하나 붙이거나 떼면 그 엔티티는 다른 아키타입으로 옮겨 갑니다.
이걸 구조 변경이라 부릅니다.

## 잠깐, 여기서 사고를 한 번 바꿔야 합니다

객체지향에 익숙하면 여기서 막힙니다. 용어가 어려워서가 아니라 **질문이 달라서**입니다.

객체지향에서 우리는 이렇게 묻습니다.

> **이 객체는 무엇인가.** 그리고 그 객체는 무엇을 할 수 있는가.

`Enemy` 는 체력을 갖고 움직일 수 있는 것입니다.
그래서 `Enemy` 클래스를 만들고, 그 안에 `Hp` 를 넣고, `Move()` 를 넣습니다.
**하나의 대상이 자기 데이터와 자기 행동을 다 들고 있습니다.**

데이터 지향에서는 이렇게 묻습니다.

> **이 데이터는 어떤 모양인가.** 그리고 그 모양에 무슨 일을 해야 하는가.

`Enemy` 라는 것은 없습니다.
`Health` 와 `MoveStats` 와 `LocalTransform` 이 **같이 붙어 있는 모양**이 있고,
그 모양에 대해 "위치를 갱신하라" 는 일이 있을 뿐입니다.

| 객체지향 | 데이터 지향 |
| --- | --- |
| 클래스 = 무엇인가 | **아키타입 = 어떤 모양인가** |
| 인스턴스 = 그 무엇 하나 | **엔티티 = 그 모양 안의 몇 번째 자리** |
| 필드 = 그 객체의 속성 | **컴포넌트 = 모양을 이루는 조각** |
| 메서드 = 그 객체가 하는 일 | **시스템 = 그 모양에 가하는 일** |
| 상속 = 무엇의 한 종류 | **컴포넌트 추가 = 다른 모양** |

마지막 줄이 제일 크게 바뀝니다.
`FlyingEnemy is a Enemy` 가 아니라 **`Enemy` 컴포넌트에 `Flying` 이 하나 더 붙은 모양**입니다.
상속 계층이 없고, 대신 조합이 있습니다.

> **엔티티는 물건이 아니라 자리입니다.**
> `Entity` 구조체 안에는 `Index` 와 `Version` 두 정수뿐입니다.
> "몇 번 청크의 몇 번째 칸" 을 가리키는 번호표이고, 데이터는 거기 줄 서 있습니다.

## 청크

아키타입마다 **고정 크기 메모리 블록**을 나눠 씁니다. 기본이 16KB 입니다.
그리고 청크 안에서는 컴포넌트가 **종류별로 배열처럼 줄지어** 있습니다.

<figure class="diagram">
<svg viewBox="0 0 680 240" role="img" aria-label="청크 안에서 컴포넌트가 종류별로 연속 배열로 놓여 있고 시스템이 필요한 배열만 앞에서 뒤로 훑는다는 그림">
<text x="20" y="24" font-size="13" fill="var(--text-muted)">청크 하나 (16KB) · 아키타입 A</text>
<g stroke="var(--border)" fill="none">
<rect x="20" y="34" width="500" height="130" rx="4"></rect>
</g>
<text x="34" y="60" font-size="11" fill="var(--text-muted)">Entity</text>
<g stroke="var(--border)" fill="var(--bg)">
<rect x="110" y="46" width="46" height="20"></rect>
<rect x="158" y="46" width="46" height="20"></rect>
<rect x="206" y="46" width="46" height="20"></rect>
<rect x="254" y="46" width="46" height="20"></rect>
<rect x="302" y="46" width="46" height="20"></rect>
<rect x="350" y="46" width="46" height="20"></rect>
<rect x="398" y="46" width="46" height="20"></rect>
<rect x="446" y="46" width="46" height="20"></rect>
</g>
<text x="34" y="96" font-size="11" fill="var(--accent)">LocalTransform</text>
<g stroke="var(--accent)" fill="var(--accent-soft)">
<rect x="110" y="82" width="46" height="20"></rect>
<rect x="158" y="82" width="46" height="20"></rect>
<rect x="206" y="82" width="46" height="20"></rect>
<rect x="254" y="82" width="46" height="20"></rect>
<rect x="302" y="82" width="46" height="20"></rect>
<rect x="350" y="82" width="46" height="20"></rect>
<rect x="398" y="82" width="46" height="20"></rect>
<rect x="446" y="82" width="46" height="20"></rect>
</g>
<text x="34" y="132" font-size="11" fill="var(--accent)">MoveStats</text>
<g stroke="var(--accent)" fill="var(--accent-soft)">
<rect x="110" y="118" width="46" height="20"></rect>
<rect x="158" y="118" width="46" height="20"></rect>
<rect x="206" y="118" width="46" height="20"></rect>
<rect x="254" y="118" width="46" height="20"></rect>
<rect x="302" y="118" width="46" height="20"></rect>
<rect x="350" y="118" width="46" height="20"></rect>
<rect x="398" y="118" width="46" height="20"></rect>
<rect x="446" y="118" width="46" height="20"></rect>
</g>
<text x="34" y="158" font-size="11" fill="var(--text-muted)">Health</text>
<g stroke="var(--border)" fill="var(--bg)">
<rect x="110" y="144" width="46" height="14"></rect>
<rect x="158" y="144" width="46" height="14"></rect>
<rect x="206" y="144" width="46" height="14"></rect>
<rect x="254" y="144" width="46" height="14"></rect>
<rect x="302" y="144" width="46" height="14"></rect>
<rect x="350" y="144" width="46" height="14"></rect>
<rect x="398" y="144" width="46" height="14"></rect>
<rect x="446" y="144" width="46" height="14"></rect>
</g>
<g stroke="var(--accent)" fill="none">
<path d="M110 174 L492 174"></path>
<path d="M482 169 L492 174 L482 179"></path>
</g>
<text x="112" y="192" font-size="12" fill="var(--accent)">이동 시스템은 칠해진 두 줄만 앞에서 뒤로 훑습니다</text>
<text x="112" y="212" font-size="12" fill="var(--text-muted)">Health 는 안 읽으니 캐시에 안 올라옵니다</text>
<text x="540" y="60" font-size="12" fill="var(--text-muted)">한 청크에</text>
<text x="540" y="78" font-size="12" fill="var(--text-muted)">몇 개가 들어가는지는</text>
<text x="540" y="96" font-size="12" fill="var(--text-muted)">컴포넌트 크기가 정합니다</text>
<text x="540" y="130" font-size="12" fill="var(--text-muted)">쓰지도 않는 필드를</text>
<text x="540" y="148" font-size="12" fill="var(--text-muted)">컴포넌트에 끼워 넣으면</text>
<text x="540" y="166" font-size="12" fill="var(--warn)">청크당 개수가 줄어듭니다</text>
</svg>
<figcaption>구조체의 배열이 아니라 배열의 구조체입니다. 필요한 줄만 읽으면 됩니다.</figcaption>
</figure>

여기서 두 가지가 나옵니다.

**하나. 안 쓰는 데이터는 캐시에 안 올라옵니다.**
`MonoBehaviour` 는 객체 하나를 만지면 그 객체의 모든 필드가 같이 딸려 옵니다.
청크는 컴포넌트별로 줄이 나뉘어 있어서 이동 시스템이 체력을 안 건드립니다.

**둘. 컴포넌트를 크게 만들면 손해입니다.**
청크 크기가 고정이라, 컴포넌트가 커지면 청크 하나에 들어가는 엔티티 수가 줄어듭니다.
같은 수를 처리하는 데 청크를 더 많이 오가야 합니다.
**"일단 다 넣어 두자" 가 여기서는 비용입니다.**

## 쿼리는 개체가 아니라 모양을 고른다

이 전환이 실제로 무슨 차이를 만드는지가 쿼리에서 드러납니다.

"`Health` 와 `MoveStats` 를 둘 다 가진 것 전부" 를 찾는다고 해 봅시다.

객체지향의 습관대로면 **1만 개를 하나씩 꺼내서 조건을 확인**합니다.
그것 말고 방법이 없습니다. 어떤 객체가 무엇을 갖고 있는지는 그 객체에게 물어봐야 아니까요.

ECS 는 그럴 필요가 없습니다.
**같은 모양끼리 이미 모여 있으므로, 모양에게 한 번만 물어보면 됩니다.**

<figure class="diagram">
<svg viewBox="0 0 680 300" role="img" aria-label="객체를 하나씩 만 번 검사하는 방식과 아키타입 셋만 검사하고 청크를 통째로 집는 방식의 대비">
<text x="20" y="22" font-size="13" fill="var(--warn)">객체를 하나씩 본다</text>
<g stroke="var(--warn)" fill="var(--bg)">
<rect x="20" y="34" width="12" height="12"></rect>
<rect x="36" y="34" width="12" height="12"></rect>
<rect x="52" y="34" width="12" height="12"></rect>
<rect x="68" y="34" width="12" height="12"></rect>
<rect x="84" y="34" width="12" height="12"></rect>
<rect x="100" y="34" width="12" height="12"></rect>
<rect x="116" y="34" width="12" height="12"></rect>
<rect x="132" y="34" width="12" height="12"></rect>
<rect x="148" y="34" width="12" height="12"></rect>
<rect x="164" y="34" width="12" height="12"></rect>
<rect x="180" y="34" width="12" height="12"></rect>
<rect x="196" y="34" width="12" height="12"></rect>
<rect x="212" y="34" width="12" height="12"></rect>
<rect x="228" y="34" width="12" height="12"></rect>
<rect x="244" y="34" width="12" height="12"></rect>
<rect x="260" y="34" width="12" height="12"></rect>
<rect x="276" y="34" width="12" height="12"></rect>
<rect x="292" y="34" width="12" height="12"></rect>
<rect x="308" y="34" width="12" height="12"></rect>
<rect x="324" y="34" width="12" height="12"></rect>
<rect x="20" y="50" width="12" height="12"></rect>
<rect x="36" y="50" width="12" height="12"></rect>
<rect x="52" y="50" width="12" height="12"></rect>
<rect x="68" y="50" width="12" height="12"></rect>
<rect x="84" y="50" width="12" height="12"></rect>
<rect x="100" y="50" width="12" height="12"></rect>
<rect x="116" y="50" width="12" height="12"></rect>
<rect x="132" y="50" width="12" height="12"></rect>
<rect x="148" y="50" width="12" height="12"></rect>
<rect x="164" y="50" width="12" height="12"></rect>
<rect x="180" y="50" width="12" height="12"></rect>
<rect x="196" y="50" width="12" height="12"></rect>
<rect x="212" y="50" width="12" height="12"></rect>
<rect x="228" y="50" width="12" height="12"></rect>
<rect x="244" y="50" width="12" height="12"></rect>
<rect x="260" y="50" width="12" height="12"></rect>
<rect x="276" y="50" width="12" height="12"></rect>
<rect x="292" y="50" width="12" height="12"></rect>
<rect x="308" y="50" width="12" height="12"></rect>
<rect x="324" y="50" width="12" height="12"></rect>
</g>
<text x="340" y="46" font-size="12" fill="var(--text-muted)">...</text>
<g stroke="var(--warn)" fill="none" stroke-dasharray="2 3">
<path d="M26 70 L330 70"></path>
</g>
<text x="20" y="88" font-size="12" fill="var(--warn)">칸 하나마다 "조건에 맞나" 를 묻습니다. 10,000번.</text>
<g stroke="var(--warn)" fill="none">
<rect x="430" y="30" width="230" height="62" rx="4"></rect>
</g>
<text x="450" y="56" font-size="13" fill="var(--warn)">조건 검사 10,000회</text>
<text x="450" y="78" font-size="12" fill="var(--text-muted)">엔티티가 늘면 같이 늘어납니다</text>
<g stroke="var(--border)" fill="none">
<path d="M20 112 L660 112"></path>
</g>
<text x="20" y="140" font-size="13" fill="var(--accent)">모양을 하나씩 본다</text>
<g stroke="var(--accent)" fill="var(--accent-soft)">
<rect x="20" y="152" width="150" height="40" rx="4"></rect>
</g>
<text x="32" y="170" font-size="11" fill="var(--accent)">A · 위치 이동 체력</text>
<text x="32" y="186" font-size="11" fill="var(--accent)">맞음</text>
<g stroke="var(--accent)" fill="var(--accent-soft)">
<rect x="180" y="152" width="150" height="40" rx="4"></rect>
</g>
<text x="192" y="170" font-size="11" fill="var(--accent)">B · A + 사망</text>
<text x="192" y="186" font-size="11" fill="var(--accent)">맞음</text>
<g stroke="var(--border)" fill="var(--bg)">
<rect x="340" y="152" width="150" height="40" rx="4"></rect>
</g>
<text x="352" y="170" font-size="11" fill="var(--text-muted)">C · 위치 체력</text>
<text x="352" y="186" font-size="11" fill="var(--text-muted)">이동이 없음. 건너뜀</text>
<g stroke="var(--accent)" fill="none">
<path d="M95 196 L95 216"></path>
<path d="M255 196 L255 216"></path>
</g>
<g stroke="var(--accent)" fill="var(--accent-soft)">
<rect x="20" y="218" width="310" height="26" rx="3"></rect>
</g>
<text x="34" y="236" font-size="12" fill="var(--accent)">A 와 B 의 청크를 통째로 집습니다 (안에 9,800개)</text>
<text x="20" y="266" font-size="12" fill="var(--accent)">안을 열어 하나씩 확인하지 않습니다. 같은 모양인 것이 이미 보장이니까요.</text>
<g stroke="var(--accent)" fill="none">
<rect x="430" y="140" width="230" height="62" rx="4"></rect>
</g>
<text x="450" y="166" font-size="13" fill="var(--accent)">조건 검사 3회</text>
<text x="450" y="188" font-size="12" fill="var(--text-muted)">엔티티가 100만이어도 3회입니다</text>
<text x="430" y="236" font-size="12" fill="var(--text-muted)">양쪽 다 실제 계산은</text>
<text x="430" y="256" font-size="12" fill="var(--text-muted)">9,800번으로 같습니다.</text>
<text x="430" y="280" font-size="12" fill="var(--warn)">줄어든 것은 대상을 고르는 비용입니다.</text>
</svg>
<figcaption>위는 칸을 하나씩 세고, 아래는 상자를 셋만 셉니다. 상자 안은 세지 않습니다.</figcaption>
</figure>

같은 일을 코드로 놓고 보면 이렇습니다.

<x-compare>
<script type="application/json">
{
  "tabs": [
    { "label": "객체를 하나씩", "code": "// 무엇을 갖고 있는지는\n// 그 객체에게 물어봐야 안다\nforeach (var o in 오브젝트_10000개)\n{\n    if (o.Health != null &&\n        o.MoveStats != null)\n        대상.Add(o);\n}", "text": "앞의 결과가 다음 판단에 아무 도움이 안 됩니다. 1번째를 확인한 것이 2번째에 대해 알려 주는 게 없어서 매번 처음부터 봅니다.", "stats": [["조건 검사", "10,000회"], ["실제 계산", "9,800회"], ["엔티티가 10배면", "검사도 10배"]], "tone": "bad" },
    { "label": "모양을 하나씩", "code": "// 같은 모양끼리 이미 모여 있다\n// 모양에게 한 번만 물어보면 된다\nforeach (var arch in 아키타입_3개)\n{\n    if (arch.Has<Health>() &&\n        arch.Has<MoveStats>())\n        대상.AddRange(arch.청크전부);\n}", "text": "청크를 통째로 넣습니다. 그 안의 9,800개를 하나씩 보지 않습니다. 같은 모양인 것이 이미 보장돼 있으니까요.", "stats": [["조건 검사", "3회"], ["실제 계산", "9,800회"], ["엔티티가 10배면", "검사는 그대로"]], "tone": "good" }
  ]
}
</script>
</x-compare>

**계산은 양쪽 다 9,800번 합니다.** 줄어든 것은 그게 아닙니다.
줄어든 것은 **누구에게 시킬지 고르는 비용**이고, 10,000번에서 3번이 됐습니다.

## 세 단계로 뜯어보기

쿼리가 도는 과정을 단위로 나누면 이렇습니다.

<x-steps>
<script type="application/json">
{
  "steps": [
    { "tag": "1", "title": "모양을 고른다", "text": "월드에 있는 아키타입 목록을 훑으며 조건에 맞는 것만 남깁니다. 아키타입은 보통 수십 개입니다. 엔티티가 100만이어도 이 단계는 안 늘어납니다." },
    { "tag": "2", "title": "청크를 모은다", "text": "고른 아키타입에 딸린 청크들이 그대로 대상이 됩니다. 청크 안을 들여다보지 않습니다. 같은 아키타입이라는 것이 이미 보장이니까요." },
    { "tag": "3", "title": "청크 안을 훑는다", "text": "청크 하나를 잡고 앞에서 뒤로 갑니다. 필요한 컴포넌트 배열만 읽습니다. 여기가 유일하게 엔티티 수에 비례하는 단계입니다." }
  ]
}
</script>
</x-steps>

여기서 두 가지가 따라 나옵니다.

**하나. 조건을 늘려도 거의 안 비쌉니다.**
`Health` 와 `MoveStats` 에 `Enemy` 와 `LocalTransform` 을 더 걸어도
1단계에서 비교할 것이 조금 늘 뿐입니다. **엔티티 수와는 무관합니다.**
객체지향이었다면 조건 하나가 늘 때마다 1만 번의 확인이 더 붙습니다.

**둘. 1단계와 2단계 결과는 캐시됩니다.**
아키타입 목록은 엔티티를 만들거나 컴포넌트를 붙이지 않는 한 안 바뀝니다.
그래서 쿼리는 대개 한 번 만들어 두고 매 프레임 재사용합니다.
매 프레임 새로 거르는 것이 아닙니다.

## 그래서 쿼리에 안 맞는 조건이 있다

이 구조를 알면 **왜 어떤 것은 쿼리로 못 거르는지**도 같이 보입니다.

```csharp
// 됩니다. 모양으로 가릅니다
SystemAPI.Query<RefRW<LocalTransform>, RefRO<MoveStats>>()

// 안 됩니다. 값으로 가르는 것은 모양이 아닙니다
// "체력이 50 이하인 적" 같은 조건
```

**체력이 50 이하냐는 아키타입에 안 적혀 있습니다.** 값은 청크 안에 들어 있으니까요.
그래서 이런 조건은 3단계에서, 즉 순회하면서 `if` 로 걸러야 합니다.

값으로 자주 가르는 조건이 있다면 **그 상태를 컴포넌트로 만들어 모양에 넣는 것**이
ECS 다운 답입니다. `LowHealth` 태그를 붙였다 뗐다 하면 쿼리가 걸러 줍니다.
대신 붙였다 떼는 것이 곧 **구조 변경**이라 공짜가 아니고, 그 비용은 따로 재 봐야 합니다.

## 그래서 무엇이 달라지나

| | MonoBehaviour | ECS |
| --- | --- | --- |
| 데이터 위치 | 힙 여기저기 | 청크 안에 연속 |
| 순회 | 객체마다 가상 호출 | 배열 순회 |
| 조건 검사 | 객체마다 | 아키타입마다 |
| 안 쓰는 필드 | 같이 캐시에 올라옴 | 안 올라옴 |
| 병렬화 | 어렵습니다. 무엇을 참조할지 모릅니다 | 청크 단위로 쪼갤 수 있습니다 |

마지막 줄이 중요합니다. **데이터를 이렇게 둔 결과로 병렬화가 가능해집니다.**
청크 A 와 청크 B 가 서로를 안 건드린다는 것이 **구조로 보장**되기 때문입니다.

## 안 맞는 자리

ECS 가 항상 답은 아닙니다.

- **개수가 적으면** 얻는 게 없습니다. 하나짜리를 청크에 넣어 봐야 캐시 이득이 없습니다
- **컴포넌트 구성이 자주 바뀌면** 구조 변경 비용이 계속 듭니다.
  상태가 붙었다 떨어졌다 하는 대상은 아키타입을 계속 갈아탑니다
- **이터레이션이 잦으면** 개발 속도가 느려집니다.
  값 하나 노출하는 데 authoring 과 baker 를 거쳐야 하고, 로그 하나 찍기가 번거롭습니다

**개수가 많고, 구성이 안정적이고, 매 프레임 전부 도는 것.** 여기에 씁니다.