C# async/await 내부 동작 완전 분석
개요 — async/await가 해결하는 문제
Section titled “개요 — async/await가 해결하는 문제”C#에서 비동기 프로그래밍이 없던 시절, I/O 작업은 스레드를 블로킹했습니다. 스레드가 블로킹되면 CPU는 아무 일도 하지 않으면서 스레드 스택 메모리(약 1MB)만 낭비합니다.
// 동기 방식: 스레드가 I/O 완료까지 대기 (블로킹)string data = File.ReadAllText("data.txt"); // 이 동안 스레드는 멈춤Console.WriteLine(data);async/await는 I/O 대기 중에 스레드를 반환해 다른 작업에 쓸 수 있게 합니다. 스레드 수를 늘리지 않고도 높은 처리량을 달성하는 것이 목표입니다.
// 비동기 방식: I/O 대기 중 스레드 반환string data = await File.ReadAllTextAsync("data.txt");Console.WriteLine(data);1. 컴파일러가 만드는 상태 머신
Section titled “1. 컴파일러가 만드는 상태 머신”async 메서드는 C# 컴파일러에 의해 상태 머신(State Machine) 구조체로 완전히 변환됩니다. 이 사실을 이해하면 async/await의 비용과 동작을 정확히 예측할 수 있습니다.
1.1 원본 코드
Section titled “1.1 원본 코드”public async Task<string> FetchDataAsync(string url){ using var client = new HttpClient(); string result = await client.GetStringAsync(url); Console.WriteLine($"Received {result.Length} bytes"); return result;}1.2 컴파일러가 생성하는 구조 (단순화)
Section titled “1.2 컴파일러가 생성하는 구조 (단순화)”// 컴파일러가 내부적으로 생성하는 코드 (개념적 표현)[AsyncStateMachine(typeof(<FetchDataAsync>d__0))]public Task<string> FetchDataAsync(string url){ var stateMachine = new <FetchDataAsync>d__0(); stateMachine.__this = this; stateMachine.url = url; stateMachine.__builder = AsyncTaskMethodBuilder<string>.Create(); stateMachine.__state = -1; stateMachine.__builder.Start(ref stateMachine); return stateMachine.__builder.Task;}
// 상태 머신 구조체private struct <FetchDataAsync>d__0 : IAsyncStateMachine{ public int __state; public AsyncTaskMethodBuilder<string> __builder; public string url;
// 지역 변수들이 필드로 승격됨 private HttpClient <client>5__1; private string <result>5__2; private TaskAwaiter<string> <>u__1;
void IAsyncStateMachine.MoveNext() { int state = __state; string result2;
try { if (state != 0) { // 첫 번째 실행: await 전까지 실행 <client>5__1 = new HttpClient(); <>u__1 = <client>5__1.GetStringAsync(url).GetAwaiter();
if (!<>u__1.IsCompleted) { __state = 0; // 재개 지점 기록 __builder.AwaitUnsafeOnCompleted(ref <>u__1, ref this); return; // 스레드 반환 } } else { // await 이후 재개 지점 __state = -1; }
// await 완료 후 계속 실행 <result>5__2 = <>u__1.GetResult(); Console.WriteLine($"Received {<result>5__2.Length} bytes"); result2 = <result>5__2; } catch (Exception ex) { __state = -2; __builder.SetException(ex); return; }
__state = -2; __builder.SetResult(result2); }}1.3 상태 머신이 중요한 이유
Section titled “1.3 상태 머신이 중요한 이유”| 항목 | 의미 |
|---|---|
| 지역 변수 → 필드 | await 전후로 값을 유지하기 위해 힙에 저장됨 |
MoveNext() | 각 await 지점이 하나의 상태로 분리됨 |
| 스레드 반환 | IsCompleted == false이면 즉시 return으로 스레드 반납 |
| 재개 | I/O 완료 콜백에서 MoveNext()를 다시 호출 |
2. Task vs ValueTask
Section titled “2. Task vs ValueTask”2.1 Task의 비용
Section titled “2.1 Task의 비용”Task<T>는 클래스(참조 타입) 입니다. 반환할 때마다 힙 할당이 발생합니다.
// Task<int>를 반환할 때마다 힙에 Task 객체 생성public async Task<int> GetValueAsync(){ await Task.Delay(100); return 42;}자주 호출되는 메서드에서 이 할당이 누적되면 GC 압력이 증가합니다.
2.2 ValueTask의 장점
Section titled “2.2 ValueTask의 장점”ValueTask<T>는 구조체(값 타입) 기반입니다. 동기적으로 완료되는 경우(캐시 히트 등) 힙 할당 없이 처리할 수 있습니다.
private int _cachedValue = -1;
// 캐시된 경우 힙 할당 없이 즉시 반환public ValueTask<int> GetCachedValueAsync(){ if (_cachedValue >= 0) { return new ValueTask<int>(_cachedValue); // 힙 할당 없음 }
return new ValueTask<int>(FetchFromDatabaseAsync());}
private async Task<int> FetchFromDatabaseAsync(){ await Task.Delay(100); _cachedValue = 42; return _cachedValue;}2.3 사용 기준
Section titled “2.3 사용 기준”| 상황 | 권장 타입 |
|---|---|
| 일반적인 비동기 메서드 | Task<T> |
| 자주 호출되고 동기 완료가 많은 경우 | ValueTask<T> |
| 고성능 라이브러리 내부 | ValueTask<T> |
ValueTask는 한 번만 await해야 합니다. 여러 번 await하거나 캐싱하면 정의되지 않은 동작이 발생합니다.
var valueTask = GetCachedValueAsync();
// 잘못된 사용: 두 번 awaitvar result1 = await valueTask;var result2 = await valueTask; // 위험: 두 번째 await는 보장되지 않음
// 올바른 사용: 한 번만 awaitvar result = await GetCachedValueAsync();3. 흔한 실수와 올바른 패턴
Section titled “3. 흔한 실수와 올바른 패턴”3.1 Deadlock — ConfigureAwait(false)
Section titled “3.1 Deadlock — ConfigureAwait(false)”SynchronizationContext가 있는 환경(ASP.NET Classic, WinForms, WPF)에서 await 완료 후 원래 컨텍스트로 돌아오려 합니다. .Result나 .Wait()로 블로킹하면 교착 상태가 발생합니다.
// Deadlock 발생 패턴public string GetData(){ // UI 스레드에서 호출했다고 가정 // 1. Task 실행 시작 // 2. await 완료 후 UI 스레드로 복귀 시도 // 3. 그런데 UI 스레드는 .Result에서 블로킹 중 // → 영원히 대기 (Deadlock) return GetDataAsync().Result; // 절대 금지}
public async Task<string> GetDataAsync(){ var data = await FetchAsync(); // 완료 후 UI 컨텍스트로 돌아오려 함 return data;}해결책: 라이브러리 코드에서는 ConfigureAwait(false) 사용
public async Task<string> GetDataAsync(){ // ConfigureAwait(false): 완료 후 원래 컨텍스트로 돌아가지 않음 var data = await FetchAsync().ConfigureAwait(false); return data;}
// 호출 측에서도 await로 처리public async Task<string> GetData(){ return await GetDataAsync(); // .Result 사용 금지}| 코드 위치 | ConfigureAwait(false) 권장 여부 |
|---|---|
| 라이브러리 / 공통 유틸 | 사용 (컨텍스트 불필요) |
| UI 이벤트 핸들러 | 사용 안 함 (UI 업데이트 필요) |
| ASP.NET Core | 어느 쪽이든 무관 (컨텍스트 없음) |
3.2 async void — 예외 처리 불가
Section titled “3.2 async void — 예외 처리 불가”async void는 이벤트 핸들러 전용입니다. 예외가 발생하면 try/catch로 잡을 수 없고 프로세스가 크래시됩니다.
// 잘못된 패턴: async voidpublic async void LoadData(){ var data = await FetchAsync(); // 예외 발생 시 캐치 불가 UpdateUI(data);}
// 호출 측에서 await할 수 없음LoadData(); // Task를 반환하지 않으므로 await 불가
// 올바른 패턴: async Taskpublic async Task LoadDataAsync(){ var data = await FetchAsync(); UpdateUI(data);}
// WinForms/WPF 이벤트 핸들러: async void는 여기서만 허용private async void Button_Click(object sender, EventArgs e){ try { await LoadDataAsync(); // 내부는 async Task로 위임 } catch (Exception ex) { MessageBox.Show(ex.Message); }}3.3 불필요한 async/await
Section titled “3.3 불필요한 async/await”단순히 Task를 반환만 한다면 async/await를 쓸 필요가 없습니다. 상태 머신 생성 비용만 늘어납니다.
// 불필요한 async/await: 상태 머신 비용 낭비public async Task<string> GetStringAsync(){ return await _repository.FetchAsync(); // 그냥 반환만 하는 경우}
// 올바른 패턴: Task를 그대로 전달public Task<string> GetStringAsync(){ return _repository.FetchAsync(); // 상태 머신 생성 없음}단, using 블록이나 try/catch가 있다면 async/await가 필요합니다.
// using이 있으면 async/await 필수public async Task<string> GetStringAsync(){ using var client = new HttpClient(); return await client.GetStringAsync("https://example.com"); // using 블록 내에서 await해야 Dispose 타이밍이 올바름}4. CancellationToken — 취소 전파
Section titled “4. CancellationToken — 취소 전파”장시간 실행되는 비동기 작업은 반드시 취소를 지원해야 합니다.
4.1 기본 패턴
Section titled “4.1 기본 패턴”public async Task ProcessDataAsync(CancellationToken cancellationToken = default){ for (int i = 0; i < 1000; i++) { // 취소 요청 확인: 취소 시 OperationCanceledException 발생 cancellationToken.ThrowIfCancellationRequested();
await ProcessItemAsync(i, cancellationToken); }}
// 호출 측var cts = new CancellationTokenSource();cts.CancelAfter(TimeSpan.FromSeconds(5)); // 5초 후 자동 취소
try{ await ProcessDataAsync(cts.Token);}catch (OperationCanceledException){ Console.WriteLine("작업이 취소되었습니다.");}finally{ cts.Dispose();}4.2 CancellationToken 전파
Section titled “4.2 CancellationToken 전파”토큰은 모든 비동기 호출 체인에 전달해야 합니다.
public async Task<List<Item>> GetItemsAsync( string query, CancellationToken cancellationToken = default){ // HttpClient에 토큰 전달 var response = await _httpClient .GetAsync($"/api/items?q={query}", cancellationToken) .ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var json = await response.Content .ReadAsStringAsync(cancellationToken) .ConfigureAwait(false);
return JsonSerializer.Deserialize<List<Item>>(json)!;}4.3 LinkedTokenSource — 타임아웃 + 수동 취소
Section titled “4.3 LinkedTokenSource — 타임아웃 + 수동 취소”public async Task<string> FetchWithTimeoutAsync( string url, CancellationToken userToken = default){ // 사용자 취소 토큰과 타임아웃 토큰을 연결 using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( userToken, timeoutCts.Token);
try { return await _httpClient .GetStringAsync(url, linkedCts.Token) .ConfigureAwait(false); } catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) { throw new TimeoutException($"요청이 10초 초과: {url}"); }}5. 병렬 비동기 작업
Section titled “5. 병렬 비동기 작업”5.1 순차 vs 병렬 실행
Section titled “5.1 순차 vs 병렬 실행”// 순차 실행: 총 시간 = Task1 + Task2 + Task3public async Task SequentialAsync(){ var a = await FetchAAsync(); // 1초 대기 var b = await FetchBAsync(); // 1초 대기 var c = await FetchCAsync(); // 1초 대기 // 총 3초}
// 병렬 실행: 총 시간 = max(Task1, Task2, Task3)public async Task ParallelAsync(){ var taskA = FetchAAsync(); // Task 시작 var taskB = FetchBAsync(); // Task 시작 var taskC = FetchCAsync(); // Task 시작
var (a, b, c) = ( await taskA, await taskB, await taskC ); // 총 ~1초}
// Task.WhenAll: 모두 완료 대기public async Task WhenAllAsync(){ var results = await Task.WhenAll( FetchAAsync(), FetchBAsync(), FetchCAsync() ); // results[0], results[1], results[2]}
// Task.WhenAny: 하나라도 완료되면 계속public async Task WhenAnyAsync(){ var tasks = new[] { FetchAAsync(), FetchBAsync(), FetchCAsync() };
var firstCompleted = await Task.WhenAny(tasks); var result = await firstCompleted; Console.WriteLine($"가장 빠른 결과: {result}");}5.2 예외 처리 주의사항
Section titled “5.2 예외 처리 주의사항”Task.WhenAll은 모든 예외를 AggregateException으로 묶습니다.
public async Task HandleMultipleExceptionsAsync(){ var tasks = new[] { FetchAAsync(), FetchBAsync(), // 예외 발생 가정 FetchCAsync() // 예외 발생 가정 };
try { await Task.WhenAll(tasks); } catch (Exception ex) { // await 시 첫 번째 예외만 unwrap됨 Console.WriteLine($"첫 번째 예외: {ex.Message}");
// 모든 예외 확인하려면 Task들을 직접 검사 foreach (var task in tasks.Where(t => t.IsFaulted)) { foreach (var inner in task.Exception!.InnerExceptions) { Console.WriteLine($"추가 예외: {inner.Message}"); } } }}| 개념 | 핵심 요약 |
|---|---|
| 상태 머신 | async 메서드는 컴파일러가 구조체 상태 머신으로 변환, await 지점마다 상태 분리 |
| Task vs ValueTask | 일반은 Task, 동기 완료가 많고 고성능이 필요하면 ValueTask |
| Deadlock | .Result/.Wait() 금지, 라이브러리에서는 ConfigureAwait(false) 사용 |
| async void | 이벤트 핸들러 전용, 내부 로직은 async Task로 위임 |
| 불필요한 async | 단순 전달이면 async/await 제거, using/try 있으면 유지 |
| CancellationToken | 모든 체인에 전달, ThrowIfCancellationRequested() 호출 |
| 병렬 실행 | Task.WhenAll로 동시 실행, 예외는 모든 Task 개별 확인 |
- IAsyncEnumerable:
await foreach로 비동기 스트림 처리 - Channel<T>: 생산자-소비자 패턴의 고성능 비동기 큐
- Parallel.ForEachAsync: 병렬 비동기 루프 (.NET 6+)