본문내용 바로가기 주메뉴 바로가기
닫기

유니티 스퀘어

Unity 6 Awaitable로 깔끔한 비동기 프로그래밍 구현하기

관련주제
  • #async
  • #unity6
  • #비동기
2024.10.10


 


 이 아티클에서는 Unity 6에 새로 추가된 기능인 Awaitable을 통해 async/await 문법을 사용하는 방법을 소개합니다. 또한 여러분의 유니티 프로젝트에서 async await을 적극적으로 활용해야 하는 이유와 유니티 Awaitable의 기술적인 배경을 설명합니다. 

기존 유니티 비동기 프로그래밍: 불편함과의 싸움

기존에 유니티에서 비동기 프로그래밍을 구현하는 가장 흔한 방법은 다음 두 가지였습니다.
· 코루틴 사용
· 콜백 체인

그러나 기존 방법은 복잡한 코드를 만들기 쉬워 개발자에게 최적의 선택지는 아니었습니다. 콜백과 코루틴을 사용한 예시를 통해 이를 확인할 수 있습니다.

다음 코드는 어드레서블에서 비동기로 애셋을 로드하는 Addressables.LoadAssetAsync()를 사용하여 에셋을 로드하는 예시입니다.

public IEnumerator GetAssetRoutine(string assetName)
{
    var assetLoadOperation = Addressables.LoadAssetAsync(assetName);
    yield return assetLoadOperation;
    var loadedAsset = assetLoadOperation.Result;
    // 로드된 애셋을 가지고 어떤 처리를 실행...
}

public void Start()
{
    StartCoroutine(GetAssetRoutine("boat"));
}


그러나 코루틴은 코드의 실행 결과물을 전달하는데 매우 불편합니다. 코루틴은 IEnumerator를 반환합니다. 따라서 함수의 리턴 값을 명시적으로 전달받아 사용하기 어렵습니다.

위 코드의 경우 Start() 메서드에서 GetAssetRoutine(“boat”)를 통해 로드한 보트 애셋을 직접 전달받아 사용할 방법이 없습니다.

따라서 다음과 같이 GetAssetRoutine() 메서드에 비동기 작업 완료 시 실행할 콜백을 추가하도록 코드를 수정할 수 있습니다.


public IEnumerator GetAssetRoutine(string assetName, Action onAssetLoaded)

{
    var assetLoadOperation = Addressables.LoadAssetAsync(assetName);
    yield return assetLoadOperation;
    var loadedAsset = assetLoadOperation.Result;
    // 로드된 애셋을 가지고 어떤 처리를 실행

    onAssetLoaded(loadedAsset);
}


