작성일 :

프레임을 나누어 처리하는 스레드들

Unity 엔진 핵심 (3) - Unity 실행 순서에서는 FixedUpdate, Update, LateUpdate와 렌더링 콜백이 한 프레임 안에서 어떤 순서로 호출되는지 살펴보았습니다. 각 콜백이 언제 호출되는지는 이 실행 순서로 설명할 수 있습니다.

다만 실행 순서만으로는 각 콜백이 어디에서 실행되는지까지 알기 어렵습니다. Unity는 한 프레임을 하나의 스레드로 처리하지 않고 여러 스레드에 나누어 처리하기 때문입니다. 게임 로직을 실행하는 메인 스레드(Main Thread), GPU에 렌더링 명령을 제출하는 렌더 스레드(Render Thread), 병렬 연산을 처리하는 워커 스레드(Worker Thread)가 한 프레임의 작업을 나누어 맡습니다.

이 글에서는 먼저 메인 스레드와 렌더 스레드가 각각 무엇을 담당하는지, 두 스레드가 어디에서 서로를 기다리며 동기화되는지 정리합니다. 그다음 워커 스레드를 활용하는 Job System, Job 코드를 더 빠른 네이티브 코드로 바꾸는 Burst 컴파일러, Job이 다루는 데이터 컨테이너인 NativeContainer를 차례로 살펴봅니다. 이 구조를 알아 두면 Profiler(Unity 에디터의 성능 분석 도구)에서 어느 스레드가 어디서 기다리는지 읽어 내고, 어떤 작업을 워커 스레드로 넘겨 병렬화할 수 있는지 판단하는 데 도움이 됩니다.


메인 스레드

메인 스레드는 Unity 런타임의 중심 스레드입니다. 게임 상태를 갱신하고, 대부분의 Unity API 접근을 처리하며, 한 프레임에서 다른 시스템들이 사용할 데이터를 준비합니다.

모든 게임 로직의 실행 장소

게임 로직은 기본적으로 메인 스레드에서 실행됩니다. Awake, Start, Update, FixedUpdate, LateUpdate 같은 콜백뿐 아니라 충돌 콜백과 코루틴도 메인 스레드에서 실행됩니다. 일반적인 MonoBehaviour 스크립트 코드는 메인 스레드에서 호출된다고 보면 됩니다.


메인 스레드 FixedUpdate() (0~N회) 물리 시뮬레이션 시작 (내부적으로 워커 스레드 활용) 충돌 콜백 (OnCollisionEnter 등) 입력 이벤트 Update() 코루틴 재개 애니메이션 갱신 LateUpdate() 렌더링 준비 (컬링, 정렬, 드로우 콜 생성) 모든 MonoBehaviour 콜백 모든 Unity API 호출 물리: 메인 스레드가 시작, 연산은 워커 스레드 분산 애니메이션 평가 렌더링을 위한 데이터 준비


물리 시뮬레이션은 메인 스레드가 맡는 작업이지만, 그 연산까지 메인 스레드가 전부 처리하지는 않습니다. Unity가 쓰는 PhysX SDK는 멀티스레드를 지원하므로, 충돌 판정에 드는 무거운 계산은 여러 워커 스레드에 나누어 처리합니다. 다만 시뮬레이션을 시작하는 일, 워커 스레드의 계산이 끝나기를 기다려 그 결과를 받아 모으는 일, OnCollisionEnter 같은 충돌 콜백을 호출하는 일 등은 여전히 메인 스레드가 맡습니다.

무거운 계산을 이렇게 워커 스레드로 넘기는 이유는, 메인 스레드에 주어진 시간이 한정되어 있기 때문입니다. 물리뿐 아니라 메인 스레드가 처리하는 모든 작업은 한 프레임에 허용된 시간, 곧 프레임 예산(frame budget) 안에 끝나야 합니다. 예를 들어 화면을 60fps로 갱신한다면 한 프레임에 쓸 수 있는 시간은 약 16.6ms(1000ms / 60)에 불과합니다. 메인 스레드의 작업이 이 예산을 넘기면 프레임이 제때 끝나지 못하고, 화면이 끊기는 프레임 드롭(frame drop)으로 이어집니다. 충돌 판정처럼 비용이 큰 계산을 워커 스레드에서 처리하면, 그만큼 메인 스레드의 프레임 시간에 여유가 생깁니다.

프레임 예산과 프레임 드롭의 관계는 게임 루프의 원리 (1) - 프레임의 구조에서 자세히 다룹니다.


워커 스레드로 넘어가는 이 계산은, 결국 두 물체가 실제로 겹치는지 확인하는 일입니다. 장면에 물체가 늘어날수록 확인해야 할 물체 쌍이 빠르게 많아지고, 검사 비용도 그만큼 커집니다. PhysX는 이 검사를 두 단계로 나누어, 정밀한 판정을 꼭 필요한 쌍에만 집중시킵니다.

먼저 브로드페이즈(Broadphase)는 각 Collider의 AABB(Axis-Aligned Bounding Box, 축 정렬 경계 상자)만 비교해, 서로 닿을 가능성이 없는 쌍을 미리 걸러 냅니다. 실제 형상까지 들여다보지 않으므로 비용이 적습니다. 그다음 내로우페이즈(Narrowphase)는 이렇게 추려진 쌍만을 대상으로 실제 Collider 형상을 따져 충돌 여부를 정밀하게 판정합니다. 비용이 큰 계산은 이 내로우페이즈에 몰립니다.

워커 스레드가 나눠 맡는 것이 바로 이 내로우페이즈의 판정입니다. 한 쌍을 판정하는 계산은 다른 쌍의 판정과 얽혀 있지 않아, 어떤 쌍을 먼저 확인하든 결과가 달라지지 않습니다. 이렇게 쌍마다 독립적이므로, PhysX는 수많은 쌍의 판정을 여러 워커 스레드에 나누어 동시에 처리합니다.


Unity API의 메인 스레드 제약

게임 로직이 메인 스레드에 집중되는 또 다른 이유는 Unity API의 대부분이 메인 스레드에서만 호출할 수 있도록 설계되어 있기 때문입니다.


API 스레드 제약
transform.position 메인 스레드만 가능
transform.rotation 메인 스레드만 가능
gameObject.SetActive() 메인 스레드만 가능
GetComponent<T>() 메인 스레드만 가능
Instantiate() 메인 스레드만 가능
Destroy() 메인 스레드만 가능
Resources.Load() 메인 스레드만 가능


이렇게 호출 스레드가 제한되는 이유는, 이 API들이 다루는 네이티브 엔진 객체가 여러 스레드의 동시 접근을 염두에 두고 만들어지지 않았기 때문입니다. 백그라운드 스레드에서 이런 객체에 접근하면, Unity가 잘못된 호출을 미리 막아 예외를 던지기도 하고, 아무 경고 없이 예측하기 어려운 동작으로 이어지기도 합니다.

