Unity 엔진 핵심 (4) - Unity의 스레딩 모델 - soo:bak
작성일 :
프레임을 나누어 처리하는 스레드들
Unity 엔진 핵심 (3) - Unity 실행 순서에서 FixedUpdate, Update, LateUpdate, 렌더링 콜백이 매 프레임 정해진 순서로 호출된다는 것을 다루었습니다.
이전 글까지는 “언제” 실행되는지를 다루었다면, 이 글에서는 “어떤 스레드에서” 실행되는지를 다룹니다. Unity의 스레딩 모델을 이해하면 Profiler(Unity 에디터에 내장된 성능 분석 도구)에서 메인 스레드 대기의 원인을 파악하고, 특정 작업의 병렬화 가능 여부를 판단하는 데 도움이 됩니다.
메인 스레드
모든 게임 로직의 실행 장소
Unity에서 메인 스레드(Main Thread)는 게임 로직의 중심입니다. Awake, Start, Update, FixedUpdate, LateUpdate, 충돌 콜백, 코루틴은 모두 메인 스레드에서 실행됩니다.
물리 시뮬레이션은 이 중 유일하게 실제 연산을 다른 코어에 분산합니다. Unity가 사용하는 PhysX SDK가 멀티스레드로 설계되어 있어, 브로드페이즈(Broadphase)와 내로우페이즈(Narrowphase) 연산을 여러 코어에 분배합니다.
브로드페이즈는 각 콜라이더의 AABB(Axis-Aligned Bounding Box, 축 정렬 경계 상자)만으로 충돌 가능성이 없는 쌍을 빠르게 걸러내는 단계입니다.
내로우페이즈는 브로드페이즈를 통과한 쌍에 대해 실제 콜라이더 형상으로 충돌 여부를 정밀 계산하는 단계입니다.
각 쌍의 연산이 독립적이므로, 두 단계 모두 여러 코어에 나누어 병렬 처리할 수 있습니다.
단, 시뮬레이션의 시작·완료 동기화와 OnCollisionEnter 등 충돌 콜백 호출은 메인 스레드가 담당합니다.
게임 루프의 원리 (1) - 프레임의 구조에서 다룬 것처럼, 60fps 기준으로 한 프레임에 허용되는 시간은 16.6ms(1000ms / 60)입니다. 메인 스레드의 작업량이 이 프레임 예산을 초과하면 프레임 드롭이 발생합니다.
Unity API의 메인 스레드 제약
Unity API의 대부분은 메인 스레드에서만 호출할 수 있습니다.
| API | 스레드 제약 |
|---|---|
transform.position |
메인 스레드만 가능 |
transform.rotation |
메인 스레드만 가능 |
gameObject.SetActive() |
메인 스레드만 가능 |
GetComponent<T>() |
메인 스레드만 가능 |
Instantiate() |
메인 스레드만 가능 |
Destroy() |
메인 스레드만 가능 |
Resources.Load() |
메인 스레드만 가능 |
백그라운드 스레드에서 이 API들을 호출하면 예외가 발생하거나 예측 불가능한 동작으로 이어집니다. Unity의 네이티브 엔진 코드가 멀티스레드 안전(Thread-Safe)하게 설계되지 않았기 때문입니다.
네이티브 C++ 측의 오브젝트 상태를 여러 스레드에서 동시에 수정하면 데이터 경합(Data Race, 여러 스레드가 같은 메모리에 동시에 접근하고 그 중 하나 이상이 쓰기인 상황)이 발생하고, 크래시나 데이터 손상으로 이어집니다.
예외적으로 Debug.Log()는 백그라운드 스레드에서 호출해도 크래시 없이 동작합니다. Unity 내부의 로그 시스템이 동기화 메커니즘을 갖추고 있어, 실무에서도 백그라운드 스레드의 디버깅에 널리 활용됩니다. 다만 이 동작이 Unity 공식 문서에서 thread-safe로 명시적으로 보장되지는 않으므로, 로그 출력 이상의 복잡한 진단 작업에는 의존하지 않는 것이 안전합니다.
복잡한 계산을 백그라운드 스레드에서 수행하더라도, 그 결과를 Unity 오브젝트에 적용하는 단계는 반드시 메인 스레드에서 수행해야 합니다.
렌더 스레드
GPU 명령 제출 담당
메인 스레드가 모든 작업을 혼자 처리하는 것은 아닙니다. 렌더링 작업의 일부를 분담하는 렌더 스레드(Render Thread)가 존재합니다. 메인 스레드가 “무엇을 그릴지”를 결정하면, 렌더 스레드가 그 명령을 GPU가 이해하는 형태로 변환하여 제출합니다.
메인 스레드가 렌더링 준비를 끝내면, 그 데이터를 내부 명령 큐(Command Queue)에 넣습니다. 이 명령 큐는 메인 스레드와 렌더 스레드 사이의 중간 저장소입니다. 스크립트에서 사용하는 CommandBuffer 클래스(커스텀 렌더링 명령을 추가하는 API)와는 별개의 엔진 내부 메커니즘입니다.
렌더 스레드는 이 명령 큐에서 명령을 꺼내 GPU에 제출합니다. 이 구조 덕분에 메인 스레드와 렌더 스레드가 서로 다른 프레임의 데이터를 동시에 처리할 수 있습니다.
이 구조는 게임 루프의 원리 (1) - 프레임의 구조에서 다룬 CPU/GPU 파이프라이닝과 같은 원리입니다. 이러한 병렬 실행은 처리량(Throughput, 단위 시간에 완료하는 프레임 수)을 높이지만, 입력에서 화면 표시까지의 지연(Latency)이 1프레임 추가되는 트레이드오프가 있습니다. 메인 스레드가 프레임 N의 렌더링 데이터를 준비하면, 렌더 스레드가 GPU에 제출하고 실제 화면에 표시되는 것은 프레임 N+1 이후입니다.
멀티스레드 렌더링은 모든 플랫폼에서 기본으로 활성화되어 있습니다. 모바일(Android, iOS)에서는 Player Settings의 “Multithreaded Rendering” 옵션으로 비활성화할 수 있지만, 데스크톱에서는 이 옵션이 노출되지 않습니다.
비활성화하면 메인 스레드가 GPU 명령 제출까지 직접 수행하므로 병렬성을 잃지만, 위에서 언급한 1프레임 지연이 사라집니다. 플랫폼에 관계없이, Unity 에디터나 빌드된 실행 파일을 터미널에서 실행할 때 -force-gfx-direct 인수를 추가하면 강제로 비활성화할 수 있으며, 주로 렌더링 디버깅 용도로 사용됩니다.
메인 스레드와 렌더 스레드의 동기화
동기화 대기 지점
메인 스레드와 렌더 스레드는 병렬로 실행되지만, 두 스레드의 처리 속도가 항상 같지는 않습니다. 프레임마다 정해진 시점에 데이터를 주고받아야 하므로, 빠른 쪽이 느린 쪽을 기다리게 됩니다. 두 스레드의 실행 시점을 맞추는 이 과정을 동기화(Synchronization)라고 합니다.
Profiler에서의 동기화 마커
동기화 대기가 발생하면, Unity Profiler에는 해당 구간이 별도의 마커(marker)로 기록됩니다. 이 마커를 통해 병목이 어느 스레드에서 발생했는지 판단할 수 있습니다.
| 마커 이름 | 의미 |
|---|---|
Gfx.WaitForPresentOnGfxThread |
메인 스레드가 다음 프레임을 시작하려 하지만, 렌더 스레드가 GPU의 프레임 표시(present) 완료를 아직 기다리는 중 (GPU-bound 또는 VSync 대기) |
Gfx.WaitForCommands |
렌더 스레드가 메인 스레드의 렌더링 명령을 기다리는 중 (메인 스레드 병목 가능성) |
Gfx.WaitForRenderThread |
메인 스레드가 렌더 스레드의 명령 스트림 처리 완료를 대기 (멀티스레드 렌더링에서만 발생, 렌더 스레드 또는 GPU 병목) |
Profiler에서 Gfx.WaitForPresentOnGfxThread가 긴 시간을 차지한다면, GPU 또는 렌더 스레드가 병목일 가능성이 높습니다.
다만 VSync(수직 동기화)가 활성화된 상태에서도 이 마커가 나타날 수 있습니다. VSync는 GPU가 디스플레이의 갱신 주기에 맞추어 프레임을 출력하도록 강제하는 기능으로, 프레임 처리가 갱신 주기보다 빨리 끝나면 다음 갱신 시점까지 대기합니다. 이 대기 시간도 같은 마커로 기록되므로, 실제 GPU 병목인지 VSync 대기인지 구분해야 합니다. Profiler의 Timeline 뷰에서 이 마커 안에
WaitForTargetFPS서브 샘플이 포함되어 있다면 VSync 대기이고, 렌더 스레드가Gfx.PresentFrame에 시간을 쓰고 있다면 실제 GPU 병목으로 판단할 수 있습니다.
반대로 Gfx.WaitForCommands가 길다면 메인 스레드가 병목입니다.
게임 루프의 원리 (2) - CPU-bound와 GPU-bound에서 이 병목 진단과 최적화 방향을 더 상세하게 다루고 있습니다.
Job System
멀티스레드 프로그래밍의 필요성
메인 스레드와 렌더 스레드 중심의 구조만으로는 CPU 바운드 구간에서 현대 CPU의 다중 코어를 충분히 활용하기 어렵습니다. 동시에 실행되는 CPU 작업이 2개 수준에 머물면, 나머지 코어에는 게임이 채울 작업이 부족합니다.
나머지 코어를 활용하려면 동시에 실행할 CPU 작업을 늘려야 합니다. System.Threading으로 직접 멀티스레드 코드를 작성할 수도 있지만, 스레드 동기화, 데드락 방지, 데이터 경합 감지 등을 개발자가 직접 관리해야 합니다.
Unity의 Job System은 이런 부담 없이 안전하게 멀티스레드 작업을 수행할 수 있도록 설계된 프레임워크입니다.
Job의 기본 구조
Job System은 작업을 여러 워커 스레드(Worker Thread)에 분배하여 실행합니다. 메인 스레드가 Job(작업 단위)을 생성하면, 워커 스레드가 이를 받아 실행하는 구조입니다.
Unity는 애플리케이션 시작 시 CPU 코어 수에 기반하여 워커 스레드 풀을 미리 생성해 둡니다. 메인 스레드와 렌더 스레드가 이미 코어를 사용하고 있으므로, 워커 스레드는 코어 수보다 적게 생성됩니다. 정확한 수는 플랫폼과 하드웨어에 따라 달라지며, JobsUtility.JobWorkerCount로 확인할 수 있습니다.
IJob: 단일 작업
IJob은 하나의 작업을 워커 스레드에서 실행합니다. Execute 메서드에 수행할 로직을 작성합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// IJob 사용 예시
struct CalculatePathJob : IJob
{
public Vector3 start;
public Vector3 end;
public NativeArray<Vector3> result;
public void Execute()
{
// 경로 계산 로직 (워커 스레드에서 실행)
// Unity API 호출 불가
// result에 계산 결과 저장
}
}
// 메인 스레드에서:
var job = new CalculatePathJob { ... };
JobHandle handle = job.Schedule(); // 워커 스레드에 예약
handle.Complete(); // 완료 대기
// result 사용
Schedule()을 호출하면 Job이 워커 스레드에 예약됩니다. 메인 스레드는 Job이 실행되는 동안 다른 작업을 계속할 수 있습니다.
Complete()를 호출하면 Job이 끝날 때까지 대기하고, 완료 후 결과를 사용합니다.
IJobParallelFor: 데이터 병렬
IJob은 하나의 작업을 하나의 워커 스레드에서 실행합니다. 같은 종류의 작업을 대량으로 처리해야 하는 경우에는 IJobParallelFor를 사용할 수 있습니다. IJobParallelFor는 배열의 각 요소를 여러 워커 스레드에 분배하여 병렬로 처리합니다.
코드 구조는 IJob과 유사하지만, Execute 메서드에 int index 매개변수가 추가됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// IJobParallelFor 구조
struct UpdateEnemiesJob : IJobParallelFor
{
public NativeArray<Vector3> positions;
public NativeArray<Vector3> velocities;
public float deltaTime;
public void Execute(int index)
{
// index번째 요소만 처리
positions[index] += velocities[index] * deltaTime;
}
}
// 메인 스레드에서:
var job = new UpdateEnemiesJob { ... };
JobHandle handle = job.Schedule(
positions.Length, // 총 작업 수
64 // 배치 크기 (워크 스틸링 단위, 한 번에 가져가는 요소 수)
);
handle.Complete();
Execute의 매개변수 index는 현재 처리 중인 요소의 인덱스입니다. 각 워커 스레드가 서로 다른 인덱스 범위를 담당하므로, 같은 인덱스에 동시에 접근하는 일이 없습니다.
이 구조 덕분에 락(Lock) 없이도 데이터 경합이 발생하지 않습니다.
Schedule의 두 번째 매개변수인 배치 크기(innerloopBatchCount)는 워크 스틸링(Work Stealing)의 단위입니다. Job System은 전체 인덱스를 이 크기의 덩어리로 나누어 워커 스레드에 분배합니다.
한 워커가 자기 몫을 먼저 끝내면, 아직 처리 중인 다른 워커의 남은 덩어리를 가져와서 대신 처리합니다. 배치 크기가 너무 크면 일부 워커에 작업이 편중되고, 너무 작으면 배분 오버헤드가 커집니다. 단순 연산(벡터 덧셈 등)에는 32~128, 연산이 무거운 작업에는 1~16 정도가 일반적입니다.
참고로, 최신 Unity에서는 IJobFor와 ScheduleParallel()도 제공됩니다. IJobFor는 같은 코드를 Run()(메인 스레드 즉시 실행), Schedule()(단일 워커), ScheduleParallel()(병렬) 중 상황에 맞게 선택하여 실행할 수 있어 더 유연합니다.
의존성 그래프 (JobHandle)
Job은 워커 스레드에서 비동기로 실행되므로, 순서가 보장되지 않습니다. 한 Job의 결과를 다음 Job이 사용해야 하는 경우, JobHandle로 의존 관계를 명시하여 실행 순서를 지정합니다.
1
2
3
4
JobHandle handleA = jobA.Schedule(count, 64);
JobHandle handleB = jobB.Schedule(count, 64, handleA);
JobHandle handleC = jobC.Schedule(count, 64, handleB);
handleC.Complete();
Schedule()의 마지막 매개변수로 이전 Job의 핸들을 전달하면, 이전 Job이 완료된 후에만 현재 Job이 시작됩니다. 의존성이 없는 Job들은 동시에 실행됩니다.
Burst 컴파일러
IL에서 최적화된 네이티브 코드로
Job System은 작업을 여러 워커 스레드에 분배하여 병렬성을 확보합니다.
여기에 더해 각 Job의 실행 속도 자체를 높이는 방법이 Burst 컴파일러입니다.
Burst는 Job System과 함께 사용되는 고성능 컴파일러로, C# 컴파일러가 생성한 IL(Intermediate Language, 중간 언어)을 LLVM(다양한 플랫폼에 맞는 최적화된 기계어를 생성하는 컴파일러 프레임워크) 기반으로 네이티브 기계어로 변환합니다.
Burst 컴파일러의 대표적인 최적화 기법은 SIMD(Single Instruction, Multiple Data) 자동 벡터화입니다. SIMD는 하나의 CPU 명령어로 여러 데이터를 동시에 처리하는 하드웨어 기능입니다. 예를 들어, 4개의 float 덧셈을 4번의 개별 명령어 대신 1번의 SIMD 명령어로 처리할 수 있습니다(128비트 레지스터, 32비트 float 기준). Job System이 작업을 여러 코어에 분배하는 스레드 수준의 병렬성이라면, SIMD는 한 코어 내부에서 하나의 명령어가 여러 데이터를 처리하는 데이터 수준의 병렬성입니다.
Burst는 개발자가 SIMD 명령어를 직접 작성하지 않아도, 코드를 분석하여 자동으로 SIMD 명령어를 생성합니다.
[BurstCompile] 어트리뷰트
Job에 [BurstCompile] 어트리뷰트를 추가하면 Burst 컴파일이 적용됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Burst 적용
[BurstCompile]
struct UpdatePositionsJob : IJobParallelFor
{
public NativeArray<float3> positions;
[ReadOnly] public NativeArray<float3> velocities;
public float deltaTime;
public void Execute(int index)
{
positions[index] += velocities[index] * deltaTime;
}
}
// [BurstCompile]을 추가하는 것만으로 Burst 최적화 적용
// Vector3 대신 float3 (Unity.Mathematics) 사용 권장
// Burst가 SIMD 벡터화, 루프 언롤링 등 자동 최적화 수행
Burst 컴파일된 Job은 일반 C# 코드 대비 수 배에서 수십 배의 속도 향상을 보이는 경우가 있습니다. SIMD 벡터화의 효과가 큰 수학 연산 집약적 작업(위치 갱신, 거리 계산, 행렬 연산 등)에서 가장 큰 차이가 나타나며, 분기가 많거나 메모리 접근이 불규칙한 작업에서는 효과가 제한적입니다.
Burst의 제약
Burst가 모든 C# 코드를 지원하는 것은 아닙니다. Burst는 HPC#(High-Performance C#)이라는 C#의 부분 집합만 지원합니다. 관리 객체(class 인스턴스), 문자열, try-catch, virtual 메서드 호출, 가비지 컬렉션이 필요한 코드는 사용할 수 없습니다. 값 타입(struct), NativeContainer, Unity.Mathematics의 타입만 사용할 수 있습니다.
| 사용 가능 | 사용 불가 |
|---|---|
struct |
class |
NativeArray<T> |
List<T>, Dictionary<K,V> |
float, int, bool |
string |
float3, float4x4 |
박싱 |
math.sin(), math.dot() |
try-catch |
if, for, while |
virtual 메서드 |
| 함수 호출 (인라인 가능) | 인터페이스 디스패치 |
NativeArray 인덱싱 |
GC 할당 |
이 제약들은 Burst가 고성능 코드를 생성하기 위한 전제 조건입니다.
관리 객체와 GC를 배제하면 두 가지 이점이 생깁니다. 첫째, NativeContainer처럼 연속 메모리에 배치된 데이터만 사용하므로 CPU 캐시 효율이 높아집니다. 둘째, 런타임에 GC가 메모리를 재배치하거나 가상 디스패치가 발생하는 일이 없으므로, 메모리 접근 패턴이 컴파일 시점에 예측 가능해집니다.
LLVM은 이 예측 가능성을 기반으로 SIMD 벡터화와 루프 최적화를 적극적으로 적용합니다.
참고로 Job System과 Burst 컴파일러는 Unity의 DOTS(Data-Oriented Technology Stack) 아키텍처의 핵심 구성 요소이기도 합니다. DOTS는 이 두 기술에 ECS(Entity Component System, 데이터를 엔티티·컴포넌트·시스템으로 분리하여 관리하는 아키텍처)를 더하여 대규모 오브젝트를 캐시 친화적으로 처리하는 프레임워크입니다.
NativeContainer
Job에서 안전한 데이터 공유
Job 내부에서는 List
C# 런타임 기초 (1) - 값 타입과 참조 타입에서 다룬 GC가 관여하지 않으므로, GC 스파이크(GC가 한꺼번에 대량의 메모리를 회수하면서 발생하는 일시적 프레임 정지)를 유발하지 않습니다.
대신 사용이 끝나면 반드시 Dispose()를 호출하여 직접 해제해야 하며, 해제하지 않으면 메모리 누수가 발생합니다.
할당 수명 (Allocator)
NativeContainer를 생성할 때 Allocator를 지정하여 메모리의 수명을 결정합니다.
| Allocator | 용도와 수명 |
|---|---|
Allocator.Temp |
1프레임 이내 사용. 할당/해제 속도 가장 빠름. 프레임 종료 시 자동 해제. 메인 스레드에서 Job으로 전달 불가 |
Allocator.TempJob |
Job에 전달 가능. 수동 Dispose 필요. 4프레임 이내에 Dispose하지 않으면 콘솔 누수 경고 출력 |
Allocator.Persistent |
수명 제한 없음. 할당 속도 가장 느림. 수동 Dispose 필수. 여러 프레임에 걸쳐 유지 |
Job 내부에서 임시 데이터가 필요하면 Allocator.Temp를 사용할 수 있으며, Job 종료 시 자동으로 해제됩니다. Persistent는 씬 전체에서 유지되는 데이터(경로 맵, 공간 분할 구조 등)에 적합하고, 오브젝트 파괴 시 Dispose합니다.
안전성 검사
Job System은 NativeContainer에 대한 접근 규칙을 스케줄링 시점과 런타임에 검사합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// Job System의 안전성 검사
[ReadOnly] public NativeArray<float3> positions;
// 이 Job에서 positions에 쓰기를 시도하면 런타임 에러
public NativeArray<float3> results;
// 두 개의 Job이 동시에 같은 NativeArray에
// 쓰기 접근하면 런타임 에러
// 데이터 경합을 Job System이 자동으로 감지
// 기존 멀티스레드 프로그래밍의 락/뮤텍스가 불필요
[ReadOnly] 어트리뷰트를 지정하면 해당 NativeContainer에 대한 쓰기 접근이 차단됩니다.
읽기 전용으로 지정된 데이터는 여러 Job에서 동시에 접근할 수 있지만, 쓰기 접근이 필요한 NativeContainer는 한 번에 하나의 Job만 접근할 수 있습니다. Job System이 이 규칙을 자동으로 강제하므로, 개발자가 락이나 뮤텍스를 직접 관리할 필요가 없습니다. 단, 이 안전성 검사는 에디터와 Development Build에서만 동작하며, Release Build에서는 성능을 위해 비활성화됩니다.
Unity API의 메인 스레드 제약과 우회
네트워크 응답 처리, 파일 I/O 완료 콜백 등 백그라운드 스레드에서 실행되는 코드가 Unity 오브젝트를 조작해야 하는 경우, 메인 스레드로 작업을 전달하는 패턴이 필요합니다.
UnitySynchronizationContext
SynchronizationContext는 비동기 작업이 완료된 후 어느 스레드에서 이어서 실행할지를 결정하는 런타임 메커니즘입니다.
Unity는 메인 스레드에 UnitySynchronizationContext를 설정하여, await 이후의 코드가 자동으로 메인 스레드에서 실행되도록 합니다. C# 런타임 기초 (4) - 스레딩과 비동기에서 async/await과 SynchronizationContext의 동작 원리를 상세히 다룹니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// async/await로 메인 스레드 복귀
async void LoadDataAndApply()
{
// 1. 메인 스레드에서 시작
string url = _dataUrl;
// 2. 네트워크 요청을 비동기로 실행 (await로 완료 대기)
string json = await DownloadAsync(url);
// 3. await 후에는 메인 스레드로 자동 복귀
// (UnitySynchronizationContext 덕분)
ParseAndApply(json);
gameObject.SetActive(true); // Unity API 호출 가능
}
// await 이후에 UnitySynchronizationContext가
// 메인 스레드에서 후속 코드를 실행하도록 스케줄링
위 코드에서 await DownloadAsync(url) 이후의 ParseAndApply와 SetActive 호출은 자동으로 메인 스레드에서 실행되므로, 개발자가 스레드 전환 코드를 직접 작성할 필요가 없습니다.
코드 예시의
async void는 호출자가 예외를 관찰하거나 완료를 대기할 수 없으므로, 실무에서는async Task를 반환하는 것이 안전합니다. C# 런타임 기초 (4)에서async void의 문제와 대안을 상세히 다룹니다.
await 대기 중에 해당 GameObject가 파괴되거나 씬이 전환되면, 후속 코드가 이미 파괴된 오브젝트에 접근하여 MissingReferenceException이 발생할 수 있습니다.
코루틴은 MonoBehaviour가 파괴되면 자동으로 중단되지만, async 메서드의 continuation은 비동기 작업이 완료되는 시점에 UnitySynchronizationContext의 실행 큐에 등록되므로, 오브젝트 파괴 여부와 관계없이 재개됩니다.
await 이후에 this == null 검사를 추가하거나, CancellationToken(비동기 작업에 취소 신호를 전달하는 객체)을 활용하여 오브젝트 파괴 시 비동기 작업을 취소하는 것이 안전합니다. Unity 2022.2 이상에서는 MonoBehaviour의 destroyCancellationToken을 사용하면 오브젝트 파괴 시 자동으로 취소 신호가 전달됩니다.
메인 스레드 디스패치 패턴
UnitySynchronizationContext의 내부 원리는 큐 기반 디스패치입니다. async/await 없이도 같은 원리를 직접 구현할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 큐 기반 메인 스레드 디스패치
ConcurrentQueue<Action> _mainThreadQueue = new();
// 백그라운드 스레드에서:
void OnNetworkResponse(string data)
{
_mainThreadQueue.Enqueue(() =>
{
// 이 코드는 메인 스레드에서 실행됨
UpdateUI(data);
gameObject.SetActive(true);
});
}
// 메인 스레드에서 (Update):
void Update()
{
while (_mainThreadQueue.TryDequeue(out Action action))
{
action.Invoke();
}
}
// 백그라운드 스레드: 큐에 작업 추가
// 메인 스레드: Update에서 큐의 작업을 꺼내 실행
// Unity API 호출은 메인 스레드의 Update에서만 발생
ConcurrentQueue<T>는 스레드 안전한(Thread-Safe) 큐이므로, 백그라운드 스레드에서 Enqueue하고 메인 스레드에서 TryDequeue하는 것이 안전합니다.
async/await를 사용하지 않는 코드에서, 이 패턴으로 백그라운드 스레드의 결과를 메인 스레드에 전달하여 Unity API 호출 규칙을 지킬 수 있습니다.
Job System vs 직접 스레딩
Job System은 데이터 병렬 연산에, async/await과 큐 기반 디스패치는 I/O 대기가 긴 비동기 작업에 각각 적합합니다.
| 항목 | Job System | 직접 스레딩 (System.Threading, async/await) |
|---|---|---|
| 안전성 검사 | 자동 (스케줄링/런타임) | 수동 (개발자 책임) |
| 데이터 경합 방지 | NativeContainer로 자동 보장 | 락/뮤텍스 직접 관리 |
| Burst 사용 | 가능 | 불가 |
| Unity API 호출 | 불가 (Job 내부) | 불가 (백그라운드 스레드) |
| 데이터 전달 | NativeContainer | 공유 변수, 큐 등 |
| 적합한 용도 | 데이터 병렬 연산 (수학, 위치 갱신) | I/O, 네트워크 등 대기가 긴 작업 |
대량의 데이터를 병렬로 계산하는 작업(적 위치 갱신, 경로 탐색, 시야 검사 등)에는 Job System이 적합합니다. Burst 컴파일까지 적용하면 성능 향상 폭이 더 커집니다.
네트워크 통신, 파일 I/O처럼 대기 시간이 긴 비동기 작업에는 async/await나 System.Threading이 적합합니다.
표준 Task 기반 async/await 외에도, Unity 2023.1 이상의 Awaitable은 PlayerLoop에 직접 연결되어 SynchronizationContext 캡처 없이 메인 스레드에서 재개되며, 내부 풀링으로 힙 할당을 줄입니다.
서드파티 라이브러리인 UniTask는 힙 할당 없는 async/await 실행과 PlayerLoop 재개 시점의 세밀한 지정을 지원합니다.
이에 대한 상세한 비교는 C# 런타임 기초 (4) - 스레딩과 비동기에서 다룹니다.
마무리
Unity는 메인 스레드 중심 아키텍처입니다. 모든 MonoBehaviour 콜백과 Unity API는 메인 스레드에서 실행되며, 렌더 스레드가 GPU 명령 제출을 분담하고, Job System이 연산 작업의 병렬화를 담당합니다.
- 메인 스레드에서 모든 MonoBehaviour 콜백, 애니메이션, 렌더링 준비가 실행됩니다. 물리 시뮬레이션은 메인 스레드가 시작하지만, 연산 자체는 내부적으로 워커 스레드에 분산됩니다. Unity API는 메인 스레드에서만 호출할 수 있습니다
- 렌더 스레드는 메인 스레드가 준비한 렌더링 명령을 GPU에 제출하며, 두 스레드는 병렬로 실행됩니다
- Profiler의
Gfx.WaitForPresentOnGfxThread는 GPU 병목 또는 VSync 대기를,Gfx.WaitForCommands는 메인 스레드 병목을 시사합니다 - Job System은 IJob(단일 작업)과 IJobParallelFor(데이터 병렬)로 워커 스레드에서 안전하게 작업을 실행하며, JobHandle로 의존성을 관리합니다
- Burst 컴파일러는 Job 코드를 LLVM 기반으로 네이티브 기계어로 변환하고, SIMD 자동 벡터화를 적용하여 수학 연산 집약적인 작업의 실행 속도를 크게 높입니다
- NativeContainer는 네이티브 메모리에 할당되어 GC 부담이 없으며,
Dispose()로 수동 해제해야 합니다 - 백그라운드 스레드의 결과로 Unity API를 호출해야 하는 경우, async/await의
UnitySynchronizationContext나 큐 기반 디스패치 패턴으로 메인 스레드에 작업을 전달합니다
Unity 엔진 핵심 시리즈는 이 글로 마무리됩니다. GameObject/Component 구조, Transform 계층, 실행 순서, 스레딩 모델까지 Unity 엔진의 기본 구조를 다루었습니다. 이 기초 위에서 게임 루프의 원리 (2) - CPU-bound와 GPU-bound의 병목 분석, 스크립트 최적화 시리즈의 성능 개선 기법, C# 런타임 기초 (4) - 스레딩과 비동기의 스레드 관리와 비동기 패턴을 더 깊이 이해할 수 있습니다.
관련 글
- C# 런타임 기초 (1) - 값 타입과 참조 타입
- C# 런타임 기초 (4) - 스레딩과 비동기
- 게임 루프의 원리 (1) - 프레임의 구조
- 게임 루프의 원리 (2) - CPU-bound와 GPU-bound
- 하드웨어 기초 (1) - CPU 아키텍처와 파이프라인
- 스크립트 최적화 (1) - C# 실행과 메모리 할당
시리즈
- Unity 엔진 핵심 (1) - GameObject와 Component
- Unity 엔진 핵심 (2) - Transform 계층과 씬 그래프
- Unity 엔진 핵심 (3) - Unity 실행 순서
- Unity 엔진 핵심 (4) - Unity의 스레딩 모델 (현재 글)
전체 시리즈
- 하드웨어 기초 (1) - CPU 아키텍처와 파이프라인
- 하드웨어 기초 (2) - 메모리 계층 구조
- 하드웨어 기초 (3) - GPU의 탄생과 발전
- 하드웨어 기초 (4) - 모바일 SoC
- 그래픽스 수학 (1) - 벡터와 벡터 연산
- 그래픽스 수학 (2) - 행렬과 변환
- 그래픽스 수학 (3) - 좌표 공간의 전환
- 그래픽스 수학 (4) - 투영
- C# 런타임 기초 (1) - 값 타입과 참조 타입
- C# 런타임 기초 (2) - .NET 런타임과 IL2CPP
- C# 런타임 기초 (3) - 가비지 컬렉션의 기초
- C# 런타임 기초 (4) - 스레딩과 비동기
- 색과 빛 (1) - 빛의 물리적 원리
- 색과 빛 (2) - 색 표현과 색공간
- 색과 빛 (3) - 셰이딩 모델
- 래스터화 파이프라인 (1) - 삼각형에서 프래그먼트까지
- 래스터화 파이프라인 (2) - 버퍼 시스템
- 래스터화 파이프라인 (3) - 디스플레이와 안티앨리어싱
- Unity 엔진 핵심 (1) - GameObject와 Component
- Unity 엔진 핵심 (2) - Transform 계층과 씬 그래프
- Unity 엔진 핵심 (3) - Unity 실행 순서
- Unity 엔진 핵심 (4) - Unity의 스레딩 모델 (현재 글)
- Unity 에셋 시스템 (1) - Asset Import Pipeline
- Unity 에셋 시스템 (2) - Serialization과 Instantiation
- Unity 에셋 시스템 (3) - Scene Management
- Unity 렌더링 (1) - Camera와 Rendering Layer
- Unity 렌더링 (2) - Render Target과 Frame Buffer
- Unity 렌더링 (3) - Render Pipeline 개요