---
title: "수신자가 살아 있으면 버퍼, 사라지면 큐"
description: "데미지는 적의 버퍼에 담고 사망은 월드 전역 큐에 담습니다. 주인공이 파괴되기 때문입니다. 그리고 값을 담을지 참조를 담을지도 같은 이유로 갈립니다."
author: "유니얼"
pubDate: 2026-09-09
category: "game-dev/unity/dots"
tags: ["Unity", "DOTS"]
canonical: https://blog.unialgames.com/posts/dots-buffer-vs-queue/
---
공격 판정이 끝나면 "이 적이 10 맞았다" 를 어딘가에 적어야 합니다.
그리고 체력이 0이 되면 "이 적이 죽었다" 를 또 어딘가에 적어야 합니다.

둘 다 사건인데 **담는 곳이 다릅니다.**

## 데미지는 버퍼에

```csharp
public struct DamageEvent : IBufferElementData
{
    public int Amount;
    public float3 SourcePos;
    public float KnockbackScale;
}
```

`IBufferElementData` 는 엔티티마다 붙는 가변 길이 배열입니다.
한 프레임에 세 명이 때렸으면 그 적의 버퍼에 세 개가 쌓입니다.

**데미지는 "이 적에게 일어난 일" 이라 그 적이 들고 있으면 됩니다.**
그리고 적용할 때 합산하고 비웁니다.

```csharp
int total = 0;
foreach (var d in buffer) total += d.Amount;
if (total > 0) health.Value -= total;
buffer.Clear();                      // 안 비우면 다음 프레임에 또 맞습니다
```

`Clear()` 를 빼먹으면 **유령 데미지** 가 계속 들어갑니다. 한 대 맞고 계속 죽습니다.

## 사망은 큐에

사망도 버퍼에 담고 싶어지는데, 안 됩니다.

**주인공이 `DestroyEntity` 로 사라지기 때문입니다.**
죽은 엔티티의 버퍼에 사망 이벤트를 담으면 엔티티와 함께 그 버퍼도 사라집니다.
받아 갈 사람이 읽기 전에요.

그래서 **엔티티 바깥의 전역 수집처** 가 필요합니다.

```csharp
public struct DeathEventQueue : IComponentData
{
    public NativeQueue<DeathEvent> Value;
}
```

기준을 한 문장으로 하면 이렇습니다.

> **사건이 끝난 뒤에도 수신자가 살아 있으면 그 엔티티의 버퍼, 사라지면 월드 전역 큐.**

그리고 둘 다 안 비우면 문제가 생기는데 **증상이 다릅니다.**
버퍼는 유령 데미지, 큐는 메모리 누수입니다. 같은 병에 다른 증상입니다.

## 파괴 전에 스냅샷

사망 이벤트에는 이펙트를 터뜨릴 위치가 필요합니다. 그런데 적은 곧 사라집니다.

```csharp
foreach (var (transform, entity) in SystemAPI.Query<RefRO<LocalTransform>>().WithEntityAccess())
{
    queue.Enqueue(new DeathEvent { Position = transform.ValueRO.Position });  // 값을 복사
    ecb.DestroyEntity(entity);
}
```

`ecb.DestroyEntity` 는 **예약일 뿐** 이라 실제 파괴는 `Playback` 에서 일어납니다.
그래서 foreach 안에서는 위치가 아직 유효합니다. 거기서 값을 떠 옵니다.

## 소비는 `LateUpdate` 에서

큐를 비우는 쪽은 `MonoBehaviour` 입니다. 그런데 `Update` 에서 받으면 **1프레임 밀립니다.**

`SimulationSystemGroup` 이 유니티 플레이어 루프의 Update 페이즈 안에 있어서,
`MonoBehaviour.Update` 와 순서가 뒤섞입니다. 어떤 프레임은 시스템이 먼저 돌고
어떤 프레임은 브릿지가 먼저 돕니다.

