[Unity] 스레드와 비동기
유니티를 어느정도 사용해봤다면 코루틴을 한 번쯤은 사용해본 적이 있을 것이다.
우리는 보통 코루틴을 시간 지연이나 파일 로딩, 네트워크 호출 등의 작업을 할 때 사용하였다.
하지만, 네트워크 호출을 하는 방법을 찾으려고 구글링을 하다보면 비동기로 하는게 더 좋다는 글들을 볼 수 있다.
또한 계산이 많은 작업은 코루틴 대신 스레드로 구현해야한다는 말도 들어본 적이 있을 것이라 생각한다.
왜 비동기가 더 좋은걸까? 왜 계산이 많아지면 스레드를 사용하는게 좋을까?
이를 알아보기 위해, 이번 글에서는 스레드와 비동기에 대해 정리해보았다.
프로세스란?
스레드 대해 알아보기 전에, 이해의 도움을 위해 프로세스에 대해 알아보겠다.
프로세스란 운영체제에서 실행 중인 프로그램을 나타내는 단위이다.
프로세스는 다음의 순서로 생성되고 실행된다.
- 프로그램을 실행하면, 운영체제는 해당 프로그램의 실행 파일을 메모리(RAM)에 로드한다.
- 이때, 운영체제는 새로운 프로세스들을 생성하며 프로세스의 메모리 공간을 할당&초기화하고,
필요한 환경변수(코드, 데이터, 스택, 힙) 등을 설정한다. - 운영체제는 생성된 프로세스를 스케줄링하여 CPU에 할당한다.
- CPU가 프로세스를 실행하면, 프로세스의 코드가 메모리에 올라간다.
- 프로세스는 자체의 실행 흐름을 가지고 있으며, 코드를 읽고 명령어를 실행하여 작업을 수행한다.
프로세스는 다음과 같은 특성을 가지고 있다.
- 각 프로세스는 독립적인 메모리 공간을 가지며, 다른 프로세스의 메모리에 직접 접근할 수 없다.
- 프로세스 간의 통신을 위해서는 파이프, 메시지 큐, 공유 메모리, 소켓 등의 메커니즘이 필요하다.
- 각 프로세스는 자체의 실행 흐름을 가지며, 프로세스 간의 독립적인 실행 흐름이 존재한다.
- 운영체제로부터 메모리, CPU 시간, 파일, 네트워크 등의 시스템 자원을 할당받는다.
스레드란?
프로세스 내에서 실행되는 가장 작은 실행 단위이며, 하나의 프로세스는 여러 개의 스레드를 포함할 수 있다.
프로세스를 구성하는 스레드가 하나라면 싱글 스레드라 하며, 여러 개라면 멀티 스레드라 한다.
유니티 로직은 기본적으로 하나의 메인 스레드에서 실행되는 싱글 스레드 환경이다.
또한 Thread, Task 등의 문법을 사용하여 스레드를 추가할 수 있으며, 추가된 스레드는 워커 스레드라고 불린다.
스레드는 다음과 같은 특성을 가지고 있다.
- 프로세스 내의 스레드들은 같은 주소 공간(프로세스의 메모리 공간)을 공유하며,
이로 인해 스레드 간의 데이터를 쉽게 공유할 수 있다. - 프로세스 내에서 실행되므로, 프로세스보다 생성 및 관리의 효율이 좋다.
- 프로세스 내부의 작업을 병렬로 처리할 수 있기에 성능이 향상된다.
또한 스레드에서는 Unity API를 호출할 수 없다.
예를 들어, 스레드 내부에서 GameObject를 생성하려고 하면 다음과 같은 오류가 뜬다.
동기와 비동기란?
동기 방식이든 비동기 방식이든, 모두 하나의 프로세스에서 실행된다.
동기 | 코드를 순차적으로 실행하는 방식 한 작업이 시작되면 그 작업이 완료될 때까지 다음 작업을 시작할 수 없다. |
비동기 | 작업이 완료될 때까지 기다리지 않고, 다음 작업을 시작할 수 있다. I/O 작업이나 네트워크 호출과 같이 시간이 오래 걸리는(블로킹) 작업을 처리할 때 사용한다. ※ 블로킹 작업: 특적 작업이 완료될 때까지 대기해야하는 작업 |
Thread
Thread 클래스는 C# 언어의 일부로, 닷넷 프레임워크에 처음부터 포함되어 있었다.
C# 언어가 나온 2000년 7월 이후로 계속해서 사용되어 왔다.
Unity 1.0이 2005년 6월에 출시되었을 때부터 Thread 클래스를 사용하여 스레드를 생성하고 관리할 수 있었다.
Thread 클래스는 System.Threading 네임스페이스에 정의되어 있으며, 동기 방식으로 실행된다.
동기 방식으로 실행되기 때문에, 스레드가 완료될 때 까지 게임이 멈춰버린다.
직접 스레드의 생성, 시작, 생명 주기, 상태, 우선순위, 중지 등을 직접 제어해야하기 때문에 관리가 복잡하며,
잘못된 관리로 인해 리소스 누수가 발생할 수 있다.
예를 들면, 스레드 간의 경쟁 상태나 데드락과 같은 문제를 초래할 수 있다.
경쟁 상태(Race Condition)
여러 스레드가 동시에 자원에 접근하여, 그 자원을 변경하려 할 때 어떤 스레드에서 먼저 값을 변경할지 모르기 때문에 예상치 못한 상황이 발생할 수 있다.
데드락(Deadlock)
각 스레드가 다른 스레드가 가진 자원을 기다리며 시스템이 멈추는 상황이 발생할 수 있다.
이렇게만 들으면 Thread 클래스를 왜 사용하나 싶지만,
오히려 하나하나 제어가 가능하기 때문에 세밀한 제어가 필요한 경우에는 사용한다.
프로퍼티&메서드
Thread의 프로퍼티 | |
CurrentThread | 현재 Thread 객체 리턴 (읽기 전용, 정적) |
ManagedThreadId | 스레드를 식별하는 ID (읽기 전용) |
ThreadState | 스레드의 상태 (읽기 전용) 상태는 enum 형식의 ThreadState 변수로 반환 |
IsAlive | 스레드가 아직 실행 중인지 여부 (읽기 전용) |
IsBackground | 백그라운드 스레드의 사용 여부 ※ 백그라운드 스레드는 메인 스레드가 실행 중이 안니 경우에도 독립적으로 실행되는 스레드 |
Priority | 스레드의 우선순위 |
Name | 스레드의 이름 |
Thread의 메서드 | |
Start() | 스레드 시작 |
Join() | 스레드가 종료될 때 까지 대기 |
Abort() | 스레드 강제 종료 |
Sleep(int) | 스레드 일시 정지 (정적) 매개인자는 시간을 나타내며, 단위는 밀리세컨드이다. (1초 = 1000밀리초) TimeSpan 구조체를 사용하여 깔끔하게 구현이 가능하다. |
예제
using System;
using System.Threading;
using UnityEngine;
public class ThreadExample : MonoBehaviour
{
void Start()
{
DisplayCurrentThreadInfo();
// 스레드 생성
Thread firstThread = new Thread(() => NewThreadFunction(1));
Thread secondThread = new Thread(() => NewThreadFunction(2));
// 스레드 시작
firstThread.Start();
secondThread.Start();
// 스레드 강제 종료
firstThread.Abort();
// 스레드가 종료될 때까지 대기
firstThread.Join();
secondThread.Join();
DisplayCurrentThreadInfo();
}
// 현재 스레드 정보 출력
void DisplayCurrentThreadInfo()
{
Thread currentThread = Thread.CurrentThread;
Debug.Log($"현재 스레드 ID: {currentThread.ManagedThreadId}" +
$"\t스레드 상태: {currentThread.ThreadState}" +
$"\t실행 여부: {currentThread.IsAlive}" +
$"\n백그라운드 스레드인가?: {currentThread.IsBackground}" +
$"\t우선 순위: {currentThread.Priority}" +
$"\t스레드 이름: {currentThread.Name}");
}
void NewThreadFunction(int n)
{
// 스레드 일시 정지
Debug.Log($"{n} 번째 스레드 3초 대기..");
Thread.Sleep(TimeSpan.FromSeconds(3));
DisplayCurrentThreadInfo();
}
}
Coroutine
코루틴은 2007년 Unity 2.0 에서 처음 도입되었다.
기존 닷넷 프레임워크에 코루틴이 존재하지 않았기에 Unity가 IEnumerator 형식으로 자체 개발한 기능이다.
코루틴은 싱글 스레드로 비동기 방식을 흉내내어 사용할 수 있다.
Update 메서드 이후에 yield 를 통해 순서를 양보받아 코루틴이 실행된다.
하지만 결국 싱글 스레드 방식이며, 동기적으로 처리된다.
그러면 유니티는 굳이 왜 코루틴을 개발하였을까?
더 많은 개발자가 게임을 개발할 수 있도록 도와주고 싶었던 유니티는 프로그래머가 쉽게 병렬 및 비동기 작업을 처리할 수 있도록 하고 싶었다. 하지만 기존에 있던 Thread는 사용하기 힘들어 초보 개발자들은 잘 사용하지 못했었다.
이를 해결하기 위해, 코루틴을 개발하였는데..
- Unity API를 병렬 형식으로 처리할 수 있다.
- 스레드는 오버헤드가 크고 관리가 어려울 수 있다.
- 코루틴은 스레드보다 경량적으로 동작한다.
- 게임은 매 프레임마다 수행되는 작업이 많기 때문에, 스레드를 사용하면 동기화 문제와 복잡성이 증가할 수 있다.
정도의 이유로 인해 코루틴을 개발한 것 같다.
코루틴은 다른 좋은 글들이 많기도 하고.. 다들 잘 아실꺼라 생각해서 넘어가겠다.
Task
Task 클래스는 2010년 .NET Framework 4.0 에서 도입되었다.
하지만 유니티에서는 여러 기술적인 문제로 인해 C# 4.0 부터 C# 7.3 버전이 Unity 2018.3.0f1 버전에서 처음 도입되었다.
Task 클래스를 사용하려면 System.Threading.Task 네임스페이스를 정의해야 한다.
Task 클래스는 비동기 작업을 추상화한 클래스이며, ThreadPool 이라는 Thread 인스턴스 리스트를 가지고 있다.
당연히 새롭게 객체를 생성할 필요 없이 ThreadPool 내부의 스레드를 불러와 스레드 작업을 할 수 있다.
또한 스레드를 제어해야하는 귀찮은 작업들은 ThreadPool에서 내부적으로 관리해주기 때문에 구현하고자 하는 로직에 집중할 수 있다는 장점이 있다.
나는 코루틴이 있는데도 Task를 사용해야 하는 가장 결정적인 이유는 코루틴과 달리 값을 리턴할 수 있어서 로컬이나 서버에서 받은 값도 반환할 수 있다는 점이라 생각한다.
async/await 키워드를 사용하여 비동기를 구현할 수 있다.
async/await
async는 Asynchronous(비동기) 의 줄임말로 메서드 앞에 붙여서 사용할 수 있다.
async를 메서드 앞에 붙이면, 해당 메서드는 비동기로 작동한다는 의미를 가지게 되며,
메서드 내에서 await 키워드를 사용하겠다고 정의하는게 된다.
다시 말해, async가 붙은 메서드는 반드시 하나 이상의 await가 필요하다는 말이 된다.
await는 기다리다 라는 뜻으로, 비동기로 실행되는 작업이 완료될 때까지 대기하는 키워드이다.
Task에서 비동기 메서드는 void, Task, Task<T> 라는 3가지 형식으로 반환할 수 있다.
void | 아무런 결과도 반환할 수 없으며, 단순히 비동기로 실행된다. Task를 반환하지 않으므로 비동기 작업이 완료될 때 까지 대기할 수 없다. 실행 후 잊어버릴 작업이나, 이미 void를 반환해야 한다고 정해져 있는 메서드에서 사용된다. 예를 들어, EventHandler와 같은 메서드에서 비동기를 사용할 경우 사용한다. 해당 메서드의 반환타입이 void 이기 때문이다. |
Task | 동기 메서드의 void 와 같이 아무련 결과를 반환하지 않는다. 비동기 작업의 완료 여부를 반환하고, 완료될 때 까지 대기할 수 있다. |
Task<T> | 제너럴 타입 T에 정의된 자료형을 반환한다. 비동기 작업의 완료 여부를 반환하고, 완료될 때 까지 대기할 수 있다. |
프로퍼티 & 메서드
Task의 프로퍼티 | |
CurrentId | 현재 스레드의 Task ID (읽기 전용, 정적) |
CompletedTask | 이미 완료된 Task (읽기 전용, 정적) |
Factory | TaskFactory 인스턴스 (읽기 전용, 정적) |
Id | Task를 식별하는 고유한 ID (읽기 전용) |
Status | Task의 현재 상태 (읽기 전용) |
CreationOptions | Task의 생성 옵션 (읽기 전용) |
AsyncState | 비동기 작업에 대한 사용자 지정 상태. (읽기 전용) |
IsCompleted | Task가 완료되었는지 여부 (읽기 전용) |
IsCompletedSuccessfully | Task가 성공적으로 완료되었는지 여부 (읽기 전용) |
IsFaulted | Task가 예외로 완료되었는지 여부 (읽기 전용) |
IsCanceled | Task가 취소되었는지 여부 (읽기 전용) |
Task의 메서드 | |
Start() | Task 시작 직접 생성한 Task에서만 사용될 수 있다. Task task = new Task(Action); task.Start(); |
Factory.StartNew() | Task 생성 .NET 4.0 에서 사용되던 방식이다. Task.Factory.StartNew(Action); |
Run() | Task 생성 (정적) .NET 4.5 에서 새롭게 도입된 방식으로 기존 방식에 비해 1. 간결성과 가독성 향상 2. 디폴트 스케줄러 사용 3. 취소 토큰 및 생성 옵션의 간소화 등의 이점이 있다. Task.Run(Action); |
Delay(int) | 지정된 시간동안 대기 후 완료되는 Task를 생성 (정적) 매개인자는 시간을 나타내며, 단위는 밀리세컨드이다. (1초 = 1000밀리초) TimeSpan 구조체를 사용하여 깔끔하게 구현이 가능하다. |
Wait() | Task가 완료될 때까지 현재 스레드를 차단 UI 스레드에서는 사용하면 안되며, await 키워드로 비동기 작업을 처리하는 것이 권장됩니다. |
WaitAny() | Task가 하나라도 완료될 때까지 스레드를 대기 (정적) Task.WaitAny(task1, task2, ...) |
WaitAll() | Task가 모두 완료될 때까지 스레드를 대기 (정적) Task.WaitAll(task1, task2, ...) |
ContinueWith() | 작업이 완료된 후 지정된 작업을 실행 |
Factory.ContinueWhenAny() | Task중 하나라도 완료된다면 지정된 작업을 실행 |
Factory.ContinueWhenAll() | Task가 모두 완료된 후에 지정된 작업을 실행 |
Factory.FromAsync() | 비동기 메서드를 기반으로 Task를 생성 |
FromResult() | 이미 완료된 결과를 가지는 Task를 생성 (정적) |
FromCanceled() | 취소된 Task를 생성 (정적) |
FromException() | 예외로 완료된 Task를 생성 (정적) |
생성 옵션
CancellationToken
Task를 생성할 때, 작업이 취소되었는지 여부를 확인하고 작업을 중지하는데 사용된다.
Task.Factory.StartNew()와 Task.Run() 모두 설정되어있는 기본값은 None이다.
public class TaskExample : MonoBehaviour
{
public Button button;
private async void Start()
{
await CancellationTokenTest();
}
private async Task CancellationTokenTest()
{
// 작업의 취소를 허용하기 위해 토큰 생성
CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
button.onClick.AddListener(() =>
{
// 취소 요청
cts.Cancel();
});
Task task = Task.Run(async () =>
{
for (int i = 1; i <= 100000; i++)
{
// 취소가 요청되었는지 확인하고 true면 OperationCanceledException을 throw
token.ThrowIfCancellationRequested();
Debug.Log($"{i} 번째 반복");
await Task.Delay(TimeSpan.FromSeconds(1));
}
}, token);
try
{
await task;
Debug.Log("테스크 완료");
}
catch
{
Debug.Log("테스크 취소");
}
}
}
TaskCreateOptions
Task를 생성할 때, 작업의 동작을 제어하는데 사용하는 열거형 변수
None | 아무런 특별한 옵션이 없음 |
PreferFairness | 동일한 우선순위 간의 Task 스케줄링을 공평하게 수행 |
LongRunning | 해당 Task를 긴 시간 동안 실행될 것으로 표시 → TaskScheduler에게 해당 Task에 더 많은 스레드를 할당하도록 표시 |
AttachedToParent | 작업을 부모 작업에 첨부 → 부모 작업이 완료될 때, 자식 작업도 함께 종료 |
DenyChildAttach | 작업이 부모 작업에 첨부되지 않음 → 부모 작업과 자식 작업이 서로 독립적으로 실행 |
HideScheduler | 특정 스케줄러에 대한 작업 정보를 숨김 |
RunContineations Asynchronously |
연속 작업(ContinueWith 등)을 비동기적으로 실행 |
TaskScheduler
비동기 작업을 특정 스레드에 할당하거나 스레드 풀에서 실행하도록 관리하는 역할을 한다.
동기로 처리할지 비동기로 처리할지 정할 수 있다.
Default | 스레드 풀에서 작업을 실행 |
FromCurrent SynchronizationContext |
현재 동기화된 컨텍스트(부모 스레드)에서 작업을 실행 → 부모 스레드가 없다면, 메인 스레드에서 작업을 실행 |
예시
직접 생성
using System.Threading.Tasks;
using UnityEngine;
public class TaskExample : MonoBehaviour
{
private void Start()
{
NewTask();
}
private void NewTask()
{
Task task = new Task(() => {
for(int i = 1; i <= 10; i++)
{
Debug.Log($"{i} 번째 반복");
}
});
// Start 메서드로 작업 시작
task.Start();
// Wait 메서드로 작업이 완료될 때까지 대기
task.Wait();
Debug.Log("테스크 완료");
}
}
Task.Factory.StartNew()
using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
public class TaskExample : MonoBehaviour
{
private async void Start()
{
await FactoryTaskAsync();
}
// 기본적으로 Task.Factory.StartNew(Action, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Current)로 매개변수가 적용된다.
private async Task FactoryTaskAsync()
{
await Task.Factory.StartNew(() =>
{
for (int i = 1; i <= 10; i++)
{
Debug.Log($"{i} 번째 반복");
Task.Delay(TimeSpan.FromSeconds(1)).Wait();
}
});
Debug.Log("테스크 완료");
}
}
Task.Run()
using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
public class TaskExample : MonoBehaviour
{
private async void Start()
{
await RunTaskAsync();
}
private async Task RunTaskAsync()
{
// Task.Factory.StartNew(Action, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default)와 같다.
await Task.Run(async () =>
{
for (int i = 1; i <= 10; i++)
{
Debug.Log($"{i} 번째 반복");
await Task.Delay(TimeSpan.FromSeconds(1));
}
});
Debug.Log("테스크 완료");
}
}
UniTask
UniTask는 유니티에서 비동기 작업을 수행할 수 있도록 구현된 UniRx 파생 라이브러리이다.
C#의 Task 및 async/await 는 유니티에서 사용하기에 무거웠으며 이를 해결하기 위해 유니티에 맞게 최적화하고,
코루틴을 사용하는 것과 유사하게 개발하여 코루틴을 대체하여 사용하기 쉽도록 구현되었다.
Task와 비슷하지만 다른 Cysharp.Threading.Tasks 네임스페이스를 정의해야 사용 가능하다.
기본적으로 메인쓰레드에서 동작하고, 스위칭을 통해 ThreadPool에서 동작하게 할 수 있다.
코루틴과 Task 보다 발전된 기능은 다음과 같다.
- Task와 마찬가지로 로컬이나 서버에서 받은 값을 반환할 수 있다.
- Struct 기반으로 구현되어 있어서 힙 메모리에 할당되지 않아 GC 부하를 최소화 할 수 있다.
- LINQ 및 DoTween의 비동기 버전을 지원한다.
- WhenAny(), WhenAll() 등의 Task에서 사용하던 메서드를 사용 가능하다.
라이브러리 설치 방법
1. Window > Package Manager > Add package from git URL...

