---
title: "잡의 시그니처가 곧 쿼리다"
description: "IJobEntity 는 Execute 파라미터를 보고 쿼리를 만듭니다. 그리고 잡의 필드와 파라미터는 각각 1만 개가 공유하는 값과 1만 번 반복되는 대상입니다."
author: "유니얼"
pubDate: 2026-09-09
category: "game-dev/unity/dots"
tags: ["Unity", "DOTS", "최적화"]
canonical: https://blog.unialgames.com/posts/dots-ijobentity-burst/
---
1만 마리를 플레이어 쪽으로 걷게 합니다. 잡 하나면 됩니다.

```csharp
[BurstCompile]
partial struct MoveJob : IJobEntity
{
    public float3 Target;      // 1만 개가 공유하는 값
    public float DeltaTime;

    void Execute(ref LocalTransform transform, in MoveStats stats)
    {
        var diff = Target - transform.Position;
        var distance = math.length(diff);
        if (distance < stats.StopDistance) return;

        var direction = math.normalize(diff);
        transform.Position += direction * stats.Speed * DeltaTime;
    }
}
```

여기서 쿼리를 어디에도 안 적었습니다. **적을 필요가 없습니다.**

## 시그니처가 쿼리다

`Execute` 의 파라미터가 곧 "이런 컴포넌트를 가진 엔티티" 입니다.
위 코드는 `LocalTransform` 과 `MoveStats` 를 둘 다 가진 엔티티만 돕니다.
소스 제너레이터가 그 쿼리와 순회 코드를 컴파일 시점에 만들어 줍니다.

한정자가 접근 권한을 정합니다.

| | 뜻 | 언제 |
| --- | --- | --- |
| `ref` | 읽고 쓴다 | 갱신할 컴포넌트 |
| `in` | 읽기만 한다 | 참조만 하는 컴포넌트 |

**`in` 을 쓰는 것이 성능 습관이 아니라 안전 장치입니다.**
읽기 전용이라고 선언해 두면 잡 스케줄러가 "이건 동시에 읽어도 된다" 를 알고,
다른 잡과 병렬로 돌릴 수 있는지 판단합니다.
전부 `ref` 로 두면 안 되는 게 아니라 **직렬화됩니다.**

## `[BurstCompile]` 은 둘 다

잡 구조체에도, 그 잡을 스케줄하는 시스템에도 붙여야 합니다.
한쪽만 붙이면 조용히 반쪽만 최적화됩니다.

그리고 잡 안에서는 `Time.deltaTime` 이 아니라 `SystemAPI.Time.DeltaTime` 입니다.
`Time` 은 유니티 엔진 쪽 정적 클래스라 **Burst 에서 터집니다.**
값을 시스템에서 미리 읽어 잡 필드에 넣어 두는 방식이라 위 코드처럼 됩니다.

## 필드와 파라미터가 다른 것

이게 이 글에서 제일 중요한 부분입니다.

<figure class="diagram">
<svg viewBox="0 0 680 230" role="img" aria-label="잡 구조체가 워커 스레드마다 복사되고, 필드는 모든 복사본이 같은 값을 들고 파라미터는 각 워커가 맡은 엔티티마다 반복된다는 그림">
<g stroke="var(--accent)" fill="var(--accent-soft)">
<rect x="240" y="20" width="200" height="52" rx="4"></rect>
</g>
<text x="254" y="40" font-size="12" fill="var(--accent)">MoveJob</text>
<text x="254" y="60" font-size="11" fill="var(--accent)">Target · DeltaTime (필드)</text>
<g stroke="var(--border)" fill="none">
<path d="M280 76 L120 104"></path>
<path d="M320 76 L280 104"></path>
<path d="M360 76 L400 104"></path>
<path d="M400 76 L560 104"></path>
</g>
<text x="120" y="96" font-size="11" fill="var(--text-muted)">복사</text>
<g stroke="var(--border)" fill="var(--bg)">
<rect x="30" y="108" width="140" height="34" rx="3"></rect>
<rect x="190" y="108" width="140" height="34" rx="3"></rect>
<rect x="350" y="108" width="140" height="34" rx="3"></rect>
<rect x="510" y="108" width="140" height="34" rx="3"></rect>
</g>
<text x="44" y="130" font-size="11" fill="var(--text-muted)">워커 1 · 같은 값</text>
<text x="204" y="130" font-size="11" fill="var(--text-muted)">워커 2 · 같은 값</text>
<text x="364" y="130" font-size="11" fill="var(--text-muted)">워커 3 · 같은 값</text>
<text x="524" y="130" font-size="11" fill="var(--text-muted)">워커 4 · 같은 값</text>
<g stroke="var(--warn)" fill="none">
<rect x="30" y="156" width="140" height="26" rx="3"></rect>
<rect x="190" y="156" width="140" height="26" rx="3"></rect>
<rect x="350" y="156" width="140" height="26" rx="3"></rect>
<rect x="510" y="156" width="140" height="26" rx="3"></rect>
</g>
<text x="44" y="174" font-size="11" fill="var(--warn)">엔티티 0 ~ 2499</text>
<text x="204" y="174" font-size="11" fill="var(--warn)">2500 ~ 4999</text>
<text x="364" y="174" font-size="11" fill="var(--warn)">5000 ~ 7499</text>
<text x="524" y="174" font-size="11" fill="var(--warn)">7500 ~ 9999</text>
<text x="30" y="206" font-size="12" fill="var(--text-muted)">필드는 복사본마다 하나씩. Execute 파라미터는 각 워커가 맡은 엔티티마다 한 번씩.</text>
<text x="30" y="224" font-size="12" fill="var(--text-muted)">그래서 잡 구조체에 ref 필드는 애초에 못 만듭니다. 복사본에 참조를 담을 수 없습니다.</text>
</svg>
<figcaption>잡 구조체는 워커마다 복사됩니다. 이 한 문장이 나머지 규칙을 전부 설명합니다.</figcaption>
</figure>

