Unity는 기본적으로 싱글 스레드 기반의 게임 엔진이다. 대부분의 Unity API는 메인 스레드에서만 호출이 가능하며, 렌더링, 물리 처리, UI 갱신 등 핵심적인 작업 역시 메인 스레드에서 수행된다. 그러나 게임의 복잡성이 증가함에 따라, 병렬 처리를 통해 성능을 향상시킬 필요성이 커지고 있다. 본 글에서는 Unity에서 멀티 쓰레딩을 구현하는 다양한 방법과 주의할 점을 상세히 다루고자 한다.
Unity는 다음과 같은 구조로 스레드를 운용한다:
Main Thread: 대부분의 Unity API가 동작하는 기본 실행 스레드이다. 게임 로직, UI 처리 등이 여기에 포함된다.
Render Thread: 일부 플랫폼에서 렌더링을 담당하는 전용 스레드이다.
Worker Threads: 개발자가 직접 생성하거나 Unity의 Job System을 통해 활용 가능한 병렬 처리용 스레드이다.
C#의 Thread 클래스 사용
.NET의 표준 System.Threading.Thread 클래스를 사용하면 직접 스레드를 생성하여 작업을 분리할 수 있다.
void Start()
{
Thread thread = new Thread(HeavyTask);
thread.Start();
}
void HeavyTask()
{
// 백그라운드 작업 처리
Debug.Log("작업 실행 중");
}
단, Debug.Log()와 같은 UnityEngine API는 메인 스레드에서만 안전하게 호출될 수 있다. 위 코드는 예시일 뿐, 실제로는 주의가 필요하다.
비동기 처리를 위해 Task와 async/await를 사용할 수 있다. 이 방식은 코드 가독성이 뛰어나며 예외 처리 또한 간결하게 이뤄질 수 있다.
async void Start()
{
await Task.Run(() => HeavyComputation());
Debug.Log("비동기 작업 완료");
}
void HeavyComputation()
{
// CPU 연산 수행
Thread.Sleep(1000);
}
이 방식은 Unity API를 직접 호출하지 않는 범위 내에서 병렬 처리를 수행한 뒤, 작업 종료 후 메인 스레드로 안전하게 복귀할 수 있다는 장점을 가진다.
Unity가 제공하는 멀티 쓰레딩 프레임워크로, Burst Compiler와 함께 사용될 때 매우 높은 성능을 기대할 수 있다.
[BurstCompile]
public struct MyJob : IJob
{
public void Execute()
{
// 병렬 처리할 작업
}
}
void Start()
{
var job = new MyJob();
var handle = job.Schedule();
handle.Complete();
}
Job System은 메모리 안전성과 성능을 동시에 확보할 수 있도록 설계되었으며, NativeArray, NativeList 등의 구조체와 함께 사용된다. 단, UnityEngine 객체에 직접 접근하는 것은 불가능하다.
DOTS는 Unity의 데이터 중심 설계 방식을 따르며, ECS(Entity Component System)를 기반으로 멀티 스레딩을 극대화할 수 있다.
Entities
.WithName("MoveJob")
.ForEach((ref Translation trans) =>
{
trans.Value.y += 1f;
}).ScheduleParallel();
DOTS는 대규모 데이터를 다루는 데 적합하며, 수천 개 이상의 엔티티를 병렬로 처리할 수 있는 구조를 가진다.
UnityEngine의 대부분의 API는 스레드에 안전하지 않다. 즉, 메인 스레드가 아닌 다른 스레드에서 호출될 경우 예기치 않은 동작이나 크래시가 발생할 수 있다. 대표적인 예시는 다음과 같다.
Transform.position
GameObject.SetActive()
Debug.Log()
Instantiate(), Destroy()
따라서 별도의 스레드에서 Unity API를 호출하고자 할 경우, 반드시 메인 스레드에서 해당 작업을 실행되도록 큐잉해야 한다.
private static readonly ConcurrentQueue<Action> mainThreadQueue = new();
void Update()
{
while (mainThreadQueue.TryDequeue(out var action))
{
action?.Invoke();
}
}
// 백그라운드 스레드 내부
mainThreadQueue.Enqueue(() =>
{
someGameObject.SetActive(true); // 메인 스레드에서 실행됨
});
스레드 안전성(Thread Safety): 공유 데이터에 접근할 경우 lock, Mutex, ConcurrentDictionary 등을 이용하여 동기화해야 한다.
GC 부담: 너무 많은 스레드 또는 Task 생성은 가비지 컬렉션 비용을 증가시킬 수 있다.
스레드 수 조절: 코어 수 이상으로 스레드를 생성할 경우 오히려 병목이 발생할 수 있다.
디버깅 어려움: 병렬 처리는 디버깅이 까다로우며, 재현이 어려운 버그가 발생할 수 있다.
분야 | 설명 |
---|---|
경로 탐색 | A* 알고리즘 등 경로 계산은 연산량이 많아 별도 스레드에서 처리하는 것이 효율적이다. |
월드 생성 | 노이즈 기반 월드 생성, 텍스처 샘플링 등의 작업은 Job System으로 분산 처리할 수 있다. |
AI 의사결정 | 다수의 NPC가 동시에 의사결정을 수행할 경우 병렬 처리를 통해 성능을 확보할 수 있다. |
비동기 네트워크 | 서버와의 통신은 비동기 방식으로 처리되어야 한다. |
대용량 파일 처리 | JSON, XML, CSV 등의 파싱 작업은 Task로 처리할 수 있다. |
Unity는 기본적으로 싱글 스레드에서 동작하지만, 다양한 멀티 쓰레딩 기법을 통해 연산 효율을 높일 수 있다. C#의 Thread, Task, Unity의 Job System, DOTS 등 각각의 기법은 장단점을 가지며, 프로젝트의 성격에 맞게 선택되어야 한다.
멀티 쓰레딩은 성능 최적화의 강력한 도구가 될 수 있으나, 동시에 스레드 안전성, API 호출 제한, 디버깅 복잡도 등의 리스크도 내포하고 있다. 이러한 요소들을 충분히 고려한 후에 적절한 방법을 선택하는 것이 중요하다.