Unity에서 API를 호출하고 화면을 갱신하는 코드를 보다 보면 IEnumerator가 아주 자주 나온다. 처음에는 yield return 뒤에서 코드가 이어지는 것만으로도 편했다. 그런데 응답값을 다음 단계로 넘기거나, 사용자가 화면을 닫았을 때 작업을 중단하려고 하니 코루틴 하나만으로는 설명이 길어졌다.
최근 Unity 2023 문서를 보다가 Awaitable을 알게 됐다. C#의 async/await 문법을 Unity 작업에 맞게 사용할 수 있는 타입이었다. 당장 기존 코드를 갈아엎자는 이야기는 아니고, 내가 코루틴에서 불편하게 느꼈던 부분을 어떤 식으로 풀었는지 비교해 보기 좋았다.
코루틴에서 먼저 막힌 부분
API 응답을 기다린 다음 버튼 문구를 바꾸는 흐름을 단순화하면 이런 모양이었다.
IEnumerator LoadProfile(string userId)
{
using var request = UnityWebRequest.Get(
$"https://api.example.com/users/{userId}"
);
yield return request.SendWebRequest();
if (request.result != UnityWebRequest.Result.Success)
{
ShowError("불러오지 못했습니다.");
yield break;
}
profileLabel.text = request.downloadHandler.text;
}
한 번 기다리고 끝나는 코드라면 충분히 읽기 쉽다. 문제는 로그인 확인, 사용자 정보, 권한 목록처럼 단계가 늘어날 때였다. 반환값을 직접 돌려주기 어렵다 보니 콜백이나 공유 필드가 붙고, 중간에 화면이 사라졌는지 확인하는 조건도 여러 곳에 생겼다.
Task<T>를 쓰면 반환값은 편하지만 Unity 오브젝트는 메인 스레드에서 만져야 한다. 백그라운드 작업 뒤에 어느 스레드로 돌아오는지 신경 쓰지 않으면, 평범해 보이는 UI 갱신에서 예외가 날 수 있다.
Unity 쪽에서 만든 Awaitable
Awaitable<T>는 Unity 2023.1부터 제공된 비동기 반환 타입이다. AsyncOperation, 프레임 대기, 메인 스레드와 백그라운드 스레드 전환을 await 문법으로 표현할 수 있다.
async Awaitable<string> LoadProfileAsync(
string userId,
CancellationToken token)
{
using var request = UnityWebRequest.Get(
$"https://api.example.com/users/{userId}"
);
await request.SendWebRequest();
token.ThrowIfCancellationRequested();
if (request.result != UnityWebRequest.Result.Success)
throw new InvalidOperationException(request.error);
return request.downloadHandler.text;
}
호출하는 쪽에서는 결과를 지역 변수로 받을 수 있다. 화면 수명이 끝날 때 CancellationTokenSource를 취소한다면, 응답이 늦게 돌아와 이미 사라진 UI를 건드리는 일도 줄일 수 있다.
CancellationTokenSource viewLifetime;
async void OnEnable()
{
viewLifetime = new CancellationTokenSource();
try
{
var json = await LoadProfileAsync("example-user", viewLifetime.Token);
profileLabel.text = json;
}
catch (OperationCanceledException) { }
}
void OnDisable()
{
viewLifetime?.Cancel();
viewLifetime?.Dispose();
}
여기서 async void는 버튼 이벤트나 Unity 생명주기처럼 호출자가 기다릴 수 없는 가장 바깥쪽에만 두는 편이 낫다. 실제 작업 메서드는 Awaitable을 반환해야 예외와 완료를 호출자가 다룰 수 있다.
셋을 같은 것으로 보면 안 되는 이유
| 구분 | Coroutine | Task | Awaitable |
|---|---|---|---|
| Unity 프레임 대기 | 자연스러움 | 별도 연결 필요 | 전용 API 제공 |
| 반환값 | 직접 표현이 불편 | Task<T> |
Awaitable<T> |
| 기본 실행 문맥 | 메인 스레드 흐름 | 스케줄러 확인 필요 | Unity 동작에 맞춰 설계 |
| 취소 | 직접 플래그 관리가 많음 | 토큰 사용 | 토큰과 Unity 수명 연결 가능 |
| 여러 번 await | 해당 없음 | 일반적으로 가능 | 같은 인스턴스는 한 번만 |
특히 마지막 차이가 중요했다. Unity는 할당을 줄이려고 완료된 Awaitable 인스턴스를 풀로 돌려보낸다. 그래서 하나의 인스턴스를 두 군데에서 기다리거나 완료 후 다시 await하면 정의되지 않은 동작이 될 수 있다. Task와 문법이 비슷하다고 수명까지 같다고 생각하면 안 된다.
또 Unity 문서의 기본 설명에서는 완료 콜백이 동기적으로 이어질 수 있다고 한다. 백그라운드 스레드로 옮긴 작업 뒤 UI를 바꾸려면 의도를 코드에 드러내는 편이 안전하다.
await Awaitable.BackgroundThreadAsync();
var parsed = ParseLargeJson(rawJson);
await Awaitable.MainThreadAsync();
profileLabel.text = parsed.displayName;
내가 정한 작은 기준
- 애니메이션과 몇 프레임 대기는 코루틴이 여전히 단순하다.
- 결과를 여러 단계로 전달하는 새 코드라면
Awaitable<T>를 먼저 검토한다. - 화면이 닫힐 수 있는 요청은 화면 수명과 취소 토큰을 연결한다.
- 백그라운드 전환 뒤에는 메인 스레드 복귀를 코드로 표시한다.
- 하나의
Awaitable인스턴스를 보관해 여러 번 기다리지 않는다.
새 문법이 나왔다고 코루틴이 낡은 기술이 되는 것은 아니었다. 둘은 해결하려는 모양이 조금 다르다. 이번에 얻은 결론은 “무조건 await”가 아니라, 반환값과 취소가 중요한 비동기 흐름에는 수명을 명시적으로 적을 수 있는 도구가 더 잘 맞는다는 것이었다.