이 가운데 예외는 호출할 때 바로 발생하므로 원인을 찾기 쉽습니다. 더 까다로운 쪽은 예측하기 어려운 동작입니다. 여러 스레드가 네이티브 C++ 객체의 상태를 동시에 수정하면 데이터 경합(Data Race, 여러 스레드가 같은 메모리에 동시에 접근하고 그중 하나 이상이 쓰기인 상황)이 일어나고, 그 결과는 곧바로 크래시로 이어지기도, 한참 뒤에야 데이터 손상으로 나타나기도 합니다.

이 규칙에서 벗어나는 경우가 하나 있습니다. Debug.Log()는 백그라운드 스레드에서 호출해도 대체로 안전합니다. Unity의 로그 시스템이 내부적으로 자체 동기화를 거치기 때문이며, 이 덕분에 백그라운드 작업을 디버깅할 때 자주 쓰입니다. 다만 이는 로그 시스템에 한정된 경우일 뿐, 다른 Unity API까지 같은 안전성이 보장되지는 않습니다.

따라서 무거운 계산을 백그라운드 스레드나 Job으로 넘기더라도, 그 결과를 Unity 오브젝트에 반영하는 단계는 메인 스레드에서 처리해야 합니다.


렌더 스레드

렌더 스레드는 메인 스레드가 준비한 렌더링 데이터를 GPU 명령으로 바꾸어 제출하는 스레드입니다. 화면에 무엇을 그릴지 정하는 일과 그 내용을 GPU에 그리도록 명령하는 일은 성격이 다르므로, Unity는 이 둘을 서로 다른 스레드에 나누어 맡깁니다.

GPU 명령 제출 담당

렌더링이라고 해서 메인 스레드가 GPU에 명령을 보내는 일까지 전담하는 것은 아닙니다. 메인 스레드가 책임지는 범위는 무엇을 그릴지 정하고 그 데이터를 준비하는 데까지입니다. 화면에 보일 물체만 추려 내는 컬링, 그리는 순서를 정하는 정렬, 드로우 콜 목록 생성, 셰이더 파라미터 설정이 모두 이 단계에 해당합니다. 이렇게 준비된 데이터를 OpenGL이나 Vulkan 같은 그래픽 API 호출로 바꾸어 GPU에 제출하는 일부터는 렌더 스레드가 이어받습니다.


메인 스레드 1. 가시성 판단 (컬링) 2. 렌더 순서 정렬 3. 드로우 콜 목록 생성 4. 셰이더 파라미터 설정 렌더 스레드 1. 그래픽 API 호출 변환 (OpenGL/Vulkan/Metal/DirectX) 2. GPU에 명령 제출 3. GPU 동기화 관리


두 스레드는 내부 명령 큐(Command Queue)를 통해 연결됩니다. 메인 스레드가 렌더링 준비를 마치면 그 명령이 큐에 쌓이고, 렌더 스레드는 큐에서 명령을 하나씩 꺼내 GPU에 제출합니다. 이 명령 큐는 엔진이 내부에서만 쓰는 메커니즘으로, 스크립트에서 직접 다루는 것은 아닙니다.

프레임을 겹쳐 처리하는 파이프라이닝

메인 스레드와 렌더 스레드를 따로 둔 덕분에, 두 스레드는 동시에 일할 수 있습니다. 메인 스레드는 한 프레임의 렌더링 준비를 마쳐 명령을 큐에 넘기고 나면, 그 명령이 GPU에 제출되기를 기다리지 않고 곧바로 다음 프레임 작업에 들어갑니다. 그래서 메인 스레드가 다음 프레임의 게임 로직과 렌더링 준비를 이어 가는 동안, 렌더 스레드는 앞선 프레임의 명령을 GPU에 제출합니다. 두 프레임의 작업이 시간 위에서 한 칸씩 어긋난 채 나란히 진행되는 것입니다. 이렇게 작업을 여러 단계로 나누어 각 단계가 쉬지 않고 일하도록 맞물리는 방식을 파이프라이닝(pipelining)이라고 합니다.


시간 → 메인 스레드 프레임 N 로직 + 렌더링 준비 프레임 N+1 로직 + 준비 프레임 N+2 로직 + 준비 렌더 스레드 대기 프레임 N GPU 명령 제출 프레임 N+1 GPU 명령 제출 → 메인 스레드가 프레임 N+1을 처리하는 동안, 렌더 스레드는 프레임 N의 GPU 명령을 제출


파이프라이닝은 한 프레임을 더 빨리 끝내는 방법이 아니라, 같은 시간에 더 많은 프레임을 처리하는 방법입니다. 한 프레임이 온전히 끝나기를 기다렸다 다음을 시작할 때보다, 두 프레임을 겹쳐 처리하면 단위 시간에 완성하는 프레임 수, 곧 처리량(Throughput)이 늘어납니다.

다만 한 프레임이 화면에 나오기까지 걸리는 시간은 오히려 늘어날 수 있습니다. 어떤 프레임이든 메인 스레드의 준비를 거친 뒤에야 렌더 스레드가 GPU에 제출하므로, 준비와 제출이라는 두 단계를 차례로 지나야 화면에 나타나기 때문입니다. 그만큼 입력이 화면에 반영되기까지의 지연(Latency)은 한 프레임만큼 늘어날 수 있습니다. 메인 스레드와 렌더 스레드뿐 아니라 CPU와 GPU 사이에서도 같은 원리의 파이프라이닝이 작동합니다.

CPU/GPU 파이프라이닝은 게임 루프의 원리 (1) - 프레임의 구조에서 자세히 다룹니다.


메인 스레드와 렌더 스레드의 동기화

메인 스레드와 렌더 스레드가 파이프라인으로 맞물려 있는 한, 전체 프레임 속도는 둘 가운데 더 느린 쪽이 정합니다. 빠른 쪽은 느린 쪽이 일을 넘겨주거나 받아 갈 때까지 기다려야 할 뿐, 혼자 앞서 나아가지는 못하기 때문입니다. 따라서 이 절에서는 두 스레드가 어디에서 서로를 기다리는지 살펴보고, 그 기다림을 통해 게임의 병목이 어느 쪽에 있는지 읽어 내는 방법을 정리합니다.

동기화 대기 지점

한 프레임에서 렌더 스레드는 메인 스레드가 렌더링 데이터를 넘겨주어야 비로소 그 프레임의 작업을 시작할 수 있습니다. 두 스레드가 작업을 주고받는 이 지점은 프레임마다 정해져 있어, 먼저 도착한 쪽은 상대가 따라올 때까지 멈춰 기다립니다. 이렇게 정해진 지점에서 두 스레드가 진행을 맞추는 과정을 동기화(Synchronization)라고 합니다.

먼저 도착해 기다리는 쪽은, 둘 가운데 어느 스레드가 더 느린지에 따라 달라집니다. 그리고 그 느린 쪽이 메인 스레드냐 렌더 스레드냐에 따라, 병목도 서로 다른 모습으로 나타납니다.

