황현동 블로그 개발, 인생, 유우머

260205 Unity Job System Complete Guide

Tags:

🛠️ 260205 Unity Job System 완벽 가이드

🧭 1. 소개

🔹 Unity Job System이란?

Unity Job System은 멀티스레드 코드를 안전하고 간단하게 작성할 수 있게 해주는 시스템입니다. 모든 CPU 코어를 활용하여 게임 성능을 극대화할 수 있습니다.

┌─────────────────────────────────────────────────────────────────┐
│                     Traditional Approach                         │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │                    Main Thread                            │   │
│  │  [Task1] [Task2] [Task3] [Task4] [Task5] [Task6]         │   │
│  └──────────────────────────────────────────────────────────┘   │
│                        순차 실행 → 느림                          │
├─────────────────────────────────────────────────────────────────┤
│                      Job System Approach                         │
│  ┌──────────────────┐                                           │
│  │   Main Thread    │ [Schedule] ──→ [Complete]                 │
│  └──────────────────┘                                           │
│  ┌──────────────────┐                                           │
│  │  Worker Thread 1 │ [Task1] [Task4]                           │
│  └──────────────────┘                                           │
│  ┌──────────────────┐                                           │
│  │  Worker Thread 2 │ [Task2] [Task5]                           │
│  └──────────────────┘                                           │
│  ┌──────────────────┐                                           │
│  │  Worker Thread 3 │ [Task3] [Task6]                           │
│  └──────────────────┘                                           │
│                        병렬 실행 → 빠름                          │
└─────────────────────────────────────────────────────────────────┘

🔹 필수 네임스페이스

using Unity.Jobs;           // Job 인터페이스
using Unity.Burst;          // Burst 컴파일러
using Unity.Collections;    // NativeArray 등
using Unity.Mathematics;    // SIMD 최적화 수학 함수

📌 2. 원리

🏗️ 2.1 Job System 아키텍처

graph TD
    A[Main Thread] -->|Schedule| B[Job Queue]
    B --> C[Worker Thread Pool]
    C --> D[Worker 1]
    C --> E[Worker 2]
    C --> F[Worker N]
    D -->|Execute| G[Job 완료]
    E -->|Execute| G
    F -->|Execute| G
    G -->|Complete| A

🔹 2.2 핵심 구성 요소

구성 요소 설명
Job Struct IJob 또는 IJobParallelFor 인터페이스 구현
NativeContainer 스레드 안전한 데이터 컨테이너 (NativeArray, NativeList 등)
JobHandle Job의 스케줄링과 의존성 관리
Worker Thread CPU 코어당 하나씩 자동 생성

🔹 2.3 데이터 흐름

┌─────────────┐    Schedule()    ┌─────────────┐    Execute()    ┌─────────────┐
│ Main Thread │ ───────────────→ │ Job Queue   │ ───────────────→ │ Worker      │
│             │                  │             │                  │ Thread      │
│ NativeArray │ ←─────────────── │             │ ←─────────────── │             │
│ 결과 사용    │    Complete()    │             │    작업 완료      │ NativeArray │
└─────────────┘                  └─────────────┘                  └─────────────┘

🔹 2.4 Safety System

Unity Job System은 Race Condition을 방지하기 위한 안전 시스템을 내장:

// [ReadOnly] - 여러 Job이 동시에 읽기 가능
[ReadOnly] public NativeArray<float> inputData;

// [WriteOnly] - 해당 Job만 쓰기 가능
[WriteOnly] public NativeArray<float> outputData;

// 기본값 - Read/Write (단일 Job만 접근 가능)
public NativeArray<float> data;

⚠️ 3. 장단점

✅ 장점

장점 설명
성능 향상 모든 CPU 코어 활용, 15 FPS → 70 FPS 사례 보고
안전성 Safety System으로 Race Condition 자동 방지
Burst 통합 SIMD 최적화로 5ms → 0.1ms 성능 향상
간단한 API Schedule/Complete 패턴으로 쉬운 사용
자동 스레드 관리 Worker Thread 수동 관리 불필요

⚠️ 단점

단점 설명
Blittable 타입만 class, string 등 managed 타입 사용 불가
NativeContainer 필수 일반 배열 대신 NativeArray 사용 필요
메모리 수동 관리 Dispose() 호출 필수
디버깅 복잡 멀티스레드 디버깅의 일반적 어려움
학습 곡선 새로운 패턴과 제약사항 학습 필요

🔹 Blittable 타입이란?

