목차
0. 문제 상황
public void StartNextNode()
{
if (currentNode == null) return;
if (currentNode is EncounterLineNode || currentNode is CutSceneNode)
{
if (currentNode is CutSceneNode) cutSceneUIController.Close();
MoveToNextNode(currentNode.GetNextNode());
}
else
{
DOVirtual.DelayedCall(0.01f, () =>
{
MoveToNextNode(currentNode.GetNextNode());
});
}
}
이 기회에 동기 / 비동기 실행에 대해 공부하고, UniTask 사용법을 익히는 것이 좋다는 생각이 들어 시작합니다.
1. 동기와 비동기
- 동기(Synchronous) : 앞선 작업이 끝날떄까지 스레드가 멈춰서 대기하는 방식 (Block)
- 비동기(Asynchronous) : 작업을 지시해두고 스레드를 블럭하지 않은 채 다른 작업을 하러 가는 방식
https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/
Asynchronous programming - C#
Explore an overview of the C# language support for asynchronous programming by using async, await, Task, and Task.
learn.microsoft.com
공식 문서를 보면 동기 방식에 대한 다음과 같은 코멘트가 있습니다.
The code blocks the current thread from doing any other work. The code doesn't interrupt the thread while there are running tasks.
이 코드는 현재 스레드가 다른 작업을 수행하지 못하도록 차단합니다. 실행 중인 작업이 있는 동안에는 코드가 스레드를 중단하지 않습니다.
C#에서 기본적으로 제공하는 비동기 프로그래밍 방법은 Async / Await입니다.
Async / Await를 사용하는 비동기 방식에 대해서는 다음과 같이 설명합니다.
You can start by updating the code so the thread doesn't block while tasks are running. The await keyword provides a nonblocking way to start a task, then continue execution when the task completes.
태스크가 실행되는 동안 스레드가 차단되지 않도록 코드를 업데이트하여 시작할 수 있습니다.
await 키워드는 작업을 시작한 다음 태스크가 완료되면 실행을 계속할 수 있는 비블로킹 방법을 제공합니다.
// ❌ 동기 방식: 1초 동안 메인 스레드가 완전히 멈춤 (게임 멈춤)
Thread.Sleep(1000);
Debug.Log("1초 끝!");
// ⭕ 비동기 방식: 1초를 기다리는 동안 메인 스레드는 다른 게임 로직을 처리함
await Task.Delay(1000);
Debug.Log("1초 끝!");
2. C# 비동기의 숨겨진 원리 : 상태 기계
비동기 코드는 상태 기계를 사용해 동작합니다.
컴파일러가 Await를 만나면 현재의 실행 상태를 기록하고 제어권을 반환합니다.
이 때 메서드 내부의 지역변수들은 상태 기계의 멤버 변수로 격상되어 메모리에 안정하게 저장되고, 작업이 끝나면 저장된 지점으로부터 코드가 다시 실행되 됩니다.
IAsyncStateMachine.MoveNext 메서드 (System.Runtime.CompilerServices)
상태 시스템을 다음 상태로 이동합니다.
learn.microsoft.com
3. 유니티 코루틴과 Async / Await의 차이
- 코루틴 : 유니티의 MonoBehaviour 생명주기에 종속되어 있고, 매 프레임마다 엔진이 상태를 체크합니다. 결과를 반환하려면 콜백 등을 써야해 콜백 지옥에 빠지기 쉽습니다.
- Async / Await : 언어 자체의 기능이므로 유니티 생명주기와 무관하게 쓸 수 있고, 일반 동기 함수처럼 return을 사용할 수 있습니다.
// 코루틴 방식 (콜백 필요)
public IEnumerator GetLevelCoroutine(Action<int> onComplete)
{
yield return new WaitForSeconds(1f);
onComplete?.Invoke(5); // 결과를 콜백으로 전달
}
// async/await 방식 (직관적인 반환)
public async Task<int> GetLevelAsync()
{
await Task.Delay(1000);
return 5; // 일반 함수처럼 바로 리턴 가능
}
그럼에도 불구하고 Unity에서는 코루틴을 쓰는 것을 처음에 배우게 됩니다.
유니티 환경에서 기본 Task를 사용하게 되면 다음과 같은 문제점들이 발생하기 때문입니다.
- 메인 스레드 보장과 엔진 안전성
유니티는 내부적으로 C++로 이루어져 있고, 처리 속도를 극한으로 끌어올리기 위해 내부 컴포넌트에 Lock을 걸지 않았습니다.
( 여러 개의 게임 오브젝트의 위치값 등을 수정할 때 , 락이 걸려 있다면 병목 현상이 나타나게 됨 )
이 경우 스레드의 경쟁 상태 문제가 발생할 수 있습니다. ( 여러 곳에서 하나의 컴포넌트에 접근해 값을 바꾸게 되면, 제대로 동작하지 않는 문제 ) . 따라서 Unity는 오직 메인 스레드에서만 유니티 API에 접근해야 한다는 규칙을 정해두었습니다.
기본 Task를 사용할 때, 무거운 연산을 위해 Task.Run 등으로 백그라운드 스레드를 생성하는 경우가 많습니다. 이때 백그라운드 스레드에서 유니티 컴포넌트(UI, Transform 등)를 건드리면 에러가 발생합니다.
반면 코루틴의 경우, 태생적으로 유니티의 생명주기 안에서만 동작하기 때문에 고민이 필요 없다는 장점이 있습니다. - 생명주기의 자동 동기화
Task 기반의 비동기 작업은 유니티와 무관하게 동작하기 때문에 특정 오브젝트가 파괴되었는데도 계속 연산하다 오류를 낼 수 있습니다. 이를 막으려면 매 번 CancellationToken을 수동으로 관리해 주어야 합니다.
반면 코루틴의 경우 MonoBehaviour을 상속받은 오브젝트에 의해서만 실행되기 때문에, 오브젝트가 사라지면 자연스럽게 같이 사라지게 됩니다. (마찬가지로 SetActive(false)에서도 실행이 멈춥니다)
4. UniTask
그렇다면 Async / Await의 직관적인 코드 작성 ( 리턴 값 반환 ) 과 Coroutine의 유니티 친화적 장점을 합친다면 더 편하고 좋은 비동기 프로그래밍 방식이 나올 것입니다. 이것이 바로 UniTask입니다. UniTask는 다음과 같은 강점이 있습니다.
- GC의 감소
기본 Task는 참조 타입(Class)이므로 생성할 때마다 Heap에 생성되게 되고, 이는 GC를 호출하여 프레임 드랍을 유발합니다.
예를 들어 총알이 생성되고 3초뒤에 사라지는 코드를 Task로 작성하게 된다면 총알이 생성되는 수 만큼의 클래스가 생성되어 Heap 영역에 올라가게 되고, 이는 프레임 드랍을 유발합니다.
반면 UniTask는 값 타입 (Struct)로 설계되어 Stack 영역에서 생성 및 즉시 소멸되므로 추가적인 메모리 할당이 필요 없습니다.
- 메인 스레드로의 안전한 복귀
다음과 같은 코드로 메인 스레드로 이동이 가능합니다
기본 Task는 유니티에 최적화되어 있지 않기에 UnitySynchronizationContext라는 과정을 거쳐야 하지만, UniTask는 코어 엔진 루프에 직접 기생하기 때문에 더욱 효율적입니다.public async UniTask CalculateAndApplyUIAsync() { // 1. 무거운 연산은 백그라운드 스레드 풀에서 실행 (게임 프레임 방어) int result = await Task.Run(() => ComplexMath.Calculate()); // 2. 엔진 컴포넌트(UI)에 접근하기 위해 안전하게 메인 스레드로 제어권 복귀 await UniTask.SwitchToMainThread(); // 3. 메인 스레드 위이므로 UI 조작 시 에러가 발생하지 않음! uiText.text = $"결과: {result}"; } - 안전한 작업 취소
비동기 작업 도중 게임 오브젝트에 변화가 생긴다면, 다음과 같은 코드로 방어할 수 있습니다.
// 현재 스크립트(오브젝트)가 파괴될 때 비동기 작업도 함께 안전하게 즉시 취소됨
await UniTask.Delay(3000, cancellationToken: this.GetCancellationTokenOnDestroy());
기본 Task를 사용한다면 다음과 같은 과정을 거쳐야 합니다.
private CancellationTokenSource cts;
void Start() {
cts = new CancellationTokenSource(); // 할당 발생
_ = MyTaskAsync(cts.Token);
}
// 개발자가 실수로 OnDestroy를 안 쓰거나 cts.Cancel()을 빼먹으면 에러 폭탄 발생!
void OnDestroy() {
if (cts != null) {
cts.Cancel();
cts.Dispose();
}
}
'개념공부' 카테고리의 다른 글
| C# Type (0327 수정) (0) | 2026.03.26 |
|---|---|
| C# 프로퍼티(Property) (0) | 2026.03.14 |
| Delegate,Action,Event,UnityEvent (0) | 2026.03.04 |
| 동적 계획법 (Dynamic Programming, DP) (0) | 2025.03.19 |
| SocketIO 관련 정리 (0) | 2025.03.12 |