Unity에서 흔하게 사용하는 Coroutine과 Task.

그동안 너무 제대로 알지 못하고 사용한 것 같아, 오늘은 이 두 개념에 대해 상세히 조사해보는 시간을 가져보았다.




🔷 비동기 프로그래밍 ( Asynchronous Programming )

코딩을 하면서, 비동기 프로그래밍 ( Asynchronous Programming ) 이라는 말을 많이 들어보았을 것이다. 본 주제를 진행하기 앞서, 각 항목별로 어떤 개념인지 살펴보고 가도록 하겠다.

비동기 프로그래밍은 프로그램의 실행 흐름을 비동기적으로 처리하여, 하나의 작업이 완료될 때까지 기다리지 않고 다른 작업을 수행할 수 있도록 하는 프로그래밍 방식이다.

비동기 프로그래밍은 현대 소프트웨어 개발에서 중요한 개념 중 하나이다. 이는 프로그램이 여러 작업을 동시에 처리할 수 있게 하여, 효율성과 속도를 크게 향상시킬 수 있기 때문이다. 이에 관한 적절한 예시가 MS Learn에 있는데, 아래와 같이 아침을 준비하는 방식에 대한 예시이다.

동기 프로그래밍 방식
비동기 프로그래밍 방식

아침 식사를 준비하기 위해 커피와 계란 후라이, 베이컨 구이, 잼 바른 토스트, 주스 한 컵이 필요하다고 했을 때, 주방이 여유롭다고 가정하면 보통 요리를 순서대로 하나씩 준비하지 않는다. 먼저 커피를 따른 후, 계란 후라이를 하며 다른 화구에서는 베이컨을 굽고, 토스터기에 빵을 넣는다. 재료들이 가열되는 동안 할게 없으니 오랜지 주스를 따른다.

그럼 우리는 왜 이렇게 행동할까? 바로, 시간이 오래 걸리는 작업을 계속 붙잡고 있으면 병목이 생기는 것을 무의식적으로 알고 있기 때문이다. 왼쪽 방식에서는 계란 후라이가 구워질 때 계란 후라이를 조리하느라 아무것도 못하고 대기하기에, 오른쪽 방식보다 시간이 더 오래 걸리는 것이다.

비동기 프로그래밍도 똑같다. Main Thread에서 모든 작업을 처리하게 된다면, 시간이 오래걸리는 작업이 병목이 되어 프로그램 전체가 멈추는 느낌을 받게 될 수 있다. 따라서 이를 해결하기 위해 Main Thread 대신 다른 Thread에서 시간이 오래걸리는 작업을 처리하는 것이다. 실제로 UI 어플리케이션의 경우, 실행에 50ms 이상 소요되는 메서드는 Async로 만들어 처리하는 것을 권장하고 있다. ( ex. I/O 작업, 네트워크 작업 등 )




🔷 C# 에서의 비동기 프로그래밍

🔶 Thread

그렇다면 C#에서는 이 비동기를 구현하기 위해 어떻게 해야하는가?

(앞에서 약간 스포했지만) 바로 Thread이다. Thread는 익히 알다시피, 프로세스내의 실행 흐름 단위라고 볼수 있는데 오래 걸리는 작업은 새로운 Thread를 만들어 실행하게 되면 Main Thread의 부담이 줄어들 수 있게 된다. Thread를 만드는 방법은 여러가지가 있는데, 대표적으로는 Thread객체를 만드는 방법이 있다.

using System;
using System.Threading;

class Program 
{
    public static void Foo() 
    {
        Console.WriteLine("Foo Start");
        Thread.Sleep(2000);
        Console.WriteLine("Foo End");
    }
    
    public static void Main() 
    {
        Console.WriteLine("Main Start");
        Thread t = new Thread(Program.Foo);
        t.Start();
        Console.WriteLine("Main End");
    }
}

위 프로그램을 실행하면, 다음과 같이 출력된다.

Main Start
Foo Start
Main End
Foo End