// ✅ 사용 가능 (Blittable)
int, float, bool, byte
Vector3, Quaternion (Unity 구조체)
float3, quaternion (Unity.Mathematics)
사용자 정의 struct (Blittable 필드만 포함)

// ❌ 사용 불가 (Non-Blittable)
string
class
object[]
List<T>
Dictionary<K,V>

🧪 4. 간단한 예제

🔹 4.1 IJob - 단일 작업

using Unity.Jobs;
using Unity.Burst;
using Unity.Collections;

// 두 숫자를 더하는 간단한 Job
[BurstCompile]
public struct AddNumbersJob : IJob
{
    public float a;
    public float b;
    public NativeArray<float> result;

    public void Execute()
    {
        result[0] = a + b;
    }
}

// 사용법
public class SimpleJobExample : MonoBehaviour
{
    void Start()
    {
        // 1. 결과를 저장할 NativeArray 생성
        var result = new NativeArray<float>(1, Allocator.TempJob);

        // 2. Job 생성 및 데이터 설정
        var job = new AddNumbersJob
        {
            a = 10f,
            b = 20f,
            result = result
        };

        // 3. Job 스케줄링
        JobHandle handle = job.Schedule();

        // 4. Job 완료 대기
        handle.Complete();

        // 5. 결과 사용
        Debug.Log($"Result: {result[0]}");  // 출력: Result: 30

        // 6. 메모리 해제 (필수!)
        result.Dispose();
    }
}

🔹 4.2 IJobParallelFor - 병렬 배열 처리

using Unity.Jobs;
using Unity.Burst;
using Unity.Collections;

// 배열의 각 요소에 값을 더하는 병렬 Job
[BurstCompile]
public struct AddValueJob : IJobParallelFor
{
    public float valueToAdd;

    [ReadOnly] public NativeArray<float> input;
    [WriteOnly] public NativeArray<float> output;

    // index: 현재 처리 중인 배열 인덱스 (자동 할당)
    public void Execute(int index)
    {
        output[index] = input[index] + valueToAdd;
    }
}

// 사용법
public class ParallelJobExample : MonoBehaviour
{
    void Start()
    {
        int dataLength = 10000;

        // 입력/출력 배열 생성
        var input = new NativeArray<float>(dataLength, Allocator.TempJob);
        var output = new NativeArray<float>(dataLength, Allocator.TempJob);

        // 입력 데이터 초기화
        for (int i = 0; i < dataLength; i++)
            input[i] = i;

        // Job 생성
        var job = new AddValueJob
        {
            valueToAdd = 100f,
            input = input,
            output = output
        };

        // 병렬 Job 스케줄링 (배열 길이, 배치 크기)
        // 배치 크기 64: 64개씩 묶어서 Worker Thread에 분배
        JobHandle handle = job.Schedule(dataLength, 64);

        handle.Complete();

        // 결과 확인
        Debug.Log($"output[0] = {output[0]}");      // 100
        Debug.Log($"output[9999] = {output[9999]}"); // 10099

        input.Dispose();
        output.Dispose();
    }
}

🔹 4.3 Job 의존성 체인

// Job A의 결과를 Job B가 사용하는 경우
JobHandle handleA = jobA.Schedule();
JobHandle handleB = jobB.Schedule(handleA);  // A 완료 후 B 실행

handleB.Complete();  // B가 완료되면 A도 완료됨

🧪 5. 실용적이고 복잡한 예제

🧠 5.1 실제 프로젝트: OBB 충돌 검사 (IJobParallelFor)

아래는 현재 프로젝트(mepia)에서 사용 중인 OBB(Oriented Bounding Box) 충돌 검사 Job입니다.

using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;

/// <summary>
/// OBB-OBB 교차 검사 Job (Separating Axis Theorem 기반)
/// Broad Phase에서 Spatial Hash 후보 쌍을 정밀 필터링
/// </summary>
[BurstCompile]
public struct ObbIntersectionJob : IJobParallelFor
{
    // 객체 데이터 (ReadOnly - 여러 스레드가 동시 읽기 가능)
    [ReadOnly] public NativeArray<float3> Centers;
    [ReadOnly] public NativeArray<float3> HalfExtents;
    [ReadOnly] public NativeArray<quaternion> Rotations;
    [ReadOnly] public NativeArray<bool> IsValid;

    // 입력: Spatial Hash에서 추출된 후보 쌍
    [ReadOnly] public NativeArray<int2> CandidatePairs;

    // 출력: OBB 교차를 통과한 쌍 (ParallelWriter로 스레드 안전 쓰기)
    [WriteOnly] public NativeList<int2>.ParallelWriter PassedPairs;

