유니티/개념

[Unity DOTS] 3. Job System (Multithreading)

tae-woong 2026. 1. 3. 02:47

참고

 

C# 잡 시스템 - Unity 매뉴얼

Unity C# 잡 시스템(Job System)을 사용해 Unity 엔진과 상호작용하는 간단하고 안전한 멀티스레드 코드를 작성하여 게임 성능을 개선할 수 있습니다.

docs.unity3d.com

 

Unity Job System

Unity Job System 소개 Unity Job System은 Unity에서 제공하는 멀티스레딩 프레임워크로, CPU 리소스를 효율적으로 활용하여 게임 성능을 개선합니다. 이 시스템은 Unity의 내부 기능과 통합되어 있으며, 개

unialgames.tistory.com

0. 서론

지난 글에서 ECS아키타입(Archetype)청크(Chunk)를 통해 데이터를 메모리상에 예쁘게 정렬했다.

 

고속도로(Memory Layout)는 완성되었고, 자동차(Data)들도 줄을 섰다.

 

이제 이 데이터들을 빠르게 처리할 엔진이 필요하다. 바로 C# Job System이다.

 

이번 글에서는 유니티가 기존의 비효율적인 스레딩 모델을 어떻게 혁신했는지, 그리고 int[] 대신 왜 NativeArray를 써야만 하는지 그 이유를 심층 분석하려 한다.

 

1. 기존 방식의 한계 vs Job System

1) 기존 방식 (Standard Threading)의 한계

유니티의 기본적인 멀티스레딩 모델 (메인 스레드와 렌더 스레드, 그리고 내부 워커 스레드). 그리고, 필요하면, 개발자가 별도 워커 스레드를생성해서 사용.

유니티는 기본적으로 싱글 스레드(Single-Thread) 위주로 돌아가는 엔진이다.

 

위 그림처럼 유니티 엔진 내부적으로는 유니티의 그래픽 처리를 위한 RenderThread나, 내부 작업을 위한 WorkerThread가 존재한다.

 

하지만 게임 로직(Script)은 오직 MainThread에서만 실행된다.

  • 상황 : 만약 개발자가 게임 로직에서 길 찾기(Pathfinding) 같은 무거운 연산을 해야 한다면? 메인 스레드가 멈추고 화면이 버벅거린다(Freezing).
  • ★ 기존 해결책 (개발자의 개입) : 개발자가 new Thread()나 Task.Run()을 사용해 직접 별도의 스레드를 만든다. (위 그림의 엔진 내부 스레드와는 별개로 OS에게 스레드 생성을 요청하는 것이다.)
  • 문제점 :
    1. 과도한 스레드 생성 : CPU 코어보다 많은 스레드를 만들면, OS가 작업을 교체하느라 컨텍스트 스위칭(Context Switching) 비용이 폭발한다.
    2. 경쟁 상태 (Race Condition) : 데이터 동기화가 어렵고, 락(Lock)을 걸면 성능이 저하된다.

2) Job System의 해결책 (Task와 비슷해 보이지만 다른)

※ 추가 정리

 

Job System : 멀티스레딩과 잡 시스템의 효율성 비교

기본 개념 정리 (Hardware & OS)물리 코어 (Physical Core) : 실제로 일을 처리하는 일꾼의 수 (예: 8코어 = 일꾼 8명). 절대적인 물리적 한계치입니다.소프트웨어 스레드 (Software Thread) : 프로그램이 요청하

tae-woong.tistory.com

엔진이 초기화 시 코어 수에 맞춰  Job Worker Thread 를 미리 생성 후 재사용.

Job System은 C#의 Task와 개념적으로 비슷하다.

 

"스레드를 직접 관리하지 않고, 일감(Job)만 던져주면 시스템이 알아서 처리한다"는 점에서는 같다.

 

하지만 스레드를 운용하는 방식이 완전히 다르다.

  • Task/기존 방식 : 필요할 때마다 스레드를 생성하거나 OS의 스레드 풀을 쓴다. 가비지(Garbage)가 발생하고 스케줄링이 상대적으로 무겁다.
  • Job System : 게임 퍼포먼스에 특화되어 있다. 가비지가 전혀 발생하지 않으며(Zero Allocation), 엔진 초기화 시점에 CPU 코어 수에 맞춰 고정된 'Job Worker Thread'를 미리 만들어두고 재사용한다.
    • 보충 설명 : 예를 들어 8코어 CPU라면, [메인 스레드 1개 + Job Worker Thread 7개]를 딱 만들어두고 절대 그 이상 늘리지 않는다. 이렇게 하면 코어끼리 자원 경쟁을 하지 않아 효율이 극대화된다.

