본문 바로가기

Unity

유니티에서 async/await를 어떻게 쓰면 좋을까?

async/await는 비동기? 병렬?

C#의 async/await와 Task는 비동기를 표현한 것일까 병렬실행을 표현한 것일까? 이 점을 헷갈려하는 사람들이 아주 많다. 결론부터 말하면 Task는 비동기 작업을 표현하는 클래스이다. 그러나 병렬로 실행되기도 한다. 이 점 때문에 Task를 쓰면 멀티스레드를 사용하여 병렬 작업이 일어난다고 알고 있는 경우가 많다. 그러나 이것은 단순히 .NET에 포함된 Task의 구현이 스레드 풀을 사용하도록 되어 있어서 그렇기 때문이다. 


Task는 어디서 실행될까?

await SomethiingAsync();

async Task SomethingAsync()
{
    Console.WriteLine(Environment.CurrentManagedThreadId);
    await Task.Delay(1000);
    Console.WriteLine(Environment.CurrentManagedThreadId);
    await Task.Delay(1000);
    Console.WriteLine(Environment.CurrentManagedThreadId);
}

 

이 코드를 실행시켜보자.

그러면 대략 다음과 비슷하게 보인다.

실행 환경에 따라 구체적인 숫자는 달라질 수 있음

 

 

첫번째 await 전까지는 async 메서드를 호출한 쓰레드에서 실행되고, 그 후로는 어떤 별도의 쓰레드에서 실행된다. 지금은 첫번째, 두번째 await 뒤의 구문이 각각 같은 쓰레드에서 실행되었지만, 그 두개도 다른 쓰레드에서 실행될 수도 있다.


유니티에서 쓸 일이 있을까?

async 메서드를 실행시키는 기능은 SynchronizationContext에 달려 있다. 그런데 유니티에서는 UnitySynchronizationContext를 사용해서 단순히 async를 사용한다면 메서드의 모든 부분이 전부 주 스레드에서 실행되도록 한다. 이렇게 되면 결국 코루틴과 그다지 다를게 없어진다. 

 

그래서 유니티 문서에는 async/await를 지양하라고 명시하고 있다. 

 

 

Unity의 .NET 개요 - Unity 매뉴얼

Unity는 오픈 소스 .NET 플랫폼을 사용하여 Unity로 만드는 애플리케이션이 다양한 하드웨어 설정에서 실행될 수 있도록 합니다. .NET 플랫폼은 여러 언어와 API 라이브러리를 지원합니다.

docs.unity3d.com

 

그래도 코루틴 대신 async를 쓰고 싶은 경우가 생긴다. 대표적으로는 다음과 같은 경우들이다.

  1. 비동기 동작을 특정 GameObject에 묶이게 하고 싶지 않다.
  2. 코루틴에서 값을 리턴할 수 있는 방법이 없어 불편하다.
  3. 다른 외부 라이브러리, 혹은 닷넷 메서드가 Task를 사용하고 있어 불편하다.

 

그 외에도 여러 합리적인 이유가 있지만, 장기적으로는 그래도 Task 및 async를 사용하지 않는 것이 좋다고 본다. 해결하기 어려운 버그를 유발하는 가능성보다는 당장 약간 불편해도 버그를 덜 발생시키는 편이 낫기 때문이다. 특정 GameObject에 묶이지 않는 건 장점이기도 하지만 실행 중 GameObject가 삭제되는 등의 유효한 상황인지 검사하기가 번거롭고, 이 부분에서 버그가 발생하면 수정하기가 매우 곤란할 때가 있다.

 

코루틴에서 직접 결과 값을 얻어올 수 있는건 확실히 불편하지만, 외부 변수나 콜백으로 얼마든지 회피할 수 있다. 3번의 경우는 케이스 바이 케이스로 고려해야 하지만, 보통은 결정적인 문제로 작용하지는 않는다. 

또한 별도의 이야기로, async/await에 대해 피상적인 이해만 하고 사용하는 경우가 많아서 팀 작업을 할 때 가독성 및 유지보수성을 크게 해치는 가능성도 고려해야 한다. 일반적으로 새로 입문하는 유니티 프로그래머의 경우 이러한 사항을 잘 모르기 마련이므로, 학습비용에 대한 부분을 충분히 고려해야 한다. 어떤 새로운 기능을 도입할 때 도입으로 얻는 이득보다 팀 전체적인 학습비용 및 잘못 사용하여 생기는 문제를 해결하며 쓰는 비용이 더 큰 경우도 흔하기 때문이다


대안 : UniTask

 

널리 알려진 유니티용 Awaiter 패키지이다.

 

GitHub - Cysharp/UniTask: Provides an efficient allocation free async/await integration for Unity.

Provides an efficient allocation free async/await integration for Unity. - Cysharp/UniTask

github.com

 

UniTask의 장점은 유니티 PlayerLoop에 실행기를 끼워넣기 떄문에 유니티 실행 주기와 완벽하게 통합되어 동작하고, 유니티 전용의 각종 확장을 제공한다는 점에 있다. AsyncOperation/YieldInstruction 등을 확장해서 기존의 코루틴도 그대로 await 할수 있는데다 DoTween 등의 서드파티 클래스에도 awaiter를 제공하는 등 사용이 아주 편리하다. 또한 제한적인 병렬처리 기능을 지원하여 로딩 등 시간이 많이 걸리는 작업을 병렬로 쉽게 구현할 수있다.

 

그러나 GameObject에 연동되지 않는다는 점은 여전히 주의해야 하고, 팀 내 기술 수준에 따라 가독성 및 유지보수성을 저해하는 문제 또한 여전하다. 오히려 서드파티인 만큼 Task보다 더욱 익히고 사용하기 어렵다.


대안 : Awaitable (유니티 2023+)

유니티측도 async 지원이 미비한 문제를 염두에는 두고 있었던 것 같다. UniTask와 매우 유사한 컨셉으로, Awaitable이란 기능이 추가되었다. 아직 추가된 지 얼마 안 된 기능이고 UniTask에 비하면 굉장히 간단해서 그다지 할만한 이야기가 없다. 또한 async를 사용하며 계속 마주치는 문제가 이미 삭제된 GameObject를 참조하는 문제인데, 유니티에서 Awaitable을 추가하면서 GameObject가 삭제되는 타이밍을 알 수 있도록 MonoBehaviour에 전용 CancellationToken이 추가되었다. 유니티 2023부터는 좀 더 견고하게 async를 사용할 수 있을 것으로 보인다.


결론

 

C#의 async는 매우 강력한 기능이지만, 굉장히 고도로 추상화되어 있어 내부에서 문제가 발생할 경우 해결이 어려울 수 있다. 거기에 더해 특히 게임의 경우 에러의 허용치가 다른 앱들에 비해 낮고 더 높은 정합성이 요구되기 마련이라 문제 발생의 소지는 적을수록 좋다.

특히나 유니티에는 코루틴이라는 강력한 비동기 작업 도구가 있으므로 정말 async가 필요한지에 대해 깊게 고민해봐야 한다.