먼저 렌더 스레드가 느린 경우입니다. 렌더 스레드가 GPU에 명령을 제출하더라도, 화면이 실제로 그려지는 것은 GPU가 그 명령을 모두 처리한 뒤입니다. GPU가 그리는 데 오래 걸리면 렌더 스레드는 그 완료를 기다리고, 다음 프레임 준비를 먼저 마친 메인 스레드도 렌더 스레드를 기다리게 됩니다. 이처럼 GPU의 처리가 전체 속도를 제한하는 상태를 GPU 바운드(GPU-bound)라고 합니다.

반대로 메인 스레드가 느린 경우입니다. 메인 스레드가 게임 로직과 렌더링 준비에 오래 걸리면, 앞 프레임의 명령 제출을 먼저 마친 렌더 스레드는 다음 명령이 올 때까지 메인 스레드를 기다립니다. 이처럼 CPU 쪽, 곧 메인 스레드의 처리가 전체 속도를 제한하는 상태가 CPU 바운드(CPU-bound)입니다.

다음 그림은 두 경우에서 어느 스레드가 어디서 기다리게 되는지를 나타냅니다.


상황 1: 렌더 스레드가 느린 경우 (GPU-bound) 메인 스레드 프레임 N (빠름) 대기 프레임 N+1 렌더 스레드 프레임 N-1 (느림) 프레임 N → 메인 스레드가 준비를 끝냈지만, 렌더 스레드가 이전 프레임을 아직 처리 중 → 메인 스레드 대기 상황 2: 메인 스레드가 느린 경우 (CPU-bound) 메인 스레드 프레임 N (느림) 프레임 N+1 렌더 스레드 프레임 N-1 (빠름) 대기 프레임 N → 렌더 스레드가 제출을 끝냈지만, 메인 스레드가 다음 프레임의 준비를 아직 마치지 못함 → 렌더 스레드 대기


Profiler에서의 동기화 마커

앞서 본 두 상황은 Unity Profiler에 그대로 드러납니다. 스레드가 무엇을 기다렸는지에 따라 기록되는 마커(marker)가 달라지므로, 어떤 마커가 길게 잡히는지를 보면 병목이 어느 쪽에 있는지 좁힐 수 있습니다.


마커 이름 의미
Gfx.WaitForPresentOnGfxThread 메인 스레드가 다음 프레임을 시작하려 하지만, 렌더 스레드가 GPU의 프레임 표시(present) 완료를 아직 기다리는 중 (GPU-bound 또는 VSync 대기)
Gfx.WaitForCommands 렌더 스레드가 메인 스레드의 렌더링 명령을 기다리는 중 (메인 스레드 병목 가능성)
Gfx.WaitForRenderThread 메인 스레드가 렌더 스레드의 명령 스트림 처리 완료를 대기 (멀티스레드 렌더링에서만 발생, 렌더 스레드 또는 GPU 병목)


Gfx.WaitForPresentOnGfxThread가 길게 잡힌다면, 메인 스레드가 다음 프레임을 시작하려다 렌더 스레드와 GPU 쪽 처리가 끝나기를 기다렸다는 뜻입니다. 대체로 앞서 본 GPU 바운드를 가리키며, 병목은 GPU 작업이나 렌더 스레드 작업에 있을 수 있습니다.

다만 이 마커는 VSync(수직 동기화)가 켜져 있을 때도 길게 잡힐 수 있어, 해석에 주의가 필요합니다. VSync는 GPU가 디스플레이의 갱신 주기에 맞추어 프레임을 출력하도록 강제하는 기능으로, 프레임 처리가 갱신 주기보다 빨리 끝나면 다음 갱신 시점까지 기다립니다. 이 대기 시간도 Gfx.WaitForPresentOnGfxThread로 기록되므로, 마커가 길다고 해서 곧바로 GPU 병목이라고 단정할 수는 없습니다. 둘을 구분하려면 Profiler의 Timeline 뷰를 확인하면 됩니다. 이 마커 안에 WaitForTargetFPS 서브 샘플이 들어 있다면 VSync 대기이고, 렌더 스레드가 Gfx.PresentFrame에 시간을 쓰고 있다면 실제 GPU 병목입니다.

반대로 Gfx.WaitForCommands가 길다면, 렌더 스레드가 메인 스레드에서 렌더링 명령이 오기를 기다렸다는 뜻입니다. 명령을 준비하는 메인 스레드가 제때 따라오지 못한 CPU 바운드 신호이므로, 메인 스레드 쪽 부하를 살펴봐야 합니다.


게임 루프의 원리 (2) - CPU-bound와 GPU-bound에서 이 병목 진단과 최적화 방향을 더 상세하게 다루고 있습니다.


Job System

Job System은 메인 스레드에 몰린 계산을 여러 워커 스레드에 나누어 동시에 처리하기 위해 Unity가 제공하는 구조입니다. 무거운 연산을 워커 스레드로 넘기면, 그만큼 메인 스레드가 한 프레임에 쓸 시간에 여유가 생깁니다.

멀티스레드 프로그래밍의 필요성

앞에서 본 구조는 게임의 CPU 작업을 주로 메인 스레드와 렌더 스레드로 나눕니다. 하지만 현대 CPU는 여러 코어를 가지므로, 동시에 실행할 작업이 두 개뿐이라면 나머지 코어는 충분히 활용되지 못합니다. CPU 바운드 구간에서 프레임 시간이 길어져도, 병렬로 나눌 작업이 없다면 비어 있는 코어를 활용할 수 없습니다.


동시 실행 가능한 CPU 작업이 2개인 경우 (4코어 예시) 메인 스레드 — 게임 로직, 물리 시작, 렌더링 준비 렌더 스레드 — GPU 명령 제출 코어 2 — 게임의 CPU 작업 부족 코어 3 — 게임의 CPU 작업 부족 동시에 실행할 CPU 작업이 부족하면 나머지 코어를 활용하지 못함 (실제 동시 실행 단위는 SMT/Hyper-Threading 여부에 따라 물리 코어 수와 다를 수 있음 — 하드웨어 기초 (1) 참고)


남는 코어를 활용하려면 동시에 실행할 수 있는 작업을 더 만들어야 합니다. System.Threading으로 스레드를 직접 만들 수도 있지만, 이 경우 스레드 동기화, 데드락 방지, 데이터 경합 처리까지 개발자가 직접 관리해야 합니다.

Unity의 Job System은 이런 위험을 줄이면서 멀티스레드 작업을 예약하고 실행할 수 있도록 만든 프레임워크입니다.


Job의 기본 구조

Job System은 작업을 Job이라는 단위로 만들고, 이를 여러 워커 스레드(Worker Thread)에 예약해 실행합니다. 메인 스레드가 Job을 스케줄링하면, 대기 중인 워커 스레드가 Job을 가져가 실제 연산을 수행합니다.

이 워커 스레드들은 Unity가 애플리케이션 시작 시점에 스레드 풀(pool)로 미리 만들어 둡니다. 개수는 CPU 코어 수, 플랫폼, Unity 설정에 따라 달라지며 보통 전체 코어 수보다 적습니다. 실제 워커 스레드 수는 JobsUtility.JobWorkerCount로 확인할 수 있습니다.


