---
title: "스레드를 직접 만들지 않고 일을 맡긴다"
description: "잡 시스템은 워커 스레드를 미리 띄워 두고 일감을 나눠 줍니다. 스레드를 안 만드는 것이 핵심이고, 안전 장치가 나머지 절반입니다."
author: "유니얼"
pubDate: 2026-09-10
category: "game-dev/unity/dots"
tags: ["Unity", "DOTS", "동시성"]
canonical: https://blog.unialgames.com/posts/dots-job-system/
---
멀티스레드로 성능을 올리려면 보통 스레드를 만듭니다.
그런데 **스레드를 만드는 것 자체가 비쌉니다.**

만드는 비용도 있고, 만들고 나면 OS 가 코어 하나를 여러 스레드에 번갈아 배정하면서
**컨텍스트 스위칭**이 일어납니다. 실행 중이던 상태를 저장하고 다른 상태를 불러오는 일입니다.
코어가 여덟인데 스레드를 마흔 개 만들면 계산보다 자리 바꾸는 데 시간을 씁니다.

잡 시스템은 반대로 합니다. **스레드를 만들지 않습니다.**

## 스레드는 이미 떠 있다

유니티는 시작할 때 세 종류의 스레드를 준비합니다.

| | 무엇을 하나 |
| --- | --- |
| **메인 스레드** | 게임 로직, 입력, UI, `Update`, 씬 관리 |
| **워커 스레드** | 잡을 받아 처리합니다. **논리 코어 수만큼** 미리 만들어 둡니다 |
| **렌더 스레드** | 그래픽스 명령을 GPU 로 보냅니다 |

유니티 자신도 이 워커들을 씁니다. 우리 잡만 도는 자리가 아닙니다.

<figure class="diagram">
<svg viewBox="0 0 680 300" role="img" aria-label="메인 스레드가 렌더 명령을 워커 스레드에 넘기고 워커가 만든 명령이 렌더 스레드를 거쳐 그래픽스 장치로 가는 흐름">
<g stroke="var(--border)" fill="var(--accent-soft)">
<rect x="20" y="20" width="640" height="26" rx="3"></rect>
</g>
<text x="30" y="38" font-size="12" fill="var(--text-muted)">메인 스레드</text>
<g stroke="var(--accent)" fill="var(--bg)">
<rect x="150" y="24" width="80" height="18" rx="2"></rect>
<rect x="290" y="24" width="80" height="18" rx="2"></rect>
<rect x="430" y="24" width="80" height="18" rx="2"></rect>
</g>
<text x="166" y="37" font-size="10" fill="var(--accent)">렌더 요청</text>
<text x="306" y="37" font-size="10" fill="var(--accent)">렌더 요청</text>
<text x="446" y="37" font-size="10" fill="var(--accent)">렌더 요청</text>
<g stroke="var(--text-muted)" fill="none">
<path d="M190 46 L190 92"></path>
<path d="M185 87 L190 92 L195 87"></path>
<path d="M330 46 L330 122"></path>
<path d="M325 117 L330 122 L335 117"></path>
<path d="M470 46 L470 152"></path>
<path d="M465 147 L470 152 L475 147"></path>
</g>
<text x="196" y="70" font-size="11" fill="var(--text-muted)">명령 요청</text>
<g stroke="var(--border)" fill="var(--bg)">
<rect x="20" y="96" width="640" height="22" rx="3"></rect>
<rect x="20" y="126" width="640" height="22" rx="3"></rect>
<rect x="20" y="156" width="640" height="22" rx="3"></rect>
</g>
<text x="30" y="112" font-size="11" fill="var(--text-muted)">워커 1</text>
<text x="30" y="142" font-size="11" fill="var(--text-muted)">워커 2</text>
<text x="30" y="172" font-size="11" fill="var(--text-muted)">워커 3</text>
<g stroke="var(--accent)" fill="var(--accent-soft)">
<rect x="130" y="99" width="150" height="16" rx="2"></rect>
<rect x="270" y="129" width="150" height="16" rx="2"></rect>
<rect x="410" y="159" width="150" height="16" rx="2"></rect>
</g>
<text x="146" y="111" font-size="10" fill="var(--accent)">명령 목록을 만든다</text>
<text x="286" y="141" font-size="10" fill="var(--accent)">명령 목록을 만든다</text>
<text x="426" y="171" font-size="10" fill="var(--accent)">명령 목록을 만든다</text>
<g stroke="var(--text-muted)" fill="none">
<path d="M190 118 L190 212"></path>
<path d="M185 207 L190 212 L195 207"></path>
<path d="M330 148 L330 212"></path>
<path d="M325 207 L330 212 L335 207"></path>
<path d="M470 178 L470 212"></path>
<path d="M465 207 L470 212 L475 207"></path>
</g>
<text x="196" y="196" font-size="11" fill="var(--text-muted)">만든 명령을 넘김</text>
<g stroke="var(--border)" fill="var(--accent-soft)">
<rect x="20" y="216" width="640" height="26" rx="3"></rect>
</g>
<text x="30" y="234" font-size="12" fill="var(--text-muted)">렌더 스레드</text>
<text x="150" y="234" font-size="11" fill="var(--accent)">받은 순서대로 그래픽스 API 를 호출</text>
<g stroke="var(--text-muted)" fill="none">
<path d="M190 242 L190 272"></path>
<path d="M185 267 L190 272 L195 267"></path>
<path d="M330 242 L330 272"></path>
<path d="M325 267 L330 272 L335 267"></path>
<path d="M470 242 L470 272"></path>
<path d="M465 267 L470 272 L475 267"></path>
</g>
<text x="20" y="290" font-size="12" fill="var(--text-muted)">그래픽스 장치 (드라이버 · GPU)</text>
</svg>
<figcaption>렌더링도 이 워커들 위에서 돕니다. 우리가 잡을 던지는 자리와 같은 자리입니다.</figcaption>
</figure>

