[PART13.비동기와 스레딩 기초(10/15)] Task의 예외 처리
async/await 안에서 던진 예외는 어디로 가는가 / Task.WhenAll의 첫 예외 함정 / AggregateException과 ExceptionDispatchInfo의 진실
목차
한 줄 정의
async 메서드 안에서 던진 예외는 즉시 호출자에게 전달되지 않고 반환된 Task 객체 안에 저장되어 Faulted 상태가 됩니다. 호출자가 그 Task를 await하는 순간에야 예외가 다시 살아나 호출자의 try-catch로 흘러들어 갑니다.
시작하기 전에 알아야 할 핵심
- 예외는
Task에 저장됩니다:async메서드 안에서 발생한 예외는 곧바로 위로 전파되지 않고 반환된Task의 상태로 보관됩니다. 그Task를await해야 다시 throw됩니다. - 스택 트레이스는 보존됩니다: 단순한
throw ex;가 아니라ExceptionDispatchInfo(예외 발송 정보 보관 객체)를 통해 원래 발생 지점의 스택 트레이스가 그대로 살아납니다. Task.WhenAll은 첫 예외만 던집니다: 여러 작업이 동시에 실패해도await로는 첫 번째 예외 하나만 잡힙니다. 나머지를 보려면task.Exception(AggregateException)을 직접 확인해야 합니다.async void는 예외를 잡을 수 없습니다:Task가 없으니await도 못하고, 처리되지 않은 예외는 동기화 컨텍스트로 퍼져 프로세스를 종료시킵니다. 이벤트 핸들러를 제외하면 사용하지 않습니다.- 관찰되지 않은 예외는 조용히 사라집니다:
Task를await하지도Result로 받지도 않으면 그 예외는 GC가 수집할 때UnobservedTaskException이벤트로만 흘러갑니다. 로깅 후크가 없으면 영원히 묻힙니다.
1. 왜 이 주제가 까다로운가 — "예외가 사라졌어요"
동기 코드와 다른 점
동기 코드에서 예외는 즉시 호출 스택을 거슬러 올라갑니다. 메서드가 던지면 바로 호출자가 받고, 받지 못하면 그 위로 넘어갑니다. 흐름이 단순합니다.
async는 다릅니다. async 메서드는 도중에 await를 만나면 호출자에게 미완료 Task 하나만 돌려주고 자신은 일단 반환됩니다. 그 뒤에 발생하는 예외는 누구의 호출 스택에도 남아 있지 않습니다. 다른 스레드에서 발생할 수도 있고, 발생 시점에는 호출자가 이미 다른 일을 하고 있을 수도 있습니다.
이 차이가 신입 개발자에게 다음 같은 혼란을 만듭니다.
// Unity에서 흔히 마주치는 상황
public async void OnButtonClicked()
{
LoadDataAsync(); // ⚠️ await 빼먹음
}
private async Task LoadDataAsync()
{
await Task.Delay(100);
throw new InvalidOperationException("데이터 없음");
}
위 코드는 어떤 일이 벌어질까요?
- 예외가 발생하지만
OnButtonClicked의 호출 스택에는 도달하지 않습니다. try-catch로 감싸도 잡히지 않습니다.- 콘솔에는 아무 로그도 남지 않을 수 있습니다(미관찰 예외 정책에 따라).
비동기 예외 처리의 첫 번째 규칙은 "예외는 Task와 함께 흐른다"입니다. Task가 끊기면 예외도 끊깁니다.
Unity에서 왜 더 무서운가
Unity 모바일 게임에서는 네트워크 호출, 에셋 로드, 어드레서블 다운로드, 인앱 결제 같은 비동기 흐름이 끊임없이 등장합니다. 한 번의 잘못된 예외 처리가 다음 결과로 이어집니다.
- 무음 실패: 데이터 로드가 실패했는데 로그가 없어 QA가 재현할 수 없습니다.
- 상태 불일치: 트랜잭션 중간에서 끊긴 채 UI가 다음 화면으로 넘어가 NRE(
NullReferenceException)를 연쇄적으로 발생시킵니다. - 앱 크래시:
async void에서 예외가 새어 나가면 모바일 OS가 앱을 강제 종료합니다. 사용자에게는 "튕겼다"는 인상만 남습니다. - 재현 불가능한 GC 시점 크래시: 미관찰 예외가 GC 시점에 보고되어 원래 발생 위치와 무관한 곳에서 알림이 뜹니다.
비동기 예외를 제대로 다루지 않으면 디버깅의 9할은 추리 게임이 됩니다. 이 글은 그 추리를 줄이는 메커니즘 정리를 목표로 합니다.
2. 핵심 메커니즘 — Task에 예외가 저장되고 await에서 부활한다
비유로 이해하기
Task는 운송 컨테이너입니다. async 메서드 안에서 작업이 진행되다가 예외가 발생하면, 작업자는 그 예외를 컨테이너 안에 넣고 컨테이너에 빨간 라벨(Faulted 상태)을 붙입니다. 컨테이너는 호출자의 창고로 배송됩니다.
호출자가 컨테이너를 그냥 책상에 두면 아무 일도 일어나지 않습니다. 호출자가 await로 컨테이너를 여는 순간에야 안의 예외가 튀어나와 호출자의 코드에 도달합니다.

