UniTask 의 예외는 꺼내 가야 보고된다
Esc

    UniTask 의 예외는 꺼내 가야 보고된다

    2026년 10월 4일 수정–Unity라이브러리동시성
    try
    {
        await UniTask.WhenAll(LoadMapAsync(), LoadUnitsAsync(), LoadSoundAsync());
    }
    catch (Exception ex)
    {
        Debug.LogException(ex);   // 둘이 실패해도 여기에는 하나만 온다
    }

    로딩 둘이 실패했는데 catch 에는 하나만 오고, 나머지 하나는 어디에도 찍히지 않습니다. 반대로 await 를 깜빡하고 버린 태스크의 예외는 몇 초 뒤 엉뚱한 때 찍힙니다.

    규칙 둘 때문입니다. 완료는 처음 한 번만 이기고, 예외는 누군가 결과를 꺼내 가야(GetResult) 보고됩니다.

    WhenAll 은 먼저 실패한 하나만 전한다

    WhenAll 은 자식이 끝날 때마다 자식의 결과를 직접 꺼냅니다. 성공이면 자식 번호 칸에 넣고, 실패면 자기를 그 예외로 끝냅니다. 끝내기는 한 번만 성공하니 두 번째 실패는 갈 곳이 없습니다.

    static void TryInvokeContinuation(WhenAllPromise<T> self, in UniTask<T>.Awaiter awaiter, int i)
    {
        try
        {
            self.result[i] = awaiter.GetResult();        // 자식의 결과를 자식 번호 칸에 넣는다. 실패면 여기서 던진다
        }
        catch (Exception ex)
        {
            self.core.TrySetException(ex);               // WhenAll 을 이 예외로 끝낸다. 먼저 온 하나만 성공한다
            return;                                      // 카운터를 올리지 않고 나간다
        }
    
        // 마지막으로 끝난 자식이 WhenAll 을 완료한다
        if (Interlocked.Increment(ref self.completeCount) == self.result.Length)
            self.core.TrySetResult(self.result);
    }

    셋 다 성공하면 끝나는 순서와 상관없이 결과가 자식 번호 칸에 들어갑니다. B, A, C 순서로 끝나도 await 결과는 [10, 20, 30] 입니다.

    A 가 성공하고 B, C 가 차례로 실패하면 이렇게 됩니다.

    1. A 가 끝납니다. 0 번 칸에 10 을 넣고 카운터가 1 이 됩니다.
    2. B 의 GetResult() 가 예외를 던집니다. TrySetException 이 성공해 WhenAll 이 B 의 예외로 끝나고, 그 예외가 await 한 쪽으로 갑니다.
    3. C 의 GetResult() 도 예외를 던집니다. WhenAll 은 이미 끝났으므로 TrySetException 이 false 를 돌려줍니다. C 의 예외는 여기서 사라집니다.

    C 의 예외는 파이널라이저 보고로도 이어지지 않습니다. WhenAll 이 C 의 GetResult() 를 불렀으니 C 쪽에서는 관측된 예외이기 때문입니다. UnobservedTaskException 에 카운터를 걸고 WhenAll(ok, throwA, throwB) 를 받은 뒤 GC 를 두 번 돌렸더니, 보고는 0 번이었습니다.

    Task.WhenAll 처럼 AggregateException 에 모아 주지 않습니다. catch 에 온 예외가 실패의 전부가 아닐 수 있습니다.

    이렇게 쓴다

    실패를 전부 알아야 하면 자식이 던지지 않게 감싸고, 예외를 결과로 받습니다. 아무도 던지지 않으니 WhenAll 은 셋이 다 끝난 뒤에 끝나고, 결과 배열에 각자의 예외가 들어 있습니다.

    // 예외를 던지는 대신 결과로 돌려준다
    static async UniTask<Exception> Capture(UniTask task)
    {
        try
        {
            await task;
            return null;
        }
        catch (Exception ex)
        {
            return ex;
        }
    }
    
    Exception[] errors = await UniTask.WhenAll(
        Capture(LoadMapAsync()),
        Capture(LoadUnitsAsync()),
        Capture(LoadSoundAsync()));
    
    foreach (var ex in errors)
    {
        if (ex != null)
            Debug.LogException(ex);   // 실패한 것이 모두 찍힌다
    }

    첫 실패만 알면 되는 곳은 처음 코드 그대로 둡니다.

    WhenAny 도 처음 끝난 하나가 이긴다

    WhenAny 는 완료 조건이 “처음 끝난 하나” 일 뿐 같은 규칙입니다.

    • 먼저 끝난 자식이 실패면 WhenAny 도 그 예외로 끝납니다. 실패도 “끝남” 입니다.
    • 먼저 끝난 자식이 성공이면, 나중에 실패한 자식의 예외는 WhenAll 의 두 번째처럼 사라집니다.

    그리고 어느 조합기든 나머지 자식을 멈추지 않습니다. 콜백을 걸어 둘 뿐 멈출 방법이 없어서, 실패 뒤에도, WhenAny 에서 진 뒤에도 끝까지 돕니다.

    이렇게 쓴다

    자식들에게 같은 CancellationTokenSource 의 토큰을 넘기고, 조합기가 끝난 자리에서 취소합니다.

    var cts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
    
    try
    {
        await UniTask.WhenAll(
            LoadMapAsync(cts.Token),
            LoadUnitsAsync(cts.Token),
            LoadSoundAsync(cts.Token));
    }
    catch
    {
        cts.Cancel();   // 하나가 실패하면 아직 도는 나머지를 멈춘다
        throw;
    }
    finally
    {
        cts.Dispose();
    }

    WhenAny 는 성공으로 끝나도 진 쪽이 계속 돌므로, await 다음 줄에서 취소합니다.

    await UniTask.WhenAny(WaitClickAsync(cts.Token), UniTask.Delay(5000, cancellationToken: cts.Token));
    cts.Cancel();       // 진 쪽을 멈춘다

    아무도 꺼내 가지 않은 예외는 GC 때에야 보고된다

    예외를 보고하는 쪽은 결과를 꺼내 가는 쪽입니다. await 가 꺼내 가면 catch 로 옵니다. Forget() 과 async UniTaskVoid 는 끝나는 순간 꺼내서 곧바로 UniTaskScheduler 에 넘깁니다. 아무도 꺼내 가지 않으면 예외를 감싼 ExceptionHolder 가 GC 에 수거될 때 파이널라이저에서 보고합니다.

    예외를 던지는 작업 10개로 쟀습니다.

    경로 10 프레임 안에 보고 GC.Collect() + WaitForPendingFinalizers() 뒤
    반환값을 버림 0 / 10 10 / 10
    .Forget() 10 / 10
    async UniTaskVoid 10 / 10

    버린 쪽은 GC 가 돌 때까지 하나도 보고되지 않았습니다. 실제 게임에서는 몇 초 뒤나 씬을 옮길 때라, 찍힌 로그가 예외가 난 자리와 동떨어집니다.

    이렇게 쓴다

    반환된 UniTask 는 셋 중 하나로 받습니다. 어느 쪽이든 예외가 끝나는 순간 보고되고, 대기 객체도 풀로 돌아갑니다.

    await SaveAsync();              // 기다릴 것. 예외가 catch 로 온다
    
    SaveAsync().Forget();           // 기다리지 않을 것. 예외가 끝나는 순간 보고된다
    
    async UniTaskVoid Start()       // 진입점. 예외가 끝나는 순간 보고된다
    {
        await SaveAsync();
    }

    Forget() 과 UniTaskVoid 의 예외는 catch 로 오지 않고 UniTaskScheduler 로 갑니다. 출시 빌드에서도 남기려면 핸들러를 겁니다.

    UniTaskScheduler.UnobservedTaskException += ex =>
    {
        Debug.LogException(ex);     // 크래시 리포트로 보내는 코드를 여기에 둔다
    };

    정리

    UniTask 의 완료는 처음 한 번만 이기고, 예외는 누군가 GetResult() 로 꺼내 가야 보고됩니다.

    • WhenAll 은 먼저 실패한 하나만 전하고, 두 번째 예외는 보고 없이 사라집니다.
    • WhenAny 는 실패도 “끝남” 으로 치고, 어느 조합기든 나머지 자식을 멈추지 않습니다.
    • 버린 태스크의 예외는 GC 가 ExceptionHolder 를 치울 때에야 보고되고, Forget() 과 async UniTaskVoid 는 곧바로 보고합니다.

    그래서 실패를 전부 알아야 하면 예외를 결과로 받고, 남은 자식은 토큰으로 직접 멈추고, 반환된 UniTask 는 await 나 Forget() 으로 받습니다.