📊 비교 : 기존 스레딩 vs Job System

특징 기존 방식 (Thread / Task) C# Job System
스레드 생성 개발자가 new Thread() 등으로 직접 생성
(OS 레벨, 비용 높음)
엔진이 초기화 시 코어 수에 맞춰 Job Worker Thread를 미리 생성 후 재사용
컨텍스트 스위칭 스레드가 많아지면 빈번하게 발생
(성능 저하)
물리 코어 수에 딱 맞춰 실행되므로 최소화
메모리 관리 관리 힙 (GC 발생, 속도 느림) 비관리 힙 = NativeContainer (GC 없음, 속도 빠름)
안전성 개발자가 직접 Lock 등으로 방어해야 함 Safety System이 경쟁 상태를 감지하여 원천 봉쇄

 

2. 왜 NativeContainer인가? (메모리 구조 관점)

Job System을 사용할 때 가장 큰 진입 장벽은 NativeContainer (예: NativeArray)다.

 

"그냥 편한 int[]나 List<T>를 쓰면 안 되나? 왜 귀찮게 NativeArray를 써야 하지?"

 

이유는 C#의 메모리 관리 구조안전성 때문이다.

 

많은 개발자가 ECS 데이터가 배열 형태라 힙(Heap)에 있을 것이라 착각하지만, ★ NativeContainer는 비관리 힙 메모리(Unmanaged Heap Memory)에 존재한다.

(int[]와 List<T>는 Managed Heap에 생성되고, GC의 관리를 받는다.)

 

이 차이가 어떤 결과를 만드는지 프로세스로 비교해보자.

 

❌ 시나리오 A  : 기존 방식 (Managed Heap)

개발자가 int[] (관리 힙) 데이터를 만들어 일반 스레드로 처리하려는 경우다.

  1. 할당 : int[] data = new int[100]; → 관리 힙(Managed Heap)에 생성된다. 가비지 컬렉터(GC)가 이곳을 관리한다.
  2. 접근 : 개발자가 만든 스레드가 data[0]에 접근을 시도한다.
  3. 위험 : 이때 메모리가 부족해진 GC가 청소를 시작하며 data의 메모리 주소를 옮겨버릴 수 있다.
  4. 결과 : 스레드는 엉뚱한 주소를 참조하게 되어 크래시(Crash)가 나거나 데이터가 깨진다.

 

✅ ★ 시나리오 B : Job System 방식 (Unmanaged Heap)

사용 예시

ECS 데이터(Chunk) 혹은 NativeArray를 Job System으로 처리하는 경우다.

  1. 할당 : NativeArray<int> data... → 비관리 힙(Unmanaged Heap)에 고정된 주소로 할당된다. GC는 이곳을 건드리지 못한다.
  2. 래핑(Wrapping) : Job을 생성할 때 데이터를 복사하는 게 아니라, "저기 비관리 메모리에 데이터가 있어"라는 핸들(출입증)만 전달한다.
  3. 안전성 검사 (Safety System) : 유니티는 이 출입증을 누가 가져갔는지 감시한다.
    • "Job A가 쓰기(Write) 권한으로 가져갔네? 그럼 Job B는 A가 끝날 때까지 접근 금지."
    • 이를 통해 경쟁 상태(Race Condition)를 원천 봉쇄한다.
  4. 실행 : Job Worker Thread가 고정된 메모리 주소로 달려가 빠르게 데이터를 처리한다.
  5. 해제 : GC가 없으므로, 반드시 Dispose()를 호출해 수동으로 반납한다.

결국 NativeContainer는 "GC의 방해를 받지 않는 고속 메모리"이자, "경쟁 상태를 막아주는 안전한 출입증"인 셈이다.

 

 

3. 데이터 병렬 처리 : IJobParallelFor

Job System의 진정한 파워는 데이터를 잘게 쪼개서 여러 코어가 동시에 처리하는 데이터 병렬성(Data Parallelism)에서 나온다.

데이터가 선형(Linear)으로 배치되어 있어, 스레드 간의 작업 영역을 명확하게 분배할 수 있다.