가장 단순한 예시
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
try
{
await LoadAsync();
}
catch (InvalidOperationException ex)
{
// ✅ 여기서 잡힙니다
Console.WriteLine($"잡았다: {ex.Message}");
}
}
static async Task LoadAsync()
{
await Task.Delay(50);
throw new InvalidOperationException("로드 실패");
}
}
LoadAsync는 자기 안에서 예외를 던졌지만 Main의 try-catch가 잡습니다. 정확히 await LoadAsync() 줄에서 다시 throw되기 때문입니다.
async— 비동기 메서드 한정자 메서드를 컴파일러가 상태 머신(State Machine)으로 변환하도록 지시합니다. 안에서await를 사용할 수 있게 되며, 메서드의 모든 throw·반환은Task로 감싸집니다.
예시:async Task LoadAsync() { await Task.Delay(100); }호출자는Task를 받고, 안에서 발생하는 예외도 그Task에 담겨 흐릅니다.
IL로 확인 — async 메서드는 try-catch로 감싸인다
C# 컴파일러는 async 메서드를 보면 별도의 상태 머신 클래스(struct)를 만들어 작업의 진행 상태를 관리합니다. 그 안의 MoveNext 메서드는 사실상 거대한 try-catch로 둘러싸여 있어, 사용자가 던진 예외를 잡아 AsyncTaskMethodBuilder.SetException으로 Task에 저장합니다.
.method private hidebysig instance void MoveNext () cil managed
{
.locals init (
[0] int32 V_0,
[1] class [System.Runtime]System.Exception V_1
)
.try
{
// 사용자 코드 본문 (await 분기·throw 포함)
IL_0040: newobj instance void [System.Runtime]System.InvalidOperationException::.ctor(string)
IL_0045: throw // ← 사용자가 던진 예외
}
catch [System.Runtime]System.Exception
{
IL_0050: stloc.1
IL_0051: ldarg.0
IL_0052: ldflda '<>t__builder'
// 예외를 Task로 흘려보낸다 — 호출 스택으로 던지지 않는다
IL_0057: call instance void
[System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder::SetException(class System.Exception)
IL_005c: leave.s IL_0070
}
IL_0070: ret
}
핵심 한 줄은 AsyncTaskMethodBuilder::SetException입니다. 사용자가 throw로 던진 예외는 호출 스택을 거슬러 올라가는 대신 Task의 상태로 변환됩니다. 이 변환이 "예외가 Task에 담긴다"의 실체입니다.
어디까지가 동기 throw인가
async 메서드 안에 await가 등장하기 전까지의 코드는 동기적으로 실행되지만, 그 부분에서 던진 예외도 똑같이 Task에 담깁니다.
static async Task ValidateThenWorkAsync(string? input)
{
if (input is null)
throw new ArgumentNullException(nameof(input)); // ❗ 즉시 throw가 아님
await Task.Delay(50);
}
호출자가 보기에 ValidateThenWorkAsync(null)은 즉시 예외를 던지지 않고 이미 Faulted 상태인 Task를 돌려줍니다. 호출자는 await해야만 알게 됩니다. 이는 인수 검증을 즉시 알리고 싶을 때 의도와 다른 동작이며, 뒤의 [함정] 섹션에서 분리 패턴을 다룹니다.
3. 내부 동작 — ExceptionDispatchInfo가 스택 트레이스를 보존하는 방법
단순 throw였다면 어떻게 됐을까
비동기 작업은 여러 스레드를 넘나들 수 있습니다. 만약 await가 단순히 throw exception;을 호출했다면 다음 일이 벌어집니다.
- 원래 발생 지점의 스택 트레이스가 사라지고
await라인의 스택 트레이스로 덮어씌워집니다. IOException이 어느 파일 작업에서 발생했는지 알 수 없게 됩니다.- 디버거가 가리키는 위치는 항상
await라인입니다.
이는 디버깅에 치명적입니다. 그래서 .NET은 System.Runtime.ExceptionServices.ExceptionDispatchInfo(예외 발송 정보 보관 객체)라는 우회 장치를 사용합니다.
두 단계 동작

직접 확인하는 코드
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
try
{
await Outer();
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
static async Task Outer()
{
await Task.Delay(10);
await Inner();
}
static async Task Inner()
{
await Task.Delay(10);
throw new InvalidOperationException("진짜 발생 지점");
}
}
실행하면 스택 트레이스에 다음 두 줄이 모두 등장합니다.
System.InvalidOperationException: 진짜 발생 지점
at Program.Inner() in Program.cs:line 26 ← 원래 발생 지점이 살아있다
at Program.Outer() in Program.cs:line 19 ← 호출 체인도 보존
at Program.Main() in Program.cs:line 11
ExceptionDispatchInfo 덕분에 Inner 안의 라인 번호가 await 호출 라인에 묻히지 않고 남습니다. C# 5 이전 시절 Task.Wait()로 동기 대기하던 방식은 이 보존이 약했고, 그래서 진단이 훨씬 어려웠습니다.
4. Task.WhenAll의 첫 예외 함정
직관과 다른 동작
Task.WhenAll(여러 작업을 모두 기다리는 결합 메서드)을 await하면 하나의 예외만 던져집니다. 여러 작업이 동시에 실패해도 마찬가지입니다.
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
var t1 = FailAsync(50, "A 실패");
var t2 = FailAsync(60, "B 실패");
var t3 = FailAsync(70, "C 실패");
try
{
await Task.WhenAll(t1, t2, t3);
}
catch (Exception ex)
{
// ⚠️ 첫 번째 예외 하나만 잡힙니다
Console.WriteLine($"잡힌 예외: {ex.GetType().Name} — {ex.Message}");
}
}
static async Task FailAsync(int delay, string msg)
{
await Task.Delay(delay);
throw new InvalidOperationException(msg);
}
}
출력은 다음 한 줄뿐입니다.
잡힌 예외: InvalidOperationException — A 실패
B 실패와 C 실패는 어디로 갔을까요? Task.WhenAll이 반환한 합쳐진 Task 안에 살아있습니다. await가 첫 번째만 꺼내 보여주고 나머지를 숨긴 것뿐입니다.
왜 이렇게 설계되었나
언어 설계자의 선택입니다. await가 AggregateException(여러 예외를 하나로 묶은 컨테이너)을 그대로 던졌다면 사용자는 항상 catch (AggregateException) + InnerExceptions 순회 코드를 써야 했을 것입니다. 절대 다수의 코드는 작업이 하나일 때를 다루므로, "단일 예외만 던지는" 단순성을 우선한 결정입니다.
대신 모든 예외를 잃어버리지는 않게 했습니다. 결합된 Task의 Exception 속성에는 AggregateException이 그대로 들어 있어 직접 꺼낼 수 있습니다.
모든 예외를 보는 두 가지 패턴