    // 설정: OBB 확장 마진
    public float Tolerance;

    public void Execute(int pairIndex)
    {
        int2 pair = CandidatePairs[pairIndex];
        int indexA = pair.x;
        int indexB = pair.y;

        // 유효성 검사 (early return)
        if (!IsValid[indexA] || !IsValid[indexB])
            return;

        // OBB 데이터 추출
        float3 centerA = Centers[indexA];
        float3 halfExtentsA = HalfExtents[indexA];
        quaternion rotationA = Rotations[indexA];

        float3 centerB = Centers[indexB];
        float3 halfExtentsB = HalfExtents[indexB];
        quaternion rotationB = Rotations[indexB];

        // SAT 기반 OBB-OBB 교차 검사
        if (ObbCollisionMath.IntersectOBB(
            centerA, halfExtentsA, rotationA,
            centerB, halfExtentsB, rotationB,
            Tolerance))
        {
            // 충돌한 쌍만 결과에 추가
            PassedPairs.AddNoResize(pair);
        }
    }
}

사용 방법:

public class CollisionSystem
{
    public NativeList<int2> DetectCollisions(
        NativeArray<int2> candidatePairs,
        CollisionDataStore dataStore,
        float tolerance)
    {
        // 결과 리스트 생성 (최대 크기 미리 할당)
        var passedPairs = new NativeList<int2>(
            candidatePairs.Length,
            Allocator.TempJob);

        // Job 설정
        var job = new ObbIntersectionJob
        {
            Centers = dataStore.Centers,
            HalfExtents = dataStore.HalfExtents,
            Rotations = dataStore.Rotations,
            IsValid = dataStore.IsValid,
            CandidatePairs = candidatePairs,
            PassedPairs = passedPairs.AsParallelWriter(),
            Tolerance = tolerance
        };

        // 스케줄링 (배치 크기 32)
        JobHandle handle = job.Schedule(candidatePairs.Length, 32);
        handle.Complete();

        return passedPairs;
    }
}

🔹 5.2 실제 프로젝트: 배치 레이캐스트 (IJobParallelFor)

여러 개의 Ray를 동시에 처리하는 복잡한 Job 예제입니다.

/// <summary>
/// 배치 레이캐스트 Job
/// 여러 레이를 병렬로 처리
/// </summary>
[BurstCompile]
public unsafe struct BatchRaycastJob : IJobParallelFor
{
    // 입력: 처리할 레이 배열
    [ReadOnly] public NativeArray<BurstRay> Rays;
    public float CellSize;
    public bool TrianglePrecision;
    public int LayerMask;

    // 씬 데이터 (ReadOnly)
    [ReadOnly] public NativeArray<float3> Centers;
    [ReadOnly] public NativeArray<float3> HalfExtents;
    [ReadOnly] public NativeArray<quaternion> Rotations;
    [ReadOnly] public NativeArray<bool> IsValid;
    [ReadOnly] public NativeArray<int> Layers;
    [ReadOnly] public NativeArray<ObjectMeshInfo> MeshInfos;

    // Spatial Hash Map (공간 분할 가속 구조)
    [ReadOnly] public NativeParallelMultiHashMap<uint, int> SpatialMap;

    // 출력: 각 레이의 충돌 결과
    [WriteOnly] public NativeArray<BurstRaycastHit> Results;

    public void Execute(int rayIndex)
    {
        var ray = Rays[rayIndex];
        var bestHit = BurstRaycastHit.NoHit;

        // DDA 알고리즘으로 공간 순회
        var checkedIndices = new NativeHashSet<int>(64, Allocator.Temp);

        int3 currentCell = (int3)math.floor(ray.Origin / CellSize);
        // ... DDA 순회 로직 ...

        while (/* 조건 */)
        {
            // 현재 셀의 객체들 검사
            uint hash = math.hash(currentCell);
            if (SpatialMap.TryGetFirstValue(hash, out int objIndex, out var iterator))
            {
                do
                {
                    if (IsValid[objIndex] && !checkedIndices.Contains(objIndex))
                    {
                        checkedIndices.Add(objIndex);

                        // LayerMask 필터링
                        if (LayerMask != -1 && ((1 << Layers[objIndex]) & LayerMask) == 0)
                            continue;

                        // OBB 충돌 검사 후 삼각형 정밀 검사
                        // ... 충돌 로직 ...
                    }
                } while (SpatialMap.TryGetNextValue(out objIndex, ref iterator));
            }

            // 다음 셀로 이동 (DDA)
            // ...
        }

        checkedIndices.Dispose();
        Results[rayIndex] = bestHit;
    }
}