public void Start()
{
    StartCoroutine(GetAssetRoutine("boat", (loadedAsset) => {

        DoSomething(loadedAsset);

    });
}


즉, async 명령어를 사용하지 않는 구현에서는 위와 같이 결과 값을 전달받아 사용할 콜백을 코루틴 입력으로 전달하는 등을 다른 간접적인 방법을 통해 코루틴의 결과를 받아야 합니다.

콜백을 사용하여 비동기를 구현하는 방식은 코드를 직관적이지 않은 복잡한 코드를 만들어냅니다.

예를 들어 위 구현에서 DoSomething()이 비동기 작업을 수행하므로 DoSometing() 내부의 비동기 작업이 완료되는 타이밍에 이어서 DoSomething2()를 실행하고 DoSomething2()의 비동기 처리가 완료되면 DoSomething3()를 실행해야 하는 상황을 가정해 보겠습니다.

그러면 코드는 다음과 같이 복잡해집니다.


public IEnumerator GetAssetRoutine(string assetName, Action onAssetLoaded)

{
    var assetLoadOperation = Addressables.LoadAssetAsync(assetName);
    yield return assetLoadOperation;
    var loadedAsset = assetLoadOperation.Result;
    // 로드된 애셋을 가지고 어떤 처리를 실행

    onAssetLoaded(loadedAsset);
}


public void Start()
{
    StartCoroutine(GetAssetRoutine("boat", (loadedAsset) => {

        DoSomething(loadedAsset, (result) => {

            DoSomething2(result, (result) => {

                DoSomething3(result, (result) => {

                // 어떤 처리를 계속 이어서 실행…

            });

        });

    });
}


이렇게 코드가 복잡해지는 문제 외에도 코루틴을 사용하는 기존 방법은 다음과 같은 문제가 있습니다.

· 메모리 할당 오버헤드

코루틴 함수를 실행할 때마다 IEnumerator 타입의 객체가 생성되어 메모리 할당이 발생합니다.

· 멀티스레드 사용 불가

코루틴은 플레이어 루프를 통해 분할 실행됩니다. 즉 코루틴의 코드들은 메인스레드에서만 실행됩니다.

유니티 API들은 대부분 메인스레드에서만 정상 동작합니다. 따라서 다른 방식으로 구현한 유니티 비동기 코드들도 일반적으로는 메인스레드에서 동작합니다. 하지만 원한다면 백그라운드 스레드를 사용할 수 있는 Awaitable와 달리 코루틴은 반드시 메인스레드에서만 동작합니다.

C# async await 사용

C#의 async await
비동기 프로그래밍을 하는 가장 우아한 방법은 다음과 같이 C#의 async await 키워드를 사용하는 것입니다. 위의 복잡했던 코루틴과 콜백 예시의 코드를 async await으로 다음과 같이 더 간결한 코드로 옮길 수 있습니다.

public async Task GetAssetAsync(string assetName)
{
    var assetLoadOperation = Addressables.LoadAssetAsync(assetName);
    await assetLoadOperation.Task;
    return loadedAsset = assetLoadOperation.Result;
}


public async void Start()
{
    var loadedAsset = await GetAssetRoutine("boat");

    await DoSomethingAsync1(loadedAsset);

    await DoSomethingAsync2(loadedAsset);

    await DoSomethingAsync3(loadedAsset);
}


async와 await 키워드를 사용하면 현재 코드가 어떤 작업을 대기하고 있는지 깔끔하고 명확하게 표현할 수 있습니다. Callback Hell도 피할 수 있으며, 비동기 작업이 반환한 결과값을 await을 통해 바로 전달받을 수 있습니다.

유니티는 이전 버전에서도 async/await 키워드를 지원했습니다. 다만 다음 문제가 있어 자주 사용되지 않았습니다.

· 유니티 게임 오브젝트의 라이프사이클이나, 플레이어 루프의 각 이벤트 타이밍을 감지할 수 없었습니다.
· async Task 메서드를 호출할 때마다 메모리 할당이 발생합니다.
· AsyncOperation들을 await 할수 없었습니다.

이러한 문제는 Awaitable 타입을 통해 해결할 수 있습니다. 같은 코드에서 다음과 같이 Task 타입 대신 Awaitable 타입을 사용하여 async 메서드를 작성함으로써 위 문제를 해결하면서도 async, await 키워드를 사용할 수 있습니다.

public async Awaitable GetAssetAsync(string assetName)
{
    var assetLoadOperation = Addressables.LoadAssetAsync(assetName);
    await assetLoadOperation.Task;
    return loadedAsset = assetLoadOperation.Result;
}


public async void Start()
{
    var loadedAsset = await GetAssetRoutine("boat");

    await DoSomethingAsync1(loadedAsset);

    await DoSomethingAsync2(loadedAsset);

    await DoSomethingAsync3(loadedAsset);
}


Awaitable 타입 소개

Awaitable 타입은 유니티에서 Task를 대체하여 비동기 작업을 표현하는 타입으로, 유니티가 추적할 수 있는 타입입니다. Awaitable은 Task와 비교하여 유니티 개발 환경에 최적화되어 설계되었습니다.

Awaitable 풀링

Awaitable 타입은 기존 Task 타입과 달리 풀로 관리됩니다. 따라서 async 메서드를 실행할 때마다 Task 타입을 위한 메모리 할당이 발생하는 문제를 해결합니다. 따라서 더 나은 성능으로 async 메서드를 사용할 수 있습니다.

동일한 Awaitable 인스턴스를 여러 번 await 해서는 안된다는 것에 주의해야 합니다. 예를 들어 Task 타입을 사용한 다음 타입을 살펴봅시다.

async Task TaskExample()
{
    Debug.Log("Starting Task...");
    await Task.Delay(1000);
    Debug.Log("Continuing Task...");
}

async Task StartTaskExample()
{
    var taskInstance = TaskExample();
    await taskInstance; // 첫 번째 await
    await taskInstance; // 두 번째 await, 정상 동작
}


StartTaskExample()을 실행했을때 같은 Task 인스턴스를 두번 await해도 코드는 정상 동작합니다. 첫번째 await에서 해당 Task가 이미 완료된 상태이므로 두번째 await은 즉시 실행이 완료됩니다.

이번에는 Awaitable을 사용한 경우를 살펴봅시다.

async Awaitable AwaitableExample()
{
    Console.WriteLine("Starting Awaitable...");
    await Awaitable.WaitForSecondsAsync(1f);
    Console.WriteLine("Continuing Awaitable...");
}

async Awaitable StartAwaitableExample()
{
    var awaitableInstance = AwaitableExample();
    await awaitableInstance; // 첫 번째 await

    // 이 시점에서 awaitableInstance는 풀로 반환되었을 수 있습니다.
    await awaitableInstance; // 두 번째 await, 비정상 동작 (풀에서 재사용된 인스턴스 가능성)
}
 

StartAwaitableExample()을 실행했을 때, 처음 await으로 완료된 awaitableInstance는 풀로 돌아가게 됩니다.

이미 await한 awaitableInstance를 다시 await하려고 할 때, 해당 Awaitable 인스턴스가 사실 다른 곳에 실행된 async 메서드의 반환 값으로 이미 사용 중일 수 있습니다.

이로 인해 예상하지 못한 동작이 일어날 수 있습니다. 예를 들어 awaitableInstance가 본래 표현하던 작업이 완료가 되었음에도, 두 번째 await에서는 awaitableInstance가 다른 비동기 메서드로 인한 작업을 표현하고 있는 상황을 고려해야 합니다.

따라서 본래라면 두 번째 await 코드 실행은 즉시 완료되어야 하지만, 그러지 못하고 다른 작업을 대기하게 될 수 있습니다.

이는 정상적인 동작이 아니며, 최악의 경우 데드락 상태를 유발할 수 있습니다. 이 동작을 좀 더 자세히 살펴봅시다. 다음과 같은 코드 작성을 가정해보겠습니다.

async void Start()
{
  Awatiable awaitableInstance1 = TestAsyncMethod();
  await awaitableInstance1;
 
  Awatiable awaitableInstance2 = TestAsyncMethod();
 
  // awaitableInstance1 and awaitableInstance2 is same? - YES
  if(awaitableInstance1 == awaitableInstance2){
      Debug.LogError("awaitableInstance1 and awaitableInstance2 is SAME instance");
  }
 
  // await awaitableInstance1 is same with

   // await awaitableInstance2
  await awaitableInstance1; // SO DON'T DO THIS!


만약 Task 타입을 사용했다면 awaitableInstance1과 awaitableInstance2는 다른 인스턴스여야 합니다.

하지만 Awaitable을 풀을 통해 재활용하므로 awaitableInstance1와 awaitableInstance2는 같은 인스턴스이며 위 코드를 실행하면 다음 에러 로그 코드가 실행될 것입니다.

Debug.LogError("awaitableInstance1 and awaitableInstance2 is SAME instance"); 

이는 awaitableInstance1을 재차 await할 경우, 실제로는 awaitableInstance2를 await하는 것과 같은 효과를 발생시켜 의도하지 않은 동작으로 이어질 수 있음을 뜻합니다.

Awaitable로 플레이어 루프 대기

Awaitable의 다른 특징은 유니티 플레이어 루프의 각 타이밍을 대기할 수 있다는 것입니다.

예를 들어 기존 async 메서드에서는 Task.Delay()를 사용하여 시간을 기다릴 수 있었지만 이 시간은 플레이어 루프의 영향을 받지 않았습니다.

따라서 예를 들어 Time.timeScale 값을 변경하여 게임 시간의 배속을 두배 느리게해도 Task.Delay()로 대기하는 시간은 두배 늘어나지 않는 문제가 있었습니다.

이 문제는 Awaitable.WaitForSecondsAsync() 함수로 해결 할 수 있습니다. 만약 게임 시간이 아닌 실제 시간을 사용하여 대기하고 싶다면 기존의 Task.Delay()를 사용하면 됩니다.

using System.Threading.Tasks;
using UnityEngine;

public class AwaitableExample : MonoBehaviour
{
    async void Start()
    {
        Debug.Log("Starting... Time: " + Time.time);
        Time.timeScale = 2f;


        // Task.Delay를 사용한 대기 (실제 시간 기준)
        await Task.Delay(2000);
        Debug.Log("Task.Delay Completed. Time: " + Time.time);

        // Awaitable.WaitForSecondsAsync를 사용한 대기 (게임 시간 기준)
        await Awaitable.WaitForSecondsAsync(2f);
        Debug.Log("Awaitable.WaitForSecondsAsync Completed. Time: " + Time.time);


        Time.timeScale = 1f;
    }
}
 
 


이외에도 Awaitable은 다음과 같이 플레이어 루프의 각 타이밍을 기다릴 수 있는 정적 메소드들을 제공합니다

· EndOfFrameAsync : 현재 프레임에 대한 모든 다른 유니티 서브 시스템의 동작이 완료된 다음 실행 재개
· FixedUpdateAsync : 다음 FixedUpdate 프레임 시점까지 대기
· NextFrameAsync : 다음 프레임 시점까지 대기


메인스레드와 백그라운드 스레드 스위

기본적으로 유니티에서 실행되는 async Task 메서드들은 모두 메인스레드에서 동작합니다. 즉 여러분들이 MonoBehaviour 스크립트의 Start() 나 Update() 등에서 실행하는 async 메서드들은 기본적으로는 메인스레드에서 실행됩니다.

· 이에 대한 기술적 배경 설명이 궁금하다면 UnitySynchronizationContext에 대한 유니티의 기술 문서들이나 해당 타입의 구현을 살펴볼 수 있습니다.

이는 다수의 유니티 API들이 메인스레드에서만 안전하게 실행될 수 있기 때문입니다. 그러나 필요에 따라 Awatiable은 다음 코드를 통해 await 이후 이어지는 코드(연속작업)들이 실행될 스레드를 백그라운드 스레드와 메인스레드 사이에서 쉽게 변경할 수 있게 합니다.

· await Awatiable.MainThreadAsync();
· await Awatiable.BackgroundThreadAsync();

예를 들어 다음 코드를 살펴봅시다.

public class ThreadSwitchExample : MonoBehaviour
{
  async void Start()
  {
      await DoSomethingAsync();
  }
 
  async Awaitable DoSomethingAsync()
  {
      // 백그라운드 스레드에서 무거운 작업 실행
      var result = await DoSomethingHeavyBackgroundAsync();

      // 메인 스레드로 전환
      await Awaitable.MainThreadAsync();
     
      foreach (var position in result)
      {
          var go = new GameObject();
          go.transform.position = position;
      }
  }
 
  async Awaitable DoSomethingHeavyBackgroundAsync()
  {
      // 백그라운드 스레드로 전환
      await Awaitable.BackgroundThreadAsync();

      // 수학적으로 헤비한 연산 (백그라운드 스레드)
      var results = new Vector3[10000];
      for (var i = 0; i < results.Length; i++)
      {
          results[i] = new Vector3(
              Mathf.Sin(i),
              Mathf.Cos(i),
              Mathf.Tan(i)
          );
      }

      return results;
  }
}


예제 코드에서는 수학적으로 헤비한 연산인 여러 Vector3의 삼각함수 연산을 백그라운드에서 실행했습니다. 그리고 연산 결과를 받은 다음에는 메인스레드로 전환하고 남은 연속 작업들을 실행합니다.

이 연산의 결과를 받은 직후에 실행될 코드들은 Transform의 위치를 수정하는 등 메인스레드에서만 동작할 수 있는 유니티 API들을 호출하고 있기 때문입니다.

이런식으로 퍼포먼스 헤비한 연산들을 백그라운드로 스레드로 전환할 수 있습니다. 예를 들어 이미지의 픽셀들을 CPU에서 순차적으로 읽어 변형하는 처리를 백그라운드 스레드에서 실행하는 것을 생각해볼 수 있습니다.

다만 대부분의 유니티 API들은 메인스레드에서만 실행가능하므로, 백그라운드 스레드에서는 유니티 오브젝트가 아닌 타입들을 주로 다루게 된다는 것에 유의합니다.

또한 멀티스레드를 활용하여 수학적으로 헤비한 연산을 극단적으로 최적화하여 실행할 필요가 있을 때는 async 메서드가 아닌 JobSystem과 버스트컴파일러를 사용하는 것이 권장됩니다.

성능 상 주의점
백그라운드 스레드에서 메인스레드로 전환하는 경우 이어지는 코드 실행이 다음 Update() 이벤트 시점까지 지연됩니다.

즉 백그라운드 스레드에서 메인스레드를 전환할때 마다 코드가 다음 프레임을 기다려야하기 때문에 백그라운드 스레드에서 메인스레드를 너무 자주 전환하는 것은 성능상 권장되지 않습니다.

정리

이 아티클에서는 Unity 6에 새로 소개된 Awaitable 타입을 다루었습니다.
Awaitable을 사용함으로써 여러분들의 비동기 처리 코드들을 좀 더 간결하고 나은 성능을 내도록 개선할 수 있습니다.










 














Unity Square 로그인
Unity MWU Korea Awards 2021 TOP 36 투표와 관련하여, 본인의 개인정보를 유니티테크놀로지스코리아 유한회사(이하 ‘회사‘)에서 수집 및 이용하는 것에 대해 동의합니다.

- 단, 관계법령의 규정에 의하여 보전할 필요가 있는 경우, 일정 기간 동안 개인정보를 보관할 수 있습니다. 그 밖의 사항은 회사의 개인정보취급방침을 준수합니다.
- 개인정보 수집/이용에 동의하지 않을 수 있으나, 미동의시 이벤트에 참여가 불가능합니다.
개인정보 수집 항목 이름, 휴대폰번호, 이메일
수집 목적 어뷰징 등을 통한 부정 투표 방지 및 이벤트 당첨, 경품 발송
보유기간 투표 종료 후 3개월 이내 파기
본 이벤트의 당첨자 추첨 및 배송, 응모 및 당첨자 경품 배송관련 상담 업무 등은 슈퍼와이 주식회사, 피엠지 아시아에 위탁됩니다.

- 개인정보 수집/이용에 동의하지 않을 수 있으나, 미동의시 이벤트에 참여가 불가능합니다.
위탁업체명 위탁업무
슈퍼와이 주식회사 TOP 36 투표 참여자 정보 처리 및 관리
피엠지 아시아 TOP 36 투표 참여자 문의/답변 대응 및 경품 발송
확인 발표자료 신청하기
닫기