메인 스레드 1. Job 생성 2. Job 스케줄링 3. (다른 작업 수행) 4. Job 완료 대기 5. 결과 사용 스케줄링 워커 스레드 풀 워커 0 Job 실행 워커 1 Job 실행 워커 2 Job 실행 ...


IJob: 단일 작업

Job을 정의하는 기본 인터페이스는 IJob입니다. 작업 하나를 Job 하나로 만들어 워커 스레드 하나에서 처리하는, 가장 단순한 형태입니다. structIJob을 구현하고, 워커 스레드에서 실행할 코드를 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 사용


코드 아래쪽은 이 Job을 메인 스레드에서 다루는 부분입니다. Schedule()을 호출하면 Job이 워커 스레드에 예약되고, 곧바로 JobHandle이 반환됩니다. 아직 예약만 된 상태라 메인 스레드는 Job이 실행되는 동안 멈추지 않고 다른 일을 이어 갈 수 있습니다. 그러다 결과가 필요해지면 JobHandle.Complete()를 호출하며, 이때 비로소 메인 스레드는 Job이 끝나기를 기다렸다가 결과를 받아 씁니다.


IJobParallelFor: 데이터 병렬

IJob은 작업 하나를 워커 스레드 하나에 맡깁니다. 할 일이 하나라면 이것으로 충분하지만, 같은 계산을 수많은 데이터에 반복하는 작업에서는 그렇지 않습니다. 예를 들어 오브젝트 1000개의 위치를 갱신하려면 같은 계산을 1000번 반복해야 하는데, IJob으로는 워커 하나가 이 1000번을 차례로 실행하는 동안 나머지 코어는 활용되지 못합니다.

IJobParallelFor는 이처럼 같은 계산을 대량으로 반복하는 작업을 위한 인터페이스입니다. 전체 반복을 인덱스 구간 단위로 여러 워커 스레드에 분배하면, 각 워커가 맡은 구간만 계산합니다. 코어가 네 개라면 1000개의 갱신을 네 워커가 250개씩 나누어 동시에 진행하므로, 워커 하나가 모두 계산할 때보다 전체 처리 시간이 크게 줄어듭니다. 다음 그림은 단일 스레드와 병렬 실행의 차이를 보여 줍니다.


IJobParallelFor의 동작 1000개의 오브젝트 위치 갱신 단일 스레드 (IJob) 코어 0 [0 ~ 999] 전부 순차 처리 병렬 (IJobParallelFor) 코어 0 [0 ~ 249] 코어 1 [250 ~ 499] 코어 2 [500 ~ 749] 코어 3 [750 ~ 999] 약 1/4 처리 시간이 약 1/4로 감소 (이상적인 경우)


코드의 형태는 IJob과 거의 같지만, IJobParallelForExecuteint index를 매개변수로 받습니다. 작업 전체를 한 번에 처리하던 IJobExecute와 달리, 이 Execute는 호출될 때마다 index가 가리키는 요소 하나만 처리합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// IJobParallelFor 사용 예시 — 오브젝트 1000개 위치 갱신

struct UpdatePositionsJob : IJobParallelFor
{
    public NativeArray<Vector3> positions;              // 갱신 대상 (쓰기)
    [ReadOnly] public NativeArray<Vector3> velocities;  // 읽기 전용
    public float deltaTime;

    public void Execute(int index)
    {
        // index번째 요소 하나만 처리한다
        positions[index] += velocities[index] * deltaTime;
    }
}

// 메인 스레드에서:
var job = new UpdatePositionsJob { ... };
JobHandle handle = job.Schedule(
    positions.Length,   // 첫 번째 인자: 총 작업 수 = Execute 호출 횟수
    64                  // 두 번째 인자: 배치 크기 (innerloopBatchCount)
);
handle.Complete();      // 완료를 기다린 뒤 positions 사용


Schedule의 첫 번째 인자는 처리할 요소의 총개수, 즉 Execute를 호출할 횟수입니다. 위 코드에서는 positions.Length를 전달했으므로, Job System은 index를 0부터 끝까지 옮겨 가며 Execute를 그 횟수만큼 호출하고, 이 호출들을 여러 워커에 분배합니다.

여러 워커가 같은 positions 배열을 동시에 갱신하는데도 데이터 경합이 생기지 않는 것은, IJobParallelFor가 작업을 나누는 방식 덕분입니다. 전체 인덱스가 워커들에게 겹치지 않게 분배되어, 배열의 각 요소는 그 인덱스를 맡은 한 워커만 갱신하기 때문입니다. 직접 스레드를 다룰 때라면 락(Lock)으로 실행 중에 막아야 할 경합이, IJobParallelFor에서는 분배 단계에서 미리 사라집니다.


Schedule의 두 번째 인자인 배치 크기(innerloopBatchCount)는 전체 인덱스를 몇 개 단위의 덩어리로 잘라 나눌지를 정합니다. 위 코드에서는 64로 지정했으므로, 인덱스가 64개씩 한 덩어리가 되어 워커에 분배됩니다.

앞의 그림에서 네 코어는 1000개를 250개씩 고르게 나눠 맡았지만, 이는 요소마다 계산량이 같을 때의 이상적인 경우입니다. 실제로는 요소마다 계산량이 달라 워커마다 끝나는 시점이 달라질 수 있습니다. 인덱스를 코어 수대로 크게 가르지 않고 64개 요소씩 묶은 작은 배치로 나누는 것은 바로 이 차이에 대응하기 위해서입니다. 배치가 잘게 나뉘어 있으면, 맡은 작업을 먼저 끝낸 워커가 아직 처리되지 않은 다른 배치를 가져와 계속 실행할 수 있습니다. 특히 워커가 다른 워커에게 남아 있는 작업을 가져오는 방식은 워크 스틸링(Work Stealing)이라고 부르며, 이런 동적 분배 덕분에 일부 코어만 계속 연산하고 나머지가 유휴 상태로 남는 상황이 줄어듭니다.

다만 덩어리를 잘게 나눌수록 좋기만 한 것은 아닙니다. 배치 크기를 정하는 일은 부하 균형과 관리 비용 사이의 절충이기 때문입니다. 덩어리가 너무 크면 전체가 몇 조각으로만 나뉘어, 한 덩어리의 처리가 늦어져도 다른 워커가 나눠 맡을 작업이 남지 않습니다. 반대로 너무 작으면 부하가 고르게 분산되지만, 덩어리를 잘게 쪼개어 나눠 주는 관리 비용이 그만큼 늘어납니다.

따라서 알맞은 배치 크기는 Execute 호출 하나의 작업량에 따라 달라집니다. 벡터 덧셈처럼 호출 하나가 가벼운 연산이라면 관리 비용이 상대적으로 커지므로, 인덱스를 32~128개씩 묶어 그 비용을 줄입니다. 반대로 호출 하나가 무거운 작업이라면 관리 비용은 무시할 만하고 부하를 고르게 나누는 이득이 더 크므로, 1~16개씩만 묶습니다.


