---
title: "결과가 같은 두 API 사이에서 33퍼센트가 갈린다"
description: "1만 마리가 동시에 죽을 때 DeadTag 를 AddComponent 로 붙이느냐 비트로 켜느냐. 코드는 몇 줄 차이인데 프레임 스파이크가 31.71ms 와 42.26ms 로 갈렸습니다."
author: "유니얼"
pubDate: 2026-09-09
category: "game-dev/unity/dots"
tags: ["Unity", "DOTS", "최적화", "프로파일링"]
canonical: https://blog.unialgames.com/posts/dots-structural-change/
---
<x-repo>
<script type="application/json">
{
  "repo": "SeoBYP/ecs-warriors",
  "text": "적 1만 마리를 돌리는 3D 무쌍 액션. 이 글의 A/B 는 여기 씬에서 조건을 맞춰 돌렸습니다.",
  "chips": ["Unity 6000.5.4f1", "Entities 6.5.0", "Burst 1.8.29", "Collections 6.5.0"],
  "links": [
    { "text": "week4-deadtag-ab.csv (측정 원본)", "path": "docs/benchmarks/week4-deadtag-ab.csv" }
  ],
  "note": "측정 조건: 광역기로 9,600 마리를 동시에 사망시키고 그 프레임의 스파이크만 각 조건 20라운드씩. 발사 직전 스폰 정지, 비포커스 프레임 제외."
}
</script>
</x-repo>

광역기 한 방에 적 1만 마리가 동시에 죽습니다. 그 프레임만 튑니다.

죽었다는 표시를 하는 방법이 두 가지 있습니다.

```csharp
// A. 이미 붙어 있는 컴포넌트를 켠다
if (health.Value <= 0) dead.ValueRW = true;

// B. 컴포넌트를 붙인다
if (health.Value <= 0) ecb.AddComponent<DeadTag>(sortKey, entity);
```

**결과는 똑같습니다.** 둘 다 "이 엔티티는 죽었다" 를 표시하고,
뒤따르는 시스템이 그걸 보고 파괴합니다.

## 구조 변경이란 무엇인가

ECS 는 같은 컴포넌트 구성을 가진 엔티티를 한 청크에 모아 둡니다.
그 구성을 아키타입이라 합니다.

컴포넌트를 하나 붙이면 **아키타입이 바뀝니다.**
`Enemy + Health` 였던 것이 `Enemy + Health + DeadTag` 가 되니 다른 청크로 가야 합니다.
데이터를 통째로 옮기고, 원래 청크의 빈자리를 메우고, 다른 엔티티의 인덱스를 갱신합니다.
**이사입니다.**

이걸 구조 변경이라 부르고, 공식 문서가 제약을 하나 못 박아 뒀습니다.

> You can only perform them on the main thread; **not from jobs.**

잡 안에서는 못 합니다. 여러 워커가 동시에 청크를 재배치하면 서로를 밟으니까요.
그래서 잡 안에서는 `EntityCommandBuffer` 에 **예약만** 하고,
잡이 끝난 뒤 **메인 스레드가 하나씩 재생**합니다.

반면 `IEnableableComponent` 는 컴포넌트를 미리 붙여 두고 켜고 끄기만 합니다.

> the target entity **doesn't change its archetype**, ECS **doesn't move any data**

아키타입이 안 바뀌니 이사가 없습니다. 그리고 이사가 없으니 **잡 안에서 해도 됩니다.**

## 재 봤습니다

원리는 A가 유리하다고 말합니다. 얼마나 유리한지는 다른 문제입니다.

광역기로 **9,600 마리를 동시에 죽이고** 그 프레임의 스파이크를 쟀습니다. 각 조건 20라운드씩.

### 측정 조건

이 실험은 조건을 맞추는 데 재는 시간보다 오래 걸렸습니다.

- **수렴을 기다립니다.** 스폰 반경 100에 속도 3이면 적이 다 모이는 데 최대 47초 걸립니다.
  95퍼센트가 사거리 안에 들어올 때까지 기다린 뒤 발사합니다.
  덕분에 40라운드가 전부 9,592에서 9,651 사이(**0.6퍼센트 안**)로 맞았습니다.
  **죽은 수가 다르면 애초에 비교가 안 됩니다**
- **발사 직전에 `SpawnSystem` 을 끕니다.** 안 끄면 `프레임 N: 1만 파괴` 다음에
  `프레임 N+1: 1만 생성` 이 겹쳐서 사망 비용만 떼어낼 수가 없습니다
- **비포커스 프레임은 버립니다.** 에디터가 백그라운드면 유니티가 루프를 스로틀해서
  프레임타임이 작업 시간이 아니라 대기 시간이 됩니다
- **라벨을 손으로 안 답니다.** `TypeManager.IsEnableable(GetTypeIndex<DeadTag>())` 로
  타입이 스스로 말하게 했습니다. 브랜치를 바꿔 놓고 라벨을 안 바꾸는 사고를 원천 차단합니다

### 결과

