핵심 정리
public async Task<string> LoadDataAsync(HttpClient http)
{
using HttpResponseMessage response = await http.GetAsync("/api/items");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
// 취소 토큰으로 3초 제한
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
string json = await http.GetStringAsync(url, cts.Token);기본 흐름
어떤 비동기 형태가 있나
async 카드에서는 보통 아래 네 가지를 먼저 구분합니다.
async Task LoadAsync() { await Task.Delay(100); }
async Task<int> CountAsync() { await Task.Delay(100); return 1; }
async ValueTask<int> ReadCachedAsync() => 1;
async void OnClick(object? sender, EventArgs e) { await Task.Delay(100); }Task: 반환값 없는 비동기Task<T>: 결과가 있는 비동기ValueTask<T>: 자주 완료되는 고성능 경로async void: 이벤트 핸들러 전용
// ❌ 반환 타입이 없어 await할 수 없다
// async string LoadAsync() { ... }
// ✅ 비동기 메서드는 Task 계열을 반환
async Task<string> LoadAsync() => await http.GetStringAsync(url);내부 동작 — 상태 머신으로 변환
async 메서드는 컴파일러가 상태 머신(state machine) 으로 변환합니다. await 지점이 상태 전환점이 되고, I/O를 기다리는 동안 현재 스레드는 반환됩니다. 작업이 완료되면 이전 컨텍스트에서 이어서 실행됩니다.
// 작성한 코드
async Task<int> GetCountAsync()
{
var items = await FetchAsync();
return items.Count;
}
// 컴파일러가 생성하는 구조 (개념)
// IAsyncStateMachine 구현체가 MoveNext()를 통해 상태를 전환
// await 전: 비동기 작업 시작 + 콜백 등록
// await 후: 콜백에서 MoveNext() 재호출핵심은 스레드를 블록하지 않는다는 점입니다. await 시 스레드 풀에 스레드를 반납하고, 완료되면 다시 가져와 나머지를 실행합니다.
Task, Task<T>, ValueTask
| 반환 타입 | 의미 | 사용 상황 |
|---|---|---|
Task | 완료만 알림 | 반환값 없는 비동기 |
Task<T> | 완료 + 결과 반환 | 반환값 있는 비동기 |
ValueTask<T> | 힙 할당 최소화 | 자주 호출되는 고성능 경로 |
async void | 화재 후 망각 | 이벤트 핸들러 전용 |
async void 금지 — 예외를 추적할 수 없다
async void는 예외가 발생해도 호출자가 await할 수 없어 예외가 소실됩니다. 이벤트 핸들러 외에는 절대 사용하지 마세요.
// ❌ async void — 예외 추적 불가
async void LoadData() { throw new Exception("오류"); }
LoadData(); // 예외가 어디론가 사라짐
// ✅ async Task — 예외 전파 가능
async Task LoadDataAsync() { throw new Exception("오류"); }
try { await LoadDataAsync(); }
catch (Exception ex) { Handle(ex); }
// 이벤트 핸들러는 void가 강제되므로 예외를 내부에서 처리
button.Click += async (s, e) =>
{
try { await LoadDataAsync(); }
catch (Exception ex) { ShowError(ex); }
};// ❌ 동기 블로킹으로 흐름을 깨뜨림
var text = http.GetStringAsync(url).Result;
// ✅ 호출 체인 전체를 async로 유지
var text = await http.GetStringAsync(url);ConfigureAwait(false)
await 후 기본적으로 원래 동기화 컨텍스트에서 이어서 실행됩니다. UI 스레드에서는 이것이 중요하지만, 라이브러리 코드에서는 컨텍스트 캡처가 불필요한 오버헤드입니다.
// 라이브러리 코드 — UI 스레드로 돌아올 필요 없음
public async Task<Data> FetchAsync()
{
var result = await http.GetAsync(url).ConfigureAwait(false);
return await result.Content.ReadFromJsonAsync<Data>().ConfigureAwait(false);
}
// ASP.NET Core / 최신 .NET: SynchronizationContext가 없으므로 없어도 무방
// WPF/WinForms: UI 업데이트가 필요 없는 라이브러리 코드에서 필요언제 Task.WhenAll로 묶고 언제 순차 await를 쓰나
독립적인 I/O 두 개를 동시에 기다릴 수 있으면 Task.WhenAll이 더 낫습니다. 반대로 앞 결과가 뒤 호출에 필요하면 순차 await가 맞습니다.
// ✅ 서로 독립적인 작업은 병렬 대기
Task<User> userTask = userApi.GetAsync(id);
Task<Order[]> orderTask = orderApi.GetByUserAsync(id);
await Task.WhenAll(userTask, orderTask);
// ✅ 앞 결과가 뒤 입력이면 순차 대기
User user = await userApi.GetAsync(id);
Order[] orders = await orderApi.GetByUserAsync(user.Id);이미지
체크포인트
| 규칙 | 이유 |
|---|---|
Task 또는 Task<T> 반환 | 호출자가 await하고 예외를 추적할 수 있음 |
async void 금지 | 예외가 소실되고 완료를 기다릴 수 없음 |
진짜 비동기 API를 await | Task.Run으로 동기 코드를 감싸는 것은 병목을 숨김 |
| 취소 토큰 전달 | 긴 대기 작업을 사용자 흐름에 맞게 중단 가능 |
CPU 작업은 Task.Run | async는 I/O 대기용, CPU 병렬화는 별도 문제 |
주의할 점
.Result나 .Wait()로 비동기를 동기로 강제 전환하지 마세요. 특히 UI 스레드나 ASP.NET 동기화 컨텍스트에서 이 패턴을 쓰면 데드락이 발생합니다. await로 대기할 수 없는 상황이라면 Task.Run(() => ...).GetAwaiter().GetResult()를 사용하거나 설계를 재검토하는 편이 좋습니다.
"호출하는 쪽은 동기, 안쪽만 비동기"처럼 중간에서 흐름을 끊으면 디버깅이 매우 어려워집니다. 비동기는 호출 체인 전체를 async로 유지하는 것이 원칙입니다.
참고 링크
2 sources