
async/await는 실행이 아니라 기술이다
- Async/AwaitState MachineSynchronizationContextC#
문법과 실행 모델은 다른 계층에 있다
async 키워드를 메서드에 붙이면 그 메서드가 비동기로 실행된다고 생각하기 쉽지만, 실제로는 그렇지 않다. async는 컴파일러에게 보내는 신호이지 런타임에게 보내는 지시가 아니다. 이 메서드 안에 await가 있을 것이니 상태 머신으로 변환하라는 표식에 가깝다.
1편에서 return이 값의 반환이 아니라 제어의 양도라는 이야기를 했다. 3편에서는 yield가 제어를 일시적으로 돌려주되 상태를 보존하는 구조라는 걸 다뤘다. async/await는 이 yield의 연장선에 있는데, 상태를 보존하면서 제어를 돌려주는 패턴을 콜백이 아닌 순차적 문법으로 기술한다는 점이 다르다.
핵심은 **"기술"**이라는 단어에 있다. 비동기 실행을 만들어내는 게 아니라 비동기 흐름이 어떤 순서로 이어지는지를 적어두는 것이고, 실제로 무엇이 비동기인지, 어디서 실행되는지는 전혀 다른 계층이 결정한다.
컴파일러가 만드는 상태 머신
개발자가 쓴 코드를 보자.
async Task<string> LoadAsync()
{
var raw = await ReadFileAsync();
var parsed = Parse(raw);
return parsed;
}컴파일러는 이걸 대략 이런 구조체로 변환한다.
struct LoadAsync_StateMachine
{
int state;
string raw;
string parsed;
TaskAwaiter<byte[]> awaiter;
void MoveNext()
{
switch (state)
{
case 0:
awaiter = ReadFileAsync().GetAwaiter();
if (!awaiter.IsCompleted)
{
state = 1;
builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
return; // 호출자에게 제어를 돌려준다
}
goto case 1;
case 1:
raw = awaiter.GetResult();
parsed = Parse(raw);
builder.SetResult(parsed);
return;
}
}
}case 0에서 ReadFileAsync()를 호출하고, 아직 완료되지 않았으면 상태를 1로 저장한 뒤 return한다. 이 return은 1편에서 말한 바로 그 return으로, 제어를 호출자에게 돌려주는 동작이다. 나중에 파일 읽기가 완료되면 MoveNext()가 다시 호출되고 case 1에서 이어서 실행된다.
결국 컴파일러가 콜백을 대신 작성해주는 구조에 가깝다. 개발자가 보는 코드는 위에서 아래로 흐르지만, 실행은 await 지점마다 잘리고 나중에 이어 붙여진다. 순차적으로 읽힌다는 것이 순차적으로 실행된다는 것을 보장하지는 않는다.
await 이후의 코드는 어디서 실행되는가
상태 머신이 재개될 때 어떤 스레드에서 MoveNext()가 호출되는지를 결정하는 것은 SynchronizationContext다. 같은 코드라도 런타임에 따라 전혀 다르게 동작할 수 있다.
async Task DoWork()
{
Console.WriteLine(Thread.CurrentThread.ManagedThreadId); // 1
await Task.Delay(100);
Console.WriteLine(Thread.CurrentThread.ManagedThreadId); // ?
}
// 콘솔 앱: 1 → 4 (스레드풀의 아무 스레드)
// WinForms: 1 → 1 (UI 스레드로 복귀)
// Unity: 1 → 1 (메인 스레드로 복귀)코드는 한 줄도 바뀌지 않았는데 실행 환경에 따라 결과가 달라진다. 콘솔 앱에서는 SynchronizationContext가 null이라 continuation이 스레드풀의 아무 스레드에서 실행되고, WinForms는 UI 스레드로, Unity는 메인 스레드로 보낸다. 문법만으로는 실행 모델을 추론하기 어렵다.
콜백의 평탄화
async/await가 대체한 것을 보면 이 문법의 본질이 더 명확해진다. 1편에서 콜백이 호출 방향의 역전이라고 했다. 비동기 I/O에서 이 역전은 구체적으로 이런 형태가 된다. 호출자가 자신의 처리 함수를 넘겨두고, I/O가 완료되면 피호출자 쪽에서 그 함수를 호출한다.
// 콜백 방식: 호출 방향이 역전된 구조가 코드에 그대로 드러난다
ReadFileAsync("data.bin", (raw) => {
var parsed = Parse(raw);
SaveAsync(parsed, (result) => {
Log(result);
// 중첩이 깊어진다
});
});
// async/await 방식: 같은 역전을 순차적 문법으로 감싼다
var raw = await ReadFileAsync("data.bin");
var parsed = Parse(raw);
var result = await SaveAsync(parsed);
Log(result);구조가 평탄해졌고 try/catch로 에러 처리도 가능해졌지만, 실행 모델 자체는 동일하다. 컴파일러가 콜백 체인을 대신 작성해주는 것이라서 성능이 달라지거나 스레드가 추가되는 것은 아니고, 가독성이 개선된 것에 가깝다.
여기서 계층의 구분이 다시 중요해진다. 2편에서 블로킹이 OS 스레드 레벨의 현상이라고 했다. ReadFileAsync 내부에서 실제 I/O가 일어날 때, OS 커널은 여전히 디스크 드라이버에 요청을 보내고 완료 인터럽트를 기다린다. async/await는 이 OS 레벨의 동작을 바꾸는 것이 아니라, 그 대기를 어떤 스레드가 감당하고 완료 후 어디서 이어갈 것인지를 언어 레벨에서 기술하는 방식이다. 블로킹의 실체는 OS 안에 그대로 있고, async/await는 그 위에 놓인 언어의 계층에서 제어 흐름을 재구성한다.
이건 async/await를 이해하려면 그것이 대체한 것을 이해해야 한다는 뜻이기도 하다. 모든 await는 제어가 떠났다가 돌아오는 지점이고, 돌아올 때 같은 맥락일 수도 있고 아닐 수도 있다. 콜백 시절에는 이 사실이 코드 구조에 그대로 드러났는데, async/await는 그걸 감추되 없애지는 않았다.
이 상태 머신이 특정 런타임에서 어떻게 작동하는지는 맥락에 따라 달라진다. SynchronizationContext가 모든 continuation을 단일 스레드로 보내고, 그 스레드가 60fps로 렌더링까지 담당하고 있다면 어떤 일이 벌어질 수 있는지를 다음 글에서 다룬다.