| | **A: Enableable** | **B: AddComponent** |
| --- | --- | --- |
| 죽은 수 | 9,614 | 9,624 |
| **스파이크 최대** | **31.71 ms** (sd 6.96) | **42.26 ms** (sd 4.03) |
| 범위 | 24.26 ~ 44.38 | **34.20** ~ 51.63 |
| | | +10.55 ms (**+33퍼센트**) · t = 5.87 |

```text
A:  24.26 ────────── 44.38
B:                34.20 ────────── 51.63
                    ↑ B 의 최솟값이 A 의 평균(31.71)보다 높다
```

**B 의 20라운드 중 최고 성적이 A 의 평균보다 나쁩니다.** 우연으로 설명이 안 됩니다.

## 무엇이 갈랐나

"비트라서 싸다" 가 절반입니다. 그리고 나머지 절반이 더 큽니다.

<x-compare>
<script type="application/json">
{
  "tabs": [
    { "label": "A. 비트 토글", "code": "// 잡 안에서 바로\nif (health.Value <= 0)\n    dead.ValueRW = true;", "text": "워커 스레드가 자기 자리에서 비트만 켭니다. 1만 명이 동시에 합니다.", "stats": [["누가 하나", "워커 4개 병렬"], ["스파이크 최대", "31.71 ms"]], "tone": "good" },
    { "label": "B. 컴포넌트 추가", "code": "// 잡 안에서는 예약만\nif (health.Value <= 0)\n    ecb.AddComponent<DeadTag>(sortKey, entity);\n\n// 시스템에서\nhandle.Complete();\necb.Playback(state.EntityManager);", "text": "ECB 에 1만 건을 예약한 뒤, 메인 스레드가 혼자 하나씩 재생합니다.", "stats": [["누가 하나", "메인 스레드 1개"], ["스파이크 최대", "42.26 ms"]], "tone": "bad" }
  ]
}
</script>
</x-compare>

> **1만 명이 동시에 하는 것과 1명이 순서대로 하는 것의 차이입니다.**
> 연산이 싸다는 것보다 **병렬로 할 수 있다**는 쪽이 큽니다.

## Enableable 도 공짜는 아니다

A가 이겼다고 비용이 0인 것은 아닙니다. 문서에 이렇게 적혀 있습니다.

> to prevent race conditions, **jobs with write access to enableable components might cause
> main-thread operations to block until the job completes**, even if the job doesn't enable
> or disable the component on any entities.

`EnabledRefRW<DeadTag>` 를 쓰는 것만으로, **한 마리도 안 죽여도**
메인 스레드가 막힐 수 있습니다. 데미지 적용 잡이 정확히 그 경우입니다.

정리하면 이렇습니다.

| | 비용의 정체 |
| --- | --- |
| A. Enableable | **동기화 대기** |
| B. AddComponent | **구조 변경 1만 회** |

둘 다 비용이 있고 **종류가 다릅니다.** A 가 항상 답인 것도 아닙니다.
한 프레임에 몇 개만 바뀌는 상태라면 구조 변경이 문제가 안 되고,
오히려 쿼리에서 아예 빠지는 쪽이 순회 비용을 줄입니다.

## 지표를 바꾸니 결론이 나왔다

이 실험은 **같은 데이터로 두 번 결론이 갈렸습니다.** 이 부분이 결과보다 더 남았습니다.

처음 쓴 지표는 이랬습니다.

```text
초과분 = (발사 후 15프레임 합) - (기준선 × 15)
```

| | 평균 | sd | 범위 |
| --- | --- | --- | --- |
| A | 11.84 | **17.48** | **-20.78** ~ 60.17 |
| B | 16.46 | 10.01 | 2.08 ~ 39.82 |

`t = 1.03`. 아무 말도 못 합니다. 그리고 **A 에 음수가 나옵니다.**
9,600마리를 죽였는데 평상시보다 빨랐다는 뜻이라 말이 안 됩니다.

원인은 지표의 모양이었습니다. 기준선을 재는 60프레임 동안 에디터 히칭이 한 번만 끼어도
기준선이 부풀고, 그만큼 초과분이 음수로 튑니다.

> **두 노이즈 값을 빼서 만든 지표는 노이즈가 더해집니다.**

`스파이크 최대` 로 바꾸니 `t = 5.87` 이 나왔습니다.
발사 프레임 하나만 보므로 기준선을 안 뺍니다.
40라운드 **전부 `@frame+0`** 에서 최댓값이 나온 것이 이 지표가 옳다는 증거였습니다.

표본도 결론을 바꿨습니다. 같은 실험이 **n=5 에서 `t = 1.27`** 이었습니다.
n을 20으로 늘리니 뚫렸습니다. **노이즈가 크면 표본으로 이깁니다.**

다만 이건 신호가 10ms 라서 가능했습니다.
신호가 1ms 인데 노이즈가 ±0.5ms 인 실험은 표본을 늘려도 가망이 없습니다.
**무엇을 재느냐가 정해지면 이길 수 있는 싸움인지도 대체로 정해집니다.**