IJobIJobParallelFor는 인터페이스 자체가 실행 방식을 결정합니다. IJob을 구현하면 워커 하나가 순차로, IJobParallelFor를 구현하면 여러 워커가 나누어 병렬로 처리하며, 방식을 바꾸려면 구현하는 인터페이스부터 바꿔야 합니다.

IJobFor는 이런 번거로움을 없애기 위해 두 인터페이스보다 나중에 추가되었습니다. 이 인터페이스는 실행 방식 선택을 코드가 아니라 예약 시점으로 미룹니다. Execute(int index)는 그대로 두고, 예약할 때 어떤 메서드를 호출하느냐로 방식이 정해집니다.

메인 스레드에서 곧바로 처리하려면 Run(), 워커 스레드 하나에서 순차로 처리하려면 Schedule(), 여러 워커에 나누어 병렬로 처리하려면 ScheduleParallel()을 호출합니다. 덕분에 데이터가 적어 병렬 분배의 관리 비용이 이득을 웃돌 때는 Schedule()로, 데이터가 많을 때는 ScheduleParallel()로 예약하는 식으로, 같은 코드를 새로 작성하지 않고 실행 방식만 바꿀 수 있습니다.


의존성 그래프 (JobHandle)

Job은 워커 스레드에서 비동기로 실행되므로, 여러 Job을 예약하면 완료 순서가 자동으로 보장되지 않습니다. 만약, 한 Job의 결과를 다른 Job이 사용해야 한다면 JobHandle로 의존 관계를 지정해 실행 순서를 연결해야 합니다.


Job 의존성 그래프 Job A 적 위치 갱신 Job B 갱신된 위치로 충돌 검사 (A에 의존) Job C 충돌 결과로 HP 갱신 (B에 의존) A가 끝난 후 B 실행, B가 끝난 후 C 실행 Complete는 마지막 Job에서만 호출하면 됨 의존 체인 전체가 순서대로 완료됨


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()의 마지막 인자(dependsOn)로 앞선 Job의 핸들을 넘기면, 앞선 Job이 끝나기 전에는 현재 Job이 시작되지 않습니다. 이렇게 핸들을 연결하면 마지막 핸들에서 한 번만 Complete()를 호출해도 의존 체인 전체가 순서대로 완료됩니다. 반대로 서로 의존 관계가 없는 Job들은 가능한 경우 동시에 실행됩니다.


Burst 컴파일러

Job System은 작업을 여러 코어에 나누어 동시에 처리하지만, 코어 하나가 맡은 코드 자체를 더 빠르게 만들지는 않습니다. 따라서 Job을 아무리 잘게 나눠도 각 Job의 코드가 느리면 성능은 그만큼 제한됩니다. Burst 컴파일러는 바로 이 코드 자체의 실행 속도를 높이는 도구입니다.

IL에서 최적화된 네이티브 코드로

Burst의 컴파일은 IL에서 출발합니다. C# 컴파일러는 작성한 코드를 곧바로 기계어로 만들지 않고, IL(Intermediate Language, 중간 언어)이라는 중간 형태로 바꿔 둡니다. Burst는 이 IL을 입력으로 받아 LLVM(여러 플랫폼에 맞는 최적화된 기계어를 만들어 주는 컴파일러 프레임워크)에 넘기고, LLVM이 이를 실행 플랫폼에 맞춘 네이티브 기계어로 변환합니다. 이 변환 과정에서 일반적인 C# 실행 경로에는 없는 저수준 최적화까지 함께 적용됩니다.


일반 C# 코드 vs Burst 컴파일된 코드의 실행 경로 일반 C# (IL2CPP) C# 소스 IL (중간 언어) IL2CPP C++ 네이티브 코드 일반적인 C++ 최적화 Burst C# 소스 IL (중간 언어) Burst LLVM 네이티브 코드 SIMD 자동 벡터화 루프 최적화 메모리 접근 최적화 플랫폼별 명령어 최적화


Burst가 성능을 높이는 대표적인 방법은 SIMD(Single Instruction, Multiple Data) 자동 벡터화입니다. SIMD는 CPU 명령어 하나로 여러 데이터를 한꺼번에 처리하는 하드웨어 기능입니다. 예를 들어 float 4개의 덧셈을 명령어 4번으로 처리하는 대신, SIMD 명령어 1번으로 처리할 수 있습니다(128비트 레지스터, 32비트 float 기준).

Job System과 SIMD는 서로 다른 수준의 병렬성입니다. Job System은 작업을 여러 코어에 나누는 스레드 수준 병렬성이고, SIMD는 한 코어 안에서 명령어 하나가 여러 데이터를 처리하는 데이터 수준 병렬성입니다. Burst는 코드를 분석해 가능한 경우 SIMD 명령어를 자동으로 생성합니다.


SIMD 벡터화 예시 일반 처리 a[0] + b[0] c[0] 명령어 1 a[1] + b[1] c[1] 명령어 2 a[2] + b[2] c[2] 명령어 3 a[3] + b[3] c[3] 명령어 4 4번의 덧셈 명령어 SIMD 벡터화 a[0] a[1] a[2] a[3] + b[0] b[1] b[2] b[3] c[0] c[1] c[2] c[3] 1번의 SIMD 명령어로 4개 동시 처리 (4-lane 예시)


[BurstCompile] 어트리뷰트

Burst를 켜는 방법은 단순합니다. Job 구조체에 [BurstCompile] 어트리뷰트만 붙이면, 해당 Job이 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이 빨라지는 정도는 작업의 성격에 따라 달라집니다. 위치 갱신이나 거리 계산, 행렬 연산처럼 규칙적인 데이터에 같은 수학 연산을 반복하는 작업에는 SIMD 벡터화가 잘 적용되므로, 일반 C# 코드로 실행할 때보다 실행 시간이 뚜렷하게 줄어듭니다. 반면 분기가 많거나 메모리 접근 패턴이 불규칙한 작업에서는 같은 명령어로 여러 데이터를 한꺼번에 처리한다는 SIMD의 전제가 성립하기 어려워, Burst를 적용해도 이득이 제한될 수 있습니다.


Burst의 제약

Burst로 컴파일하는 코드에서는 C#의 일부 기능만 쓸 수 있습니다. 컴파일 결과로 나오는 네이티브 코드가 GC나 예외 처리 같은 C# 런타임의 기능과 연동되지 않은 채 실행되기 때문입니다.

class 인스턴스나 문자열 같은 관리 객체는 GC가 할당과 회수를 담당하는 힙에 놓이므로, GC와 연동되지 않는 코드에서는 안전하게 다룰 수 없습니다. 그리고 try-catch가 동작하려면 던져진 예외를 catch 블록까지 전달하는 런타임의 장치가, virtual 메서드와 인터페이스 호출이 동작하려면 객체의 실제 타입에서 호출 대상을 찾는 런타임 절차가 필요합니다.

