---
title: "방향이 다르면 통로의 모양도 다르다"
description: "GameObject 세계와 ECS 세계를 잇는 통로 세 개를 뚫었는데 셋의 모양이 전부 달랐습니다. 방향과 건수가 모양을 정합니다."
author: "유니얼"
pubDate: 2026-09-09
category: "game-dev/unity/dots"
tags: ["Unity", "DOTS"]
canonical: https://blog.unialgames.com/posts/dots-go-ecs-bridge/
---
플레이어는 GameObject 입니다. 콤보, 타격감, 카메라, UI 가 전부 거기 붙어 있습니다.
적 1만은 ECS 입니다. 그리고 둘은 서로를 직접 못 봅니다.

엔티티는 `GetComponent` 로 못 찾고, `MonoBehaviour` 는 잡 안에서 못 만집니다.
통로를 뚫어야 하는데, 통로 셋을 만들고 나니 **셋의 모양이 전부 달랐습니다.**

## 통로 셋

<figure class="diagram">
<svg viewBox="0 0 680 240" role="img" aria-label="GameObject 세계와 ECS 세계를 잇는 세 가지 브릿지의 방향과 방식 비교">
<g stroke="var(--border)" fill="none">
<rect x="20" y="30" width="150" height="180" rx="4"></rect>
<rect x="510" y="30" width="150" height="180" rx="4"></rect>
</g>
<text x="34" y="52" font-size="12" fill="var(--text-muted)">GameObject</text>
<text x="34" y="70" font-size="11" fill="var(--text-muted)">플레이어 · UI · VFX</text>
<text x="524" y="52" font-size="12" fill="var(--text-muted)">ECS</text>
<text x="524" y="70" font-size="11" fill="var(--text-muted)">적 1만 · 시스템</text>
<g stroke="var(--accent)" fill="none">
<path d="M174 104 L506 104"></path>
<path d="M496 99 L506 104 L496 109"></path>
</g>
<text x="196" y="98" font-size="11" fill="var(--accent)">싱글톤 덮어쓰기 · 최신값 1개</text>
<text x="196" y="120" font-size="11" fill="var(--text-muted)">PlayerStateBridge (위치)</text>
<g stroke="var(--accent)" fill="none">
<path d="M174 150 L506 150"></path>
<path d="M496 145 L506 150 L496 155"></path>
</g>
<text x="196" y="144" font-size="11" fill="var(--accent)">요청 엔티티 · 사건 1건</text>
<text x="196" y="166" font-size="11" fill="var(--text-muted)">PlayerAttackBridge (공격)</text>
<g stroke="var(--warn)" fill="none">
<path d="M506 196 L174 196"></path>
<path d="M184 191 L174 196 L184 201"></path>
</g>
<text x="196" y="190" font-size="11" fill="var(--warn)">NativeQueue 싱글톤 · 사건 N건</text>
<text x="196" y="212" font-size="11" fill="var(--text-muted)">DeathEventBridge · PlayerHealthBridge</text>
</svg>
<figcaption>방향이 하나 다를 뿐인데 세 번째만 모양이 다릅니다.</figcaption>
</figure>

| 브릿지 | 방향 | 방식 | 왜 이 방식인가 |
| --- | --- | --- | --- |
| `PlayerStateBridge` | GO → ECS | 싱글톤 덮어쓰기 | 매 프레임 최신값 하나면 충분합니다 |
| `PlayerAttackBridge` | GO → ECS | 요청 엔티티 던지기 | 사건 1건, 수명 1프레임 |
| `DeathEventBridge` · `PlayerHealthBridge` | **ECS → GO** | `NativeQueue` 싱글톤 | 사건 N건, 받는 쪽이 GO |

모양을 정한 것은 두 가지입니다. **방향**과 **건수** 입니다.

위치는 매 프레임 최신값 하나만 있으면 되니 덮어쓰면 됩니다.
공격은 일어난 순간에 한 번이니 사건 하나를 던집니다.
사망은 한 프레임에 9,600건이 한꺼번에 일어날 수 있고, 받는 쪽이 GO 라서 통로가 달라집니다.

## `GetSingleton` 은 복사본을 준다

여기서 한 번 크게 헷갈렸습니다.

```csharp
var state = SystemAPI.GetSingleton<PlayerState>();
state.Position = newPos;              // 복사본을 고친 것뿐입니다
```

`GetSingleton` 은 **값을 복사해서 줍니다.** 고쳐도 원본이 안 바뀝니다.
`SetComponentData` 로 되돌려 써야 반영됩니다.

그런데 큐는 반대로 동작합니다.

```csharp
var queue = SystemAPI.GetSingleton<DeathEventQueue>();
while (queue.Value.TryDequeue(out var e)) { ... }   // 원본이 비워집니다
```

**`NativeQueue` 는 구조체 안에 포인터 하나만 들고 있습니다.**
복사본을 만들어도 그 포인터가 가리키는 메모리는 하나입니다.

같은 `GetSingleton` 인데 하나는 되돌려 써야 하고 하나는 안 그래도 되는 이유가 이것입니다.
**값이냐 포인터냐** 입니다. 앞에서 잡 필드를 볼 때 나왔던 것과 같은 구분입니다.

## `CreateSingleton` 은 편의 함수가 아니라 가드다

```csharp
public void OnCreate(ref SystemState state)
{
    state.EntityManager.CreateSingleton(new PlayerState());
}
```

`CreateEntity` 하고 `AddComponentData` 하는 것과 동작은 같습니다.
다른 점은 **"이미 있는데 또 만든다" 를 만드는 시점에 잡아 준다**는 것입니다.

수동으로 하면 두 개가 조용히 만들어지고, 한참 뒤 `GetSingleton` 에서
"싱글톤이 하나가 아니다" 로 터집니다. 그때는 어디서 두 번 만들었는지 찾아야 합니다.

## 만드는 것과 공개하는 것은 별개다

이 구분을 몰라서 큐가 통째로 안 돌았습니다.

```csharp
_queue = new NativeQueue<DeathEvent>(Allocator.Persistent);   // 만들었습니다
// CreateSingleton 을 안 했습니다
```

`new NativeQueue` 는 **사물함을 하나 사는 일** 이고,
`CreateSingleton` 은 **그 사물함 주소를 게시판에 붙이는 일** 입니다.

후자를 빼먹으면 큐는 멀쩡히 있는데 아무도 못 찾습니다.
그리고 소비자 쪽 코드가 이렇게 생겼습니다.

```csharp
if (_queue.IsEmpty) return;   // 큐를 못 찾았는데 여기서 조용히 나갑니다
```

**가드가 실패를 삼킵니다.** 에러도 로그도 없고 사망 연출만 안 나옵니다.
방어 코드가 진단을 막는 자리입니다.

## 월드는 언제 존재하나

브릿지는 `MonoBehaviour` 라서 `Start` 나 `Update` 에서 ECS 월드를 잡아야 합니다.

```csharp
_em = World.DefaultGameObjectInjectionWorld.EntityManager;   // Start 에서 안전합니다
```

`AutomaticWorldBootstrap` 이 `[RuntimeInitializeOnLoadMethod(BeforeSceneLoad)]` 라서
**`Start` 시점에는 월드가 반드시 있습니다.**

**단 SubScene 엔티티는 아직 없습니다.** 비동기로 로드되기 때문입니다.
그래서 시스템 쪽은 `RequireForUpdate` 로 막고,
브릿지 쪽은 쿼리가 비었는지 확인하고 나가는 방어가 필요합니다.

이 두 가지를 헷갈리면 "월드는 있는데 싱글톤이 없다" 로 터집니다.