using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
var t1 = FailAsync(50, "A 실패");
var t2 = FailAsync(60, "B 실패");
var t3 = FailAsync(70, "C 실패");
var all = Task.WhenAll(t1, t2, t3);
try
{
await all;
}
catch
{
// ✅ 모든 예외를 보고 싶으면 합쳐진 Task의 Exception을 직접 꺼낸다
if (all.Exception is AggregateException ae)
{
foreach (var inner in ae.Flatten().InnerExceptions)
{
Console.WriteLine($"- {inner.Message}");
}
}
}
}
static async Task FailAsync(int delay, string msg)
{
await Task.Delay(delay);
throw new InvalidOperationException(msg);
}
}
출력:
- A 실패
- B 실패
- C 실패
AggregateException— 여러 예외를 묶는 컨테이너 예외 병렬 작업처럼 한 번에 여러 예외가 발생할 수 있는 상황을 다루기 위한 타입입니다.InnerExceptions컬렉션에 모든 예외가 들어 있고,Flatten()메서드로 중첩된AggregateException을 평탄화할 수 있습니다.
예시:foreach (var ex in agg.Flatten().InnerExceptions) Log(ex);모든 자식 예외를 한 번에 순회합니다.
IL로 보는 await의 예외 처리 코드
await all; 한 줄을 IL로 보면 GetAwaiter().GetResult() 호출이 보입니다. 이 GetResult가 내부적으로 첫 예외만 꺼내는 책임자입니다.
// await all; 컴파일 결과 (요약)
IL_0080: ldloca.s awaiter
IL_0082: call instance !0 valuetype
[System.Runtime]System.Runtime.CompilerServices.TaskAwaiter::GetResult()
// ↑ Task.Exception이 AggregateException이라도
// 여기서 InnerExceptions[0]만 ExceptionDispatchInfo로 꺼내 throw
TaskAwaiter.GetResult 내부 구현은 Task 상태를 점검하다 Faulted이면 ExceptionDispatchInfo로 첫 예외만 발사합니다. 이 동작은 모든 await에 공통이며, Task.WhenAll만의 특별한 처리가 아니라 await라는 키워드 자체의 일관된 의미론입니다.
Task.WhenAny는 다르다
Task.WhenAny(여러 작업 중 가장 먼저 끝난 하나만 기다리는 메서드)는 자체로는 절대 throw하지 않습니다. 어떤 작업이 끝났는지를 알려주는 Task<Task>를 반환할 뿐입니다. 예외가 발생한 작업이 가장 먼저 끝났다면, 그 작업을 한 번 더 await해야 예외가 도달합니다.
var winner = await Task.WhenAny(t1, t2);
await winner; // 예외 처리는 여기서
이 차이는 PART 13-08 Task.WhenAll · WhenAny에서도 다룬 부분이지만, 예외 흐름 관점에서 다시 짚어둘 가치가 있습니다.
5. async void와 미관찰 예외 — 사라지는 예외의 정체
async void는 왜 위험한가
async 메서드의 반환형은 셋 중 하나입니다.
| 반환형 | 호출자가 await 가능? | 예외 전달 경로 |
|---|---|---|
Task |
✅ | await 시점에 throw |
Task<T> |
✅ | await 시점에 throw + 결과값 반환 |
void |
❌ | 동기화 컨텍스트로 직접 발사 → 처리 안 되면 프로세스 종료 |
async void는 반환할 Task가 없으니 호출자가 결과도 예외도 받을 수 없습니다. 처리되지 않은 예외는 호출 당시의 SynchronizationContext(또는 ThreadPool)로 던져지고, 이를 받는 사람이 없으면 .NET 런타임이 처리되지 않은 예외로 간주해 프로세스를 종료시킵니다.

