← 문제로

37. async void vs async Task: 예외 전파와 완료 추적

난이도 상
내 리뷰 · C#
해설 · C#

해설 — async void vs async Task: 예외 전파와 완료 추적

난이도: 상

요약

async void 는 호출자에게 돌려줄 Task 가 없다. 그래서 (1) 호출부의 try/catch 는 그 예외를 잡지 못하고, (2) 예외는 메서드가 시작될 때 캡처한 SynchronizationContext(없으면 스레드풀)로 다시 던져지며, (3) 호출자는 완료 시점도 알 수 없다. 반면 async Task 는 예외를 반환된 Task 에 저장했다가 await 지점에서 원래 예외로 다시 던지므로 정상적으로 잡힌다.

예측: "caught void" 는 출력되지 않고, "caught task: boom from async Task" 만 출력된다. FireAndForget 의 예외는 스레드풀로 올라가 콘솔 앱에서는 처리되지 않은 예외로 프로세스를 종료시킬 수 있다(타이밍에 따라 "Main done" 출력 전/후로 갈릴 수 있음).

핵심 개념

  • async 메서드는 첫 await 에서 호출자에게 반환한다. FireAndForget() 호출은 await Task.Yield() 를 만나는 순간 즉시 반환하므로, (A)try 블록은 이미 정상적으로 빠져나온 뒤다. 이후 스레드풀에서 재개돼 던진 예외는 그 try 와 아무 관계가 없다.
  • 예외 저장 위치: async Task → 반환 Task.Exception 에 저장 → await 가 언래핑해 재던짐. async void → 저장할 Task 가 없어 캡처된 SynchronizationContext.Post 로 올라감(콘솔/서버는 보통 컨텍스트가 null → 스레드풀에서 재던짐 → AppDomain.UnhandledException → 프로세스 종료).

단계별 내부 동작

  1. FireAndForget() 진입 → Task.Yield() 에서 상태 머신이 continuation 을 예약하고 void 반환. (A) 의 try 정상 종료(예외 없음). "caught void" 는 절대 실행되지 않는다.
  2. (B) await FireTask(): FireTask 도 yield 에서 반환하지만 Task 를 돌려준다. 그 Task 가 예외로 완료되면 await 가 원래 InvalidOperationException 을 다시 던진다 → catch 가 잡아 "caught task: boom from async Task" 출력.
  3. 한편 1에서 예약된 continuation 이 스레드풀에서 재개돼 throw → 잡아줄 곳이 없는 async void 예외 → 처리되지 않은 예외로 상승. 콘솔 앱이면 프로세스 크래시. (A) 의 catch 와는 무관.
  4. "Main done" 은 프로세스가 크래시하기 전이면 출력될 수 있으나, async void 예외 처리 시점과 경합한다(비결정적).

흔한 오해·함정

  • "try/catch 로 감쌌으니 안전하다"는 착각: async void 호출을 감싼 try/catch 는 동기 부분(첫 await 이전)만 보호한다. await 이후 예외는 못 잡는다.
  • 완료 추적 불가: async void 는 await/Task.WhenAll 대상이 될 수 없어, 호출자가 "끝났는지" 알 수 없다. 서버에서 fire-and-forget 작업을 async void 로 던지면 종료 시점에 진행 중 작업이 유실되거나 예외가 프로세스를 죽인다.
  • 유일한 정당한 용도는 이벤트 핸들러(시그니처가 void 를 요구). 그 경우에도 핸들러 본문 전체를 try/catch 로 감싸 예외가 밖으로 새지 않게 해야 한다.

면접 포인트

  • 왜 async void 를 피하는가: 예외를 호출부에서 못 잡고 프로세스를 죽일 수 있으며, 완료를 await 할 수 없다. 라이브러리/서버 코드는 항상 async Task(또는 Task<T>)를 반환.
  • 부득이한 async void(이벤트 핸들러)의 안전장치: 본문 전체 try/catch, 또는 Func<Task> 를 받아 안전하게 실행하는 래퍼.
  • async 메서드가 첫 await 에서 반환한다는 점, 예외가 Task 에 저장됐다 await 에서 재던져지는 메커니즘(ExceptionDispatchInfo 로 스택 보존)을 설명할 수 있어야 한다.