[PART13.비동기와 스레딩 기초(6/15)] async / await — 비동기 흐름을 동기처럼 쓰는 문법
콜백 지옥을 동기 코드처럼 / 반환형 5종 / I/O는 await, CPU는 Task.Run / DoAsync 보면 무조건 그쪽
목차
- [문제 제기] — 비동기 작업이 늘어나면 코드가 콜백 미로가 된다
- [개념 정의] — async 는 "이 메서드 안에서 await 를 쓰겠다"는 표시, await 는 "제어권 양보 + 콜백 등록"
- [내부 동작] — 반환형 5종과 그들이 awaitable 인 이유
- [실전 적용] — I/O 는 await, CPU 는 await Task.Run, DoAsync 가 보이면 동기 버전 금지
- [함정과 주의사항] — .Result/.Wait, fire-and-forget, async void 남용
- [C# 버전별 변화] — 시작은 5.0, 점차 확장
- [정리] — 핵심 체크리스트
1. [문제 제기] — 비동기 작업이 늘어나면 코드가 콜백 미로가 된다
Unity에서 모바일 게임을 만들다 보면 비동기로 처리해야 하는 일이 끊이지 않습니다. 서버에서 유저 정보를 받아오고, 그 정보로 인벤토리를 조회하고, 아이콘 텍스처를 다운로드하고, 마지막으로 캐시에 저장하는 흐름은 흔합니다. 이 모든 단계가 네트워크를 타기 때문에 메인 스레드를 막을 수는 없습니다.
async/await 가 없던 시절에는 콜백으로 이 흐름을 표현했습니다. 결과를 받는 함수가 또 다른 비동기 호출을 시작하고, 그 결과를 받는 함수가 다시 다른 비동기 호출을 시작하는 식이었습니다.
// async/await 없이 콜백으로 비동기 흐름을 잇는 옛날 패턴
public void LoadUserFlow(int userId, Action<UserProfile> onDone)
{
api.GetUser(userId, user =>
{
api.GetInventory(user.Id, inventory =>
{
api.DownloadIcon(inventory.IconUrl, icon =>
{
cache.Save(icon, () =>
{
onDone(new UserProfile(user, inventory, icon));
});
});
});
});
}
콜백이 4단계만 쌓여도 들여쓰기가 화면 오른쪽으로 흘러갑니다. 예외가 발생하면 어디서 터졌는지 추적하기 어렵고, try/catch 한 번으로 전체 흐름을 감쌀 수도 없습니다. 함수가 어디서 끝나는지도 한눈에 보이지 않습니다.
C# 5.0에 도입된 async / await 는 이 문제를 정면으로 해결합니다. 비동기 작업을 마치 동기 코드처럼 위에서 아래로 한 줄씩 읽히게 만들어 주는 문법입니다. 컴파일러가 뒤에서 상태 머신(State Machine, 메서드를 여러 조각으로 쪼개고 어디까지 실행했는지 기억하는 자동 생성 자료구조)을 만들어 주기 때문에, 우리는 그 결과만 누리면 됩니다.
본 글은 사용 문법·반환형 규칙·실전 패턴에 집중합니다. 컴파일러가 만드는 상태 머신의 내부 구조와ConfigureAwait,SynchronizationContext의 동작 원리는 이미 앞 주제(05. Task와 Task<T>)와 별도 심화 글에서 다룹니다.
2. [개념 정의] — async 는 "이 메서드 안에서 await 를 쓰겠다"는 표시, await 는 "제어권 양보 + 콜백 등록"
2-1. 비유: 우체국 등기와 알림 문자
async / await 를 직관적으로 이해하려면 우체국 등기 신청을 떠올리면 좋습니다.
async— 비동기 메서드 한정자 (asynchronous modifier) 메서드·람다·로컬 함수 앞에 붙어 "이 안에서await를 사용하겠다"는 신호를 컴파일러에게 전달합니다.async자체가 메서드를 백그라운드에서 실행시키는 것은 아닙니다.
예시:public async Task<string> GetAsync() { ... }컴파일러가 이 메서드를 상태 머신으로 변환합니다.
await— 비동기 대기 연산자 (await operator)Task·Task<T>·ValueTask·ValueTask<T>같은 awaitable 객체 앞에 붙어 "작업이 끝날 때까지 이 메서드의 실행을 멈추되, 호출자에게 제어권을 즉시 돌려준다"를 의미합니다. 작업이 끝나면 멈췄던 그 줄 다음부터 자동으로 이어 실행됩니다.
예시:var html = await client.GetStringAsync(url);HTTP 응답이 도착할 때까지 스레드를 점유하지 않습니다.

핵심은 단어가 주는 인상에 속지 않는 것입니다. await 는 "기다린다"가 아니라 "기다릴 일을 예약하고 즉시 자리를 비운다" 입니다. 작업이 끝나면 시스템이 알아서 우리를 다시 호출해 줍니다.
2-2. 가장 기본적인 사용 형태
문제 정의에서 인용한 사양 그대로의 형태가 가장 단순한 예입니다.
using System.Net.Http;
using System.Threading.Tasks;
public class HtmlFetcher
{
private static readonly HttpClient client = new();
// async 한정자, Task<string> 반환형, 메서드 이름 끝에 Async
public async Task<string> GetAsync(string url)
{
// await 는 Task<string> 앞에만 사용 가능
var html = await client.GetStringAsync(url);
return html.Trim();
}
}
세 줄짜리 메서드지만 약속이 빼곡합니다.
async한정자 — "이 안에서await를 쓰겠다"는 컴파일러 신호Task<string>반환형 —await가능한 awaitable. 메서드 본문은string을return하지만 컴파일러가Task<string>으로 감싸 줍니다.Async접미사 — 호출자 입장에서 "이건 비동기니까await붙여야 해"를 즉시 알아챌 수 있는 명명 규약await—Task<T>가 끝나면 그 결과(여기서는string)를 꺼내html에 대입. 끝나기 전이면 호출자에게 제어권을 돌려줍니다.
2-3. IL 레벨에서 본 변환 결과
이 메서드를 컴파일하고 IL을 확인하면 우리가 작성한 한 줄짜리 메서드가 사라지고, 컴파일러가 만든 자동 생성 코드가 그 자리를 대체했음을 볼 수 있습니다.
// 호출자 진입점 — 우리가 작성한 GetAsync() 의 실제 IL
.method public hidebysig instance class Task`1<string> GetAsync(string url) cil managed
{
.custom instance void AsyncStateMachineAttribute::.ctor(class Type) // 컴파일러가 붙인 어트리뷰트
.locals init ([0] valuetype Sample1/'<GetAsync>d__1') // 상태 머신 구조체
IL_0000: ldloca.s 0
IL_0002: call AsyncTaskMethodBuilder`1<string>::Create() // 빌더 생성
IL_0007: stfld ...t__builder
IL_000c: ldloca.s 0
IL_000e: ldarg.1
IL_000f: stfld string ...url // 인자를 상태 머신 필드에 저장
IL_0014: ldloca.s 0
IL_0016: ldc.i4.m1
IL_0017: stfld int32 ...'<>1__state' // 초기 상태 -1
IL_001c: ldloca.s 0
IL_001e: ldflda ...t__builder
IL_0023: ldloca.s 0
IL_0025: call AsyncTaskMethodBuilder`1::Start<...>(!!0&) // 상태 머신 가동
IL_002a: ldloca.s 0
IL_002c: ldflda ...t__builder
IL_0031: call get_Task() // 빌더에서 Task 꺼내서 호출자에게 반환
IL_0036: ret
}
// 우리가 적은 한 줄(`var html = await client.GetStringAsync(url);`)이
// 컴파일러가 만든 MoveNext 안에서 어떻게 펼쳐졌는지 핵심 부분만
IL_000a: ldsfld HttpClient Sample1::client
IL_0010: ldfld string ...url
IL_0015: callvirt Task`1<string> HttpClient::GetStringAsync(string)
IL_001a: callvirt TaskAwaiter`1::GetAwaiter()
IL_001f: stloc.2
IL_0022: call TaskAwaiter`1::get_IsCompleted() // 이미 끝났으면 그대로 진행
IL_0027: brtrue.s IL_0065
// 끝나지 않았으면: 상태 머신을 힙으로 박싱, 콜백 등록, 메서드 빠져나감
IL_0042: call AsyncTaskMethodBuilder`1::AwaitUnsafeOnCompleted<...>(...)
IL_0047: leave.s IL_009f
// ↓ 작업 완료 시 시스템이 다시 들어와 IL_0049 부터 실행
IL_0065: ldloca.s 2
IL_0067: call TaskAwaiter`1::GetResult() // string 결과 꺼냄
IL_006c: callvirt String::Trim() // 우리가 적은 .Trim() 호출
핵심만 짚으면 이렇습니다.
async한정자가 붙은 메서드는 IL 레벨에서 본문이 통째로 비워지고,AsyncStateMachine구조체가 만들어져MoveNext라는 메서드 안으로 본문이 옮겨집니다. 호출자가 부르는 메서드는 이제 "상태 머신을 만들고 시작 →Task를 반환"만 합니다.await는 IL 한 줄로 컴파일되지 않습니다.GetAwaiter()→IsCompleted검사 → (미완료면) 콜백 등록 후 메서드 종료, (완료면)GetResult()라는 패턴으로 펼쳐집니다.- 그래서
await가 "기다림"이 아니라 "콜백 등록 후 자리 비움"이라는 비유가 정확합니다.IsCompleted가false면AwaitUnsafeOnCompleted가 호출되고, 메서드는leave.s로 빠져나갑니다.
상태 머신의 모든 상태 전이와MoveNext의 분기 구조는05. Task와 Task<T>에서 이미 자세히 분석했습니다. 본 글은 "이 메커니즘이 있다는 사실"만 활용해 사용 문법에 집중합니다.
3. [내부 동작] — 반환형 5종과 그들이 awaitable 인 이유
async 메서드의 반환형에는 엄격한 제약이 있습니다. await 로 결과를 받을 수 있는 타입(awaitable) 이거나, 이벤트 시스템이 요구하는 void 여야 합니다.
3-1. 반환형 5종 한 눈에
| 반환형 | 언제 쓰는가 | await 가능 | 메모리 |
|---|---|---|---|
Task |
결과값 없는 비동기 작업. 동기의 void 와 짝 |
✅ | 참조 타입(힙) |
Task<T> |
결과값이 있는 비동기 작업. 가장 흔함 | ✅ | 참조 타입(힙) |
ValueTask |
동기 완료 가능성이 매우 높은 결과값 없는 작업 | ✅ | 값 타입(스택) |
ValueTask<T> |
동기 완료 가능성이 매우 높은 결과값 있는 작업 | ✅ | 값 타입(스택) |
void |
이벤트 핸들러 한정. 그 외에는 절대 금지 | ❌ | 추적 불가 |

3-2. 코드로 보는 4종
using System.IO;
using System.Threading.Tasks;
public class ReturnTypeShowcase
{
// 1. Task : 결과값이 없는 작업
public async Task SaveAsync(string path, string text)
{
await File.WriteAllTextAsync(path, text);
// return 키워드 자체를 안 쓰는 게 자연스럽다
}
// 2. Task<T> : 결과값이 있는 작업 — 가장 흔한 형태
public async Task<int> CountLinesAsync(string path)
{
var lines = await File.ReadAllLinesAsync(path);
return lines.Length; // int 를 return 하면 컴파일러가 Task<int> 로 감싸 준다
}
// 3. ValueTask<T> : 동기 완료 가능성이 매우 높을 때 (캐시 적중 등)
private string? cached;
public ValueTask<string> ReadCachedAsync(string path)
{
if (cached is not null)
return new ValueTask<string>(cached); // 동기 완료, 힙 할당 0
return new ValueTask<string>(LoadAsync(path));
}
private async Task<string> LoadAsync(string path)
{
var text = await File.ReadAllTextAsync(path);
cached = text;
return text;
}
// 4. async void : 이벤트 핸들러에서만 — 일반 메서드에서는 절대 금지
// private async void OnButtonClick(object sender, EventArgs e) { ... }
}
각 반환형은 자신만의 빌더(AsyncTaskMethodBuilder, AsyncTaskMethodBuilder<T>, AsyncValueTaskMethodBuilder, AsyncValueTaskMethodBuilder<T>, AsyncVoidMethodBuilder)를 가지고 있고, 컴파일러는 반환형에 맞는 빌더로 상태 머신을 구동합니다. 우리가 신경 써야 할 것은 "어떤 시그니처를 골라야 하는가"뿐입니다.
Task<T>vsValueTask<T>의 정확한 트레이드오프와 다회 await 금지·풀링 같은 디테일은 다음 주제(07. 비동기 메서드의 반환형 — Task vs Task<T> vs void)에서 따로 다룹니다. 본 글에서는 "기본은Task/Task<T>, 핫패스에서만 측정 후ValueTask도입" 정도만 기억하면 충분합니다.
3-3. async void 가 위험한 이유
async void 가 일반 메서드에서 금지되는 이유는 두 가지입니다.
public class VoidDanger
{
// 절대 따라하면 안 되는 패턴
public async void DoBadlyAsync()
{
await Task.Delay(10);
throw new InvalidOperationException("터졌어요"); // 어디서도 잡을 수 없음
}
public async Task CallerAsync()
{
try
{
DoBadlyAsync(); // Task 가 없어 await 불가
// 메서드가 끝났는지 알 길도 없음
}
catch (InvalidOperationException) // 여기로 안 들어옴
{
// 도달 불가
}
}
}
- 완료 추적 불가 — 반환형이
void니까await할 대상이 없습니다. 호출자는 그 비동기 메서드가 끝났는지, 성공했는지조차 알 수 없습니다. - 예외 처리 불가 —
async void안에서 발생한 예외는 호출자의try/catch로 전파되지 않습니다. 대신SynchronizationContext에 즉시 던져져 보통 프로세스가 강제 종료됩니다.
이벤트 핸들러(OnButtonClick, Unity 의 일부 UI 이벤트)는 이벤트 시그니처가 void 를 강제하기 때문에 어쩔 수 없이 사용합니다. 그 외에는 무조건 Task 또는 Task<T> 를 반환하도록 합니다.
4. [실전 적용] — I/O 는 await, CPU 는 await Task.Run, DoAsync 가 보이면 동기 버전 금지
비동기 코드를 처음 만나는 신입 개발자가 가장 먼저 부딪치는 결정은 "어떤 호출 앞에 await 를 붙일 것인가" 입니다. 두 가지 큰 패턴만 익히면 됩니다.
4-1. I/O 바운드: 외부에 일을 맡기고 스레드를 풀어준다
네트워크 요청·파일 입출력·DB 쿼리 같은 작업은 대부분의 시간을 "결과가 도착하기를 기다리는" 데 씁니다. CPU 가 연산하는 시간이 거의 없습니다. 이런 호출은 라이브러리 쪽에서 이미 비동기 버전을 제공합니다.
// ❌ Before — 동기 호출로 스레드를 통째로 점유
public string Fetch(string url)
{
var client = new HttpClient();
string html = client.GetStringAsync(url).Result; // 응답 올 때까지 스레드 블로킹
return html.Trim();
}
// ✅ After — await 로 외부에 위임, 스레드 자유
public async Task<string> FetchAsync(string url)
{
var client = new HttpClient();
string html = await client.GetStringAsync(url); // 응답 동안 스레드 점유 없음
return html.Trim();
}
After 의 IL 은 앞서 본 것처럼 상태 머신과 콜백 등록 코드로 변환됩니다. Before 의 IL 은 어떤지 보겠습니다.
// Before: GetSync 의 핵심 IL — 동기 .Result 호출
.method public hidebysig instance Task`1<string> GetSync(string url) cil managed
{
IL_0000: ldsfld HttpClient Sample2::client
IL_0005: ldarg.1
IL_0006: callvirt Task`1<string> HttpClient::GetStringAsync(string)
IL_000b: callvirt !0 Task`1<string>::get_Result() // ← 여기서 스레드 블로킹
IL_0010: callvirt string String::Trim()
IL_0015: call Task`1<!!0> Task::FromResult<string>(!!0)
IL_001a: ret
}
After 와 Before 의 IL 차이가 결정적입니다.
- After — 컴파일러가 상태 머신 구조체와
MoveNext를 만들고,IsCompleted가false면AwaitUnsafeOnCompleted로 콜백을 등록한 뒤leave로 메서드를 빠져나갑니다. 응답이 오면 시스템이 다시 진입해 다음 줄을 실행합니다. 스레드는 그 사이에 풀려 있습니다. - Before —
Task<string>::get_Result()한 방으로 끝납니다. 짧고 단순하지만, 이 IL 한 줄은 "응답이 올 때까지 이 스레드를 통째로 멈춘다"는 뜻입니다. UI 스레드에서 호출하면 화면이 얼어붙고, ASP.NET 스레드 풀에서 호출하면 스레드 풀이 고갈됩니다.
같은 비동기 API 를 부르는데 IL 한 줄을 잘못 쓰면 동작 모델 자체가 뒤집힙니다.
4-2. CPU 바운드: 내가 직접 무거운 계산을 한다면 Task.Run
I/O 와 정반대로 CPU 자원을 길게 점유하는 작업(이미지 디코딩, 복잡한 게임 로직 시뮬레이션, 대량 데이터 변환)은 await 를 붙일 비동기 API 가 따로 없습니다. 그냥 메서드를 호출하면 그 스레드에서 CPU 가 돕니다.
이때 메인 스레드(UI · Unity 메인 스레드)에서 무거운 계산을 직접 하면 화면이 얼어붙기 때문에, Task.Run 으로 스레드 풀에 일을 넘긴 뒤 그 Task 를 await 합니다.
public class HeavyCompute
{
// ❌ Before — 메인 스레드에서 직접 계산. UI 가 멈춘다.
public int RunSync()
{
return Compute(); // CPU 100% 사용, 그동안 화면·이벤트 처리 정지
}
// ✅ After — Task.Run 으로 스레드 풀에 위임 후 await
public async Task<int> RunAsync()
{
int result = await Task.Run(() => Compute());
return result;
}
private int Compute()
{
int sum = 0;
for (int i = 0; i < 100; i++) sum += i;
return sum;
}
}
// After: RunAsync 의 핵심 IL — Task.Run 도 결국 awaitable Task 를 반환
IL_0011: ldloc.1
IL_0012: ldftn instance int32 Sample3::'<RunAsync>b__0_0'() // 람다 본문
IL_0018: newobj Func`1<int32>::.ctor(...)
IL_001d: call Task`1<!!0> Task::Run<int32>(class Func`1<!!0>) // 스레드 풀에 작업 던지기
IL_0022: callvirt TaskAwaiter`1<int32> Task`1<int32>::GetAwaiter()
IL_002a: call TaskAwaiter`1<int32>::get_IsCompleted()
IL_002f: brtrue.s IL_006d
// 완료 안 됐으면 콜백 등록 후 메서드 종료 (메인 스레드는 자유)
IL_004a: call AsyncTaskMethodBuilder`1<int32>::AwaitUnsafeOnCompleted<...>(...)
IL_006f: call TaskAwaiter`1<int32>::GetResult()
핵심은 await Task.Run(...) 도 IL 레벨에서는 await 의 표준 패턴을 그대로 따른다는 점입니다. 즉, "다른 스레드에 일을 던지고 → 호출자에게 즉시 제어권 반환 → 끝나면 다음 줄 이어서 실행" 입니다. I/O 와 차이는 단 하나, 누가 그 작업을 처리하는가입니다.
| 패턴 | 작업을 처리하는 주체 | 호출자 스레드 |
|---|---|---|
I/O 바운드 (await client.GetStringAsync(...)) |
OS / 네트워크 카드 / 디스크 컨트롤러 | 자유 |
CPU 바운드 (await Task.Run(() => Compute())) |
스레드 풀의 다른 스레드 | 자유 |
Unity메인 스레드 주의: Unity 의Transform,GameObject, 대부분의UnityEngine.*API 는 메인 스레드에서만 호출해야 합니다.Task.Run안에서transform.position = ...을 호출하면 예외가 터집니다. 따라서 Unity 에서Task.Run으로 보낼 수 있는 일은 순수 C# 계산(파싱, 압축, 게임 로직 시뮬레이션)에 한정됩니다. 코루틴이나UniTask.SwitchToMainThread()로 메인 스레드 복귀 패턴을 함께 익히는 게 좋습니다.
4-3. 라이브러리 API 의 DoAsync 규약 — 보이면 무조건 그쪽
.NET BCL 과 잘 만든 외부 라이브러리는 동일 작업에 대해 동기 버전과 비동기 버전을 함께 제공합니다. 그리고 비동기 버전에는 반드시 Async 접미사가 붙어 있습니다.
// File.ReadAllText vs File.ReadAllTextAsync
// HttpClient.Send vs HttpClient.SendAsync
// SqlCommand.ExecuteReader vs SqlCommand.ExecuteReaderAsync
// Stream.Read vs Stream.ReadAsync
규칙은 단순합니다.
async메서드 안에서 같은 작업의 동기 버전과 비동기 버전이 모두 보이면, 무조건Async가 붙은 쪽을await한다.
이유는 IL 비교에서 본 그대로입니다. 비동기 버전을 await 로 호출하면 호출 스레드가 풀려나가지만, 동기 버전을 그대로 호출하면 그 스레드는 작업이 끝날 때까지 통째로 점유됩니다. ASP.NET 같은 서버 환경에서는 처리량이 직접 떨어지고, Unity 메인 스레드에서는 화면이 얼어붙습니다.
// ❌ async 메서드 안에서 동기 API 호출 — async 의 의미가 사라짐
public async Task<string> LoadConfigBadAsync()
{
string text = File.ReadAllText("config.json"); // 스레드 블로킹
return text;
}
// ✅ 같은 메서드의 비동기 버전을 await
public async Task<string> LoadConfigGoodAsync()
{
string text = await File.ReadAllTextAsync("config.json");
return text;
}
라이브러리에 Async 버전이 없으면 어쩔 수 없이 동기 버전을 써야 하지만, Async 가 있는데 동기 버전을 쓰는 것은 명백한 실수입니다.
5. [함정과 주의사항] — .Result/.Wait, fire-and-forget, async void 남용
5-1. .Result / .Wait() 로 동기 흐름에 끼워 넣기 — Deadlock 위험
비동기 메서드를 호출하는 코드를 만들었는데, 호출하는 쪽이 동기 메서드라서 await 를 못 붙이는 상황이 생깁니다. 가장 흔한 잘못된 해결책이 .Result 나 .Wait() 입니다.
// ❌ async 코드와 동기 코드를 .Result 로 억지로 붙이는 패턴
public string LoadSync()
{
return LoadAsync().Result; // UI 스레드/ASP.NET 컨텍스트에서 deadlock 가능
}
private async Task<string> LoadAsync()
{
var html = await client.GetStringAsync("https://...");
return html.Trim(); // ← 이 줄은 원래 호출 스레드(UI)로 돌아가고 싶어함
}
// ✅ async 를 끝까지 전파(async all the way)
public async Task<string> LoadAsync2()
{
return await LoadAsync();
}
Deadlock 의 메커니즘은 거칠게 말해 이렇습니다.
- UI 스레드가
LoadAsync().Result로 자신을 블로킹. LoadAsync내부의await가 끝나면html.Trim()줄을 원래 호출 스레드(UI 스레드) 에서 실행하고 싶어 합니다.- 그런데 UI 스레드는
.Result때문에 막혀 있습니다. - 서로 무한 대기.
ConfigureAwait(false) 같은 우회 수단도 있지만, 본질적인 해결은 async 를 끝까지 일관되게 전파하는 것(async all the way)입니다. 어디선가 동기로 끊는 순간 비동기의 이점이 사라지고 deadlock 위험이 생깁니다.
5-2. 반환된 Task 를 무시 — Fire-and-forget 의 함정
Task 를 받아 놓고 await 도 안 하고 변수에 담지도 않으면, 그 작업은 백그라운드에서 흘러갑니다. 예외가 터져도 알 길이 없습니다.
// ❌ Task 를 그냥 버린다 — 컴파일러 경고 CS4014 가 뜨지만 무시되곤 함
public async Task SaveUserAsync(User user)
{
db.WriteAsync(user); // await 누락
log.WriteAsync($"Saved {user.Id}"); // await 누락
// 메서드는 두 작업이 끝나기도 전에 반환됨
}
// ✅ 반드시 await
public async Task SaveUserAsync2(User user)
{
await db.WriteAsync(user);
await log.WriteAsync($"Saved {user.Id}");
}
// ✅ 정말 fire-and-forget 이 의도라면 명시적으로 _ 에 대입하고 예외 처리 책임을 진다
public void FireAndForget(User user)
{
_ = Task.Run(async () =>
{
try { await audit.WriteAsync(user); }
catch (Exception ex) { logger.LogError(ex, "audit 실패"); }
});
}
대부분의 경우 await 를 빼먹은 것은 버그입니다. 정말 의도한 fire-and-forget 이라면 _ = 로 명시하고 내부에서 예외를 잡아야 합니다.
5-3. async void 남용 — 버튼 클릭 핸들러를 일반 헬퍼 메서드로 재사용
이벤트 핸들러는 어쩔 수 없이 async void 지만, 그 메서드를 일반 메서드처럼 다른 곳에서 호출하면 안 됩니다.
public class BadHandler
{
// 이벤트 시그니처상 async void — 여기까지는 OK
private async void OnButtonClick(object sender, EventArgs e)
{
await DoSomethingAsync();
}
// ❌ 이 메서드를 다른 곳에서 호출 — 추적 불가, 예외 손실
public void RetryAll()
{
OnButtonClick(this, EventArgs.Empty);
OnButtonClick(this, EventArgs.Empty);
// 두 번 모두 끝났는지 알 수 없고, 실패해도 못 잡음
}
}
public class GoodHandler
{
// 실제 로직은 Task 반환 메서드로 분리
public async Task DoSomethingAsync() { /* ... */ }
// 이벤트 핸들러는 진짜 한 줄짜리 어댑터
private async void OnButtonClick(object sender, EventArgs e)
{
try { await DoSomethingAsync(); }
catch (Exception ex) { ShowError(ex); }
}
public async Task RetryAllAsync()
{
await DoSomethingAsync();
await DoSomethingAsync();
}
}
규칙은 단순합니다. async void 는 이벤트 핸들러의 어댑터 한 줄짜리에서만, 실제 로직은 Task 반환 메서드에서 작성합니다.
5-4. Unity 모바일에서의 추가 함정 — UnityEngine API 와 GC
public class BadCoroutineLikeAsync : MonoBehaviour
{
// ❌ Task.Run 안에서 UnityEngine API 호출
public async Task LoadAsync()
{
await Task.Run(() =>
{
transform.position = Vector3.zero; // 메인 스레드 외 호출 → 예외
});
}
// ❌ Update 마다 async 메서드를 호출하며 await 누락 — 매 프레임 fire-and-forget
private void Update()
{
FetchOptionalAsync(); // Task 누수 + 예외 손실
}
private async Task FetchOptionalAsync() { await Task.Yield(); }
}
public class GoodAsyncInUnity : MonoBehaviour
{
// ✅ Task.Run 에는 순수 C# 계산만, UnityEngine API 는 메인 스레드 복귀 후
public async Task LoadAsync()
{
byte[] data = await DownloadAsync();
var parsed = await Task.Run(() => HeavyParse(data)); // 순수 C# 계산
// 여기는 다시 메인 스레드 (Task.Run 이 완료된 뒤 await 가 복귀시킴)
transform.position = parsed.Spawn;
}
private Task<byte[]> DownloadAsync() => Task.FromResult(new byte[] { });
private SpawnInfo HeavyParse(byte[] _) => new() { Spawn = Vector3.zero };
private struct SpawnInfo { public Vector3 Spawn; }
}
Unity 모바일에서는 Task 한 번 생성에도 힙 할당과 GC 부담이 따릅니다. 매 프레임 async 호출이 발생하는 핫패스에서는 Task 대신 UniTask(Cysharp 의 GC-zero 비동기 라이브러리)를 도입하는 것을 강력히 추천합니다. 코루틴과 비교해도 try/catch, 반환값, 다중 await 가 자연스럽다는 장점이 있습니다.
6. [C# 버전별 변화] — 시작은 5.0, 점차 확장
| 버전 | 변화 |
|---|---|
| C# 5.0 (.NET 4.5) | async / await 키워드 도입. 반환형은 void / Task / Task<T>. |
| C# 7.0 (.NET 4.7 / Core 2.0) | ValueTask<T> 도입. 동기 완료가 잦은 비동기 메서드의 힙 할당 회피. |
| C# 7.1 | async Main — 콘솔 앱 진입점에 async 허용. |
| C# 8.0 | 비동기 스트림 (IAsyncEnumerable<T> + await foreach) — 결과를 여러 번 산출하는 비동기 시퀀스 표현. |
| C# 9.0+ | 모듈 초기화·기타 컴파일러 최적화. 사용자 코드 시그니처 변화는 거의 없음. |
// C# 5.0 (.NET 4.5) — 첫 등장 시 가능한 모양
public async Task<int> CountAsync_v5(string path)
{
using var reader = new StreamReader(path);
int count = 0;
while (await reader.ReadLineAsync() != null) count++;
return count;
}
// C# 7.0 + : ValueTask<T> 로 동기 완료 경로 최적화
public ValueTask<int> CountCachedAsync(string key)
{
if (cache.TryGetValue(key, out int v))
return new ValueTask<int>(v); // 힙 할당 0
return new ValueTask<int>(LoadAndCacheAsync(key));
}
// C# 7.1 + : async Main
public static async Task<int> Main(string[] args)
{
await SomeStartupAsync();
return 0;
}
// C# 8.0 + : 비동기 스트림
public async IAsyncEnumerable<string> StreamLinesAsync(string path)
{
using var reader = new StreamReader(path);
string? line;
while ((line = await reader.ReadLineAsync()) is not null)
yield return line;
}
신입 개발자 단계에서는 C# 5.0 의 기본 모양(async Task / async Task<T> + await) 만 손에 익혀도 충분합니다. ValueTask 와 IAsyncEnumerable 은 측정·필요성 판단 후 도입하는 도구입니다.
7. [정리] — 핵심 체크리스트
- [ ]
async한정자는 "이 메서드 안에서await를 쓰겠다"는 컴파일러 신호. 그 자체로 백그라운드 실행은 아니다. - [ ]
await의 의미는 "기다림"이 아니라 "제어권 양보 + 콜백 등록". IL 레벨에서GetAwaiter()→IsCompleted검사 →AwaitUnsafeOnCompleted등록 →GetResult()패턴으로 펼쳐진다. - [ ]
async메서드의 반환형은 5종.Task/Task<T>/ValueTask/ValueTask<T>/void(이벤트 핸들러 한정). 기본은Task/Task<T>. - [ ]
async void는 이벤트 핸들러 외에서는 절대 금지 — 완료 추적 불가, 예외 처리 불가, 프로세스 강제 종료 위험. - [ ] I/O 바운드는
await client.SomethingAsync(...), CPU 바운드는await Task.Run(() => Compute()). 둘 다 호출 스레드를 풀어준다는 점에서 동일하지만, 작업을 처리하는 주체가 다르다. - [ ] 라이브러리 API 가
DoAsync형태를 제공하면 무조건 비동기 버전을await. 동기 버전을async메서드 안에서 부르면async의미가 사라진다. - [ ]
.Result/.Wait()는 deadlock 의 지름길.async를 끝까지 전파(async all the way)한다. - [ ] 반환된
Task는 반드시await. fire-and-forget 이 의도라면_ =로 명시하고 내부에서 예외를 잡는다. - [ ] 메서드 이름은
PascalCaseAsync규약 (GetUserAsync,LoadConfigAsync). 이벤트 핸들러는 예외. - [ ] Unity 모바일 핫패스에서는
Task의 GC 부담을 의식하고, 필요시 UniTask 도입.Task.Run안에서는UnityEngine.*API 호출 금지. - [ ] 상태 머신·
ConfigureAwait·SynchronizationContext의 깊은 동작은 별도 심화 주제로 분리. 본 글의 사용 패턴만 익히면 99% 실수는 피할 수 있다.
'C# 기초' 카테고리의 다른 글
| [PART13.비동기와 스레딩 기초(8/15)] 여러 Task 조합 — Task.WhenAll · Task.WhenAny (1) | 2026.05.09 |
|---|---|
| [PART13.비동기와 스레딩 기초(7/15)] 비동기 메서드의 반환형 — Task vs Task<T> vs void (0) | 2026.05.08 |
| [PART13.비동기와 스레딩 기초(5/15)] Task와 Task<T> — "언젠가 끝날 작업" 객체 (0) | 2026.05.08 |
| [PART13.비동기와 스레딩 기초(4/15)] ThreadPool — 스레드를 재사용하는 공용 풀 (0) | 2026.05.08 |
| [PART13.비동기와 스레딩 기초(3/15)] Thread 클래스 — 스레드를 직접 다루는 원시 도구 (0) | 2026.05.08 |