실제 예시 — Unity 버튼 핸들러
using UnityEngine;
using UnityEngine.UI;
using System;
using System.Threading.Tasks;
public class ShopController : MonoBehaviour
{
[SerializeField] private Button buyButton;
void Start() => buyButton.onClick.AddListener(OnBuyClicked);
// ❌ Bad — async void에서 예외가 새면 앱이 죽는다
private async void OnBuyClicked()
{
await PurchaseAsync(); // 네트워크 실패 시 throw
}
// ✅ Good — 이벤트 핸들러 자체는 async void라도, 안에서 try-catch로 감싼다
private async void OnBuyClickedSafe()
{
try
{
await PurchaseAsync();
}
catch (Exception ex)
{
ShowErrorPopup(ex.Message);
Debug.LogException(ex);
}
}
private async Task PurchaseAsync()
{
await Task.Delay(100);
throw new InvalidOperationException("결제 서버 응답 없음");
}
private void ShowErrorPopup(string msg) { /* ... */ }
}
Button.onClick.AddListener는 시그니처상 void만 받으므로 핸들러를 async Task로 바꿀 수 없습니다. 이때 핸들러를 async void로 두되 본문 전체를 try-catch로 감싸는 것이 표준 패턴입니다. 절대 catch에 Debug.LogException만 넣고 끝내지 말고, 사용자에게도 에러를 알리는 UI를 띄우는 것이 좋습니다.
미관찰 예외(UnobservedTaskException)
Task를 만들고 await도 Result 호출도 하지 않으면 그 Task 안의 예외는 누구의 눈에도 띄지 않습니다. 이 상태에서 Task 객체가 GC에 의해 수집되면 .NET은 TaskScheduler.UnobservedTaskException 이벤트를 발생시킵니다.
- .NET Framework 4.0 이전: 이 이벤트가 처리되지 않으면 프로세스를 종료시켰습니다.
- .NET 4.5 이후 (현재): 기본적으로 프로세스를 죽이지 않고 이벤트만 발생시킵니다. 이벤트 핸들러가 없으면 예외는 조용히 사라집니다.
// 프로그램 진입점에서 한 번만 등록
TaskScheduler.UnobservedTaskException += (sender, e) =>
{
Debug.LogError($"[Unobserved] {e.Exception}");
e.SetObserved(); // 관찰됐다고 표시 — 더 이상 위로 전파하지 않음
};
// fire-and-forget 패턴 (보통은 피해야 함)
_ = LongRunningAsync(); // ← await 없이 그냥 시작
이 이벤트는 GC 시점에만 발생합니다. 작업 실패 시점이 아닙니다. 따라서 미관찰 예외를 발견했을 때 이미 한참 시간이 지난 뒤일 수 있고, 발생 위치를 정확히 추적하기 어렵습니다. 의지하기보다는 모든 Task를 누군가 책임지고 await하는 구조를 만드는 것이 우선입니다.
6. 실전 적용 — Unity 모바일에서의 패턴
패턴 1. 입력 검증을 동기 메서드로 분리
async 메서드 본문에 인수 검증을 두면 예외가 즉시 throw되지 않고 Task에 담겨 호출자에게 전달됩니다. 호출자가 await 없이 _ =로 무시하면 검증 실패가 영원히 묵살됩니다.
// ❌ Bad — async 본문에 검증
public async Task SaveAsync(string? path)
{
if (path is null)
throw new ArgumentNullException(nameof(path)); // Task로 감싸짐
await File.WriteAllTextAsync(path, "data");
}
// 호출자
_ = controller.SaveAsync(null); // 검증 예외가 묵살됨
// ✅ Good — 검증은 동기 래퍼, 비동기 본체는 분리
public Task SaveAsync(string? path)
{
if (path is null)
throw new ArgumentNullException(nameof(path)); // 즉시 throw
return SaveCoreAsync(path);
}
private async Task SaveCoreAsync(string path)
{
await File.WriteAllTextAsync(path, "data");
}
호출자가 _ =로 무시해도 ArgumentNullException은 동기 throw이므로 즉시 잡혀 디버그 로그에 남습니다.
패턴 2. 여러 다운로드 작업의 결과 집계
게임 시작 시 여러 어드레서블 번들을 동시에 받는 코드입니다. 한두 개가 실패해도 나머지를 살리고 실패 목록을 보고합니다.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
public sealed class AssetBootstrap
{
private readonly List<string> _bundleKeys = new() { "ui", "stage1", "stage2", "audio" };
public async Task<BootResult> LoadAllAsync()
{
var tasks = _bundleKeys
.Select(key => (Key: key, Task: LoadBundleAsync(key)))
.ToList();
// 모두 끝날 때까지 기다리되, 예외는 따로 모은다
try
{
await Task.WhenAll(tasks.Select(x => x.Task));
}
catch
{
// await는 첫 예외만 던지므로, 여기서 catch만 하고 실제 분류는 아래에서
}
var failures = tasks
.Where(x => x.Task.IsFaulted)
.Select(x => (x.Key, Error: x.Task.Exception!.GetBaseException()))
.ToList();
return new BootResult(
Successful: tasks.Where(x => !x.Task.IsFaulted).Select(x => x.Key).ToList(),
Failed: failures);
}
private async Task LoadBundleAsync(string key)
{
await Task.Delay(50);
if (key == "stage2")
throw new InvalidOperationException("stage2 번들 손상");
}
}
public record BootResult(
List<string> Successful,
List<(string Key, Exception Error)> Failed);
핵심은 개별 task 객체를 보존하는 것입니다. Task.WhenAll에 흘려보내고 끝내지 않고 컬렉션에 두면 IsFaulted 검사로 어느 작업이 어떤 이유로 실패했는지 정확히 알 수 있습니다. await의 첫 예외 의미론을 우회하는 가장 깔끔한 방법입니다.
패턴 3. ConfigureAwait(false)와 라이브러리 코드
async 메서드는 기본적으로 await 직전의 SynchronizationContext(예: Unity 메인 스레드)에서 이어 실행되도록 컨텍스트를 캡처합니다. 라이브러리 내부에서까지 캡처하면 메인 스레드 데드락의 원인이 됩니다.
// 게임 라이브러리 내부 코드
public async Task<byte[]> DownloadAsync(string url)
{
using var http = new HttpClient();
// ✅ 라이브러리 코드는 컨텍스트 복귀가 불필요
var bytes = await http.GetByteArrayAsync(url).ConfigureAwait(false);
return bytes;
}
ConfigureAwait(bool)— 컨텍스트 복귀 여부 설정await후 원래 동기화 컨텍스트로 돌아갈지 결정합니다.false면 ThreadPool에서 이어 실행합니다. UI 라이브러리 내부, 네트워크 호출, 파일 I/O 같은 곳에 사용합니다. UI를 직접 갱신해야 하는 코드(예: Unity의transform.position갱신)에서는 사용하지 않습니다.
예시:await db.QueryAsync().ConfigureAwait(false);응답 파싱은 ThreadPool에서 처리되어 UI 스레드를 차지하지 않습니다.
ConfigureAwait(false)는 예외 처리와 직접 관련은 없지만, 데드락으로 인한 가짜 예외(타임아웃, OperationCanceledException 등)를 줄여주므로 묶어서 기억해두면 좋습니다.
패턴 4. 취소와 예외의 구분
OperationCanceledException(CancellationToken이 발사되었음을 알리는 표준 예외)은 일반 오류와 구분해서 처리해야 합니다. PART 13-09에서 다룬 취소 토큰의 동반자입니다.
try
{
await DownloadAsync(url, token);
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
// 정상적인 취소 — 사용자에게 에러 토스트를 띄우지 않는다
Debug.Log("사용자가 다운로드를 취소했습니다.");
}
catch (Exception ex)
{
// 진짜 오류
ShowErrorPopup(ex);
Debug.LogException(ex);
}
when— catch 필터 (Exception filter)catch절에 추가 조건을 붙여서 조건이 참일 때만 그 catch가 활성화되도록 합니다. 거짓이면 마치 그 catch가 없는 것처럼 다음 catch를 찾으러 갑니다. 스택을 풀지 않은 채 검사하므로 진단에 유리합니다.
예시:catch (HttpException ex) when (ex.StatusCode == 503)503일 때만 잡고, 그 외 HttpException은 다른 catch로 넘어갑니다.
패턴 5. ValueTask의 예외 처리 차이
ValueTask(할당을 줄이기 위한 구조체 기반 Task 대체재)는 GC 압박이 큰 핫패스에서 사용됩니다. 예외 처리 자체는 Task와 동일하지만 두 가지 주의점이 있습니다.
- 두 번 await 금지:
ValueTask는 한 번만 await할 수 있습니다. 두 번 하면 예외가 아닌 정의되지 않은 동작이 발생합니다. 두 번 쓸 일이 있다면.AsTask()로 변환해Task로 보관합니다. Task.WhenAll에 못 넣음:Task.WhenAll은IEnumerable<Task>를 받습니다.ValueTask는 일단.AsTask()로 변환해야 합니다.
public ValueTask<int> GetCachedOrLoadAsync(string key)
{
if (_cache.TryGetValue(key, out var cached))
return new ValueTask<int>(cached); // 즉시 반환 — 할당 없음
return new ValueTask<int>(LoadAsync(key));
}
Unity 모바일에서 매 프레임 호출되는 코드라면 ValueTask 도입을 검토할 가치가 있습니다. 하지만 예외 처리 패턴은 그대로이므로 추가로 익힐 것은 없습니다.
7. 함정과 주의사항
함정 1. catch (AggregateException)으로 잡으려 시도
Task.Wait() 시절의 패턴을 그대로 가져오는 실수입니다.
// ❌ Bad — await는 AggregateException을 풀어서 던진다
try
{
await Task.WhenAll(t1, t2, t3);
}
catch (AggregateException ae) // 절대 잡히지 않음
{
foreach (var inner in ae.InnerExceptions) Log(inner);
}
// ✅ Good — 예외 타입 자체로 잡고, 모든 예외를 보려면 Task.Exception 사용
try
{
var all = Task.WhenAll(t1, t2, t3);
await all;
}
catch (Exception)
{
if (all.Exception is AggregateException ae)
foreach (var inner in ae.Flatten().InnerExceptions) Log(inner);
}
await는 AggregateException.InnerExceptions[0]만 풀어 던집니다. catch (AggregateException) 절은 영원히 활성화되지 않습니다.
함정 2. fire-and-forget을 _ =로 가린다
// ❌ Bad — 예외가 미관찰로 묻힘
_ = SaveLogAsync(payload);
// ✅ Good — 예외 처리를 명시한 fire-and-forget 헬퍼
public static class TaskExt
{
public static void Forget(this Task task, string context)
{
task.ContinueWith(t =>
{
if (t.IsFaulted)
Debug.LogError($"[{context}] {t.Exception?.Flatten()}");
},
TaskContinuationOptions.OnlyOnFaulted);
}
}
// 호출
SaveLogAsync(payload).Forget(nameof(SaveLogAsync));
UniTask라면 .Forget()이 내장되어 있어 동일한 안전성을 제공합니다. Task만 쓰는 환경에서는 위 같은 헬퍼를 한 번 정의해두고 fire-and-forget 호출에 일관되게 적용합니다.
함정 3. try-catch가 await보다 위에 있어 예외를 못 잡는다
// ❌ Bad — try가 너무 일찍 끝난다
public async Task LoadAsync()
{
Task download;
try
{
download = StartDownloadAsync(); // 시작만 함
}
catch (Exception ex)
{
Log(ex);
return;
}
await download; // ← 진짜 예외는 여기서 발생
}
// ✅ Good — await를 try 안에 둔다
public async Task LoadAsync()
{
try
{
var download = StartDownloadAsync();
await download;
}
catch (Exception ex)
{
Log(ex);
}
}
async 메서드를 호출만 하면 동기 부분에서 던진 예외만 잡힙니다. 실제 작업 실패는 await 시점에 도달하므로 try-catch는 반드시 await를 둘러싸야 합니다.
함정 4. Task.Result나 Task.Wait()로 동기 대기
이 함정은 데드락 문제이기도 하지만, 예외 처리 관점에서도 위험합니다.
// ❌ Bad — Wait/Result는 AggregateException을 그대로 던진다
try
{
var result = LoadAsync().Result; // ← 데드락 위험 + AggregateException
}
catch (InvalidOperationException) // 잡히지 않음
{
// ...
}
// ✅ Good — 항상 await 사용
try
{
var result = await LoadAsync();
}
catch (InvalidOperationException)
{
// 잡힌다
}
Unity 메인 스레드에서 .Result를 쓰면 SynchronizationContext 캡처와 결합해 영구 데드락이 발생합니다. 게다가 예외 타입 매칭도 깨집니다. 동기 대기는 시작 코드(예: Main 진입)나 콘솔 도구에서만 허용합니다.
함정 5. UniTask와 Task 혼용 시 예외 변환
UniTask 환경에서도 Task를 받는 외부 라이브러리를 호출할 일이 생깁니다. Task를 UniTask로 변환할 때 예외도 함께 따라옵니다.
// Task → UniTask 변환
UniTask uni = someTask.AsUniTask(); // 예외도 함께 전달됨
await uni; // Task의 예외 그대로 throw
문제는 변환 과정에서 추가 컨텍스트 캡처가 발생하지 않는다는 점입니다. UniTask는 PlayerLoop 기반이고 Task는 ThreadPool 기반이라 catch 시점의 스레드가 다를 수 있습니다. Debug.Log처럼 메인 스레드를 요구하는 호출이 catch 안에 있다면 문제가 생길 수 있습니다.
8. C# 버전별 변화
비동기 예외 처리의 핵심은 C# 5에서 거의 결정되었고 이후는 작은 개선입니다.
C# 5 (.NET 4.5, 2012) — async/await 도입과 ExceptionDispatchInfo 등장
async/await키워드와 상태 머신 기반 컴파일이 도입되었습니다.await는 처음부터 단일 예외만 던지는 의미론이었습니다.ExceptionDispatchInfo가 함께 추가되어 비동기 예외의 스택 트레이스 보존이 가능해졌습니다.- 기본 미관찰 예외 정책이 "프로세스 종료"에서 "이벤트만 발생"으로 변경되었습니다.
C# 6 (.NET 4.6, 2015) — catch/finally에서 await 허용
C# 5까지는 catch나 finally 블록 안에서 await를 사용할 수 없어, 예외 처리 중 비동기 정리(예: 임시 파일 비동기 삭제)가 불가능했습니다. C# 6부터 가능해졌습니다.
// ❌ C# 5에서는 컴파일 에러
try
{
await DownloadAsync();
}
catch (Exception ex)
{
// C# 6부터 OK
await LogToServerAsync(ex);
throw;
}
finally
{
// C# 6부터 OK
await CleanupTempFilesAsync();
}
이 변화는 IL 수준에서 상태 머신이 try/catch/finally 구조를 가로지를 수 있도록 컴파일러가 확장된 결과입니다.
C# 6 — 예외 필터(when)
같은 시기에 도입된 when 키워드는 비동기 예외 처리와 궁합이 좋습니다. 스택 풀기 없이 조건을 검사하므로 진단 정보가 보존됩니다.
// 취소 예외만 분리해 잡되, 토큰이 실제로 발사된 경우만
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
// 정상 종료
}
C# 7+ (.NET Core 2.0~) — ValueTask, async Main, ValueTask<T>
- C# 7.1:
async Task Main이 가능해져 콘솔 앱 진입점에서 await을 사용할 수 있게 되었습니다. - C# 7+:
ValueTask/ValueTask<T>가 도입되었지만, 예외 처리 의미론은Task와 동일합니다(앞 [실전 적용] 패턴 5 참조).
C# 8 (.NET Core 3.0, 2019) — 비동기 스트림(IAsyncEnumerable)
await foreach도 동일한 예외 의미론을 따릅니다. 스트림 안에서 던진 예외는 다음 MoveNextAsync의 await 시점에 부활합니다. 이는 PART 13-11에서 다룹니다.
C# 11+ — 변화 없음 (의미론 안정)
비동기 예외 처리 의미론은 C# 11 이후 새로운 변화가 없습니다. 언어 차원에서는 이미 안정화 단계입니다. 라이브러리 차원의 개선(예: Parallel.ForEachAsync의 예외 처리 옵션)은 계속되고 있지만 본 글의 범위를 벗어납니다.
9. 정리 — 이것만 기억하세요
- 예외는 Task와 함께 흐른다:
async메서드의 예외는 즉시 throw되지 않고 Task의 상태(Faulted)와Exception속성으로 보관됩니다. 호출자가await해야 다시 살아납니다. - 스택 트레이스는 보존된다:
ExceptionDispatchInfo덕분에 원래 발생 지점이 살아남습니다.await라인이 아닌 진짜 발생 위치로 디버그할 수 있습니다. Task.WhenAll은 첫 예외만 던진다: 모든 예외를 보려면 합쳐진 Task의Exception속성(AggregateException)을 직접 확인하거나, 개별 task 컬렉션을 보존해IsFaulted검사로 분류합니다.async void는 이벤트 핸들러에만 사용한다: 그 외에서는async Task를 사용합니다. 이벤트 핸들러로 쓸 때도 본문 전체를 try-catch로 감쌉니다.- 모든 Task는 누군가 await해야 한다: fire-and-forget이 필요하면
Task.ContinueWith또는UniTask.Forget으로 예외를 명시적으로 처리합니다._ =로 가리지 마세요. OperationCanceledException은 오류가 아니다: 일반 예외와 분리해서 처리합니다.when (token.IsCancellationRequested)필터로 정확히 잡습니다.Task.Result/Task.Wait()는 쓰지 않는다: 데드락과AggregateException매칭 깨짐을 동시에 가져옵니다. 항상await를 사용합니다.
비동기 예외 처리의 본질은 "예외가 동기 코드와 다른 시간선을 흐른다"는 사실을 받아들이는 것입니다. Task가 끊기지 않도록 호출 체인을 이어 두면, 예외도 자연스럽게 호출자까지 도달합니다.