🔹 5.3 단일 레이캐스트 (IJob)

복잡한 단일 작업에는 IJob을 사용합니다.

/// <summary>
/// 단일 레이캐스트 Job
/// DDA 알고리즘으로 Spatial Hash 셀 순회 후 OBB/BVH 테스트
/// </summary>
[BurstCompile]
public unsafe struct SingleRaycastJob : IJob
{
    // 입력
    public BurstRay Ray;
    public float CellSize;
    public bool TrianglePrecision;
    public int LayerMask;

    // 데이터 참조 (ReadOnly)
    [ReadOnly] public NativeArray<float3> Centers;
    [ReadOnly] public NativeArray<float3> HalfExtents;
    [ReadOnly] public NativeArray<quaternion> Rotations;
    [ReadOnly] public NativeArray<bool> IsValid;
    [ReadOnly] public NativeArray<int> Layers;

    [ReadOnly] public NativeParallelMultiHashMap<uint, int> SpatialMap;

    // 출력
    public NativeArray<BurstRaycastHit> Result;

    public void Execute()
    {
        var bestHit = BurstRaycastHit.NoHit;

        // DDA 알고리즘
        int3 startCell = (int3)math.floor(Ray.Origin / CellSize);
        float t = 0f;

        while (t < Ray.MaxDistance)
        {
            // 이미 찾은 충돌보다 멀면 조기 종료
            if (bestHit.HasHit && t > bestHit.Distance)
                break;

            // 현재 셀의 객체 테스트
            uint hash = math.hash(currentCell);
            if (SpatialMap.TryGetFirstValue(hash, out int objIndex, out var iterator))
            {
                do
                {
                    // OBB 테스트 → 삼각형 테스트
                    // ...
                } while (SpatialMap.TryGetNextValue(out objIndex, ref iterator));
            }

            // 다음 셀로 이동
            // ...
        }

        Result[0] = bestHit;
    }
}

⚡ 6. 성능 최적화 팁

🚀 6.1 Burst Compiler 활용

// 기본 Burst 적용
[BurstCompile]
public struct MyJob : IJob { }

// 최적화 옵션 지정
[BurstCompile(OptimizeFor = OptimizeFor.Performance)]
public struct HighPerfJob : IJob { }

// 옵션 종류
// OptimizeFor.Balanced     - 기본값, 균형 잡힌 최적화
// OptimizeFor.Performance  - 최대 성능 (컴파일 느림)
// OptimizeFor.Size         - 최소 코드 크기
// OptimizeFor.FastCompilation - 빠른 컴파일 (최적화 최소)

🔹 6.2 Unity.Mathematics 사용

// ❌ 비효율적
Vector3 a = new Vector3(1, 2, 3);
Vector3 b = a * 2;

// ✅ SIMD 최적화
float3 a = new float3(1, 2, 3);
float3 b = a * 2;

// float4 사용 시 최대 성능 (SIMD 4-wide)
float4 simdData = new float4(1, 2, 3, 4);

🛠️ 6.3 배치 크기 가이드

// 가벼운 작업 (벡터 덧셈 등)
job.Schedule(length, 128);  // 큰 배치

// 무거운 작업 (충돌 검사 등)
job.Schedule(length, 32);   // 작은 배치

// 매우 무거운 작업
job.Schedule(length, 1);    // 개별 처리

🔹 6.4 메모리 할당 전략

// Allocator 종류
Allocator.Temp        // 1프레임 내 사용 (가장 빠름)
Allocator.TempJob     // Job 내에서 사용 (4프레임 유효)
Allocator.Persistent  // 장기간 사용 (가장 느림)

// 예시
var tempData = new NativeArray<float>(100, Allocator.Temp);      // Update 내
var jobData = new NativeArray<float>(100, Allocator.TempJob);    // Job에 전달
var cached = new NativeArray<float>(100, Allocator.Persistent);  // 캐싱용

⚠️ 7. 주의사항 체크리스트

[✓] Dispose() 호출 확인
[✓] Blittable 타입만 사용
[✓] [ReadOnly] / [WriteOnly] 적절히 사용
[✓] Complete() 호출 후 결과 접근
[✓] NativeContainer 범위 초과 접근 금지
[✓] Job 내에서 managed 객체 참조 금지
[✓] static 변수 접근 금지
[✓] 디버그 모드에서 Safety Checks 활성화

📌 Sources