`LateUpdate` 는 그 페이즈 뒤라 **항상 시스템 다음** 입니다.

```csharp
void LateUpdate()
{
    while (_queue.TryDequeue(out var e)) SpawnDeathEffect(e.Position);
}
```

여기서는 `JobHandle` 이 필요 없습니다.
생산하는 `DeathSystem` 도 메인 스레드고 소비도 메인 스레드니까요.
`DestroyEntity` 자체가 메인 스레드 전용이라 병렬로 만들 이유도 없습니다.

**반면 적이 플레이어를 때리는 큐는 다릅니다.** 1만 마리가 병렬로 씁니다.

```csharp
Queue = queue.AsParallelWriter()       // 잡에는 ParallelWriter 로 넘깁니다
```

그리고 소비하는 쪽에서 `WriteHandle.Complete()` 를 먼저 불러야 합니다.
**같은 `NativeQueue` 인데 쓰는 쪽이 병렬이냐 아니냐로 다뤄지는 법이 갈립니다.**

## 검증

`DeadTag` 를 정확히 **7마리** 에만 켜고 카운터를 봤습니다.

```text
20273 → 20280      정확히 +7
큐 잔여 0
유휴 상태 3회 연속 샘플 고정 (유령 사망 없음)
```

+7 이 나오면 누락도 중복도 없다는 뜻이고,
유휴 상태에서 카운터가 안 움직이면 큐가 제대로 비워졌다는 뜻입니다.
**둘 다 봐야 합니다.** 하나만 보면 반쪽입니다.

## 값을 담을까 참조를 담을까

나중에 넉백을 넣으면서 `DamageEvent` 에 필드를 추가해야 했습니다.
세기는 이미 `Amount` 로 있었는데 **방향이 없었습니다.**

세 가지 안이 있었습니다.

| 안 | 문제 |
| --- | --- |
| `float3 Direction` (정규화) | **거리 정보가 사라집니다.** "가까울수록 세게" 를 나중에 넣으려면 스키마를 또 고쳐야 합니다 |
| `Entity Attacker` | 넉백을 계산할 시점에 **그 적이 이미 죽어 사라졌을 수** 있습니다 |
| **`float3 SourcePos`** | 방향과 거리를 둘 다 품고, 값이라 안전합니다 |

`Entity` 가 위험한 이유가 앞의 "죽기 전에 스냅샷" 과 **정확히 같습니다.**
참조는 대상이 사라지면 무효가 되고, 값은 복사해 왔으니 안전합니다.
같은 원리가 다른 자리에서 두 번 나왔습니다.

## 스키마를 바꾸면 정책이 하나 필요해진다

`Amount` 는 그냥 더하면 됐습니다. 교환법칙이 성립하니까요.
**그런데 `SourcePos` 는 여러 개일 때 무엇을 고를지 정해야 합니다.**

한 프레임에 세 명이 사방에서 때리면 방향이 셋입니다.

| 방식 | 결과 |
| --- | --- |
| 가중 평균 | 사방에서 맞으면 **상쇄돼서 안 밀립니다.** 물리적으로는 맞지만 무쌍답지 않습니다 |
| 첫 번째 것만 | 병렬로 쓰니 순서가 비결정적입니다. 매번 다르게 밀립니다 |
| **최대 데미지 한 건** | "제일 센 놈한테 밀린다". 무쌍답고 **결정론적입니다** |

그래서 루프는 **합산과 최대 건 기억만** 하고, 넉백은 **루프 밖에서 한 번** 적용합니다.

처음에는 루프 안에서 밀었는데, 그러면 세 명한테 맞을 때 세 번 밀리고
**밀린 위치에서 다음 방향을 다시 재서** 결과가 버퍼 순서에 의존하게 됩니다.
"최대 한 건만" 의 의미가 거기서 사라집니다.

`DamageEvent` 를 합산하고 한 번에 적용한 것과 같은 발상입니다.
**필드 하나를 추가하면 정책이 하나 필요해집니다.**