유니티(Unity) 엔진은 기본적으로 싱글 스레드(Single-threaded) 기반으로 설계되었습니다. 게임 로직 업데이트, 물리 연산, 애니메이션, 렌더링 명령 생성 등 게임의 핵심 사이클이 모두 하나의 메인 스레드(Main Thread)
C# 멀티스레딩 Job System 병렬 처리 실무
1. 서론: 메인 스레드 병목(Bottleneck)의 한계
유니티(Unity) 엔진은 기본적으로 싱글 스레드(Single-threaded) 기반으로 설계되었습니다. 게임 로직 업데이트, 물리 연산, 애니메이션, 렌더링 명령 생성 등 게임의 핵심 사이클이 모두 하나의 메인 스레드(Main Thread)에서 순차적으로 처리됩니다. 게임의 규모가 커지고, 처리해야 할 AI 에이전트나 물리 객체가 수백, 수천 개로 늘어나면 메인 스레드는 부하를 견디지 못하고 프레임 드랍(Spike)을 일으킵니다.
과거에는 C#의 전통적인 멀티스레딩 기법(Thread, ThreadPool, Task)을 사용하여 병렬 처리를 시도했습니다. 하지만 유니티의 강력한 API(예: Transform, GameObject)들은 스레드 안전(Thread-safe)하지 않아 메인 스레드 외부에서 접근하면 크래시가 발생했습니다. 또한, 레이스 컨디션(Race Condition)이나 데드락(Deadlock)과 같은 고질적인 멀티스레딩 버그를 잡는 것은 지옥과도 같았습니다. 이를 혁신적으로 해결한 것이 바로 유니티의 C# Job System입니다.
2. C# Job System의 핵심 개념: 안전성과 병렬성
Job System은 개발자가 복잡한 스레드 동기화 락(Lock)을 관리할 필요 없이, 멀티 코어 프로세서의 성능을 안전하고 최대한으로 끌어낼 수 있도록 설계된 워커 스레드(Worker Threads) 풀링 시스템입니다.
2.1. 네이티브 컨테이너 (NativeContainer)
Job System의 가장 큰 특징은 워커 스레드와 메인 스레드 간의 데이터 교환 시 가비지 컬렉터(GC)를 유발하는 관리형(Managed) 메모리를 사용하지 않는다는 점입니다. 대신 C++처럼 직접 메모리를 제어하는 NativeContainer (NativeArray, NativeList, NativeHashMap 등)를 사용합니다. 데이터의 생명 주기에 따라 적절한 할당자(Allocator)를 선택하는 것이 핵심입니다.
Allocator.Temp: 1프레임 이하의 아주 짧은 수명. 가장 빠름. (Job에 전달 불가)Allocator.TempJob: 4프레임 이내의 수명. 메인 스레드와 Job 간의 데이터 전달용으로 최적.Allocator.Persistent: 게임이 끝날 때까지 유지되는 장기 수명.malloc을 직접 호출하는 것과 같으므로 수동으로 반드시Dispose()를 호출하여 메모리 누수를 방지해야 합니다.
2.2. 작업의 단위: IJob과 IJobParallelFor
Job System에서는 수행할 로직을 구조체(struct)로 정의합니다. 참조형(class)을 사용할 수 없어 데이터의 복사(Value Copy) 및 고립이 보장되므로 레이스 컨디션을 원천 차단합니다.
IJob: 워커 스레드 하나에서 단일 작업을 수행합니다.IJobParallelFor: 거대한 배열(NativeArray)의 데이터를 멀티 코어에 분배하여 동시에 처리합니다. 수천 개의 유닛 이동이나 복잡한 수학 연산 배열 처리에 극적인 성능 향상을 가져옵니다.
3. 실무 구현 패턴: 의존성(Dependency) 관리
실제 게임 로직은 단순하지 않습니다. A 작업이 끝나야 B 작업을 시작할 수 있는 경우가 많습니다. Job System은 JobHandle을 통해 이러한 의존성 관리를 우아하게 해결합니다.
JobHandle jobAHandle = jobA.Schedule();
JobHandle jobBHandle = jobB.Schedule(jobAHandle); // jobA가 끝난 후 실행
JobHandle.CompleteAll(ref arrayB, ref arrayC);
핵심 팁은 Schedule()을 호출하여 작업을 대기열에 올린 후, 즉시 Complete()를 호출하여 메인 스레드를 블로킹(Blocking)하지 않는 것입니다. 프레임의 시작(Early Update) 부분에 Job을 스케줄링하고, 메인 스레드는 다른 작업을 수행한 뒤, 프레임의 끝(Late Update)에서 Complete()를 호출하여 결과를 수집하는 방식으로 병렬성을 극대화해야 합니다.
4. 궁극의 최적화: Burst Compiler의 결합
Job System의 진정한 파괴력은 Burst Compiler와 결합될 때 폭발합니다. Job 구조체 위에 [BurstCompile] 속성만 붙여주면, 유니티는 LLVM 기반 컴파일러를 통해 이 C# 코드를 해당 타겟 플랫폼(ARM, x86)에 고도로 최적화된 네이티브 머신 코드(SIMD 명령어 포함)로 변환해 줍니다.
저희 팀은 수천 개의 탄막(Bullet Hell) 충돌 판정 시스템에 IJobParallelFor와 Burst Compiler를 적용하여, 메인 스레드에서 15ms(60FPS 방어 실패) 걸리던 연산을 단 0.8ms로 줄이는 압도적인(약 18배) 성능 향상을 경험했습니다.
5. 결론: 데이터 지향 설계(DOD)로의 전환
Job System을 제대로 활용하려면 기존의 객체 지향 프로그래밍(OOP) 방식에서 벗어나 메모리 배열과 데이터의 흐름을 중심으로 사고하는 데이터 지향 설계(Data-Oriented Design)로 패러다임을 전환해야 합니다. 복잡성이라는 비용을 지불해야 하지만, 모바일 환경이나 대규모 시뮬레이션 게임에서 안정적인 프레임 레이트를 확보하기 위해 C# Job System은 더 이상 선택이 아닌 필수 기술입니다.