Thread의 특징으로는, 운영체제의 최소 실행단위로서 개발자가 직접 제어 해야 한다는 것이 있다. 생성과 시작, 중지 및 소멸까지도 직접 컨트롤 해야 한다는 뜻은, 그만큼 정밀한 제어가 가능하다는 뜻이기도 하지만 동기화 문제, 데드락 등 여러 골치아픈 문제까지 직접 신경써야 한다는 의미이므로 요즘에는 잘 사용되지 않는다.


🔶 Task

그리하여, .NET Framework 4 부터 도입된 비동기 작업을 추상화한 클래스인 Task가 도입되었다. Task 내부적으로는 ThreadPool 을 사용하여 비동기 작업을 지원하므로 개발자가 여러가지 골치아픈 문제를 신경쓰지 않아도 된다는 장점이 있다.

  Thread Task
정의 OS의 최소 실행 단위 비동기 작업을 추상화한 클래스
효율성 직접 제어 Thread pool 기반의 효율적 스레딩
사용성 생성, 시작, 중지 등 직접 관리 필요 세부 작업 내부 처리, 로직에 집중 가능
코드 복잡성 동기화 문제, 데드락 등 해결 필요 Task<T> 형태로 반환 가능
비동기 지원 직접 지원 불가 async/await 문법 지원

📚 참고자료 :

개발자가 비동기 작업을 구현할 때, Thread 보다 손쉽게 구현할 수 있도록 지원해주는 클래스라 보면 되겠다.




🔷 Unity에서 비동기 프로그래밍

🔶 Thread-safe 하지 않은 Unity 엔진

주제를 시작하기 앞서, Unity의 큰 특징 중 하나를 짚고 넘어가고자 한다.

Unity는 단일 스레드를 기반으로 설계된 아키텍처이다. 이 말은 멀티 스레드를 전혀 지원하지 않는다 라는 말이 아니다. 단일 스레드 기반으로 설계 되었다는 뜻은 엔진 자체가 Thread-safe 하지 않다는 뜻으로, UnityEngine에서 정의한 Object들을 Main Thread에서 제어할 수 없다는 뜻이다. (마치 Single-Threaded Apartment 구조처럼, 해당 작업은 Main Thread에서만 접근이 가능하다.)


따라서 다음과 같은 작업은 Main Thread 외부에서 할 수 없다.

  • UnityEngine.object를 상속받은 객체 (GameObject, Texture2D 등) 을 생성, 삭제
  • 객체의 Transform 정보 (Position, Rotation, Scale …) 를 변경
public void Start()
{
    Task.Run(() => 
    {
        await MyAsyncFunc();
    });
}

private async Task<int> MyAsyncFunc()
{
    await Task.Delay(1000);
 
    this.transform.localPosition += Vector3.up;

    return 1;
}

Unity Engine 객체를 다른 Thread 또는 Task에서 다루려면 Dispatcher를 선언한 후 이용하는 등의 작업이 필요하다.

private Queue<Vector3> myQueue;

public void Start()
{
    myQueue = new Queue<Vector3>();
    Task.Run(() => 
    {
        await MyAsyncFunc();
    });
}

private void Update() 
{
    if(myQueue.count > 0)
    {
        var vec = myQueue.Dequeue();
        this.transform.localPosition += vec;
    }
}

private async Task<int> MyAsyncFunc()
{
    await Task.Delay(1000);
 
    myQueue.Enqueue(Vector3.up);

    return 1;
}



🔶 Coroutine

그렇다면, 코루틴이란 무엇인가?

Unity Documentation에 따르면, 다음과 같다.

🔎 Coroutines
  • A coroutine allows you to spread tasks across several frames. In Unity, a coroutine is a method that can pause execution and return control to Unity but then continue where it left off on the following frame.

코루틴을 사용하면 여러 프레임에 걸쳐 작업을 분산시킬 수 있습니다. Unity에서 코루틴은 실행을 일시 중지하고 Unity에 제어권을 반환한 다음 다음 프레임에서 중단된 부분부터 계속할 수 있는 메서드입니다.