위 그림은 IJobParallelFor가 데이터를 처리하는 방식을 완벽하게 보여준다.

  • 상황 : 거대한 데이터 배열(노란색 블록들)을 처리해야 한다.
  • IJob (단일 처리) : 스레드 하나라면 처음부터 끝까지 혼자 처리한다.
  • IJobParallelFor (병렬 처리) :
    • 그림처럼 데이터를 청크(Chunk) 단위로 쪼갠다.
    • Thread 1은 앞부분을, Thread 2는 뒷부분을 가져가서 동시에 처리한다.
    • ★ ECS 데이터가 메모리에 선형적(Linear)으로 예쁘게 나열되어 있기 때문에, 이렇게 툭툭 잘라서 던져주기에 최적화되어 있다.

 

4. Job System 상세 동작 과정

▲ IJobParallelFor의 내부 실행 흐름 (출처: Unity Documentation)

그렇다면 이 병렬 처리는 내부적으로 어떻게 스케줄링 될까?

 

아래 다이어그램은 유니티 공식 문서의 IJobParallelFor 실행 흐름도다.

 

이 그림은 크게 4단계 구역으로 나뉜다.

 

구역 1 : Main Thread (일감 만들기)

  • 1. Create Job : 개발자가 ParallelFor Job을 생성한다.
  • 2. Set Data : 처리할 데이터(NativeArray)를 Job에 넣어준다.
  • 3. Schedule : Schedule() 함수를 호출하여 "이거 처리해주세요"라고 예약한다.

 

구역 2 : C# Job System (일감 쪼개기)

  • 4. Divide into Batches : 여기가 핵심이다. 시스템은 방대한 데이터를 배치(Batch)라는 작은 덩어리로 쪼갠다.
    • ★ IJob vs IJobParallelFor 비교 : 일반 IJob은 데이터를 쪼개는 이 과정(Batching)이 없다. 따라서 하나의 잡이 통째로 큐에 들어가고, 하나의 워커 스레드가 독점해서 처리한다. 반면 IJobParallelFor는 배치를 쪼개서 여러 스레드에 분배한다.
  • 5. Put into Queue : 쪼개진 배치들을 Job Queue에 집어넣는다.

 

구역 3 : Job Queue (대기소)

  • 쪼개진 일감들이 워커 스레드를 기다리며 줄 서 있는 공간이다.

 

구역 4 : Native Job System (일하기)

  • 6. Execute Batches : 놀고 있던 Job Worker Thread(워커 스레드)들이 큐에서 배치를 하나씩 낚아채 간다.
    • 예 : 워커 1번은 1번 배치를, 워커 2번은 2번 배치를 가져가서 각자의 코어(Core #1, Core #2)에서 실행한다.
  • 7. Store Results : 작업이 끝난 결과값은 다시 Native Memory(비관리 메모리)에 안전하게 기록된다.

 

5. Job Dependencies (순서 정하기)

Job System은 비동기(Asynchronous)다.

 

Schedule을 하는 순간 메인 스레드는 멈추지 않고 다음 줄로 넘어간다.

 

따라서 작업의 순서를 정해주는 것이 중요하다.

  • JobHandle : Schedule 함수는 JobHandle이라는 티켓을 반환한다.
  • 의존성 설정 : Job B가 Job A의 결과를 써야 한다면, B를 스케줄링할 때 A의 핸들을 넘겨준다. (JobB.Schedule(JobAHandle))
  • JobHandle.Complete() : 메인 스레드에서 결과값이 당장 필요할 때 호출한다. 이때까지 작업이 안 끝났다면 메인 스레드는 대기한다. (가능한 늦게 호출하는 것이 성능에 좋다.)

 

6. 결론 : 안전하고 빠른 멀티스레딩

정리하자면, ECS + Job System의 시너지는 다음과 같다.

  1. ECS가 데이터를 비관리 힙(Unmanaged Heap)에 예쁘게 줄 세운다.
  2. Job System이 이 데이터를 여러 개의 배치(Batch)로 쪼갠다 (IJobParallelFor).
  3. 유니티가 미리 만들어둔 Job Worker Thread들이 달려들어 배치를 하나씩 가져가 순식간에 처리한다.
  4. 이 모든 과정은 NativeContainer 덕분에 GC 걱정 없이 안전하게 이루어진다.

C# 레벨에서 할 수 있는 모든 최적화를 마쳤다.

 

하지만 아직 마지막 단계가 남았다.

 

이 C# 코드를 기계가 가장 좋아하는 언어로 번역해 줄 Burst Compiler가 남아 있다.