**그래서 워커가 비어 있다는 보장이 없습니다.**
잡을 아무리 잘 쪼개도, 같은 프레임에 유니티가 렌더 명령을 만들고 있으면 자리를 나눠 씁니다.
프로파일러에서 워커 타임라인이 빽빽한데 내 잡이 늦게 시작한다면 대개 이것입니다.

워커 스레드는 이미 떠 있고 일감을 기다립니다.
우리가 할 일은 스레드를 만드는 게 아니라 **일감을 큐에 넣는 것**입니다.

<figure class="diagram">
<svg viewBox="0 0 680 220" role="img" aria-label="메인 스레드가 잡을 큐에 넣고 스케줄러가 워커 스레드에 나눠 주는 흐름 그림">
<g stroke="var(--border)" fill="var(--bg)">
<rect x="20" y="40" width="120" height="40" rx="4"></rect>
</g>
<text x="34" y="65" font-size="12" fill="var(--text-muted)">메인 스레드</text>
<g stroke="var(--border)" fill="none">
<path d="M144 60 L192 60"></path>
<path d="M182 55 L192 60 L182 65"></path>
</g>
<text x="146" y="52" font-size="11" fill="var(--text-muted)">Schedule</text>
<g stroke="var(--accent)" fill="var(--accent-soft)">
<rect x="196" y="40" width="120" height="40" rx="4"></rect>
</g>
<text x="216" y="65" font-size="12" fill="var(--accent)">잡 큐</text>
<g stroke="var(--border)" fill="none">
<path d="M320 60 L368 60"></path>
<path d="M358 55 L368 60 L358 65"></path>
</g>
<g stroke="var(--border)" fill="var(--bg)">
<rect x="372" y="40" width="120" height="40" rx="4"></rect>
</g>
<text x="392" y="65" font-size="12" fill="var(--text-muted)">스케줄러</text>
<g stroke="var(--border)" fill="none">
<path d="M432 84 L120 116"></path>
<path d="M432 84 L280 116"></path>
<path d="M432 84 L440 116"></path>
<path d="M432 84 L600 116"></path>
</g>
<g stroke="var(--accent)" fill="none">
<rect x="60" y="120" width="120" height="30" rx="3"></rect>
<rect x="220" y="120" width="120" height="30" rx="3"></rect>
<rect x="380" y="120" width="120" height="30" rx="3"></rect>
<rect x="540" y="120" width="120" height="30" rx="3"></rect>
</g>
<text x="84" y="140" font-size="11" fill="var(--accent)">워커 1</text>
<text x="244" y="140" font-size="11" fill="var(--accent)">워커 2</text>
<text x="404" y="140" font-size="11" fill="var(--accent)">워커 3</text>
<text x="564" y="140" font-size="11" fill="var(--accent)">워커 4</text>
<text x="20" y="180" font-size="12" fill="var(--text-muted)">워커는 논리 코어 수만큼 미리 떠 있습니다. 우리가 만들지 않습니다.</text>
<text x="20" y="200" font-size="12" fill="var(--text-muted)">그래서 잡을 아무리 많이 만들어도 스레드 수는 안 늘고 컨텍스트 스위칭도 안 늘어납니다.</text>
</svg>
<figcaption>스레드를 만드는 대신 일감을 나눠 주는 구조입니다.</figcaption>
</figure>

