[PART13.비동기와 스레딩 기초(1/15)] 동기(Synchronous) vs 비동기(Asynchronous) — 두 가지 실행 모델
호출-대기-반환 / 호출-등록-반납-재개 / UI 멈춤·서버 처리량의 갈림길
목차
1. 문제 제기 — UI가 5초간 얼어붙는 이유
Unity 모바일 게임 클라이언트를 만들다 보면 누구나 한 번은 아래 같은 코드를 짜게 됩니다.
// Unity UI 버튼 클릭 핸들러: 서버에서 유저 데이터를 받아 화면에 표시
public void OnLoadPlayerButtonClicked()
{
using var client = new HttpClient();
string json = client.GetStringAsync("https://api.game.com/me").Result; // 여기서 멈춥니다
playerNameText.text = ParseName(json);
}
겉보기에 코드는 위에서 아래로 자연스럽게 흐릅니다. 그런데 실제로 빌드해서 실기기로 돌려 보면 버튼을 누른 순간 게임 화면이 통째로 멈춥니다. 통신이 끝나기 전까지는 캐릭터의 기본 idle 애니메이션조차 다음 프레임으로 넘어가지 않습니다. 몇 초 뒤 응답이 도착하면 그제야 화면이 부드럽게 다시 돌아갑니다.
이 5초 동안 메인 스레드는 CPU 자원을 거의 쓰지 않은 채로 그 자리에 박혀 있었습니다. 와이파이 신호를 기다리는 일은 CPU가 할 일이 아닌데도, "한 줄 한 줄 순서대로 끝까지 처리한다"는 동기 실행 모델 때문에 스레드 하나가 통째로 묶여 버린 것입니다.
같은 문제가 서버에서는 다른 모습으로 나타납니다. ASP.NET Core 웹 서버가 동시에 1,000개의 요청을 받았는데 각 요청이 1초짜리 데이터베이스 쿼리를 포함한다고 해 봅시다. 동기 방식이라면 1,000개의 스레드가 1초씩 데이터베이스 응답을 기다리며 누워 있게 됩니다. 스레드 하나는 약 1MB의 스택을 잡아먹는 비싼 자원이라, 곧 스레드 풀이 고갈되고 서버가 응답하지 않게 됩니다.
이 두 문제 — UI 멈춤과 서버 처리량 한계 — 의 뿌리는 같습니다. "기다리는 동안 스레드가 자리를 비킬 수 없다"는 점입니다. 비동기 실행 모델은 바로 이 가정을 깨뜨리기 위해 등장했습니다. 이 글에서는 두 모델이 어떻게 다른지, C# 컴파일러가 async/await를 어떻게 동기 코드처럼 보이는 비동기 코드로 변환하는지, 그리고 Unity 환경에서 이 도구를 어떻게 다뤄야 하는지를 IL 레벨까지 파고들어 살펴보겠습니다.
2. 개념 정의 — 두 가지 실행 모델의 본질
비유 — 카페 바리스타의 주문 처리 방식
손님이 줄을 서서 커피를 주문하는 카페를 떠올려 봅시다. 바리스타가 주문을 처리하는 방식은 두 가지가 있습니다.
동기식 바리스타 — 한 손님의 주문을 받으면 그 자리에서 에스프레소를 추출하고, 우유를 데우고, 라떼아트까지 완성해서 손님에게 건넵니다. 그 손님이 음료를 받기 전까지는 다음 손님의 주문을 받지 않습니다. 깔끔하고 직관적이지만, 줄이 길어지면 뒤에 선 손님은 한참을 기다려야 합니다.
비동기식 바리스타 — 주문을 받으면 진동벨을 손님에게 건네고 곧바로 다음 손님의 주문을 받습니다. 음료가 다 만들어지면 진동벨이 울리고, 그제야 해당 손님이 와서 음료를 가져갑니다. 바리스타는 "주문 접수"와 "음료 전달"이라는 두 시점만 처리하면 되고, 그 사이의 추출·스티밍 시간 동안 다른 손님의 주문을 받을 수 있습니다.
C#의 동기 코드는 첫 번째 바리스타와 같습니다. client.GetStringAsync(url).Result는 호출한 스레드가 응답을 받을 때까지 그 줄에서 꼼짝 못 합니다. 비동기 코드는 두 번째 바리스타와 같습니다. await client.GetStringAsync(url)은 "응답 도착하면 다시 부르라"는 진동벨(continuation, 이후 진행할 코드 조각)을 등록한 뒤 스레드를 풀어 줍니다.
시각화 — 스레드 점유 시간의 차이

