반응형

[PART13.비동기와 스레딩 기초(6/15)] async / await — 비동기 흐름을 동기처럼 쓰는 문법

콜백 지옥을 동기 코드처럼 / 반환형 5종 / I/O는 await, CPU는 Task.Run / DoAsync 보면 무조건 그쪽


1. [문제 제기] — 비동기 작업이 늘어나면 코드가 콜백 미로가 된다

Unity에서 모바일 게임을 만들다 보면 비동기로 처리해야 하는 일이 끊이지 않습니다. 서버에서 유저 정보를 받아오고, 그 정보로 인벤토리를 조회하고, 아이콘 텍스처를 다운로드하고, 마지막으로 캐시에 저장하는 흐름은 흔합니다. 이 모든 단계가 네트워크를 타기 때문에 메인 스레드를 막을 수는 없습니다.

async/await 가 없던 시절에는 콜백으로 이 흐름을 표현했습니다. 결과를 받는 함수가 또 다른 비동기 호출을 시작하고, 그 결과를 받는 함수가 다시 다른 비동기 호출을 시작하는 식이었습니다.

C#
// 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 응답이 도착할 때까지 스레드를 점유하지 않습니다.
async/await ≈ 등기 + 알림 문자

핵심은 단어가 주는 인상에 속지 않는 것입니다. await 는 "기다린다"가 아니라 "기다릴 일을 예약하고 즉시 자리를 비운다" 입니다. 작업이 끝나면 시스템이 알아서 우리를 다시 호출해 줍니다.

2-2. 가장 기본적인 사용 형태

문제 정의에서 인용한 사양 그대로의 형태가 가장 단순한 예입니다.

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

세 줄짜리 메서드지만 약속이 빼곡합니다.

  1. async 한정자 — "이 안에서 await 를 쓰겠다"는 컴파일러 신호
  2. Task<string> 반환형 — await 가능한 awaitable. 메서드 본문은 stringreturn 하지만 컴파일러가 Task<string> 으로 감싸 줍니다.
  3. Async 접미사 — 호출자 입장에서 "이건 비동기니까 await 붙여야 해"를 즉시 알아챌 수 있는 명명 규약
  4. awaitTask<T> 가 끝나면 그 결과(여기서는 string)를 꺼내 html 에 대입. 끝나기 전이면 호출자에게 제어권을 돌려줍니다.

2-3. IL 레벨에서 본 변환 결과

이 메서드를 컴파일하고 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
}
IL
// 우리가 적은 한 줄(`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 가 "기다림"이 아니라 "콜백 등록 후 자리 비움"이라는 비유가 정확합니다. IsCompletedfalseAwaitUnsafeOnCompleted 가 호출되고, 메서드는 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 이벤트 핸들러 한정. 그 외에는 절대 금지 추적 불가
async 메서드의 반환형 결정 흐름

3-2. 코드로 보는 4종

C#
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> vs ValueTask<T> 의 정확한 트레이드오프와 다회 await 금지·풀링 같은 디테일은 다음 주제(07. 비동기 메서드의 반환형 — Task vs Task<T> vs void)에서 따로 다룹니다. 본 글에서는 "기본은 Task / Task<T>, 핫패스에서만 측정 후 ValueTask 도입" 정도만 기억하면 충분합니다.

3-3. async void 가 위험한 이유

async void 가 일반 메서드에서 금지되는 이유는 두 가지입니다.

C#
public class VoidDanger
{
    // 절대 따라하면 안 되는 패턴
    public async void DoBadlyAsync()
    {
        await Task.Delay(10);
        throw new InvalidOperationException("터졌어요"); // 어디서도 잡을 수 없음
    }

    public async Task CallerAsync()
    {
        try
        {
            DoBadlyAsync(); // Task 가 없어 await 불가
            // 메서드가 끝났는지 알 길도 없음
        }
        catch (InvalidOperationException) // 여기로 안 들어옴
        {
            // 도달 불가
        }
    }
}
  1. 완료 추적 불가 — 반환형이 void 니까 await 할 대상이 없습니다. 호출자는 그 비동기 메서드가 끝났는지, 성공했는지조차 알 수 없습니다.
  2. 예외 처리 불가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 가 연산하는 시간이 거의 없습니다. 이런 호출은 라이브러리 쪽에서 이미 비동기 버전을 제공합니다.

C#
// ❌ 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 은 어떤지 보겠습니다.

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 를 만들고, IsCompletedfalseAwaitUnsafeOnCompleted 로 콜백을 등록한 뒤 leave 로 메서드를 빠져나갑니다. 응답이 오면 시스템이 다시 진입해 다음 줄을 실행합니다. 스레드는 그 사이에 풀려 있습니다.
  • BeforeTask<string>::get_Result() 한 방으로 끝납니다. 짧고 단순하지만, 이 IL 한 줄은 "응답이 올 때까지 이 스레드를 통째로 멈춘다"는 뜻입니다. UI 스레드에서 호출하면 화면이 얼어붙고, ASP.NET 스레드 풀에서 호출하면 스레드 풀이 고갈됩니다.

같은 비동기 API 를 부르는데 IL 한 줄을 잘못 쓰면 동작 모델 자체가 뒤집힙니다.

4-2. CPU 바운드: 내가 직접 무거운 계산을 한다면 Task.Run

I/O 와 정반대로 CPU 자원을 길게 점유하는 작업(이미지 디코딩, 복잡한 게임 로직 시뮬레이션, 대량 데이터 변환)은 await 를 붙일 비동기 API 가 따로 없습니다. 그냥 메서드를 호출하면 그 스레드에서 CPU 가 돕니다.

이때 메인 스레드(UI · Unity 메인 스레드)에서 무거운 계산을 직접 하면 화면이 얼어붙기 때문에, Task.Run 으로 스레드 풀에 일을 넘긴 뒤 그 Taskawait 합니다.

C#
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;
    }
}
IL
// 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 접미사가 붙어 있습니다.

C#
// 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 메인 스레드에서는 화면이 얼어붙습니다.

C#
// ❌ 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() 입니다.

C#
// ❌ 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 의 메커니즘은 거칠게 말해 이렇습니다.

  1. UI 스레드가 LoadAsync().Result 로 자신을 블로킹.
  2. LoadAsync 내부의 await 가 끝나면 html.Trim() 줄을 원래 호출 스레드(UI 스레드) 에서 실행하고 싶어 합니다.
  3. 그런데 UI 스레드는 .Result 때문에 막혀 있습니다.
  4. 서로 무한 대기.

ConfigureAwait(false) 같은 우회 수단도 있지만, 본질적인 해결은 async 를 끝까지 일관되게 전파하는 것(async all the way)입니다. 어디선가 동기로 끊는 순간 비동기의 이점이 사라지고 deadlock 위험이 생깁니다.

5-2. 반환된 Task 를 무시 — Fire-and-forget 의 함정

Task 를 받아 놓고 await 도 안 하고 변수에 담지도 않으면, 그 작업은 백그라운드에서 흘러갑니다. 예외가 터져도 알 길이 없습니다.

C#
// ❌ 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 지만, 그 메서드를 일반 메서드처럼 다른 곳에서 호출하면 안 됩니다.

C#
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

C#
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(); }
}
C#
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#
// 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) 만 손에 익혀도 충분합니다. ValueTaskIAsyncEnumerable 은 측정·필요성 판단 후 도입하는 도구입니다.


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% 실수는 피할 수 있다.
반응형

+ Recent posts