따라서 Burst는 이런 기능을 제외한 C#의 부분 집합, 곧 HPC#(High-Performance C#)만 컴파일합니다.


사용할 수 없는 것 이유
class 인스턴스, string, 박싱(값 타입을 힙 객체로 감싸는 변환), List<T>·Dictionary<K,V> GC가 관리하는 힙에 할당됨
try-catch 런타임의 예외 처리 장치 필요
virtual 메서드, 인터페이스 호출 호출 대상을 정하는 런타임 절차 필요


대신 float·int·bool과 struct 같은 값 타입, if·for·while 같은 제어문, 일반 함수 호출은 사용할 수 있습니다. NativeContainer도 Job 데이터로 전달할 수 있습니다. 이 범위 안의 코드는 대체로 입력 데이터를 읽고, 계산을 수행한 뒤, 결과를 다시 쓰는 형태가 됩니다.

이런 형태의 코드는 Burst가 분석하기 쉽습니다. 관리 객체, 예외 처리, 가상 호출처럼 실행 중 런타임이 개입해야 하는 요소가 빠지면 호출 대상과 데이터 접근 흐름이 단순해집니다. Burst는 이 코드를 LLVM에 넘겨 작은 함수 호출을 인라인화하고, 반복문을 정리하며, 같은 연산이 반복되는 구간을 SIMD 명령어로 바꿀 수 있습니다.

특히 float3·float4x4 같은 Unity.Mathematics 타입과 math.sin()·math.dot() 같은 연산은 Burst가 최적화하기 좋은 형태로 설계되어 있습니다. Unity가 Vector3보다 이 타입들을 쓰도록 권장하는 것도 이 때문입니다.

Burst가 최적화하는 대상은 Job의 코드입니다. 여러 스레드에서 읽고 쓸 데이터는 Job System이 접근 상태를 추적할 수 있는 형태로 전달해야 합니다. 이를 위해 사용하는 데이터 구조가 다음 절에서 다룰 NativeContainer입니다.


NativeContainer

NativeContainer는 Job에 데이터를 넘기기 위한 전용 컨테이너입니다. 데이터는 GC가 관리하는 힙이 아니라 네이티브 메모리에 놓이고, Job System은 이 컨테이너를 통해 어떤 Job이 데이터를 읽거나 쓰는지 추적합니다. 그래서 여러 워커 스레드가 같은 데이터를 다룰 때도 접근 규칙을 검사할 수 있습니다.

Job에 전달하는 데이터

Job은 메인 스레드 밖의 워커 스레드에서 실행될 수 있으므로, Job에 전달하는 데이터도 그 실행 환경에 맞아야 합니다. List<T>Dictionary<K,V> 같은 일반 C# 컬렉션은 GC가 관리하는 객체라 Burst로 컴파일되는 Job 코드에서 직접 사용할 수 없고, Job System도 그 내부 메모리에 누가 읽고 쓰는지 추적하기 어렵습니다. 그래서 Job에 전달할 데이터는 Job System이 추적할 수 있는 컨테이너에 담아야 합니다. 이 용도로 사용하는 타입이 NativeContainer이며, NativeArray<T>, NativeList<T>, NativeHashMap<TKey, TValue>가 대표적인 예입니다.


NativeContainer의 메모리 위치 관리 힙 (Managed Heap) List<int> GC가 관리 Dictionary<> GC가 관리 int[] GC가 관리 GC에 의해 수거됨 GC 스파이크 유발 가능 네이티브 메모리 (Native Memory) NativeArray<int> 수동 관리 NativeList<float3> 수동 관리 NativeHashMap<> 수동 관리 GC가 관여하지 않음 Dispose()로 직접 해제 GC 스파이크 없음


NativeContainer의 데이터는 그림처럼 관리 힙이 아니라 네이티브 메모리에 저장됩니다. 이 메모리는 C# 런타임 기초 (1) - 값 타입과 참조 타입에서 다룬 GC의 수거 대상이 아닙니다. 따라서 GC가 많은 객체를 정리하느라 프레임을 멈추는 상황과는 별도로 관리됩니다.

그 대신 해제 시점도 개발자가 직접 정해야 합니다. NativeContainer를 만든 뒤 더 이상 사용하지 않는다면 Dispose()를 호출해 네이티브 메모리를 반환해야 합니다. 이 과정을 빠뜨리면 할당된 네이티브 메모리가 계속 남아 메모리 누수로 이어집니다.


할당 수명 (Allocator)

NativeContainer를 만들 때는 메모리를 어느 정도 기간 동안 사용할지도 함께 정합니다. 이 수명 정책을 지정하는 값이 Allocator입니다. Allocator에 따라 메모리를 사용할 수 있는 기간과 해제 방식이 달라지므로, 데이터가 필요한 범위에 맞는 값을 선택해야 합니다.


Allocator 용도와 수명
Allocator.Temp 한 프레임 안에서만 사용할 임시 메모리입니다. 할당과 해제가 빠르지만 Job으로 전달할 수 없습니다.
Allocator.TempJob Job에 전달할 수 있는 임시 메모리입니다. 몇 프레임 안에 끝나는 작업에 사용하며, 4프레임 안에 해제하지 않으면 누수 경고가 발생합니다.
Allocator.Persistent 여러 프레임이나 씬 전체에 걸쳐 유지할 메모리입니다. 수명 제한은 없지만 할당 비용이 가장 크고, 직접 해제해야 합니다.


선택 기준은 데이터가 필요한 시간입니다. 한 프레임 안에서 계산을 끝내고 버릴 값이라면 Allocator.Temp를 사용합니다. Job에 넘겨야 하고 짧은 시간 안에 끝나는 작업이라면 Allocator.TempJob이 맞습니다. 경로 맵이나 공간 분할 구조처럼 여러 프레임 동안 유지되는 데이터는 Allocator.Persistent로 만들고, 더 이상 필요하지 않은 시점에 Dispose()로 해제합니다.


안전성 검사

NativeContainer에는 Job System이 읽기와 쓰기 상태를 확인할 수 있는 정보가 함께 붙습니다. Job을 스케줄하면 Job System은 각 NativeContainer가 읽기 전용으로 쓰이는지, 값을 쓰는 대상으로 쓰이는지 확인합니다. 읽기만 하는 Job들은 값을 바꾸지 않으므로 같은 데이터를 대상으로 해도 병렬로 실행될 수 있습니다.

순서가 필요한 경우는 쓰기가 포함될 때입니다. 한 Job이 NativeContainer에 값을 쓰는 동안 다른 Job이 같은 컨테이너를 읽으면, 읽은 값이 실행 순서에 따라 달라질 수 있습니다. 두 Job이 같은 컨테이너에 함께 쓰면 마지막에 쓴 값이 앞선 결과를 덮어쓸 수도 있습니다.