여기서 결론이 하나 나옵니다.
**잡을 1만 개 만들어도 스레드가 1만 개 생기지 않습니다.**
워커 넷이 1만 개를 나눠 가질 뿐입니다.

## 잡의 종류

잡은 메서드가 아니라 **구조체**입니다. 인터페이스를 구현하고 `Execute` 를 채웁니다.

| 인터페이스 | 무엇 |
| --- | --- |
| `IJob` | 워커 하나에서 한 번 실행합니다. 메인 스레드를 안 막고 싶은 단일 작업 |
| `IJobParallelFor` | 배열 인덱스를 나눠 병렬로 돕니다. **가장 많이 씁니다** |
| `IJobParallelForTransform` | Transform 여러 개를 병렬로 갱신합니다 |
| `IJobEntity` · `IJobChunk` | ECS 쪽. 엔티티와 청크 단위로 돕니다 |

```csharp
struct AddJob : IJobParallelFor
{
    [ReadOnly] public NativeArray<float> A;
    [ReadOnly] public NativeArray<float> B;
    public NativeArray<float> Result;

    public void Execute(int i) => Result[i] = A[i] + B[i];
}
```

`Execute(int i)` 가 **인덱스마다 한 번씩** 불립니다.
어느 워커가 어느 인덱스를 맡을지는 스케줄러가 정하고 우리는 모릅니다.
그래서 **인덱스 사이에 순서를 기대하면 안 됩니다.**

## 필드는 값이 복사된다

잡은 구조체라 **워커마다 복사본이 갑니다.**
그러니 잡 필드에 담은 값을 실행 중에 바꿔도 원본에 안 돌아옵니다.

결과를 받아 오려면 **NativeContainer** 가 필요합니다.
`NativeArray` 나 `NativeList` 같은 것들인데, 이들은 구조체 안에 **포인터**를 들고 있어서
복사돼도 같은 메모리를 가리킵니다.

```csharp
var handle = new AddJob { A = a, B = b, Result = result }.Schedule(a.Length, 64);
handle.Complete();
// 여기서부터 result 를 읽을 수 있습니다
```

`Schedule(length, batchSize)` 의 두 번째 인자는 **한 워커가 한 번에 가져갈 개수**입니다.
너무 작으면 나눠 주는 오버헤드가 커지고, 너무 크면 일이 고르게 안 퍼집니다.
계산이 가벼우면 크게, 무거우면 작게 잡습니다.

## 메모리는 직접 관리한다

NativeContainer 는 관리 힙이 아니라 **네이티브 메모리**를 씁니다.
GC 대상이 아니고, 그래서 **직접 해제해야 합니다.**

| Allocator | 수명 | 언제 |
| --- | --- | --- |
| `Temp` | **1프레임 미만** | 가장 빠릅니다. 함수 안에서 잠깐 쓰고 버릴 때. 자동 해제됩니다 |
| `TempJob` | **4프레임 미만** | 잡에 넘길 데이터의 기본값. 안 지우면 경고가 뜹니다 |
| `Persistent` | 앱 전체 | 가장 느리게 할당됩니다. 매 프레임 재사용할 것에만. **`Dispose()` 필수** |

`Temp` 는 잡에 못 넘깁니다. 잡이 끝나기 전에 사라질 수 있어서입니다.
매 프레임 새로 할당하는 코드를 짜고 있다면 `Persistent` 로 한 번 잡아 두고
`Clear()` 해서 재사용하는 편이 낫습니다.

## 안전 장치가 나머지 절반이다

잡 시스템의 절반은 성능이 아니라 **사고 방지**입니다.

**읽기와 쓰기를 추적합니다.** 두 잡이 같은 배열에 동시에 쓰려고 하면
실행 전에 예외를 던집니다. 경합이 일어난 뒤에 이상한 값이 나오는 게 아니라
**아예 못 돌게 막습니다.**

```csharp
[ReadOnly] public NativeArray<float> A;   // 읽기만 한다고 선언
```

`[ReadOnly]` 는 최적화 힌트가 아니라 **선언**입니다.
읽기 전용이라고 말해 두면 다른 잡과 병렬로 돌 수 있고,
안 말해 두면 스케줄러가 안전하게 직렬화합니다.

병렬 쓰기도 따로 말해야 합니다.

```csharp
Queue = queue.AsParallelWriter()          // 여럿이 동시에 넣습니다
```

그냥 넘기면 거부당합니다. 같은 이유입니다.