- **필드 = 1만 개가 공유하는 값.** 플레이어 위치, `DeltaTime`, 결과를 담을 큐
- **`Execute` 파라미터 = 1만 번 반복되는 대상.** 이 엔티티의 위치, 이 엔티티의 스탯

그리고 잡 구조체는 **워커 스레드마다 복사됩니다.**
그래서 `ref` 필드를 만들 수가 없습니다. 복사본에 참조를 담을 방법이 없습니다.

그런데 네이티브 컨테이너는 다릅니다.
`NativeQueue` 같은 것은 구조체 안에 **포인터 하나**만 들고 있어서,
복사가 되더라도 **같은 메모리를 가리킵니다.**
그래서 필드로 넣어 두면 워커 넷이 같은 큐에 씁니다.

이게 이 시리즈 뒤쪽에서 계속 돌아옵니다.
"같은 싱글톤인데 왜 하나는 되돌려 써야 하고 하나는 안 그래도 되나" 의 답이 여기 있습니다.
**값이냐 포인터냐** 입니다.

## 스케줄

```csharp
new MoveJob
{
    Target = player.Position,
    DeltaTime = SystemAPI.Time.DeltaTime,
}.ScheduleParallel();
```

`ScheduleParallel` 이 청크 단위로 쪼개서 워커에 뿌립니다.
`Schedule` 은 워커 스레드 하나에서 순서대로, `Run` 은 메인 스레드에서 즉시입니다.

디버깅할 때 `Run` 으로 바꾸면 `Debug.Log` 를 넣을 수 있는데,
**그러면 Burst 도 꺼지고 병렬도 없어져서 성능이 전혀 다른 코드가 됩니다.**
성능을 재면서 `Run` 을 쓰면 안 됩니다.

## 컴파일을 통과한 버그 셋

이 잡 하나에서 나온 것들입니다. 전부 에러 0개였습니다.

**하나. `math.normalize` 를 안 썼습니다.**

```csharp
transform.Position += diff * stats.Speed * DeltaTime;   // diff 를 정규화 안 함
```

`diff` 의 길이가 거리라서, **멀리 있을수록 빨리 움직입니다.**
등속이 아닙니다. 화면으로 보면 멀리 있던 적이 순식간에 붙습니다.

**둘. 그리고 프레임레이트 의존 버그가 딸려 옵니다.**
`Speed * DeltaTime` 이 1을 넘으면 목표를 지나쳐 버리고,
다음 프레임에 반대 방향으로 더 크게 지나칩니다. 발산합니다.
프레임이 낮은 기기에서만 터지는 버그입니다.

**셋. 멈출 때 위치를 스냅했습니다.**

```csharp
if (distance < stats.StopDistance) { transform.Position = Target; return; }
```

이러면 사거리에 들어온 1만 마리가 **전부 한 점에 겹칩니다.**
`return` 만 해서 제자리에 서게 하는 게 맞습니다.

## 검증은 눈이 아니라 숫자로

셋 다 화면만 봐서는 애매합니다. 그래서 이렇게 확인했습니다.

- 최대 거리가 폴링할 때마다 **일정하게 감소**하는가. 등속이면 그렇습니다
- 최소 거리가 **0이 아닌 값에서 고정**되는가. 스냅이 없으면 그렇습니다

`Speed` 를 3으로 뒀으니 초당 3씩 줄어야 하고, 실제로 그렇게 줄었습니다.
**숫자로 확인할 수 있는 형태로 만들어 두는 것**이 검증의 절반입니다.