이 규칙을 검사하는 기능이 안전성 시스템(Safety System)입니다. 쓰기가 포함된 Job과 같은 NativeContainer를 사용하는 다른 Job을 함께 스케줄하려면, 먼저 실행될 Job의 JobHandle을 다음 Job에 의존성으로 넘겨 순서를 연결해야 합니다. 이 순서 없이 충돌할 수 있는 Job을 스케줄하면 Job System이 예외를 던져 데이터 경합을 미리 막습니다.


1
2
[ReadOnly] public NativeArray<float3> positions;
public NativeArray<float3> results;


위 예시에서 positions[ReadOnly]로 표시되어 있어 읽기 전용으로 취급됩니다. 같은 positions를 읽기만 하는 Job들은 서로 값을 바꾸지 않으므로 병렬로 실행될 수 있습니다. 반면 results는 쓰기 가능한 컨테이너입니다. 다른 Job도 results에 접근해야 한다면 JobHandle 의존성으로 실행 순서를 연결해야 합니다.

Job System이 읽기와 쓰기 규칙을 검사하므로, 많은 경우 락이나 뮤텍스를 직접 둘 필요가 없습니다. 대신 어떤 데이터가 읽기 전용이고 어떤 데이터에 쓰기가 필요한지는 코드에 명확히 표시해야 합니다. [ReadOnly] 같은 표시와 JobHandle 의존성이 맞아야 Job System도 안전한 실행 순서를 판단할 수 있습니다.

NativeContainer와 안전성 검사는 Job 사이의 데이터 경합을 막아 줍니다. 그러나 작업 결과를 Transform, UI, GameObject 같은 Unity 오브젝트에 반영할 때는 여전히 메인 스레드 제약을 따라야 합니다. 다음 절에서는 백그라운드에서 끝난 작업을 메인 스레드로 넘겨 처리하는 방법을 살펴봅니다.


Unity API의 메인 스레드 제약과 결과 전달

Job System의 Job과 일반 비동기 작업은 메인 스레드 밖에서 실행될 수 있습니다. 그러나 그 결과를 Transform, UI, GameObject 상태에 반영하는 코드는 다시 메인 스레드에서 실행되어야 합니다. Unity API 대부분은 엔진 상태를 메인 스레드에서 읽고 쓰는 흐름을 전제로 설계되어 있기 때문입니다.

따라서 백그라운드 작업에서는 계산이나 I/O 대기까지만 처리하고, Unity 오브젝트를 바꾸는 코드는 메인 스레드의 실행 흐름으로 넘겨야 합니다. 이 절에서는 await 이후 코드가 메인 스레드에서 이어지게 하는 UnitySynchronizationContext, 같은 구조를 직접 구현하는 큐 기반 디스패치 패턴, 그리고 Job System과 일반 비동기 처리의 용도 차이를 정리합니다.


UnitySynchronizationContext

await는 비동기 작업이 끝날 때까지 현재 메서드의 이후 실행을 미루고, 남은 코드를 continuation으로 남겨 둡니다. 이 continuation을 어느 실행 큐에 올릴지 결정하는 객체가 SynchronizationContext입니다.

Unity는 시작 시점에 메인 스레드에 UnitySynchronizationContext를 설정합니다. 따라서 일반적인 Unity 스크립트에서 await 뒤의 continuation은 작업 완료 후 다시 메인 스레드의 실행 큐에 올라갑니다. 이 흐름에서는 await 이후 코드가 메인 스레드에서 실행되므로 Unity API를 호출할 수 있습니다.

async/await과 SynchronizationContext의 동작 원리는 C# 런타임 기초 (4) - 스레딩과 비동기에서 자세히 다룹니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// async/await로 메인 스레드 복귀

async void LoadDataAndApply()
{
    // 1. 메인 스레드에서 시작
    string url = _dataUrl;

    // 2. 네트워크 요청을 비동기로 실행 (await로 완료 대기)
    string json = await DownloadAsync(url);

    // 3. await 후에는 메인 스레드에서 이어짐
    ParseAndApply(json);
    gameObject.SetActive(true);    // Unity API 호출 가능
}

// await 이후에 UnitySynchronizationContext가
// 메인 스레드에서 후속 코드를 실행하도록 스케줄링


위 코드에서 await DownloadAsync(url) 다음에 실행되는 ParseAndApplySetActive는 다시 메인 스레드에서 실행됩니다. 따라서 스레드를 직접 전환하는 코드를 작성하지 않아도 됩니다.

코드 예시의 async void는 호출자가 예외를 관찰하거나 완료를 대기할 수 없으므로, 실무에서는 async Task를 반환하는 것이 안전합니다. C# 런타임 기초 (4)에서 async void의 문제와 대안을 상세히 다룹니다.

다만 await 이후 코드가 메인 스레드에서 이어진다는 사실이, 그 코드를 시작한 GameObject가 여전히 살아 있다는 뜻은 아닙니다. 네트워크 요청이나 파일 I/O를 기다리는 동안 오브젝트가 파괴되거나 씬이 바뀔 수 있고, 이후 continuation이 재개되면 이미 사라진 대상에 접근하게 됩니다. 이때 Transform, gameObject, Renderer 같은 Unity 오브젝트를 사용하면 MissingReferenceException으로 이어질 수 있습니다.

코루틴은 MonoBehaviour가 파괴될 때 함께 정리되지만, async 메서드는 같은 방식으로 자동 중단되지 않습니다. 비동기 작업이 끝나면 남은 코드는 continuation으로 UnitySynchronizationContext의 실행 큐에 올라가고, 그 시점의 오브젝트 생명주기와는 별개로 다시 실행될 수 있습니다. 그래서 await 뒤에서 Unity 오브젝트를 다시 사용하기 전에 this == null 같은 검사를 두고, 이미 파괴된 상태라면 바로 빠져나오는 방어 코드가 필요합니다.

가능하다면 비동기 작업 자체도 오브젝트의 생명주기와 연결하는 편이 좋습니다. CancellationToken을 받을 수 있는 작업에는 토큰을 함께 전달하고, 취소된 경우 이후 처리를 이어 가지 않도록 구성합니다. Unity 2022.2 이상에서는 MonoBehaviour.destroyCancellationToken을 사용할 수 있으므로, 이 토큰을 넘기면 오브젝트가 파괴되는 순간 작업 쪽에도 취소 신호가 전달됩니다.


메인 스레드 디스패치 패턴

백그라운드 스레드에서 작업이 끝났더라도, 그 자리에서 바로 Unity API를 호출할 수는 없습니다. 결과를 UI나 GameObject에 반영하려면 실행 위치를 다시 메인 스레드로 옮겨야 합니다.

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
// 큐 기반 메인 스레드 디스패치

readonly ConcurrentQueue<Action> _mainThreadQueue = new();

// 백그라운드 스레드에서: 메인 스레드에서 실행할 작업을 등록
void OnNetworkResponse(string data)
{
    _mainThreadQueue.Enqueue(() =>
    {
        // Unity API 호출은 Update에서 이 작업을 꺼낼 때 실행됨
        UpdateUI(data);
        gameObject.SetActive(true);
    });
}