뒤에서는 **`AtomicSafetyHandle`** 과 **`DisposeSentinel`** 이 돕니다.
전자는 누가 이 컨테이너를 읽고 쓰는지 추적하고,
후자는 해제 안 된 컨테이너를 잡아 경고를 냅니다.
**에디터에서만 도는 검사**라 빌드에서는 이 비용이 없습니다.
반대로 말하면 **에디터에서 안 걸린 문제가 빌드에서 조용히 터질 수 있다**는 뜻이기도 합니다.

## 의존성

잡 A 의 결과를 잡 B 가 써야 하면 순서를 지켜야 합니다.
`JobHandle` 을 넘기면 스케줄러가 알아서 기다립니다.

```csharp
var h1 = jobA.Schedule();
var h2 = jobB.Schedule(h1);      // A 가 끝난 뒤에 시작합니다
h2.Complete();
```

여기서 **`Complete()` 를 언제 부르느냐가 성능을 가릅니다.**

<figure class="diagram">
<svg viewBox="0 0 680 220" role="img" aria-label="스케줄 직후 Complete 를 부르면 메인 스레드가 계속 기다리고 늦게 부르면 그동안 다른 일을 한다는 대비">
<text x="20" y="22" font-size="13" fill="var(--warn)">스케줄하자마자 Complete</text>
<g stroke="var(--border)" fill="var(--bg)">
<rect x="20" y="32" width="70" height="22" rx="2"></rect>
</g>
<text x="30" y="47" font-size="10" fill="var(--text-muted)">Schedule</text>
<g stroke="var(--warn)" fill="none" stroke-dasharray="3 3">
<rect x="92" y="32" width="330" height="22" rx="2"></rect>
</g>
<text x="200" y="47" font-size="11" fill="var(--warn)">메인 스레드가 그냥 기다림</text>
<g stroke="var(--border)" fill="var(--bg)">
<rect x="424" y="32" width="120" height="22" rx="2"></rect>
</g>
<text x="434" y="47" font-size="10" fill="var(--text-muted)">이어서 할 일</text>
<g stroke="var(--accent)" fill="var(--accent-soft)">
<rect x="92" y="60" width="330" height="18" rx="2"></rect>
</g>
<text x="230" y="73" font-size="10" fill="var(--accent)">워커가 잡을 처리</text>
<text x="20" y="98" font-size="12" fill="var(--warn)">워커가 도는 동안 메인 스레드가 놀고 있습니다. 병렬로 만든 의미가 없습니다.</text>
<g stroke="var(--border)" fill="none">
<path d="M20 116 L660 116"></path>
</g>
<text x="20" y="142" font-size="13" fill="var(--accent)">스케줄은 일찍, Complete 는 늦게</text>
<g stroke="var(--border)" fill="var(--bg)">
<rect x="20" y="152" width="70" height="22" rx="2"></rect>
</g>
<text x="30" y="167" font-size="10" fill="var(--text-muted)">Schedule</text>
<g stroke="var(--accent)" fill="var(--accent-soft)">
<rect x="92" y="152" width="270" height="22" rx="2"></rect>
</g>
<text x="150" y="167" font-size="11" fill="var(--accent)">메인 스레드는 다른 일을 함</text>
<g stroke="var(--border)" fill="var(--bg)">
<rect x="364" y="152" width="58" height="22" rx="2"></rect>
</g>
<text x="370" y="167" font-size="10" fill="var(--text-muted)">Complete</text>
<g stroke="var(--border)" fill="var(--bg)">
<rect x="424" y="152" width="120" height="22" rx="2"></rect>
</g>
<text x="434" y="167" font-size="10" fill="var(--text-muted)">결과 사용</text>
<g stroke="var(--accent)" fill="var(--accent-soft)">
<rect x="92" y="180" width="270" height="18" rx="2"></rect>
</g>
<text x="200" y="193" font-size="10" fill="var(--accent)">워커가 잡을 처리</text>
<text x="20" y="214" font-size="12" fill="var(--accent)">같은 잡, 같은 시간인데 메인 스레드가 그만큼 더 일했습니다.</text>
</svg>
<figcaption>둘의 차이는 잡이 아니라 Complete 를 부른 위치뿐입니다.</figcaption>
</figure>

**스케줄은 일찍, `Complete` 는 늦게** 부르는 것이 원칙입니다.
그 사이에 메인 스레드가 다른 일을 하게 두는 것이 잡 시스템을 쓰는 이유입니다.

ECS 안에서는 `state.Dependency` 가 이 체인을 자동으로 걸어 줍니다.
직접 `Complete()` 를 부를 자리는 결과를 지금 당장 읽어야 할 때뿐입니다.