코루틴은 메서드의 호출과 반환이 같은 프레임에 완료되어야 하는 일반적인 메서드들과 달리, 작업을 여러 프레임에 프레임에 분산시킬 수 있는 Unity에서 제공한 메서드 이다.


왜 이런 메서드가 필요할까? 다음 2가지 이유가 있을 수 있다.

  • 여러 프레임 단위에 걸쳐 처리해야하는 작업을 해야하는 경우
  • 한 프레임 단위에서 처리하기에는 너무 오래 걸리는 경우


💠   CASE 1 . 여러 프레임 단위에 걸쳐 처리해야하는 작업을 해야하는 경우

어떤 Material의 알파값이 조금씩 사라지며 사라지는 FadeOut 효과를 주기 위해 아래와 같은 코드가 있다고 하자.

다음은 코루틴 작업의 한 예 이다.

public Renderer TestRenderer;

private void Start()
{
    Fade();
}

private void Fade()
{
    var c = TestRenderer.material.color;
    for ( float alpha = 1f ; alpha >= 0 ; alpha -= 0.1f )
    {
        c.a = alpha;
        TestRenderer.material.color = c;
    }
}

위 코드는 언듯 잘 동작할 것 처럼 보이나, 그렇지 않다. 왜냐하면 이 메서드를 실행하면 모든 for 구문이 한 프레임만에 순회하기 때문이다. 이 페이딩 효과를 보이게 하려면 Unity가 렌더링하는 중간 값을 표시하기 위해 일련의 프레임에 걸쳐 페이드의 알파를 줄여야 한다.


💠  CASE 2 . 한 프레임 단위에서 처리하기에는 너무 오래 걸리는 경우

비동기 프로그래밍이 필요한 이유와 같다. 파일 처리, 네트워크 처리 등 한 프레임에서 처리하기 너무 오래걸리는 경우, 이를 보통 메서드에서 대기하도록 처리하면 너무 오래걸려 프레임 드랍으로 이어지게 된다.

Unity Profiler로 확인할 수 있는 Frame Spike



코루틴은 C#의 Iterator를 활용해서 구현했다고 한다. Iterator는 배열과 같은 컬렉션을 단계적으로 순회하기 위해 만들어진 개념으로, Unity에서는 한 단계가 진행될 때마다 다음 프레임에서 실행되도록 하는 방식을 이 Iterator를 이용하도록 구현했다.

위에서 작성한 FadeOut 예제를 코루틴으로 구현한다면, 다음과 같다.

public Renderer TestRenderer;

private void Start()
{
    StartCoroutine(Fade());
}

private IEnumerator Fade()
{
    var c = TestRenderer.material.color;
    for ( float alpha = 1f ; alpha >= 0 ; alpha -= 0.1f )
    {
        c.a = alpha;
        TestRenderer.material.color = c;
        yield return new WaitForSeconds(0.1f);
    }
}
코루틴으로 구현한 알파값 처리


Coroutine을 모른다고 한다면, 위 상황을 어떻게 구현할 수 있을까? 아마 Task를 통해 비동기적으로 동작하게 할 수 있을 것이다.

public Renderer TestRenderer;

private void Start()
{
    Task.Run(() => FadeAsync());
}

// Async 메서드로 변경함
private async Task FadeAsync()
{
    var c = TestRenderer.material.color;
    for ( float alpha = 1f ; alpha >= 0 ; alpha -= 0.1f )
    {
        c.a = alpha;
        TestRenderer.material.color = c;
        await Task.Delay(100);
    }
}

실행해보면, 아무일도 일어나지 않는 모습을 볼 수 있다.

Task로 구현하자, 아무 일도 일어나지 않는 모습

Task에서 실행한 스레드에서 Exception이 발생한것은 아닐까? 라는생각에 Async 내부를 try-catch로 감싸보았다.

private async Task FadeAsync()
{
    // try-catch 추가
    try
    {
        var c = TestRenderer.material.color;
        for ( float alpha = 1f ; alpha >= 0 ; alpha -= 0.1f )
        {
            c.a = alpha;
            TestRenderer.material.color = c;
            await Task.Delay(100);
        }
    }
    catch ( System.Exception e )
    {
        Debug.LogError(e);
    }
}