가장 단순한 코드로 차이 보기
async/await— 비동기 메서드와 일시 중단점async는 "이 메서드 안에서await를 쓸 수 있다"고 컴파일러에 알리는 표시입니다.await는 그 자리에서 메서드를 일시 중단하고, 기다리는 작업이 끝나면 다음 줄로 자동 재개합니다.
예시:string s = await client.GetStringAsync(url);응답이 도착할 때까지 메서드는 잠시 멈추지만, 호출한 스레드는 다른 일을 하러 갑니다.
using System.Net.Http;
using System.Threading.Tasks;
class Program
{
// 동기 버전: 결과가 올 때까지 호출한 스레드를 묶어 놓는다
static string DownloadSync(HttpClient client, string url)
{
return client.GetStringAsync(url).Result;
}
// 비동기 버전: await 지점에서 스레드를 풀어 주고, 응답이 오면 이어서 실행
static async Task<string> DownloadAsync(HttpClient client, string url)
{
return await client.GetStringAsync(url);
}
}
두 메서드는 결과적으로 같은 문자열을 돌려주지만 스레드를 다루는 방식이 완전히 다릅니다. DownloadSync는 호출 스레드가 응답까지 머물러 있고, DownloadAsync는 await 시점에 스레드가 호출자에게 반납됩니다.
이 차이가 IL 레벨에서 어떻게 다른지 봅시다.
// DownloadSync — 직선적인 호출 두 번
.method private hidebysig static
string DownloadSync (
class [System.Net.Http]System.Net.Http.HttpClient client,
string url
) cil managed
{
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: callvirt instance class [System.Runtime]System.Threading.Tasks.Task`1<string>
[System.Net.Http]System.Net.Http.HttpClient::GetStringAsync(string)
IL_0007: callvirt instance !0 class [System.Runtime]System.Threading.Tasks.Task`1<string>::get_Result() // 여기서 블로킹
IL_000c: ret
}
// DownloadAsync — 상태 머신을 만들고 시작하고 끝
.method private hidebysig static
class [System.Runtime]System.Threading.Tasks.Task`1<string> DownloadAsync (
class [System.Net.Http]System.Net.Http.HttpClient client, string url
) cil managed
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.AsyncStateMachineAttribute::.ctor(...)
.locals init ([0] valuetype Program/'<DownloadAsync>d__1') // 컴파일러가 만든 상태 머신 struct
// 1) 빌더 생성
IL_0002: call valuetype AsyncTaskMethodBuilder`1<string>::Create()
IL_0007: stfld ...'<>t__builder'
// 2) 인자(client, url)를 상태 머신 필드로 옮긴다 — await 후에도 살아남도록
IL_000e: ldarg.0
IL_000f: stfld class HttpClient ...::client
IL_0016: ldarg.1
IL_0017: stfld string ...::url
// 3) 초기 상태 -1, Start 호출 → MoveNext가 첫 await까지 실행
IL_001e: ldc.i4.m1
IL_001f: stfld int32 ...'<>1__state'
IL_002d: call instance void AsyncTaskMethodBuilder`1<string>::Start<...>(!!0&)
// 4) 빌더의 Task를 호출자에게 반환 (작업이 끝나기 전에도 즉시 반환된다)
IL_0039: call instance class Task`1<!0> AsyncTaskMethodBuilder`1<string>::get_Task()
IL_003e: ret
}
핵심 관찰 — DownloadSync는 get_Result() 한 줄로 결과를 받지만 그 줄이 호출 스레드를 응답 시점까지 점유합니다. DownloadAsync는 Task<string>을 즉시 반환하고, 실제 결과는 컴파일러가 만든 상태 머신(state machine, 다음 섹션 참조)이 채워 넣습니다. Result 프로퍼티 호출은 사라지고, 대신 AsyncTaskMethodBuilder<string>이 등장한 점이 IL 상의 결정적 차이입니다.
쉬운 설명
동기는 "끝날 때까지 옆에 서서 기다리는" 모델이고, 비동기는 "기다려야 할 일을 등록만 해 두고 일단 자리를 비키는" 모델입니다.
기술 정의
- 동기 실행(Synchronous execution) — 메서드 호출이 시작된 시점부터 반환되는 시점까지, 호출한 스레드가 해당 메서드의 모든 작업을 순차적으로 직접 실행한다. 외부 작업(I/O, 타이머)이 있어도 스레드는 블로킹된 채 결과를 기다린다.
- 비동기 실행(Asynchronous execution) — 메서드 호출이 작업의 "완료 약속(Task)"을 즉시 반환하고, 실제 결과는 미래의 어느 시점에 채워진다. 작업이 외부에서 진행되는 동안 호출한 스레드는 다른 일을 처리할 수 있다.
3. 내부 동작 — 컴파일러가 만든 상태 머신
async/await의 마법은 사실 컴파일러가 자동 생성하는 상태 머신입니다. 개발자는 동기 코드처럼 위에서 아래로 쓰지만, 컴파일 시점에 코드가 통째로 다른 모양으로 변환됩니다.
변환의 큰 그림

await가 두 번 나오는 메서드는 컴파일 후 상태 0(첫 await), 상태 1(두 번째 await), 상태 -1(시작 전), 상태 -2(완료)의 네 가지 상태를 가지는 구조체로 변환됩니다.
첫 await가 만드는 분기 — IL로 보기
await client.GetStringAsync(url) 한 줄이 IL 상에서 어떻게 펼쳐지는지 봅시다. 위에서 컴파일한 <DownloadAsync>d__1::MoveNext의 핵심 흐름입니다.
.method private final hidebysig newslot virtual
instance void MoveNext () cil managed
{
.locals init ([0] int32, [1] string, [2] TaskAwaiter`1<string>, ...)
// 1) 현재 상태 번호를 로컬로 읽어 둔다 (state == -1 이면 첫 진입, 0이면 재진입)
IL_0000: ldarg.0
IL_0001: ldfld int32 '<DownloadAsync>d__1'::'<>1__state'
IL_0006: stloc.0
.try
{
// 2) state == 0 (재진입)이면 IL_004a로 점프해서 결과 회수 단계로
IL_0007: ldloc.0
IL_0008: brfalse.s IL_004a
// 3) 첫 진입: GetStringAsync 호출 → TaskAwaiter 획득
IL_0010: ldarg.0
IL_0011: ldfld string ...::url
IL_0016: callvirt Task`1<string> HttpClient::GetStringAsync(string)
IL_001b: callvirt TaskAwaiter`1<!0> Task`1<string>::GetAwaiter()
IL_0020: stloc.2
// 4) Awaiter가 이미 완료됐으면(IsCompleted == true) 동기 경로로 점프 → 박싱 없음
IL_0021: ldloca.s 2
IL_0023: call bool TaskAwaiter`1<string>::get_IsCompleted()
IL_0028: brtrue.s IL_0066
// 5) 미완료: state = 0, awaiter 보존, AwaitUnsafeOnCompleted로 콜백 등록 → leave
IL_002a: ldarg.0
IL_002b: ldc.i4.0
IL_002d: stloc.0
IL_002e: stfld int32 ...'<>1__state'
IL_0035: stfld TaskAwaiter`1<string> ...'<>u__1' // awaiter를 필드로 보존
IL_003b: ldflda AsyncTaskMethodBuilder`1<string> ...'<>t__builder'
IL_0043: call void AsyncTaskMethodBuilder`1<string>::AwaitUnsafeOnCompleted<...>(...)
IL_0048: leave.s IL_009b // 메서드 즉시 종료, 스레드 반납
// 6) 콜백이 호출되면 MoveNext가 다시 시작 → state == 0이라 IL_0007에서 IL_004a로 점프
IL_004a: ldarg.0
IL_004b: ldfld TaskAwaiter`1<string> ...'<>u__1' // 보존된 awaiter 복원
IL_0050: stloc.2
IL_005d: ldc.i4.m1
IL_005e: stfld int32 ...'<>1__state' // state = -1 (이 await는 끝)
// 7) GetResult로 실제 결과 회수 (예외가 있으면 여기서 던져진다)
IL_0066: ldloca.s 2
IL_0068: call !0 TaskAwaiter`1<string>::GetResult()
IL_006d: stloc.1
IL_006e: leave.s IL_0087
}
catch [System.Runtime]System.Exception
{
// 예외 발생 시 builder.SetException → Task가 Faulted 상태로
IL_007f: call void AsyncTaskMethodBuilder`1<string>::SetException(class Exception)
IL_0085: leave.s IL_009b
}
// 8) 정상 완료: state = -2, builder.SetResult로 Task 완료
IL_0087: ldarg.0
IL_0088: ldc.i4.s -2
IL_008a: stfld int32 ...'<>1__state'
IL_0096: call void AsyncTaskMethodBuilder`1<string>::SetResult(!0)
IL_009b: ret
}
핵심 명령어 해설
brfalse.s IL_004a(5번째 줄) — 상태가 0이면 점프하여 첫 진입 코드를 건너뛴다.MoveNext는 await마다 두 번 이상 호출되므로, 매번 처음부터 시작하지 않고 어디서 멈췄는지 상태 번호로 찾아간다.brtrue.s IL_0066(IsCompleted) —Task가 이미 완료된 경우(예: 캐시 적중) 콜백 등록을 건너뛰고 즉시 결과를 회수한다. 이 동기 경로(synchronous fast path)는 빈번한 await의 비용을 크게 줄인다.AwaitUnsafeOnCompleted— 미완료 시 호출. 이 호출이 "응답이 도착하면MoveNext를 다시 부르라"는 콜백을 등록하고, 현재 스레드는leave.s IL_009b로 메서드를 빠져나간다. 호출자에게 제어가 돌아가므로 UI 스레드라면 다음 메시지를 처리할 수 있게 된다.SetResult/SetException— 빌더가 보유한Task<string>을 완료 또는 실패 상태로 전환한다. 이 시점에await로 이 Task를 기다리던 다른 코드의MoveNext가 호출된다.
상태 머신은 구조체(valuetype) 로 만들어집니다. 첫 진입에서 Task가 동기적으로 완료되면 박싱 없이 끝나기 때문에 할당이 0회입니다. 미완료라서 콜백 등록이 필요한 시점에만 빌더가 상태 머신을 힙으로 박싱합니다.
Awaiter라는 약속
await가 호출할 수 있는 모든 객체는 awaiter 패턴을 만족해야 합니다. C# 스펙은 await x 표현이 동작하기 위해 x.GetAwaiter()가 다음 세 멤버를 가진 객체를 반환할 것을 요구합니다.
public interface INotifyCompletion
{
void OnCompleted(Action continuation);
}
// awaiter는 다음을 만족해야 한다 (인터페이스 강제는 아님 — 패턴 매칭)
// bool IsCompleted { get; }
// void OnCompleted(Action continuation); // 또는 UnsafeOnCompleted
// T GetResult(); // void GetResult()도 가능
Task, Task<T>, ValueTask<T>, Unity의 UniTask, 심지어 직접 만든 타입까지 이 패턴만 만족하면 await 대상이 됩니다. await는 인터페이스가 아니라 구조적 매칭이라는 점을 기억해 두면, 나중에 UniTask가 어떻게 zero-allocation 비동기를 만들 수 있는지 자연스럽게 이해할 수 있습니다.
4. 실전 적용 — 언제 어떻게 쓰는가
I/O 바운드 vs CPU 바운드
비동기를 적용하기 전에 먼저 작업의 성격을 분류해야 합니다.
| 작업 종류 | 시간 소요 원인 | 비동기 처리 방식 |
|---|---|---|
| I/O 바운드 | 외부 장치(네트워크·디스크·DB) 응답 대기 | async/await + 라이브러리 제공 *Async 메서드 |
| CPU 바운드 | 자체 연산(파싱·암호화·이미지 처리) | Task.Run으로 ThreadPool 스레드에 위임 |
I/O 바운드 작업에 Task.Run을 쓰는 것은 안티패턴입니다. ThreadPool 스레드 하나를 잡아다가 거기서 .Result로 블로킹하면, 스레드만 한 개 더 낭비할 뿐 응답성도 처리량도 개선되지 않습니다.
Before / After — UI 스레드를 풀어 주는 비동기
Before — 동기 호출로 메인 스레드가 멈추는 패턴
Unity에서 버튼을 누르면 서버에서 인벤토리를 받아 UI에 그리는 흔한 시나리오입니다.
using System.Net.Http;
using UnityEngine;
using UnityEngine.UI;
public class InventoryLoaderBad : MonoBehaviour
{
[SerializeField] private Text inventoryText;
[SerializeField] private Button loadButton;
private readonly HttpClient _client = new();
void Start() => loadButton.onClick.AddListener(OnClickLoad);
private void OnClickLoad()
{
// .Result는 호출 스레드(메인 스레드)를 응답까지 묶어 둔다.
// 와이파이가 느리면 게임 화면 전체가 그동안 얼어붙는다.
string json = _client.GetStringAsync("https://api.game.com/inventory").Result;
inventoryText.text = json;
}
}
After — async/await로 UI 응답성 유지
using System.Net.Http;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;
public class InventoryLoaderGood : MonoBehaviour
{
[SerializeField] private Text inventoryText;
[SerializeField] private Button loadButton;
private readonly HttpClient _client = new();
void Start() => loadButton.onClick.AddListener(async () => await OnClickLoadAsync());
private async Task OnClickLoadAsync()
{
// await 시점에 메인 스레드는 즉시 반납된다.
// 응답이 도착하면 Unity의 SynchronizationContext가
// 다음 줄을 메인 스레드에서 다시 이어 실행하므로 inventoryText 접근이 안전하다.
string json = await _client.GetStringAsync("https://api.game.com/inventory");
inventoryText.text = json;
}
}
두 코드의 IL을 비교하면, Before의 OnClickLoad는 32바이트짜리 짧은 메서드인 반면 After의 OnClickLoadAsync는 상태 머신 구조체 1개 + MoveNext(약 156바이트) + Start 진입점이라는 구조로 부풀어 오릅니다. 실행 시점에는 그러나 Before가 통신 시간 내내 메인 스레드를 묶어 두는 반면, After는 통신을 시작한 직후 메인 스레드를 풀어 줍니다. 컴파일된 코드 크기가 늘어난 대가로 런타임의 스레드 점유 시간이 줄어드는 트레이드오프입니다.
// Before: OnClickLoad의 핵심 — get_Result 한 줄로 블로킹
IL_0009: callvirt instance class Task`1<string> HttpClient::GetStringAsync(string)
IL_000e: callvirt instance !0 Task`1<string>::get_Result() // ← 여기서 메인 스레드 블로킹
// After: OnClickLoadAsync — Task를 만들고 즉시 반환, 콜백으로 재개
IL_002b: call instance void AsyncTaskMethodBuilder::Start<...>(!!0&)
IL_0034: call instance class Task AsyncTaskMethodBuilder::get_Task()
IL_0039: ret // ← Task만 반환, 메서드는 즉시 끝
해설 — Before의 get_Result()는 동기적으로 결과를 회수하는 호출이라 응답이 올 때까지 스레드가 빠져나갈 수 없습니다. After는 MoveNext가 첫 await에서 leave로 빠져나가고 외부에서 호출자에게 Task만 반환하므로, 메인 스레드는 다음 입력 처리·렌더링 루프로 즉시 돌아갑니다.
CPU 바운드 작업에는 Task.Run
using System.Threading.Tasks;
using UnityEngine;
public class HeavyCalculator : MonoBehaviour
{
private async void OnHeavyButtonClick()
{
// CPU 100%를 차지하는 무거운 계산
// → 메인 스레드에서 실행하면 await 효과가 없으므로 ThreadPool로 보낸다
long sum = await Task.Run(() =>
{
long s = 0;
for (int i = 0; i < 100_000_000; i++) s += i;
return s;
});
// await 이후는 다시 메인 스레드 → Unity API 안전하게 호출 가능
Debug.Log($"합계 = {sum}");
}
}
Task.Run은 람다를 ThreadPool에 큐잉합니다. await가 그 결과를 받으면 Unity의 SynchronizationContext(메인 스레드 컨텍스트)가 캡처되어 있으므로 Debug.Log는 다시 메인 스레드에서 실행됩니다. 이 동작 덕분에 백그라운드에서 무거운 일을 하고, 결과만 메인 스레드에서 안전하게 반영할 수 있습니다.
Unity에서Task.Run안에서gameObject.transform.position = ...같은 코드를 쓰면UnityException: get_transform can only be called from the main thread가 던져집니다. 백그라운드 스레드에서는 절대 Unity API를 호출하지 않습니다. 계산만 하고, UI 반영은 await 이후의 메인 스레드 코드에서 합니다.
Unity 핫패스에서의 GC 고려
async/await는 미완료 await가 발생할 때마다 상태 머신을 힙으로 박싱합니다. 1프레임에 수백 번 호출되는 Update나 게임플레이 핫패스에서 매번 await를 쓰면 GC 스파이크(Boehm GC, IL2CPP 빌드의 컨서버티브 가비지 컬렉터가 유발하는 일시적 프레임 멈춤) 의 원인이 됩니다. 이 영역에서는 두 가지 선택이 있습니다.
ValueTask<T>— 자주 동기적으로 완료되는 작업(캐시 조회 등)은Task<T>대신ValueTask<T>로 반환하면 동기 완료 시 0 할당이 가능합니다.UniTask(Cysharp/UniTask) — Unity 전용 awaiter로, PlayerLoop와 직접 연동되어 매 프레임 대기·시간 대기를 모두 zero allocation으로 처리합니다. Unity 클라이언트에서는 사실상 표준입니다.
이 둘은 별도 주제이므로 이 글에서는 "핫패스에서는 Task 기반 async를 무분별하게 쓰지 않는다"만 기억해 둡니다.
5. 함정과 주의사항
함정 1 — .Result / .Wait()로 동기 코드에서 비동기 호출
ASP.NET 클래식이나 WPF·WinForms처럼 단일 SynchronizationContext(특정 스레드에 코드를 다시 보내는 디스패처)를 가지는 환경에서는 async 메서드를 .Result로 호출하면 데드락이 발생합니다.
// ❌ Before — UI 스레드에서 데드락 유발
private void OnButtonClick(object sender, EventArgs e)
{
// 1) UI 스레드가 task 완료까지 블로킹
string json = LoadAsync().Result;
label.Text = json;
}
private async Task<string> LoadAsync()
{
using var c = new HttpClient();
// 2) await가 UI SynchronizationContext를 캡처
// 3) 응답 도착 시 "UI 스레드에서 다음 코드 실행"을 큐잉
// 4) 그러나 UI 스레드는 1번에서 블로킹 중이라 큐를 처리할 수 없음 → 영원히 대기
return await c.GetStringAsync("https://example.com");
}
// ✅ After — 호출자도 비동기로 만든다 ("async all the way")
private async void OnButtonClick(object sender, EventArgs e) // 이벤트 핸들러는 async void 허용
{
string json = await LoadAsync();
label.Text = json;
}
private async Task<string> LoadAsync()
{
using var c = new HttpClient();
return await c.GetStringAsync("https://example.com");
}
ASP.NET Core와 콘솔 앱에는 기본 SynchronizationContext가 없어서 이 데드락은 발생하지 않지만, 그 경우에도 .Result는 스레드를 묶어 두어 처리량을 떨어뜨리는 해악입니다.
SynchronizationContext— 코드를 특정 스레드에 다시 보내는 메커니즘 "이 코드를 메인 스레드에서 실행하라" 같은 요청을 처리하는 추상화입니다. WPF·WinForms·Unity 메인 스레드에는 모두 자체SynchronizationContext가 있어 await 이후 자동으로 원래 스레드로 돌아옵니다.
예시:await Task.Delay(1000);다음 줄은 await가 시작된 스레드에서 다시 실행됩니다(컨텍스트가 있는 경우).
함정 2 — async void의 위험성
async void는 이벤트 핸들러가 아니면 절대 쓰지 않습니다. 호출자가 완료를 기다릴 수 없고, 메서드 내부에서 던진 예외가 호출자의 try/catch에 잡히지 않으며, 보통 프로세스를 즉시 종료시킵니다.
// ❌ Before
static async void FireAndForgetBad(string url) // 반환형 void
{
await Task.Delay(100);
throw new Exception("boom"); // 잡을 수 없는 예외 — 프로세스 종료
}
// ✅ After
static async Task FireAndForgetGood(string url) // 반환형 Task
{
await Task.Delay(100);
throw new Exception("boom"); // await/try-catch로 잡을 수 있음
}
두 메서드의 IL을 보면 빌더 타입이 다릅니다.
// async void → AsyncVoidMethodBuilder (예외 발생 시 SynchronizationContext에 던져짐 → 프로세스 종료)
.field public valuetype AsyncVoidMethodBuilder '<>t__builder'
IL_0075: call instance void AsyncVoidMethodBuilder::SetException(class Exception)
// async Task → AsyncTaskMethodBuilder (예외가 Task에 보존됨 → 호출자가 await로 받음)
.field public valuetype AsyncTaskMethodBuilder '<>t__builder'
IL_0080: call instance void AsyncTaskMethodBuilder::SetException(class Exception)
해설 — AsyncVoidMethodBuilder::SetException은 잡히지 않은 예외를 현재 SynchronizationContext에 던집니다. 핸들러가 없으면 프로세스가 종료됩니다. 반면 AsyncTaskMethodBuilder::SetException은 예외를 Task의 Exception 프로퍼티에 보관하고, 호출자가 await 또는 task.Exception 접근 시점에 다시 던집니다. 빌더 타입 한 글자 차이가 예외 처리 의미를 완전히 바꿉니다.
함정 3 — 비동기 작업의 라이프사이클을 MonoBehaviour에 묶지 않음
Unity 환경 특유의 함정입니다. 비동기 작업이 진행 중인 동안 GameObject가 파괴되면, await 이후의 코드가 이미 죽은 객체의 필드를 참조하다 MissingReferenceException을 던집니다.
// ❌ Before — 오브젝트 파괴 후에도 await 결과가 도착하면 폭발
public class StaleAsync : MonoBehaviour
{
[SerializeField] private Text label;
private async void Start()
{
await Task.Delay(5000);
label.text = "도착!"; // 5초 사이에 GameObject가 destroy되면 예외
}
}
// ✅ After — CancellationToken을 라이프사이클에 묶는다
public class CancelableAsync : MonoBehaviour
{
[SerializeField] private Text label;
private readonly CancellationTokenSource _cts = new();
private async void Start()
{
try
{
await Task.Delay(5000, _cts.Token); // 토큰이 취소되면 OperationCanceledException
if (this == null) return; // 추가 안전망
label.text = "도착!";
}
catch (OperationCanceledException) { /* 정상 취소 */ }
}
private void OnDestroy()
{
_cts.Cancel();
_cts.Dispose();
}
}
CancellationTokenSource(취소 신호를 보내는 객체)와 OnDestroy를 짝지어 두면, 작업이 진행 중이더라도 오브젝트 수명이 끝날 때 안전하게 정리됩니다. UniTask에는 this.GetCancellationTokenOnDestroy() 같은 헬퍼가 있어 이 보일러플레이트가 한 줄로 줄어듭니다.
함정 4 — async void 람다의 폭발
이벤트 구독에 무심코 async를 붙이면 async void 람다가 생성됩니다. 안에서 던진 예외는 이벤트 발행자도, 구독자도 잡을 수 없는 상태로 떠다닙니다.
// ❌ Before — 익명 async void 람다
button.onClick.AddListener(async () => {
var data = await LoadAsync(); // 여기서 예외가 나면 잡을 길이 없음
UpdateUi(data);
});
// ✅ After — 명시적 try/catch + 별도 비동기 메서드
button.onClick.AddListener(() => _ = SafeOnClickAsync());
private async Task SafeOnClickAsync()
{
try
{
var data = await LoadAsync();
UpdateUi(data);
}
catch (Exception ex) { Debug.LogException(ex); }
}
UI 이벤트 시스템이 요구하는 시그니처가 동기형(void)일 때는 별도 Task 반환 메서드를 만들고 _ = MethodAsync()로 호출한 뒤 그 안에서 모든 예외를 처리합니다.
6. C# 버전별 변화 — 비동기 모델의 진화
C#은 비동기 코드를 쓰는 방식을 거의 10년에 걸쳐 점진적으로 다듬어 왔습니다. 동기/비동기 두 모델 자체는 변하지 않았지만, "비동기를 어떻게 표현하는가"가 크게 바뀌었습니다.
C# 1.0~3.0 — 콜백과 BeginXxx/EndXxx
// 가장 오래된 패턴: APM(Asynchronous Programming Model)
WebRequest request = WebRequest.Create("https://example.com");
request.BeginGetResponse(asyncResult =>
{
using var response = request.EndGetResponse(asyncResult);
using var reader = new StreamReader(response.GetResponseStream());
string body = reader.ReadToEnd();
// 결과 처리... (다른 비동기 작업이 또 필요하면 콜백이 또 중첩된다)
}, null);
콜백이 중첩될수록 들여쓰기가 오른쪽으로 끝없이 밀려 "콜백 지옥(callback hell)"이라 불렸습니다. 예외 흐름·상태 추적도 모두 수동입니다.
C# 4.0 — Task 등장
// .NET 4.0의 TPL(Task Parallel Library): Task로 통일된 표현
Task<string> task = Task.Factory.StartNew(() =>
{
using var client = new WebClient();
return client.DownloadString("https://example.com");
});
task.ContinueWith(t =>
{
string body = t.Result;
// 다음 단계도 ContinueWith로 이어 붙임
});
Task<T>는 비동기 작업을 일등 객체로 만들었습니다. 그러나 여러 단계를 이어 붙이려면 여전히 ContinueWith가 중첩됩니다.
C# 5.0 — async/await 도입 (2012)
async Task<string> LoadAsync()
{
using var c = new HttpClient();
return await c.GetStringAsync("https://example.com");
}
이 버전에서 동기 코드처럼 보이는 비동기 코드가 가능해졌습니다. 컴파일러가 상태 머신을 자동 생성하므로 개발자는 try/catch/for/using 등 모든 동기 제어 흐름을 그대로 쓸 수 있습니다. 이 글의 "비동기 = async/await" 등식이 성립하기 시작한 것이 이 시점부터입니다.
C# 7.1 — async Main
// C# 7.1 이전: Main에서 비동기 코드를 돌리려면 GetAwaiter().GetResult() 보일러플레이트
static void Main(string[] args)
{
LoadAsync().GetAwaiter().GetResult();
}
// C# 7.1+: Main도 비동기로 직접 선언 가능
static async Task Main(string[] args)
{
await LoadAsync();
}
// async Main도 일반 async 메서드와 똑같이 상태 머신으로 변환된다
.method private hidebysig static
class Task Main () cil managed
{
.custom instance void AsyncStateMachineAttribute::.ctor(class Type) = (...)
.locals init ([0] valuetype Program/'<Main>d__2')
IL_0002: call AsyncTaskMethodBuilder::Create()
IL_001d: call instance void AsyncTaskMethodBuilder::Start<Program/'<Main>d__2'>(!!0&)
IL_0029: call instance class Task AsyncTaskMethodBuilder::get_Task()
IL_002e: ret
}
async Main은 컴파일러가 내부적으로 Main 본문을 상태 머신으로 변환하고, 진입점에서 Task를 받아 GetAwaiter().GetResult()로 끝까지 기다리는 동기 진입점을 추가하는 방식으로 구현됩니다.
C# 7.2 / 8.0 — ValueTask<T>와 비동기 스트림
// C# 7.2: 자주 동기 완료되는 비동기는 ValueTask<T>로 0 할당 가능
ValueTask<int> GetCachedAsync(int id)
{
if (_cache.TryGetValue(id, out var v))
return new ValueTask<int>(v); // 박싱 없이 즉시 완료
return new ValueTask<int>(LoadFromDbAsync(id)); // 미완료면 Task 위임
}
// C# 8.0: 비동기 시퀀스 — yield return + await의 결합
async IAsyncEnumerable<int> ReadNumbersAsync()
{
using var reader = new StreamReader("nums.txt");
while (!reader.EndOfStream)
{
string line = await reader.ReadLineAsync();
yield return int.Parse(line);
}
}
이 두 가지는 별도 주제(PART 13의 5번, 11번)에서 자세히 다룹니다.
C# 11+ — static abstract 인터페이스 멤버, 일반화된 awaiter
C# 자체보다는 BCL(Base Class Library, 표준 클래스 라이브러리) 차원에서 비동기 awaiter 패턴이 점점 다양해졌습니다. Task, Task<T>, ValueTask, ValueTask<T>, IAsyncEnumerable<T>, 그리고 Unity의 UniTask 같은 사용자 정의 awaiter까지 모두 같은 await 키워드로 통합되었습니다.
7. 정리 — 핵심 체크리스트
- [ ] 동기는 호출-블로킹-반환, 비동기는 호출-등록-반납-재개 모델임을 구분합니다.
- [ ]
async/await는 컴파일러가 만든 상태 머신 위에서 동작하며, IL에는AsyncTaskMethodBuilder·MoveNext·상태 번호가 등장합니다. - [ ] I/O 바운드는
async/await, CPU 바운드는Task.Run입니다. 둘을 거꾸로 쓰면 효과가 없습니다. - [ ] UI/Unity 메인 스레드에서
.Result·.Wait()로 비동기 호출을 동기 호출하면 데드락 또는 프레임 멈춤이 발생합니다. - [ ]
async void는 이벤트 핸들러가 아니면 사용 금지 — 예외가 잡히지 않고 프로세스가 종료됩니다. - [ ] Unity에서 비동기 작업은
CancellationToken을OnDestroy에 묶어 라이프사이클을 관리합니다. - [ ] 매 프레임 호출되는 핫패스에서는
Task기반async보다ValueTask나UniTask를 우선 검토합니다.