Quick Reference
await는 비동기 작업이 끝날 때까지 현재 메서드만 중단하고 호출자에게 Task를 돌려줍니다. 일반 메서드는 Task 또는 Task<T>를 반환하고, async void는 프레임워크가 요구하는 이벤트 처리기에만 둡니다. I/O 대기는 await로 넘기고, CPU 계산을 병렬화할지는 별도로 판단합니다.
public static async Task<string> LoadDataAsync(
HttpClient http,
string url,
CancellationToken ct)
{
using HttpResponseMessage response = await http.GetAsync(url, ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(ct);
}
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
string json = await LoadDataAsync(http, url, cts.Token);문법
어떤 비동기 형태가 있나
반환 타입은 아래 네 가지를 먼저 구분합니다.
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를 기다리는 동안 호출 스레드를 점유하지 않는 것이 핵심이며, 어떤 스레드가 실제 작업을 수행하는지는 API와 런타임에 따라 다릅니다.
// 작성한 코드
async Task<int> GetCountAsync()
{
var items = await FetchAsync();
return items.Count;
}
// 컴파일러가 생성하는 구조 (개념)
// IAsyncStateMachine 구현체가 MoveNext()를 통해 상태를 전환
// await 전: 비동기 작업 시작 + 콜백 등록
// await 후: 콜백에서 MoveNext() 재호출Task를 await할 때 현재 SynchronizationContext가 있으면 기본적으로 continuation(이후 실행)을 그 컨텍스트에 게시합니다. WPF/WinForms에서는 UI 스레드 복귀가 될 수 있지만, ASP.NET Core처럼 컨텍스트가 없는 환경에서는 완료한 스레드나 스레드 풀에서 이어질 수 있습니다. 따라서 await가 항상 원래 스레드로 돌아오거나 스레드 풀을 빌린다고 가정하지 않습니다.
Task, Task<T>, ValueTask
| 반환 타입 | 의미 | 사용 상황 |
|---|---|---|
Task | 완료만 알림 | 반환값 없는 비동기 |
Task<T> | 완료 + 결과 반환 | 반환값 있는 비동기 |
ValueTask<T> | 힙 할당 최소화 | 자주 호출되는 고성능 경로 |
async void | 완료와 예외를 Task로 전달하지 않음 | 프레임워크 이벤트 핸들러 전용 |
async void 금지 — 호출자가 완료와 예외를 관찰할 수 없다
async void는 호출자가 await할 Task가 없습니다. 예외는 시작 시점의 SynchronizationContext로 전파될 수 있지만, 호출자가 작업 완료와 실패를 조합하거나 try/catch로 관찰할 수 없습니다. 이벤트 핸들러 외의 메서드는 Task 계열을 반환합니다.
// ❌ 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)
Task를 await할 때는 기본적으로 현재 SynchronizationContext를 캡처해 이어서 실행할 위치를 정합니다. UI 스레드에서는 이 동작이 필요할 수 있고, 컨텍스트에 의존하지 않는 라이브러리 코드는 ConfigureAwait(false)로 캡처를 피할 수 있습니다.
// 라이브러리 코드 — 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에는 일반적으로 SynchronizationContext가 없다.
// WPF/WinForms UI를 갱신해야 하는 호출 경로에서는 false를 쓰지 않는다.언제 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);await 실행 흐름
<ZoomImage src="/images/csharp/async-await-flow.svg" alt="async와 await가 작업을 넘기고 나중에 다시 이어지는 흐름을 보여주는 다이어그램" caption="현재 메서드는 await 지점에서 잠시 멈췄다가, 작업이 끝나면 다음 줄부터 다시 이어집니다." />
비동기 호출 규칙
| 규칙 | 이유 |
|---|---|
Task 또는 Task<T> 반환 | 호출자가 await하고 예외를 추적할 수 있음 |
async void 금지 | 호출자가 완료와 예외를 Task로 관찰할 수 없음 |
진짜 비동기 API를 await | Task.Run으로 동기 코드를 감싸는 것은 병목을 숨김 |
| 취소 토큰 전달 | 긴 대기 작업을 사용자 흐름에 맞게 중단 가능 |
CPU 작업은 Task.Run | async는 I/O 대기용, CPU 병렬화는 별도 문제 |
주의할 점
.Result나 .Wait()로 비동기를 동기로 강제 전환하지 마세요. UI 동기화 컨텍스트에서는 continuation이 막힌 UI 스레드를 기다려 데드락이 날 수 있고, 서버에서는 대기 스레드를 늘려 처리량을 떨어뜨릴 수 있습니다. 동기 경계가 꼭 필요하면 애플리케이션의 가장 바깥 어댑터 한 곳에서만 정책으로 정하고, 내부 호출 체인은 async로 유지합니다.
"호출하는 쪽은 동기, 안쪽만 비동기"처럼 중간에서 흐름을 끊으면 디버깅이 매우 어려워집니다. 비동기는 호출 체인 전체를 async로 유지하는 것이 원칙입니다.
참고 링크
2 sources