그러자 다음과 같이 에러가 발생한 모습을 확인 할 수 있다.

IsPersistent can only be called from the main thread.

앞서 언급했듯이 Unity는 단일 스레드를 기반으로 설계된 아키텍처로, thread-safe 하지 않은 엔진이기 때문에 해당 Async 메서드에서 UnityEngine.Object 를 상속받은 객체를 제어하려고 하니 발생하는 Exception임을 알 수 있다.


이를 해결하는 대표적인 방법으로는 앞서 설명한대로 Main Thread에 Queue등을 이용해 Dispatcher를 만들어 두는 방법 등 이 있는데, 어떤 방법을 쓰던 비동기 메서드에서 UnityEngine 객체를 직접 호출 할 수 없는 것은 개발자들에게 귀찮은 일일 수 밖에 없다. 이 문제에 대해 Unity Engine은 Iteratoryield를 이용한 Coroutine을 만들어두었다.



💠 반복자와 yield

여기서 잠깐, Iterater는 뭐고, yield는 뭘까?

이를 설명하기 위해, 잠깐 Unity에서 벗어나 빈 콘솔 애플리케이션에서 키워드 yield가 왜 그렇게 특별한지 알아보도록 한다. 일단, IEnumerable<int> 를 반환하는 GetNumbers 메서드를 정의해 보도록하자. 이 메서드는 List를 할당한 다음 10번 Loop하며, 매 Loop 마다 현재 반복 횟수를 List에 추가한다. 그 후, Thread.Sleep 를 통해 1초 동안 대기한다. 모든 작업을 마친 후, List를 반환한다.

using System.Collections.Generic;
using System.Threading;
 
internal static class Program
{
    private static void Main()
    {
        IEnumerable<int> numbers = GetNumbers();
        foreach (var number in numbers)
        {
            Console.WriteLine(number);
        }
    }

    private static IEnumerable<int> GetNumbers()
    {
        var list = new List<int>();
 
        for (int iterator = 1; iterator <= 10; iterator++)
        {
            list.Add(iterator);
            Thread.Sleep(1000);
        }
 
        return list.ToArray();
    }
}

이 코드를 실행하면, 10초 동안 기다린 후에 1부터 10의 값을 한꺼번에 모두 출력한다. ( 마치, 위에서 작성한 FadeOut 코드의 첫번째 코드처럼 동작하는 것이다. ) 이는 컬렉션을 foreach로 탐색하려면 해당 컬렉션이 무엇인지 알아야 하기 때문인데, 위 코드에서는 메서드가 반환된 후에만 컬렉션이 무엇인지 알 수 있고, 메서드는 내부의 for 루프가 완료된 후에만 return 되므로 즉, Thread.Sleep을 각각 1초씩 10번 수행하게 되는 것이다.


코드를 조정하여 목록 생성과 마지막에 return 문을 제거해 보자. 대신 루프 안에 문을 yield return을 추가하고 반복 횟수를 return 하도록 변경했다. GetNumbers 메서드는 이제 다음과 같다.

using System.Collections.Generic;
using System.Threading;
 
internal static class Program
{
    private static void Main()
    {
        IEnumerable<int> numbers = GetNumbers();
        foreach (var number in numbers)
        {
            Console.WriteLine(number);
        }
    }
    
    // 이 부분 변경됨!
    private static IEnumerable<int> GetNumbers()
    {
        for (int iterator = 1; iterator <= 10; iterator++)
        {
            yield return iterator;
            Thread.Sleep(1000);
        }
    }
}

이렇게 변경할 경우, 1초에 한번씩 1부터 10까지의 숫자가 나오는 모습을 볼 수 있게 된다. 이는 yield를 사용하여 모든 for loop 동안 메서드의 호출자에게 제어권을 양보하는 것이다.



💠 IEnumerableIEnumerator

