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 메서드는 이제 다음과 같다.

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


작성중 …



📢 알림

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