2. 해당 주소 입력 후, Add 버튼 눌러주기
https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask

UniTask에서 비동기 메서드는 UniTaskVoid, UniTask, UniTask<T> 라는 2가지 형식으로 반환할 수 있다.
UniTaskVoid | 비동기 작업을 수행하며, 값을 반환하지 않는다. 비동기 작업의 완료 여부를 반환하지 않아 await로 대기시킬 수 없다. 호출할 메서드에서 await 키워드를 사용할 일이 없을 때에만 사용하면 된다. 메서드명().Forget() 으로 실행시킬 수 있다. |
UniTask | 비동기 작업을 수행하며, 값을 반환하지 않는다. 비동기 작업의 완료 여부를 함께 반환하여 await로 대기시킬 수 있다. |
UniTask<T> | 비동기 작업을 수행하며, 값을 반환한다. 비동기 작업의 완료 여부를 함께 반환하여 await로 대기시킬 수 있다. |
프로퍼티 & 메서드
UniTask의 프로퍼티 | ||
Status | 현재 UniTask의 상태 (읽기 전용) Status에는 4개의 확장 메서드가 있다. Pending: 아직 완료되지 않는 작업 Succeeded: 성공적으로 완료된 작업 Faulted: 오류로 인해 실패한 작업 Canceled: 사용자에 의해 취소된 작업 |
|
IsCanceled() | 주어진 작업이 취소되었는지 여부 | |
IsCompleted() | 주어진 작업이 완료되었는지 여부 | |
IsCompletedSuccessfully() | 주어진 작업이 성공적으로 완료되었는지 여부 | |
IsFaulted() | 주어진 작업이 실패했는지 여부 |
UniTask의 메서드 | |
Create() | 비동기 메서드를 실행시킬 수 있는 UniTask를 생성 (정적) 사용 방법 Create(UniTask를 반환하는 메서드) Create(async () => { }) |
Void() | 비동기 메서드를 실행시킬 수 있는 UniTaskVoid를 생성 (정적) 사용 방법 Void(UniTaskVoid를 반환하는 메서드) Void(async () => { }) |
Delay() | 지정된 시간이 결과된 후에 작업이 완료되는 UniTask를 생성 (정적) 사용 방법 Delay(시간(밀리초), DelayType) DelayType (디폴트: DeltaTime) DeltaTime: 유니티 내부 시간으로 딜레이를 측정, 타임 스케일 O UnScaledDeltaTime: 유니티 내부 시간으로 딜레이를 측정, 타임 스케일 X RealTime: 실제 시간에 기반하여 딜레이를 측정, 타임 스케일 X |
DelayFrame() | 지정된 프레임 수 이후에 작업이 완료되는 UniTask를 생성 (정적) |
NextFrame() | 다음 프레임이 되면 작업이 완료되는 UniTask를 생성 (정적) |
Yield() | 현재 프레임에서 실행 중인 코드의 실행을 중지하고, 다음 프레임으로 넘어감 (정적) |
WhenAll() | 모든 UniTask가 완료될 때까지 대기하는 UniTask 생성 (정적) 사용 방법 WhenAll(task1, task2, ...) |
WhenAny() | 여러 UniTask 중 하나가 완료될 때까지 대기하는 UniTask 생성 (정적) 사용 방법 WhenAny(task1, task2, ...) |
WhenUntil() | 조건이 충족될 때까지 대기하는 UniTask 생성 (정적) 사용 방법 WhenUntil(bool 반환하는 람다식) WhenUntil(bool 반환하는 메서드) |
RunOnThreadPool() | 별도의 스레드를 생성 (정적) |
SwitchToMainThread() | 메인 스레드에서 동작하도록 전환 (정적) |
SwitchToThreadPool() | ThreadPool에서 동작하도록 전환 (정적) .NET 비동기 작업에 최적화 |
SwitchToTaskPool() | TaskPool에서 동작하도록 전환 (정적) Unity 비동기 작업에 최적화 |
using Cysharp.Threading.Tasks;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading;
using UnityEngine;
using UnityEngine.Networking;
using UnityEngine.UI;
public class UniTaskExample : MonoBehaviour
{
public Button button;
public RawImage image;
private CancellationTokenSource cts;
private void Start()
{
UniTaskMethodAsync().Forget();
}
private async UniTaskVoid UniTaskMethodAsync()
{
// 비동기 작업 취소 토큰 생성
cts = new CancellationTokenSource();
int count = 0;
button.onClick.AddListener(() =>
{
count++;
Debug.Log($"취소 시도 {count}회");
});
try
{
// 시간 지연용 UniTask 생성
UniTask delayTask = UniTask.Delay(
TimeSpan.FromDays(1), // 대기 시간 (하루)
DelayType.Realtime, // 시간 타입 (실시간)
PlayerLoopTiming.PreUpdate, // 유니티의 내부 루프에서 언제 작업을 실행할지 (프레임의 시작 시점)
cancellationToken: cts.Token); // 취소 토큰 등록 (매개인자 이름: 값 으로 순서 상관없이 사용 가능)
// 기본 UniTask 생성
UniTask createUniTask = UniTask.Create(async () =>
{
UniTaskInfo(delayTask);
await delayTask;
});
// UniTask 스레드 생성
var thread = UniTask.RunOnThreadPool(async () =>
{
await UniTask.WaitUntil(() => count >= 3);
Debug.Log("취소 완료");
cts.Cancel();
});
int num = 0;
while (!delayTask.Status.IsCompleted())
{
// 시간 지연용 UniTask 생성 (1초), 코루틴과 똑같다.
await UniTask.WaitForSeconds(1);
num++;
Debug.Log($"{num}초 대기중");
}
}
catch
{
Debug.Log("UniTask 취소");
// Texture 다운로드
Texture2D texture = await DownloadTexture("https://cdn.pixabay.com/photo/2024/02/23/19/37/squirrel-8592682_1280.jpg");
image.texture = texture;
Debug.Log("이미지 로드 완료");
}
}
// UniTask 프로퍼티
private void UniTaskInfo(UniTask uniTask)
{
Debug.Log($"상태: {uniTask.Status}");
Debug.Log($"취소되었는가?: {uniTask.Status.IsCanceled()}");
Debug.Log($"완료되었는가?: {uniTask.Status.IsCompleted()}");
Debug.Log($"성공하였는가?: {uniTask.Status.IsCompletedSuccessfully()}");
Debug.Log($"실패하였는가?: {uniTask.Status.IsFaulted()}");
}
// 텍스쳐 다운받기
private async UniTask<Texture2D> DownloadTexture(string url)
{
using (var request = UnityWebRequestTexture.GetTexture(url))
{
await request.SendWebRequest();
if (request.result != UnityWebRequest.Result.Success)
{
// 실패 로직
Debug.Log(request.error);
return null;
}
// 성공 로직
return DownloadHandlerTexture.GetContent(request);
}
}
}
Job System
Job System은 Unity 2018.1 버전에서 새롭게 추가되었으며, Unity.Jobs 네임스페이스에 정의되어 있다.
Job System이란 병렬 작업을 처리하는 기술로, CPU의 여러 코어에 있는 워커 스레드들을 관리한다.
각 코어당 하나의 워커 스레드가 있다.
이미 있는 워커 스레드를 사용하는 방식이기 때문에,
스레드를 생성해서 병렬 작업을 하지 않고, Job 이라는 형태로 만들어 병렬 작업을 실행한다.
생성된 Job은 Job Queue에 배치되며, 스케줄링을 통해 각 워커 스레드에 균등하게 분배되어 실행된다.
또한, 빠르게 작업이 끝난 워커 스레드가 있다면 다른 워커 스레드의 작업을 받아와 처리하기 때문에 작업 속도가 빠르다.
때문에 많은 작업을 처리해야 해서 작업 부하가 많은 상황에 유용하게 쓰인다.
ex) 물리 시뮬레이션, 인공 지능 계산, 대규모 데이터(맵 생성, 그래픽 처리, 수 많은 게임 오브젝트 등) 처리 등
사용 방법
1. 작업 정의하기
작업은 구조체에 인터페이스를 구현하여 만들 수 있다.
인터페이스 종류 | |
IJob | 가장 기본적인 작업을 정의할 때 사용되는 Job 인터페이스이다. 단일 작업 스레드에서 작업을 수행하며, 작업 단위가 작고 간단한 경우에 적합하다. ex) 특정한 계산 수행, 데이터 업데이트 |
IJobParallelFor | 컬렉션(배열, 리스트 등)의 각 요소에 대한 작업을 병렬로 수행할 때 사용된다. ex) 특정한 계산을 병렬로 수행, 배열의 각 요소를 병렬로 처리 |
IJobParallelForTransform | Transform 컬렉션(배열, 리스트 등)의 각 요소에 대한 작업을 병렬로 수행할 때 사용된다. ex) Unity의 Transform 업데이트 |
IJobParallelForBatch | 컬렉션(배열, 리스트 등)을 여러 그룹으로 분할하고, 각 그룹을 병렬로 처리할 때 사용된다. |
IJobChunk | ECS(Entity Component System)에서 사용되며, 청크에 대한 작업을 정의할 때 사용된다. |
IJobForEach | ECS(Entity Component System)에서 개별 엔티티에 대한 작업을 정의할 때 사용된다. |
struct MyJob : IJob
{
public void Execute()
{
// 수행할 작업의 로직
}
}
2. 작업 스케줄링하기
작업을 적절히 분배하고 실행할 준비를 맞출 수 있게 해주는 작업이다.
실행할 준비가 끝나면, 워커 스레드가 자동으로 작업을 수행한다.
MyJob job;
job.Schedule();
Job에 구현된 인터페이스에 따라 Schedule 메서드가 요구하는 매개인자가 바뀐다.
3. 작업 완료 대기하기
작업이 완료될 때까지 기다려야 한다면 스케줄링 한 것을 JobHandle로 받아서 대기시킬 수 있다.
JobHandle handle = job.Schedule();
handle.Complete();
※ NativeContainer
Unity의 네이티브 메모리 할당 방식을 사용하여 메모리를 효율적으로 관리하고, 스레드 간에 안전하게 데이터를 공유하도록 설계된 타입이다. 기본적으로 NativeArray가 존재한다.
만약 리스트나 큐와 같은 자료구조를 사용하고 싶다면, ECS 에서 제공하는 Collections 패키지를 설치하여 사용할 수 있다.