엥? 근데 이건 IEnumerable 에 대한 설명이 아닌가? Unity에서 Coroutine을 사용할땐 IEnumerable이 아니라 IEnumerator를 반환하던데?

맞다! Unity에서 코루틴을 사용하려면 IEnumerator 인터페이스를 반환해야 한다. IEnumerable 은 모든 “열거 가능한” 것들에 대한 기본 인터페이스이다. List, Array, Queue, Stack, Dictionary, … “열거 가능한” 모든 것은 IEnumerable을 구현한다. 반면에 IEnumerator 는 그러한 열거 가능한 것들을 어떻게 열거해야 하는지 정의하는 역할을 하는 인터페이스이다. 즉, 간단히 말해서 IEnumerable 은 값들의 컬렉션이며, IEnumerator 는 이런 컬렉션을 반복하는 역할을 하는 것이다.


하나 예를 더 들어보자. List와 같은 컬렉션들은 IEnumbable 인터페이스를 상속받고 있으며, 이 때문에 GetEnumerator 라는 메서드를 재정의해야만 한다. 이를통해 C#에서 자주 사용하는 문법인 foreach는 다음과 같이 작성할 수 있다.

foreach (int number in list)
{
    Console.WriteLine(number);
}

이는 C# 컴파일러에 의해 아래와 같이 컴파일 된다. 이번 주제에 대해 관련있는 부분만 떼어내면, 다음과 같아진다. (   전체 컴파일된 코드의 원본은 여기서 확인 할 수 있다. )

List<int>.Enumerator enumerator = list.GetEnumerator();
while (enumerator.MoveNext())
{
    Console.WriteLine(enumerator.Current);
}
enumerator.Dispose();

결국 foreach는 while문으로 컴파일 되는모습을 확인할 수 있는데, 이 while문은 List<int>Enumerator.MoveNext()가 true일 동안에만 Loop를 돌게 된다. 좀 더 자세하게 살펴보기 위해, List<T>Enumerator struct가 어떻게 구현되어 있는지 .NET Source code 페이지를 참고해보았다. 아래는 코드 전체 중 주제에 도움이 될만한 부분을 추린 것이다.

public struct Enumerator : IEnumerator<T>
{
    private readonly List<T> _list;
    private int _index;
    private T _current;
 
    public T Current => _current;
 
    public bool MoveNext()
    {
        if (_index < _list.Count)
        {
            _current = _list[_index];
            _index++;
            return true;
        }
 
        _index = _list.Count + 1;
        _current = default;
        return false;
    }
 
    // ... 생략 ...
}

위 코드에서, Enumerator가 현재 인덱스와 해당 인덱스의 값을 추적하는 것을 볼 수 있다. Current 속성은 _current 필드의 값만 반환하며, MoveNext를 호출할 때마다 인덱스가 목록 범위 내에 있는지 확인한다. 이를 통해, 위에서 본 while 루프는 단순히 열거자에게 다음 인덱스로 이동하도록 요청한 다음 Current에 액세스하는 코드임을 알 수 있다. 만약 _index가 목록의 끝에 도달한다면, MoveNextfalse를 반환하고 루프가 완료될 것이다.

Stack<T> 열거자는 뒤로 반복된다는 점을 제외하면 비슷한 방식으로 동작한다. StackEnumerator 의 소스 코드를 보면 361번째 줄에 --_index라는 가감연산을 하는 것을 볼 수 있다. 이것이 foreachGetEnumerator를 호출하는 이유이다. 다양한 컬렉션 유형을 다르게 열거해야 하기 때문이다.

그렇다면, 이것이 Coroutine과 어떤 관련이 있을까?



💠 코루틴과 yield

Unity Engine는 내부적으로 DelayedCallManager 라는 클래스가 있는데, 사용자가 StartCoroutine을 호출하게 되면 Unity는 자체 게임 루프가 반복될 때 마다 MoveNext를 호출해야 하는 코루틴 컬렉션에 IEnumerator를 추가하게 된다. 다음은 실행을 일시 중지해야 하는 기간을 나타내는 TimeSpan을 허용하는 WaitForTime의 예이다.

