Skip to content

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의 비용과 동작을 정확히 예측할 수 있습니다.

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);
}
}
항목의미
지역 변수 → 필드await 전후로 값을 유지하기 위해 힙에 저장됨
MoveNext()await 지점이 하나의 상태로 분리됨
스레드 반환IsCompleted == false이면 즉시 return으로 스레드 반납
재개I/O 완료 콜백에서 MoveNext()를 다시 호출

Task<T>클래스(참조 타입) 입니다. 반환할 때마다 힙 할당이 발생합니다.

// Task<int>를 반환할 때마다 힙에 Task 객체 생성
public async Task<int> GetValueAsync()
{
await Task.Delay(100);
return 42;
}

자주 호출되는 메서드에서 이 할당이 누적되면 GC 압력이 증가합니다.

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;
}
상황권장 타입
일반적인 비동기 메서드Task<T>
자주 호출되고 동기 완료가 많은 경우ValueTask<T>
고성능 라이브러리 내부ValueTask<T>

ValueTask한 번만 await해야 합니다. 여러 번 await하거나 캐싱하면 정의되지 않은 동작이 발생합니다.

var valueTask = GetCachedValueAsync();
// 잘못된 사용: 두 번 await
var result1 = await valueTask;
var result2 = await valueTask; // 위험: 두 번째 await는 보장되지 않음
// 올바른 사용: 한 번만 await
var result = await GetCachedValueAsync();

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어느 쪽이든 무관 (컨텍스트 없음)

async void는 이벤트 핸들러 전용입니다. 예외가 발생하면 try/catch로 잡을 수 없고 프로세스가 크래시됩니다.

// 잘못된 패턴: async void
public async void LoadData()
{
var data = await FetchAsync(); // 예외 발생 시 캐치 불가
UpdateUI(data);
}
// 호출 측에서 await할 수 없음
LoadData(); // Task를 반환하지 않으므로 await 불가
// 올바른 패턴: async Task
public 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);
}
}

단순히 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 타이밍이 올바름
}

장시간 실행되는 비동기 작업은 반드시 취소를 지원해야 합니다.

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();
}

토큰은 모든 비동기 호출 체인에 전달해야 합니다.

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}");
}
}

// 순차 실행: 총 시간 = Task1 + Task2 + Task3
public 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}");
}

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+)