---
title: "브릿지는 표면적이 좁을수록 좋다"
description: "플레이어 체력을 ECS 에 넘길 뻔했는데 결국 bool 한 비트로 끝났습니다. 필드를 추가하는 기준은 반대편이 그걸 읽느냐입니다."
author: "유니얼"
pubDate: 2026-09-09
category: "game-dev/unity/dots"
tags: ["Unity", "DOTS"]
canonical: https://blog.unialgames.com/posts/dots-bridge-surface/
---
적이 플레이어를 때리게 만들면서 질문이 하나 생겼습니다.
**플레이어 체력을 ECS 쪽에도 둬야 하나.**

적 1만 마리가 각자 "플레이어가 아직 살아 있나" 를 알아야 하니까,
`PlayerState` 싱글톤에 `Health` 를 넣으면 될 것 같았습니다.

## 안 넣었습니다

체력을 양쪽에 두면 **진실의 출처가 둘** 이 됩니다.
GameObject 쪽 `Hp` 와 ECS 쪽 복사본을 매 프레임 동기화해야 하고,
한 프레임이라도 어긋나면 "UI 는 500인데 ECS 는 300" 이 됩니다.

그래서 그때는 아예 안 넣었습니다. 기준은 이거였습니다.

> **브릿지에 필드를 추가하는 기준은 "반대편이 그걸 읽느냐" 다.**

그때는 플레이어 체력을 읽는 ECS 시스템이 **0개** 였습니다.
그러니 넘길 이유가 없었습니다.

## 나중에 읽는 쪽이 생겼다

플레이어 사망을 넣으면서 `EnemyAttackSystem` 이 "죽었나" 를 알아야 하게 됐습니다.
읽는 쪽이 생겼으니 이제 넘겨야 합니다.

그래도 `Health` 가 아니라 **`bool IsDead` 한 비트** 만 넣었습니다.

```csharp
public struct PlayerState : IComponentData
{
    public float3 Position;
    public bool IsDead;      // Health 가 아닙니다
}
```

적은 "몇 HP 남았나" 를 알 필요가 없습니다. **때릴지 말지만 정하면 됩니다.**
숫자를 넘기면 동기화 문제가 따라오지만, 비트 하나는 그렇지 않습니다.

미룬 것이 옳았습니다. 미리 넣었으면 몇 주 동안 쓰지도 않는 값을
매 프레임 동기화하고 있었을 것입니다.

> 참고로 `bool` 은 unmanaged 컴포넌트 필드로 **명시적으로 허용** 됩니다.
> 재미있는 건 문서가 이걸 blittable 타입과 **따로** 나열한다는 점입니다.
> C# 에서 `bool` 은 마샬링 크기가 정해져 있지 않아 엄밀히는 blittable 이 아닌데,
> 유니티가 예외로 허용해 준 것입니다.

## 부분 갱신을 빠뜨렸다

여기서 조용히 틀렸습니다.

브릿지가 둘인데 같은 싱글톤을 각자 씁니다.
`PlayerStateBridge` 는 매 프레임 위치를 쓰고, `PlayerHealthBridge` 는 죽을 때 `IsDead` 를 씁니다.

```csharp
// PlayerStateBridge, 매 프레임
_em.SetComponentData(e, new PlayerState { Position = transform.position });
//                       ↑ new 라서 IsDead 가 default(false) 로 리셋됩니다
```

`PlayerHealthBridge` 가 아무리 `IsDead = true` 로 세팅해도 **다음 프레임에 지워집니다.**
그리고 **에러는 한 줄도 안 납니다.** 죽은 플레이어를 적이 계속 때릴 뿐입니다.

고치는 방법은 읽고, 내 필드만 고치고, 되돌려 쓰는 것입니다.

```csharp
var s = _em.GetComponentData<PlayerState>(e);
s.Position = transform.position;         // 내 필드만
_em.SetComponentData(e, s);
```

`GetSingleton` 이 복사본을 준다는 사실이 여기서 다시 걸립니다.
**복사본이라서 되돌려 써야 하고, 되돌려 쓰기 때문에 나머지 필드가 딸려 갑니다.**
싱글톤을 여러 곳에서 쓰는 순간 이 함정이 생깁니다.

## 잡이 아니라 시스템에서 나간다

플레이어가 죽으면 적은 공격을 멈춰야 합니다. 어디서 나가느냐가 성능을 가릅니다.

```csharp
// 잡 안에서 나가기
void Execute(ref EnemyAttack attack)
{
    if (PlayerIsDead) return;     // 1만 번 순회는 그대로 합니다
}
```

```csharp
// 시스템에서 나가기
var player = SystemAPI.GetSingleton<PlayerState>();
if (player.IsDead) return;        // 잡 스케줄 자체가 없습니다
```

잡 안에서 조기 반환하면 1만 마리를 다 돌면서 매번 조건만 확인합니다.
**시스템에서 나가면 잡이 아예 안 만들어집니다.** 사망 후 이 시스템은 말 그대로 공짜입니다.

같은 조건문인데 두는 위치로 비용이 갈립니다.
**1만 개짜리 순회에서는 "어디서 판단하는가" 가 "무엇을 판단하는가" 만큼 중요합니다.**

## 검증

부분 갱신이 제대로 도는지를 이렇게 확인했습니다.

- `t = 6.6` 에 체력이 0이 되고 **`IsDead = True`** 로 전환
- 적의 공격 `Timer` 를 6초 동안 4회 샘플링했는데 **전부 `1.000` 고정**
- `IsDead` 가 수십 프레임 유지됨

두 번째가 핵심입니다. Baker 초기값이 0이라서 `1.000` 은
**한 번 때리고 쿨다운으로 리셋된 직후에만** 나오는 값입니다.
그게 고정돼 있다는 것은 시스템이 아예 안 돈다는 뜻입니다. 때리다가 딱 멈췄습니다.

세 번째는 부분 갱신이 `Position` 에 안 밟혔다는 증거입니다.
앞의 버그가 남아 있었다면 다음 프레임에 `False` 로 돌아갔을 것입니다.

**"안 도는 것" 을 확인하려면 안 도는 것의 흔적을 찾아야 합니다.**
로그가 안 찍히는 것만으로는 시스템이 멈춘 건지 로그를 안 넣은 건지 모릅니다.

## 여기까지의 경계

브릿지 넷을 만들고 나서 경계가 이렇게 정리됐습니다.

| 무엇 | 어디 | 왜 |
| --- | --- | --- |
| 플레이어 위치 | GO 가 소유, ECS 가 읽음 | 적이 추적해야 합니다 |
| 플레이어 체력 | **GO 만** | 적은 알 필요가 없습니다 |
| 플레이어 생사 | GO 가 소유, ECS 가 읽음 (`bool` 하나) | 때릴지 말지 |
| 적 체력 | **ECS 만** | GO 는 알 필요가 없습니다 |
| 적 사망 사건 | ECS → GO (큐) | 연출은 GO 쪽입니다 |

**넘기는 것이 적을수록 어긋날 자리가 적습니다.**