생성자는 현재 시간에 TimeSpan 매개 변수를 추가하여 종료 시간을 계산한다. 그런 다음 MoveNext에서는 _endTime이 미래인 경우 true를 반환하여 종료 시간을 충분히 대기했는지 여부를 간단히 확인한다.

using System;
using System.Collections;
 
public class WaitForTime : IEnumerator
{
    private readonly DateTime _endTime;
    public object Current => null;
 
    public WaitForTime(TimeSpan time)
    {
        _endTime = DateTime.Now + time;
    }
 
    public bool MoveNext() => DateTime.Now < _endTime;
 
    // ... 생략 ...
}

아래는 Unity Documentation 의 일부이다.

🔎 How do Coroutine work?
  • … The C# compiler auto generates an instance of a class that backs coroutines.

… C# 컴파일러는 코루틴을 지원하는 클래스의 인스턴스를 자동으로 생성합니다.

  • Unity then uses this object to track the state of the coroutine across multiple invocations of a single method.

그런 다음 Unity는 이 객체를 사용하여 단일 메서드의 여러 호출에서 코루틴 상태를 추적합니다.

  • Because local-scope variables within the coroutine must persist across yield calls, Unity hoists the local-scope variables into the generated class, which remain allocated on the heap during the coroutine.

Coroutine 내의 로컬 변수는 yield 호출 전반에 걸쳐 지속되어야 하기 때문에 Unity는 로컬 변수를 생성된 클래스에 저장하고 코루틴 중에 힙에 할당된 상태로 유지합니다.

  • This object also tracks the internal state of the coroutine: it remembers at which point in the code the coroutine must resume after yielding.

이 객체는 또한 Coroutine의 내부 상태를 추적합니다. 즉, yield 호출 후 Coroutine 이 다시 시작되어야 하는 코드의 어느 지점을 기억합니다.


이를 통해, 우리는 다음과 같이 코루틴을 작성할 수 있게 된다.

private IEnumerator MySimpleCoroutine()
{
    Debug.Log("Hello from the coroutine!");
    yield return new WaitForTime(5);
    Debug.Log("Slept for 5 seconds!");
}
5초 동안 기다리는 코루틴

이때, 중간에 Debug.Log 호출을 입력한다면, 똑같은 코드도 다음과 같이 보이는 것을 확인할 수 있게 된다.

중간 호출 결과를 Log로 남겼을 때

MoveNext 호출에 대한 메시지 수가 매우 빠르게 수천 개에 달하는 것을 볼 수 있다. 이는 Unity가 매 프레임 컬렉션의 모든 코루틴에서 MoveNext를 호출하기 때문이다.



💠 코루틴은 멀티스레드가 아니다

이때쯤 까지 봤으면 알 수 있듯이, 코루틴은 모두 Main Thread에서 동작을 하고 있다.

yield return에 사용되는 클래스들의 이름( WaitForSeconds, WaitForSecondsRealtime, WaitForEndOfFrame 및 자체 WaitForTime )에 Wait라는 단어가 포함되어 있음에도 불구하고 실제로 대기가 진행되지 않는다. MoveNext는 매 프레임마다 호출되며 각 호출 사이에는 일시 정지가 없는것이다. (위에서 gif로 설명한 콘솔에서 이에 대한 증거를 볼 수 있다) Slept for 5 seconds! 앞에 멈춤 현상이 나타나는 유일한 이유는 yield 때문이다. 우리는 지속적으로 Unity의 게임 루프에 제어권을 넘겨주고 명령의 MoveNext가 true를 반환하는 한 제어권은 해당 지점 이상으로 넘어가지 않게 되는 것이다.



📚 참고자료 :



🔶 UniTask

💠 UniTask 개요

그럼 UniTask는 무엇인가? UniTask 는 Unity 엔진에서 사용하는 async-await 비동기 메서드를 효율적으로 지원하기 위한 서드파티 라이브러리로, 이제 거의 업계 표준으로 자리잡고 있다.

UniTask Git repository