NativeContainer 종류 | |
NativeArray | Array와 유사하며, 크기가 고정되어 있다. Collections 패키지를 설치하지 않아도 사용할 수 있다. |
NativeList | List와 유사하며, 동적으로 크기를 조절할 수 있다. 데이터를 추가하거나 제거할 때 사용된다. |
NativeHashMap | HashMap과 유사하며, 키와 값이 1:1로 매핑된다. |
NativeMultiHashMap | HashMap과 유사하며, 키와 값이 1:N으로 매핑된다. |
NativeQueue | Queue와 유사하며, 선입선출 방식으로 데이터를 저장한다. |
(NativeList, NativeHashMap, NativeMultiHashMap, NativeQueue 등)
제네릭 컬렉션 대신 NativeContainer를 사용하는 이유는 다음과 같다.
- 하드웨어와 가까이 작동하는 네이티브 코드의 메모리 할당 방식을 사용하기 때문에 속도가 빠르다.
- 데이터를 동기화하여 스레드끼리 공유하기 때문에 안전하다.
ex) 한 스레드에서 데이터를 수정하는 동안 다른 스레드가 해당 데이터를 읽거나 수정하면 문제가 발생한다.
NativeArray는 new키워드로 생성할 수 있다.
첫 번째 매개인자는 배열의 크기이고,
두 번째 매개인자는 메모리 할당 방식을 나타내는 enum 타입 Allocator 변수이다.
메모리 할당 방식 | |
Invalid | 유효하지 못한 상태(잘못된 할당)를 나타내며, 일반적으로 사용되지 않는다. |
None | 어떠한 할당도 없음을 나타낸다. 기본값으로 사용된다. |
Temp | 할당 속도가 가장 빠르며, 1 프레임 이하의 수명을 가진 할당에 적합하다. |
TempJob | Temp와 Persistent 사이의 속도로 할당되며, 4 프레임 이하의 수명을 가진 할당에 적합하다. |
Persistent | 가장 느린 할당이지만 지속적으로 메모리가 유지된다. |
AudioKernel | 오디오 커널과 관련된 할당을 나타내며, 오디오 엔진과 관련된 작업에서 사용된다. |
NativeArray<int> array = new NativeArray<int>(10, Allocator.TempJob);
다 끝난 후에는 Dispose() 메소드를 사용하여 할당을 해제 해줘야 한다.
array.Dispose();
※ 작업(구조체)에 데이터를 전달하는 방법
1. 구조체에 public 변수를 추가해준다.
struct MyJob : IJob
{
public int blittableVariable;
public NativeArray<int> nativeVariable;
public void Execute()
{
// 수행할 작업의 로직
}
}
2_1. 객체 이니셜라이저 방식
MyJob job = new MyJob {
blittableVariable = 10,
nativeVariable = new NativeArray<int>(10, Allocator.TempJob)
};
2_2. 변수 할당 방식
MyJob job;
job.blittableVariable = 10;
job.nativeVariable = new NativeArray<int>(10, Allocator.TempJob);
예제 - 몬스터와 플레이어가 가까워지는 것을 체크하는 작업
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;
using Random = UnityEngine.Random;
public class MonsterManager : MonoBehaviour
{
public Transform playerTransform;
public float attackDistance = 3f;
NativeArray<Vector3> monsterPositions;
void Start()
{
int monsterCount = 10;
monsterPositions = new NativeArray<Vector3>(monsterCount, Allocator.Persistent);
for (int i = 0; i < monsterCount; i++)
{
monsterPositions[i] = new Vector3(Random.Range(-10f, 10f), 0f, Random.Range(-10f, 10f));
}
// 몬스터 작업 스케줄링
MonsterJob job = new MonsterJob
{
monsterPositions = monsterPositions,
playerPosition = playerTransform.position,
attackDistance = attackDistance
};
JobHandle handle = job.Schedule(monsterCount, 64);
handle.Complete();
}
void OnDestroy()
{
// 사용한 NativeArray를 메모리에서 해제
monsterPositions.Dispose();
}
struct MonsterJob : IJobParallelFor
{
public NativeArray<Vector3> monsterPositions;
public Vector3 playerPosition;
public float attackDistance;
public void Execute(int index)
{
Vector3 monsterPosition = monsterPositions[index];
float distanceToPlayer = math.distance(monsterPosition, playerPosition);
// 플레이어와의 거리가 공격 가능한 거리보다 공격
if (distanceToPlayer <= attackDistance)
{
Debug.Log($"{index} 번째 몬스터가 플레이어를 공격합니다.");
}
}
}
}