// 메인 스레드에서: 큐에 쌓인 작업을 실행
void Update()
{
    while (_mainThreadQueue.TryDequeue(out Action action))
    {
        action.Invoke();
    }
}

// 백그라운드 스레드: 큐에 작업 추가
// 메인 스레드: Update에서 큐의 작업을 꺼내 실행
// Unity API 호출은 메인 스레드의 Update에서만 발생


여기서 사용한 ConcurrentQueue<T>는 여러 스레드에서 동시에 접근해도 내부 상태가 깨지지 않도록 설계된 스레드 안전한(Thread-Safe) 큐입니다. 백그라운드 스레드는 Unity API를 직접 호출하지 않고, 메인 스레드에서 실행할 ActionEnqueue로 넣습니다.

이후 메인 스레드의 UpdateTryDequeue로 작업을 꺼내 실행합니다. 따라서 UpdateUISetActive 같은 Unity API 호출은 백그라운드 스레드가 아니라 Update 안에서 일어납니다. 이 패턴을 사용하면 async/await 없이도 백그라운드 작업 결과를 메인 스레드로 전달할 수 있습니다.

다만 큐는 실행 위치를 옮겨 주는 통로일 뿐입니다. 실제 구현에서는 한 프레임에 처리할 작업 수를 제한하거나, 작업 실행 중 발생한 예외를 따로 기록하고, 작업 대상 오브젝트가 아직 유효한지도 확인하는 편이 좋습니다.


Job System과 일반 비동기 처리의 구분

Job System과 일반 비동기 처리는 모두 메인 스레드의 부담을 줄이는 데 쓰일 수 있지만, 해결하려는 문제가 다릅니다. Job System은 이미 메모리에 있는 데이터를 여러 워커 스레드에 나누어 계산하는 구조입니다. 계산량이 많아서 한 프레임 안에서 처리하기 부담스러운 CPU 작업에 초점이 있습니다.

반면 System.Threading이나 async/await는 네트워크 통신, 파일 I/O처럼 결과를 기다리는 시간이 긴 작업에 더 잘 맞습니다. 이 경우 핵심은 계산을 빠르게 끝내는 것이 아니라, 기다리는 동안 메인 스레드를 붙잡아 두지 않는 것입니다.

항목 Job System 일반 비동기 처리 (System.Threading, async/await)
주된 목적 CPU 데이터 병렬 계산 I/O 대기와 비동기 흐름 제어
실행 단위 IJob, IJobParallelFor Task, Thread, async 메서드 등
데이터 경합 관리 NativeContainer와 JobHandle 의존성으로 관리 락, 뮤텍스, 스레드 안전 큐 등을 직접 사용
Burst 적용 가능 일반 Task나 Thread 코드에는 적용하지 않음
Unity API 호출 Job 내부에서는 불가 백그라운드 스레드에서는 불가
결과 반영 Complete() 이후 메인 스레드에서 처리 continuation이나 디스패치 큐로 메인 스레드에서 처리
적합한 작업 위치 갱신, 수학 연산, 대량 데이터 처리 네트워크 요청, 파일 I/O, 긴 대기 작업


예를 들어 적의 위치 갱신, 경로 탐색, 시야 검사처럼 같은 형태의 계산을 많은 데이터에 반복해야 한다면 Job System이 적합합니다. 여기에 Burst 컴파일을 적용하면 수학 연산 중심의 코드를 더 빠른 네이티브 코드로 실행할 수 있습니다.

반대로 서버 응답을 기다리거나 파일을 읽는 작업은 CPU를 계속 쓰는 계산이 아니라 대기 시간이 긴 작업입니다. 이 경우에는 async/awaitSystem.Threading으로 기다리는 흐름을 분리하고, 결과를 사용할 시점에 메인 스레드로 돌아오는 구성이 더 자연스럽습니다.

비동기 처리에는 표준 Task 기반 async/await 외에도 Unity 쪽 선택지가 있습니다. Unity 2023.1부터 제공되는 Awaitable은 PlayerLoop와 연결된 비동기 API를 제공하고, 내부 풀링으로 Task 기반 코드에서 생길 수 있는 힙 할당을 줄입니다. 서드파티 라이브러리인 UniTask도 async/await 문법을 유지하면서 PlayerLoop 재개 시점 제어와 낮은 할당 비용을 목표로 합니다.

이들 비동기 기법의 상세한 비교는 C# 런타임 기초 (4) - 스레딩과 비동기에서 다룹니다.


마무리

Unity의 스레딩 모델은 게임 로직과 Unity API 호출을 메인 스레드 하나에 모으고, 나머지 작업을 다른 스레드로 나누어 처리하는 구조입니다. 핵심은 다음과 같습니다.

  • 메인 스레드MonoBehaviour 콜백, 애니메이션 갱신, 물리 시뮬레이션 시작, 렌더링 준비가 실행되는 곳이며, Unity API 대부분도 여기서만 호출할 수 있습니다.
  • 렌더 스레드는 메인 스레드가 준비한 렌더링 명령을 GPU에 제출하고, 두 스레드는 서로 다른 프레임의 작업을 겹쳐 처리합니다.
  • 동기화 마커는 어느 쪽이 병목인지 알려 줍니다. Profiler에서 Gfx.WaitForPresentOnGfxThread가 길면 GPU나 VSync 대기를, Gfx.WaitForCommands가 길면 메인 스레드 병목을 의심합니다.
  • Job System은 계산량이 많은 CPU 작업을 워커 스레드로 나누어 실행하고, JobHandle로 작업 사이의 의존성을 관리합니다.
  • Burst 컴파일러는 Job 코드를 LLVM 기반 네이티브 코드로 바꾸고 SIMD 벡터화를 적용해, 수학 연산이 많은 작업의 실행 속도를 높입니다.
  • NativeContainer는 워커 스레드가 안전하게 공유하는 네이티브 메모리 데이터 구조이며, GC 부담은 줄지만 사용 후 Dispose()로 직접 해제해야 합니다.
  • 메인 스레드 복귀는 백그라운드 작업의 결과를 Unity API에 반영할 때 필요합니다. UnitySynchronizationContext나 큐 기반 디스패치를 사용하면 결과 처리를 메인 스레드에서 이어 갈 수 있습니다.

작업의 성격에 따라 실행할 스레드가 달라집니다. 메인 스레드에는 Unity API 호출과 게임 로직을 남기고, 계산량이 많은 작업은 Job System과 Burst로 워커 스레드에 넘기며, 네트워크나 파일 I/O처럼 대기 시간이 긴 작업은 async/await로 분리합니다. 어느 쪽이든 그 결과는 메인 스레드에서 반영해야 하고, 특히 async/await는 완료를 기다리는 사이에 대상 오브젝트가 파괴될 수 있으므로 다시 사용하기 전에 오브젝트가 유효한지 확인해야 합니다.



관련 글

전체 시리즈

Tags: JobSystem, Unity, 모바일, 스레딩, 엔진

Categories: ,