설치는 UPM으로 진행하는것이 편한데, Git repo에 프로젝트에 적용하는 방법이 상세하게 설명되어 있으니, 참고하면 좋을 것 같다.

Unity에서 비동기 처리를 구현할 때, 기존의 C# Task 는 힙 할당으로 인해 GC(가비지 컬렉션) 부담이 크고, Unity의 메인 스레드와의 연동이 어려워 성능 저하를 유발할 수 있다. 이를 보완하기 위해 등장한 것이 바로 UniTask 이다. UniTask 는 구조체 기반으로 설계되어 힙 할당을 최소화하고, Unity의 프레임 루프와 자연스럽게 통합되어 효율적인 비동기 처리 를 가능하게 한다.​

또한, UniTaskawait UniTask.Yield , await UniTask.WaitForSeconds 등 Unity에 특화된 비동기 API를 제공하여, 기존 코루틴보다 더 직관적이고 예외 처리가 용이한 코드를 작성할 수 있게 해준다. 모바일이나 WebGL과 같이 리소스가 제한된 환경에서도 안정적인 성능을 유지할 수 있으며, CancellationToken 을 통한 작업 취소 관리도 지원하여 더욱 세밀한 제어가 가능하다.

예를들어, 1초마다 로그를 출력하는 기능 을 UniTask를 통해 비동기로 만든다면 아래와 같다.

private CancellationTokenSource cancellationTokenSource;

private void OnEnable()
{
    cancellationTokenSource = new ();
    LoopOneSecondAsync().Forget();
}

private void OnDisable()
{
    cancellationTokenSource?.Cancel();
    cancellationTokenSource?.Dispose();
    cancellationTokenSource = null;
}

private async UniTask LoopOneSecondAsync()
{
    try
    {
        while(cancellationTokenSource is {IsCancellationRequested : false})
        {
            await UniTask.WaitForSeconds(
                durtaion : 1f,
                cancellationToken : cancellationTokenSource.Token,
                cancelImmediately: true
            )
            Debug.Log("1초가 지났습니다!");
        }
    }
    catch(OperationCanceledException)
    {
        // 캔슬 토큰 발급됨
    }
}

코드를 살펴보면 알겠지만, 사실 Task 를 사용하는 것과 크게 다르지 않아보인다. 그렇다면 UniTaskTask 는 무엇이 다를까?



💠 UniTask vs Task

Task 는 C# 표준에서 제공하는 비동기 처리 도구로, 범용성이 높고 다양한 라이브러리와 쉽게 연동된다. 하지만 Unity 환경에서는 GC 부담이 크고, await Task.Delay() 같은 호출이 실제 프레임 흐름과 맞지 않아 게임 루프에 맞지 않는다는 단점이 있다. 특히 프레임 단위로 민감한 게임에서, Task의 힙 할당은 눈에 띄는 성능 저하로 이어질 수 있다.

반면 UniTask 는 Unity에 최적화된 경량 비동기 라이브러리로, 구조체 (struct) 기반으로 설계되어 GC를 거의 발생시키지 않으며, 코루틴을 대체할 수 있을 만큼 직관적이고 안정적인 비동기 흐름을 구현할 수 있다. 특히 모바일, WebGL 같이 리소스가 제한된 환경에서도 성능을 안정적으로 유지할 수 있는 점은 UniTask 의 큰 장점이다.

좀 더 엄밀히 정리하자면, 다음과 같다.

  Task
System.Threading.Tasks
UniTask
Cysharp.Threading.Tasks
GC 발생 높음 (boxing, allocation 많음) 매우 적음 (struct 기반)
성능 비교적 느림 (특히 모바일/저사양) 훨씬 빠름 (GC 부담 적음)
Unity 특화 아님 (Unity 메인스레드 접근 어려움) Unity용으로 설계됨 (메인 스레드 접근 쉬움)
타이머/딜레이 Task.Delay() 사용 (게임 루프와 무관) UniTask.Yield() 사용 (Unity 프레임 기반 타이밍)
대기 조건 일반 조건만 가능 YieldInstruction , AsyncOperation , WaitUntil 등 Unity 타입 지원
용량/오버헤드 클래스 (class) 기반 구조체 (struct) 기반
WebGL 지원 제한적 더 나은 호환성 제공

