
Unity async / await: Awaitable
요약
Unity의 새로운 Awaitable 클래스를 활용하여 비동기(async/await) 워크플로우를 개선하는 방법을 설명합니다. 기존 C# Task와 달리 Unity의 게임 시간(Game Time) 및 타임 스케일을 준수하며, 오브젝트 파괴 시 작업 관리 및 취소 토큰 활용법을 다룹니다.
핵심 포인트
- Awaitable은 Unity의 타임 스케일(Time Scale)을 반영하여 동작함
- Task.Delay 대신 Awaitable.WaitForSeconds를 사용하여 게임 시간 기반 대기 가능
- NextFrameAsync, EndOfFrameAsync 등을 통해 코루틴 기능을 비동기로 대체 가능
- 취소 토큰(Cancellation Token)을 사용하여 비동기 작업의 안전한 중단 및 관리 가능
동영상: Unity async / await: Awaitable
채널: Tarodev
길이: 9분 21초
출처: 자막 (자동 생성, 영어)
전사:
Unity가 우리에게 제공한 기능 중, async (비동기) 워크플로우를 훨씬 더 매력적으로 만들어 주는 유용한 기능들에 대해 이야기해보고자 합니다. 또한 이는 사람들이 async 워크로드를 사용할 때 가졌던 주요 우려 사항들을 많이 제거해 줍니다. 이 영상을 보고 계신다면, 여러분이 이미 이 시리즈의 1부를 보셨다고 가정하겠습니다. 아직 보지 않으셨다면 가서 시청하시고, 만약 async에 이미 꽤 자신감이 있다면 이 영상을 계속 시청하시면 됩니다.
만약 여러분이 이전에 Unity에서 Task를 사용해 보셨다면, Task, 특히 Task.Delay가 게임 시간 (Game Time)이 아닌 실제 시간 (Real World Time)에 실행된다는 점을 눈치채셨을 것입니다. 따라서 만약 Time Scale을 0으로 변경하여 완전히 멈추려고 한다면, 여러분의 Task와 async 함수는 아무 일도 없었다는 듯이 계속 실행되는 것을 보게 될 것입니다. 이것이 바로 Unity가 새로운 Awaitable 클래스로 해결한 문제 중 하나입니다. 이것은 C#의 Task 클래스의 Unity 변형이며, 우리에게 Unity의 기능을 제공합니다. 이 영상이 끝날 때쯤이면 여러분은 이를 사용하는 데 매우 익숙해질 것입니다.
그럼 첫 번째 테스트로 들어가 보겠습니다. Start에서 Task 루프와 Awaitable 루프를 시작합니다. 이 둘의 유일한 차이점은 하나는 Task.Delay를 사용하고, 다른 하나는 새로운 Awaitable.WaitForSeconds를 사용한다는 것입니다. 이 방식은 Unity의 관례에 따라 float 값을 입력받으며, 실제로 게임 시간 (Game Time) 기반으로 실행됩니다. 여기서 재생(Play) 버튼을 누르면 Task와 Awaitable이 모두 대기 상태(Pending)로 시작되는 것을 볼 수 있습니다. 그리고 여기서 정지 시간(Stop Time) 버튼을 누르면, Task는 계속 실행되지만 Awaitable은 일시 중지된 것을 확인할 수 있습니다. 그 후 시간을 다시 재개하면 Awaitable은 멈췄던 지점부터 다시 계속됩니다. 따라서 이는 이전의 Task.Delay보다 Unity에서 사용하기에 훨씬 더 좋습니다.
WaitForSeconds.Async 외에도, 우리는 코루틴(Coroutine)의 yield return null과 동일한 역할을 하는 NextFrameAsync를 사용할 수 있으며, EndOfFrameAsync도 사용할 수 있습니다. 또한 다음 FixedUpdate를 기다릴 수도 있습니다. 이제 제가 누르면...
이 테스트를 멈추면, 사람들이 Unity에서 비동기 (async) 워크플로우를 사용할 때 갖는 주요 우려 사항을 경험하게 될 것입니다. 그것은 바로 코루틴 (co-routine)은 오브젝트가 파괴되거나 애플리케이션이 중단될 때 스스로 정리되는 반면, 태스크 (task)는 그냥 계속 실행된다는 점입니다. Unity는 이를 처리할 수 있는 매우 우아한 방법을 제공했지만, 현재의 워크플로우가 어떤 모습일지 보여드리겠습니다. 이전 영상에서 취소 토큰 (cancellation tokens)을 다루지 않았다는 것을 알고 있습니다. 만약 그것이 무엇인지 모르신다면, 기본적으로 이는 오래 걸리는 작업 (long-running operations)을 취소할 수 있는 방법을 제공합니다. 예를 들어, 게임 내 플레이어가 상당히 집약적이거나 시간이 걸리는 버튼을 클릭한다고 가정해 봅시다. 예를 들어 모든 2v2 로비(lobbies)를 불러오고 싶어 한다고 칩시다. 만약 그들이 2v2 로비가 아니라 3v3 로비를 원한다는 것을 결정한다면, 이제 2v2 로비에 대한 취소 토큰 (cancellation token)을 트리거하여 해당 작업을 중단할 수 있습니다. 그런 다음 3v3 로비를 위한 새로운 취소 토큰을 생성하여 거기서부터 계속 진행할 수 있습니다. 이는 작업에서 빠져나올 수 있는 쉬운 방법을 제공할 뿐입니다. 여러분의 워크플로우는 대략 다음과 같은 모습일 것입니다. 이전에는 토큰을 생성하고, 우리의 Update 함수에서 오래 걸리는 태스크를 시작하며, 토큰을 취소하고 폐기(disposing)합니다. 그리고 오래 걸리는 태스크의 각 반복(iteration)마다 취소되었는지 확인하고, 만약 그렇다면 루프를 중단합니다. 여기서 주목할 점은, 이제 Awaitable이 있기 때문에 Task.Delay를 정말로 사용해서는 안 된다는 것입니다. 아마도 에디터(Editor)에서 실제 시간 타이머(real life timers)를 사용해야 하는 것과 같은 런타임 이외의 작업을 할 때라면 모를까, 솔직히 이제 모든 런타임 작업에서는 어쨌든 Awaitable을 사용해야 합니다. 따라서 우리는 토큰을 전달하고, 이 작업이 진행되는 동안 실제로 취소된다면 예외(throw)가 발생할 것이며, 우리는 여기서 공격적으로 이를 처리할 수 있을 것입니다. 그럼 그것이 어떻게 보이는지 보겠습니다. 여기에서 우리의 오래 걸리는 태스크가 실행 중이라고 가정하고, 만약 이 오브젝트를 파괴하면 destroyToken이 취소되었습니다. 이제 제가 어떻게 하는지 보여드리겠습니다.
Unity 2023에서는 이를 처리하기 위해 이 토큰이 여기에 필요하지 않습니다. 이 토큰을 전달할 필요도 없고, 무언가를 정리하거나 파괴할 필요도 없으며, 이것을 가져올 필요도 없습니다.
이제 MonoBehaviour를 디컴파일해 보면, 상단에 destroyCancellationToken이 있는 것을 볼 수 있습니다. 이제 이것은 기본적으로 제공됩니다. 그리고 우리는 실제로 여기서 바로 수행할 수 있습니다. 정적 메서드가 아니라 멤버 메서드입니다. destroyCancellationToken.isCancellationRequested와 같이 사용할 수 있습니다. 그리고 여기에서 destroyCancellationToken을 바로 전달할 수 있으며, 이제 이 모든 추가적인 보일러플레이트 (Boilerplate) 코드 없이도 정확히 동일하게 작동할 것입니다. 따라서 이제 이것은 예를 들어 코루틴 (Coroutine)을 사용하는 것과 마찬가지로 정리될 것입니다. 이를 증명하기 위해, 우리는 파괴할 수 있습니다. 자, 보시다시피 잘 작동합니다.
여기서 주목할 점은, 만약 여기서 void를 반환한다면 이것이 실제로 예외를 잡아내지 못하며, 실제로 예외 에러가 발생하게 된다는 것입니다. 따라서 항상 Task 또는 실제로 Awaitable을 반환하도록 하십시오. Task 대신 Awaitable을 반환할 수 있습니다. 이에 대해서는 나중에 조금 더 이야기할 것이 있으니 일단 고려해 두시기 바랍니다.
우리의 destroyToken과 함께, 우리는 애플리케이션 레벨 (Application Level) 토큰, 즉 applicationExitCancellationToken도 가지고 있습니다. 이것은 정말로 에디터 (Editor) 전용입니다. 이제 우리의 장시간 실행되는 작업 (Long-running task)이 있고, 우리가 정지(Stop)를 누르면 에디터가 종료되고 취소될 것입니다. 짐작하시겠지만, OnDestroy는 플레이 모드를 중지할 때도 호출되므로, 이것은 에디터 중지 시에도 작동할 것입니다.
자, 다음 기능은 제가 엄청나게 기대하고 있는 기능인데, 저는 이것을 스레드 스와핑 (Thread Swapping)이라고 부릅니다. 공식적인 명칭이 있는지는 모르겠습니다. 이것이 실질적으로 우리에게 허용하는 것은 메인 스레드 (Main thread)와 백그라운드 스레드 (Background thread) 사이를 쉽게 오가는 것입니다. 어떻게 작동하는지 보여드리겠습니다.
여기에 무한 루프가 있습니다. 제가 가장 먼저 하는 일은 백그라운드 스레드로 이동하는 것입니다. 이 메서드를 호출하자마자, 제가 이 불리언 (Boolean) 값을 체크했는지 여부에 따라 이후의 모든 작업은 백그라운드 스레드에서 실행됩니다. 저는...
백그라운드 스레드에서 계산을 수행하거나, 보시는 것처럼 메인 스레드 (Main Thread)에서 수행하고 있습니다. 저는 지금 메인 스레드로 돌아가기 위해 메인 스레드를 호출하고 있습니다. 이제 메인 스레드에 있으므로 UI 요소들에 접근할 수 있습니다. 당연히 UI 요소는 메인 스레드에서만 조작할 수 있기 때문입니다. 예를 들어, 만약 제가 이것을 백그라운드 스레드에서 실행하려고 한다면, 해당 요소에 접근할 수 없다는 오류가 발생할 것입니다. 그리고 이 computeTotal은 의도적으로 오래 걸리는 작업 (Long-running operation)으로 설정되어 있습니다. 그럼 백그라운드 스레딩 (Background threading)을 껐을 때 어떻게 보이는지 보여드리겠습니다.
보시는 것처럼, 모든 것을 메인 스레드에서 처리하려고 하기 때문에 매우 버벅거립니다. 'Use background thread'를 클릭하자마자, 아주 매끄럽게(smooth as butter) 작동하며 보기에도 꽤 괜찮아 보입니다. 네, 정말 우아한 워크플로우이며, 단일 스레드 (Singular threaded)의 오래 걸리는 프로세스에 완벽합니다. 제가 단일 스레드라고 말씀드린 이유는, 만약 계산 작업이 멀티 스레드 (Multi-threaded)로 처리될 수 있다고 생각하신다면 아마 Jobs 시스템 같은 것을 선택하실 것이기 때문입니다. 따라서 이 방식은 API 호출이나, 혹은 알고리즘 전체는 매우 가볍지만 단 한 줄 정도가 계산 집약적 (Computationally heavy)인 경우에 완벽할 수 있습니다. 그 부분을 이 두 줄 사이에 끼워 넣어 백그라운드에서 실행하게 한 뒤, 아주 쉽게 워크플로우를 계속 이어갈 수 있습니다. 물론 그렇게 하면 함수가 반드시 동일한 프레임 (Frame) 내에 종료되지 않을 수도 있으므로, 그 점은 주의해야 합니다.
좋습니다. 다음으로, Task 대신 Awaitable을 반환할 수 있다는 점에 대해 언급하고 싶었지만, 사실 저는 그렇게 해야 할 이유를 찾지 못했고, 오히려 부정적인 면만 발견했습니다. 만약 여기서 Awaitable을 반환한다면, 예를 들어 Task.WhenAll처럼 Task를 기대하는 함수들을 사용할 수 없게 됩니다. 저는 실제로 제 Unity 담당자인 Caldaddy에게 이 부분을 추적해서, Awaitable 팀에 Awaitable을 반환해야 할 특별한 이유가 있는지 물어봐 달라고 요청했습니다. 그의 답변을 주석을 통해 화면에 띄울 수 있으면 좋겠네요. 그리고 여기서 잠시만요,
만약 Task.WhenAll을 실제로 본 적이 없다면, 이것은 단순히 실행 중인 수많은 태스크 (Tasks)를 기다리기 위한 방법일 뿐입니다. 보시는 것처럼 여기 세 개의 태스크가 있습니다. 이들은 모두 데이터를 반환하며, 저는 이들을 모두 await 하고 마지막에 모든 데이터를 합산합니다. 어떻게 보이는지 확인해 보죠.
아름답네요.
이제 우리에게는 WaitAll이라는 또 다른 함수가 있지만, 이것은 동기적 (Synchronous)입니다. await 할 수 없으며, Unity에서는 절대 사용해서는 안 됩니다. 게임을 완전히 멈추게(Freeze) 만들기 때문입니다.
자, 다음 내용은 흥미롭습니다. 누군가에게 왜 비동기 워크플로우 (Async workflow)를 사용하지 않느냐고 묻는다면, 아마 두 가지 이유 중 하나일 것입니다. 첫째는 오브젝트가 파괴되거나 에디터가 중지되었을 때 태스크 (Tasks)가 계속 실행된다는 점인데, 이는 Awaitable을 통해 해결되었음을 보여드렸습니다. 둘째는 특히 WebGL에서 작동하지 않는다는 점입니다. 구체적으로 Task.Delay는 WebGL에서 작동하지 않는데, Awaitable이 이 문제를 해결했다는 소식을 전하게 되어 기쁩니다. 에디터에서 재생 버튼을 누르면 두 방식 모두 잘 작동하는 것을 볼 수 있지만, WebGL 빌드로 넘어가 보면 오직 Awaitable만 작동하는 것을 확인할 수 있습니다. WebGL 빌드 어디에서든 Task.Delay를 사용하면 작동하지 않는다는 것을 알게 될 것입니다. 이 부분에서 많은 분이 반가워하실 것 같네요.
마지막은 다소 지루할 수 있지만, 그래도 언급하는 것이 좋겠다고 생각했습니다. 비동기 연산 (Async operation)을 반환하는 모든 Unity 함수들, 예를 들어 SceneManager가 하는 방식이나 일부 리소스 (Resource) 함수들이 그러한 경우, Awaitable은 이제 해당 함수로부터 Awaitable을 반환하는 헬퍼 함수 (Helper function)를 제공합니다. 그러면 우리는 일반적인 태스크 (Task)처럼 그냥 await 할 수 있습니다. 다소 지루한 데모를 해보겠습니다. 자, 됐습니다.
이것으로 제가 보여드리고 싶었던 모든 것을 마칩니다. 이것이 여러분이 비동기 워크플로우 (Async workflows)를 사용하도록 유도하기에 충분했기를 바랍니다. 만약 이미 UniTask와 같은 도구를 사용하고 계신다면, 네이티브 Awaitable을 바로 사용하기 시작하는 데 충분한 정보가 되었기를 바랍니다. 여러분의 생각과 우려 사항을 듣고 싶으니 아래 댓글로 남겨주세요. 다음 영상에서 뵙겠습니다. 안녕히 계세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 YouTube Tarodev (Unity 팁)의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기