정리한 표를 보면 알 수 있듯이, UniTask 의 강점 대부분은 구조체 기반 이라는 특징에서 나온다. 그렇다면 왜 Task 는 그런 강점이 없고 UniTask 에만 있는 것일까?



💠 구조체 기반의 UniTask

앞서 설명했듯, UniTask는 구조체 기반이다. 이는 힙 영역에 할당되지 않는다는 뜻으로, GC에게 부담을 주지 않는 Zero Allocation 방식이다. 그렇다면 왜 이 좋은 구조를 Task 에서는 사용하지 않을까?

C#의 Task 는 기본적으로 참조 타입(class) 으로 설계되어 있다. 이는 비동기 작업이 단순히 값을 반환하는 게 아니라, 진행 중인 상태와 결과, 예외, 취소 정보 등을 모두 포함한 복합적인 객체 이기 때문이다. Task 에서는 이런 정보를 상태 머신으로 구현했는데, 이는 내부적으로 continuation 리스트, 예외 처리, 동기화 컨텍스트 등 복잡한 상태 추적을 요구하게 된다. 하지만 이런 구조는 값 타입으로 구현하기엔 무겁고 관리가 어렵기에, 참조 타입을 사용하는 것이다. (복사되면 상태 꼬임 가능성이 높아지기 때문!)

또한, 하나의 Task를 여러 곳에서 await하거나, 외부에서 그 작업의 상태를 추적할 수 있어야 할 때, 구조체(값 타입)처럼 복사되는 구조는 적합하지 않을 수 있다. 참조 타입은 모든 곳에서 동일한 작업 객체를 바라보게 되므로, 비동기 흐름이 일관되게 유지된다.

각종 비동기 메모리 비교


하지만 Unity 환경에서 비동기 작업은 대부분 일회성 이고, GC에 민감한 상황이 많다. UniTask 는 이런 Unity의 특수한 요구에 맞춰 설계된 값 타입(struct) 기반의 비동기 타입이다. 앞서 설명했듯 값 타입이기 때문에 힙 할당 없이 스택에서 처리되어, GC 부담이 거의 없고 성능적으로도 훨씬 유리하다. 특히 프레임 단위의 반복 호출이 많은 게임 루프에서는 작은 GC도 프레임 드랍의 원인이 될 수 있기 때문에, 구조체 기반의 비동기 구현이 효과적이다.

다만, 모든 상황에서 무조건 UniTaskTask 보다 낫다고 보긴 어렵다. 값 타입인 만큼 주의할 점도 있기 때문이다. UniTask 는 기본적으로 한 번만 await 하도록 설계되어 있고, 여러 곳에서 동시에 공유하거나 상태를 추적해야 할 경우에는 .Preserve() 등의 추가 처리가 필요하다.

또한 외부 라이브러리 연동 이나 CustomEditor 스크립트 처럼 Unity 엔진과 직접적인 연관이 없는 코드에서는 오히려 Task 가 더 자연스러울 수 있으며, UniTask 는 Unity 외부 환경에서는 사용할 수 없는 구조 이기 때문에, 프로젝트의 성격에 따라 두 도구를 병행해서 사용하는 것이 현실적인 접근일 수 있다. 중요한 것은 각 도구의 특성을 이해하고, 상황에 맞게 선택해야 하는 것이다.



💠 결론

결국은 TaskUniTask 의 차이는범용성 VS 성능 특화의 차이임을 알수 있다.

만약 .NET에서 struct로 Task 를 만들었으면 지금처럼 유연한 비동기 처리가 어려웠을 것이고,

Unity와 같은 특수한 환경에서는 struct 기반 UniTask 이 진가를 발휘하는 것이다.



📚 참고자료 :


📢 알림

본 포스트의 썸네일 이미지와 상단 배경 이미지는 GPT4를 통해 생성한 이미지를 후보정 한 것입니다.