반응형

[PART13.비동기와 스레딩 기초(5/15)] Task와 Task<T> — "언젠가 끝날 작업" 객체

비동기 작업을 객체로 만든다 / 상태·결과·예외를 추적한다 / Task.Run·Delay·FromResult·CompletedTask의 사용처와 task.Result의 함정


1. 문제 제기 — "끝나면 알려줘"를 객체로 만들 수 있을까

Unity에서 모바일 게임을 만들다 보면 "지금 당장 결과를 줄 수는 없지만 언젠가 끝나는" 작업을 자주 마주친다. 서버에서 유저 데이터를 받아오거나, 큰 JSON 파일을 파싱하거나, 광고 SDK 초기화를 기다리는 일이 모두 그렇다.

문제는 "이 작업이 끝났을 때 다음 일을 해야 한다"라는 흐름을 코드로 표현하는 게 의외로 까다롭다는 점이다.

C#
// 신입이 처음에 자주 쓰는 코드 — 메인 스레드를 멈춰버림
void OnLoginClick()
{
    UserData data = LoadUserDataBlocking();   // 1.5초 동안 화면이 얼어붙음
    UpdateUI(data);
}

LoadUserDataBlocking이 1.5초 걸리면 그동안 게임이 완전히 멈춘다. 모바일에서는 OS가 ANR(Application Not Responding)로 강제 종료시킬 수도 있다.

해결책은 "백그라운드에서 실행하고 끝나면 다음을 처리"인데, 이걸 어떤 모양으로 코드에 담을지가 관건이다. .NET 1.x 시절에는 BeginInvoke/EndInvoke 콜백 짝을 쓰거나, 이벤트 핸들러 두 개를 등록하는 방식밖에 없었다. 코드가 파편화되고 예외 처리가 거의 불가능에 가까웠다.

C#이 이 문제에 내놓은 답이 Task 다. "언젠가 끝날 작업"을 그냥 평범한 객체 하나로 표현해서, 상태를 묻거나 결과를 받아오거나 다음 일을 이어붙일 수 있게 만들었다. async/await은 그 위에 얹힌 문법 설탕일 뿐이고, 진짜 핵심은 Task 객체가 비동기 작업의 라이프사이클을 통째로 들고 있다는 점이다.

이 글에서는 TaskTask<T>가 무엇인지, 내부에서 어떻게 동작하는지, Unity 핫패스에서 어떤 함정을 피해야 하는지를 차례로 본다.


2. 개념 정의 — "약속(Promise)"으로서의 Task

비유: Task는 음식 주문표다

식당에서 음식을 주문하면 종업원이 번호표를 준다. 음식은 아직 안 나왔지만, 그 번호표만 있으면 나중에 음식을 받아갈 수 있고 "지금 어디까지 됐어요?"라고 물어볼 수도 있다.

Task는 정확히 이 번호표다.

  • 메서드는 작업을 직접 끝내는 대신 Task 객체(번호표) 를 즉시 반환한다.
  • 호출자는 그 Task로 작업의 진행 상태를 묻거나, 끝났을 때 다음 동작을 예약하거나, 결과 값을 받을 수 있다.
  • 작업이 끝나면 Task 자신이 "완료" 상태로 전환된다.

Task반환값이 없는 작업의 번호표, Task<T>T 타입 결과를 돌려줄 작업의 번호표다. Task<int>라면 "끝나면 정수 하나를 줄 작업"을 의미한다.

호출 스레드 (Caller)

호출자는 Task 객체를 손에 쥔 채 자기 일을 계속한다. ThreadPool의 워커 스레드가 실제 작업을 수행하고, 완료되면 Task 객체에 결과를 채워 넣고, 등록된 연속(continuation)을 깨운다.

가장 단순한 사용

Task — "언젠가 끝날 작업" 객체 (Promise/Future) 비동기 작업의 상태(완료·실패·취소)와 결과(Task<T>의 경우)를 캡슐화한 객체다. 작업을 직접 실행하지 않고, 다른 스레드나 I/O 시스템에 위임한 뒤 그 진행 상황을 추적한다.
예시: Task<int> task = Task.Run(() => 42); ThreadPool 워커가 42를 계산하는 동안 호출자는 task 객체를 받아 다른 일을 계속한다.
C#
using System;
using System.Threading.Tasks;

class Demo
{
    static async Task Main()
    {
        // 1) 결과 없는 작업 — Task
        Task work = Task.Run(() => Console.WriteLine("백그라운드 작업"));
        await work;

        // 2) 결과 있는 작업 — Task<T>
        Task<int> compute = Task.Run(() => 1 + 2);
        int sum = await compute;             // sum == 3
        Console.WriteLine(sum);
    }
}

핵심은 Task.Run이 즉시 반환된다는 점이다. 람다 안의 코드는 ThreadPool 워커 스레드에서 실행되고, 호출자는 Task 객체를 손에 들고 다음 줄로 넘어간다.

컴파일러가 만드는 IL — 작업과 객체의 분리

C#
static Task<int> CachedFast() => Task.FromResult(42);
IL
.method private hidebysig static
    class [System.Runtime]System.Threading.Tasks.Task`1<int32> CachedFast () cil managed
{
    IL_0000: ldc.i4.s 42                                 // 정수 42를 스택에 적재
    IL_0002: call class Task`1<!!0> Task::FromResult<int32>(!!0)  // 이미 완료된 Task<int> 생성
    IL_0007: ret                                          // Task 반환 (작업 실행 X)
}

Task.FromResult(42)는 작업을 만드는 게 아니라 "이미 완료된 Task<int>" 한 덩어리를 반환한다. IL 레벨에서도 ldc.i4.s 42 → call FromResult → ret 세 줄이 전부다. ThreadPool은 일절 관여하지 않는다.

반면 Task.Run을 쓰면 IL이 길어진다.

C#
static Task<int> CachedSlow() => Task.Run(() => 42);
IL
.method private hidebysig static
    class Task`1<int32> CachedSlow () cil managed
{
    IL_0000: ldsfld class Func`1<int32> '<>c'::'<>9__4_0'        // 캐시된 람다 델리게이트 조회
    IL_0005: dup
    IL_0006: brtrue.s IL_001f                                     // 캐시 히트면 점프
    // 캐시 미스: 람다 델리게이트 생성
    IL_000e: ldftn instance int32 '<>c'::'<CachedSlow>b__4_0'()
    IL_0014: newobj instance void Func`1<int32>::.ctor(...)      // 함수 객체 할당 (1회)
    IL_001a: stsfld class Func`1<int32> '<>c'::'<>9__4_0'         // 캐시
    IL_001f: call class Task`1<!!0> Task::Run<int32>(...)         // ThreadPool 큐잉
    IL_0024: ret
}

Task.Run은 람다를 ThreadPool에 큐잉하고 새 Task<int> 객체를 할당한다. 결과가 같아도(42) 이 둘은 IL과 런타임 비용이 완전히 다르다 — 이미 답을 알고 있으면 FromResult, 진짜 백그라운드 계산이 필요하면 Run이다.


3. 내부 동작 — 상태 머신과 ThreadPool, 그리고 await의 정체

Task의 라이프사이클

Task는 내부에 Status라는 열거형 상태를 들고 있다. 시간 순서대로 상태가 흐른다.

WaitingForActivation

오른쪽의 세 상태(RanToCompletion·Faulted·Canceled)는 모두 종료 상태다. 한 번 들어가면 다른 상태로 돌아오지 않는다. IsCompleted 속성은 이 셋 중 하나에 도달했는지를 알려준다.

C#
using System;
using System.Threading.Tasks;

class StatusDemo
{
    static async Task Main()
    {
        Task<int> good = Task.Run(() => 100);
        Task<int> bad  = Task.Run<int>(() => throw new InvalidOperationException("실패"));

        try { await good; } catch { }
        try { await bad; }  catch { }

        Console.WriteLine($"good: Status={good.Status}, IsCompleted={good.IsCompleted}, IsFaulted={good.IsFaulted}");
        Console.WriteLine($"bad : Status={bad.Status}, IsCompleted={bad.IsCompleted}, IsFaulted={bad.IsFaulted}");
        // good: Status=RanToCompletion, IsCompleted=True, IsFaulted=False
        // bad : Status=Faulted,         IsCompleted=True, IsFaulted=True
    }
}

IsCompleted만으로는 성공·실패를 구분할 수 없다는 점이 중요하다. 종료됐는지만 알려주는 플래그이기 때문에, 실제 로직에서는 IsFaulted·IsCanceled까지 같이 봐야 한다. .NET 5부터는 IsCompletedSuccessfully(성공만 true)가 추가돼서 더 깔끔하게 쓸 수 있다.

async 메서드는 어떻게 Task로 변하는가

async 키워드가 붙은 메서드는 컴파일러가 상태 머신(State Machine) 구조체로 통째로 재작성한다. C# 코드와 IL을 비교하면 이게 무슨 뜻인지 확실해진다.

async — 비동기 메서드 한정자 메서드 안에서 await를 쓸 수 있게 해주는 표시. 컴파일러는 이 메서드를 IAsyncStateMachine을 구현하는 구조체로 변환하고, 본체는 MoveNext() 메서드 안의 switch 문으로 옮긴다.
예시: async Task<int> GetAsync() { await Task.Delay(100); return 42; } 100ms를 기다리는 동안 호출 스레드를 양보하고, 시간이 지나면 다시 깨어나 42를 반환한다.
C#
static async Task<int> GetResultAsync()
{
    Task<int> task = Task.Run(() => Compute());
    return await task;
}

이 메서드의 본체 IL은 의외로 짧다.

IL
.method private hidebysig static
    class Task`1<int32> GetResultAsync () cil managed
{
    .custom instance void AsyncStateMachineAttribute::.ctor(...)  // 상태 머신 표시
    .locals init ( [0] valuetype Program/'<GetResultAsync>d__1' )

    IL_0000: ldloca.s 0
    IL_0002: call AsyncTaskMethodBuilder`1<!0>::Create()          // 빌더 생성
    IL_0007: stfld ...::'<>t__builder'
    IL_000c: ldloca.s 0
    IL_000e: ldc.i4.m1
    IL_000f: stfld int32 '<GetResultAsync>d__1'::'<>1__state'    // state = -1 (시작 전)
    IL_001b: ldloca.s 0
    IL_001d: call instance void AsyncTaskMethodBuilder`1::Start<...>(!!0&)  // 상태 머신 시작
    IL_0029: call instance class Task`1<!0> AsyncTaskMethodBuilder`1::get_Task()
    IL_002e: ret                                                  // Task 반환
}

본체는 단지 상태 머신 구조체를 하나 만들고, 시작시키고, Task를 반환할 뿐이다. 진짜 로직은 컴파일러가 만든 <GetResultAsync>d__1 라는 숨겨진 구조체의 MoveNext() 메서드로 옮겨졌다.

IL
// <GetResultAsync>d__1::MoveNext (요약)
IL_0001: ldfld int32 '<>1__state'                                // 현재 상태 로드
IL_0018: ldftn instance int32 '<>c'::'<GetResultAsync>b__1_0'() // 람다 (() => Compute())
IL_0041: stfld int32 '<>1__state'                                // state = 0 (await 지점 마킹)
IL_0048: stfld TaskAwaiter`1<int32> '<>u__1'                     // awaiter 보관
IL_0056: call AsyncTaskMethodBuilder`1::AwaitUnsafeOnCompleted<...>(...)
                                                                  // ↑ 완료 시 MoveNext 재호출 등록
IL_0065: ldflda TaskAwaiter`1<int32> '<>u__1'                    // 재진입 시 awaiter 복원
IL_0087: stfld int32 '<>1__state'                                // state = -2 (완료)

핵심 흐름은 이렇다.

  1. await task를 만나면 task의 awaiter를 가져와 IsCompleted를 확인한다.
  2. 아직 미완료면 AwaitUnsafeOnCompleted로 "끝났을 때 내 MoveNext를 다시 불러줘" 콜백을 등록하고 MoveNext에서 즉시 빠져나온다(스레드 양보).
  3. task가 완료되면 ThreadPool에서 콜백이 실행되어 MoveNext가 다시 호출되고, state 필드 값을 보고 멈췄던 자리부터 이어 실행한다.

Task 객체는 이 시나리오에서 콜백을 등록받는 자리이자 결과를 보관하는 자리다. async가 주는 환상적인 동기 코드 같은 외관 뒤에는, 실은 평범한 Task와 콜백 등록이 있을 뿐이다.

Task와 ThreadPool의 관계

Task 자체는 스레드가 아니다. Task는 "할 일"이고, 누가 그 할 일을 실행하느냐는 별도 문제다.

작업 종류 누가 실행하는가 예시
CPU 바운드 ThreadPool 워커 스레드 Task.Run(() => 무거운계산())
I/O 바운드 OS·하드웨어 (스레드 점유 X) httpClient.GetStringAsync(url)
타이머 시스템 타이머, 콜백만 ThreadPool로 디스패치 Task.Delay(1000)
즉시 완료 스레드 사용 안 함 Task.FromResult(42)

CPU 바운드 작업이 Task.Run으로 들어가면 ThreadPool의 워커 큐로 디스패치된다. 워커는 자기 로컬 큐에서 작업을 LIFO로 꺼내 처리하고, 자기 큐가 비면 다른 워커의 큐에서 작업을 훔쳐 가는 work-stealing 방식으로 동작한다.

I/O 바운드 작업은 더 영리하다. httpClient.GetStringAsync는 OS 커널에 "데이터 도착하면 알려줘"를 등록하고 스레드를 즉시 반환한다. 데이터가 도착하면 OS가 인터럽트를 발생시키고, 그제서야 ThreadPool의 I/O 완료 워커가 짧게 깨어나 콜백을 실행한다. 요청을 기다리는 동안에는 스레드가 0개 점유된다.

이 차이가 중요한 이유 — Unity에서 1000개의 HTTP 요청을 동시에 보내도 스레드는 늘어나지 않는다. 하지만 1000개의 Task.Run(() => 1초짜리계산())을 보내면 ThreadPool이 폭발한다.


4. 실전 적용 — Task.Run·Delay·FromResult·CompletedTask 사용처

Task.Run — CPU 바운드 작업 오프로드 전용

Task.Run은 람다를 ThreadPool 워커 스레드에서 실행시키는 도구다. 메인 스레드에서 무거운 계산을 떼어내는 게 유일한 용도라고 봐도 과하지 않다.

Before — Unity 메인 스레드에서 무거운 작업

C#
// Unity 환경: 버튼 클릭 시 큰 JSON을 파싱
public class LoadingPanel : MonoBehaviour
{
    public void OnLoadClick()
    {
        string json = File.ReadAllText("big_data.json");        // 50ms (디스크 I/O)
        var data = JsonUtility.FromJson<HugeData>(json);        // 800ms (CPU 파싱)
        UpdateUI(data);                                          // 즉시
        // 결과: 850ms 동안 화면 멈춤. 모바일에서 ANR 위험.
    }
}

After — CPU 작업을 Task.Run으로 오프로드

C#
public class LoadingPanel : MonoBehaviour
{
    public async void OnLoadClick()
    {
        string json = await File.ReadAllTextAsync("big_data.json");  // I/O는 스레드 점유 X
        HugeData data = await Task.Run(() =>
            JsonUtility.FromJson<HugeData>(json));                    // CPU만 워커로
        UpdateUI(data);                                                // 메인 스레드로 복귀
    }
}

판단 기준 한 줄: I/O 메서드(이미 Async로 끝나는 것)는 Task.Run으로 감싸지 않는다. 직접 await만 한다. CPU 계산만 Task.Run으로 보낸다.

Task.Delay — 스레드를 잡지 않는 대기

Thread.Sleep은 스레드를 통째로 점유한 채 잠든다. ThreadPool 워커가 1초 잠들면 그 1초 동안 다른 작업이 그 워커를 못 쓴다. Task.Delay는 OS 타이머에 "1초 뒤 콜백 호출"을 등록하고 스레드를 즉시 반환한다.

C#
// ❌ 안티패턴: 워커 스레드 1초간 점유
await Task.Run(() => { Thread.Sleep(1000); });

// ✅ 올바른 패턴: 타이머만 등록, 스레드 즉시 반환
await Task.Delay(1000);
IL
// Task.Delay 호출은 단 한 줄
IL_0000: ldc.i4 0x3e8
IL_0005: call class Task Task::Delay(int32)   // OS 타이머 등록 후 미완료 Task 반환
IL_000a: ret

Unity의 WaitForSeconds와 비교하면 — WaitForSeconds는 코루틴(메인 스레드)에서만 동작하고 게임 시간(time scale)에 영향을 받는다. Task.Delay는 어느 스레드에서든 동작하고 실제 시간(real time)을 기준으로 한다. 게임이 일시정지된 상태(Time.timeScale = 0)에서도 Task.Delay는 그대로 흐른다.

Task.FromResult — 캐시 히트를 비동기 인터페이스에 맞추기

비동기 인터페이스를 구현해야 하는데 실제로는 동기로 답할 수 있는 경우가 있다. 캐시 히트, 즉시 거부, 미리 계산된 값 등이다.

C#
public interface IUserCache
{
    Task<User> GetAsync(int id);   // 인터페이스가 Task를 요구
}

public class MemoryCache : IUserCache
{
    private Dictionary<int, User> _store;

    public Task<User> GetAsync(int id)
    {
        // ❌ Task.Run으로 감싸면 ThreadPool을 낭비 — 단순 Dictionary 조회를 위해 워커 큐잉
        // return Task.Run(() => _store[id]);

        // ✅ FromResult로 이미 완료된 Task 반환
        return Task.FromResult(_store[id]);
    }
}
IL
// FromResult IL — 단 3줄
IL_0000: ldc.i4.s 42
IL_0002: call class Task`1<!!0> Task::FromResult<int32>(!!0)
IL_0007: ret

Task.FromResult도 매번 Task<T> 객체를 새로 할당한다는 점은 알아둬야 한다. 핫패스에서 매 프레임 호출되면 GC 압박이 생긴다. 그런 경우엔 ValueTask<T>로 바꾸는 게 답이다(자세한 건 다른 글에서 다룬다).

Task.CompletedTask — 결과 없는 동기 완료

Task.FromResult<T>의 반환값 없는 버전이다. 한 번 만들어둔 싱글턴 인스턴스를 반환하기 때문에 호출할 때마다 새 객체가 생기지 않는다.

C#
public interface IInitializer
{
    Task InitializeAsync();
}

public class NoOpInitializer : IInitializer
{
    // ❌ 매번 새 Task 할당
    // public Task InitializeAsync() => Task.FromResult<object>(null);

    // ✅ 싱글턴 재사용
    public Task InitializeAsync() => Task.CompletedTask;
}

Task.CompletedTask는 글 어디에도 새 객체를 만들지 않는다. 인터페이스 구현에서 "할 일 없음"을 빠르게 표현할 때 표준 관용구다.

메서드별 한 줄 정리

메서드 언제 쓰는가 스레드 사용
Task.Run(action) CPU 바운드 작업을 ThreadPool로 오프로드 워커 1개 점유 (작업 동안)
Task.Delay(ms) 비동기 대기, Thread.Sleep 대체 타이머 콜백 시점에만 짧게 사용
Task.FromResult(value) 이미 아는 값을 Task<T>로 래핑 사용 안 함
Task.CompletedTask 결과 없는 즉시 완료 Task 반환 사용 안 함 (싱글턴)

5. 함정과 주의사항

함정 1: task.Result로 동기 대기 — 데드락의 지름길

task.Resulttask.Wait()현재 스레드를 블로킹한 채 Task 완료를 기다린다. UI 스레드(WPF·WinForms)나 과거 ASP.NET 동기화 컨텍스트에서는 데드락을 100% 만든다.

❌ 잘못된 패턴

C#
// Unity UI 이벤트 핸들러
public void OnDataLoadClick()
{
    int data = LoadDataAsync().Result;   // 메인 스레드 블로킹
    UpdateUI(data);
}

private async Task<int> LoadDataAsync()
{
    await Task.Delay(100);                // await 후 메인 스레드로 복귀 시도
    return 42;
}
IL
// GetResultBlocking() IL — Result 호출
IL_001f: call class Task`1<!!0> Task::Run<int32>(class Func`1<!!0>)
IL_0024: callvirt instance !0 Task`1<int32>::get_Result()   // ← 여기서 스레드 블로킹
IL_0029: ret

데드락 시나리오LoadDataAsync 안의 await Task.Delay(100)는 100ms 후 원래 동기화 컨텍스트(메인 스레드) 로 돌아오려고 한다. 그런데 메인 스레드는 .Result를 기다리느라 블로킹되어 있다. 서로가 서로를 기다리는 데드락.

✅ 올바른 패턴

C#
public async void OnDataLoadClick()      // async void는 이벤트 핸들러에서만 OK
{
    int data = await LoadDataAsync();    // 메인 스레드 양보
    UpdateUI(data);                       // 100ms 후 메인 스레드에서 재개
}
IL
// GetResultAsync() IL — await만
IL_002e: ret                             // Task 반환만, 블로킹 없음
// MoveNext 안에서:
IL_0056: call AsyncTaskMethodBuilder`1::AwaitUnsafeOnCompleted<...>(...)
//      ↑ 콜백 등록 후 메서드 종료, 스레드 즉시 반환

규칙: await을 위에서부터 끝까지 일관되게 쓴다. .Result·.Wait()·.GetAwaiter().GetResult()테스트 코드와 콘솔 앱의 Main 메서드 외에는 절대 쓰지 않는다. C# 7.1부터는 async Task Main()도 가능하므로 Main에서도 .Result 쓸 일이 거의 없다.

함정 2: async void — 예외가 사라진다

async void로 선언된 메서드에서 발생한 예외는 호출자에게 전파되지 않는다. 그대로 SynchronizationContext로 전달되어, Unity의 경우 에디터에서는 콘솔에만 찍히고, 빌드된 앱에서는 그냥 크래시로 이어질 수 있다.

❌ 잘못된 패턴

C#
public class GameManager : MonoBehaviour
{
    async void Start()
    {
        await LoadConfigAsync();          // 여기서 예외 던지면?
    }

    async void RunBackground() => await DoWork();   // 호출자가 예외 못 잡음

    async Task DoWork() => throw new Exception("터짐");
}
IL
// async void 메서드는 AsyncVoidMethodBuilder를 사용 — Task가 없어 예외를 담을 곳이 없음
.locals init ( [0] valuetype StateMachine )
call AsyncVoidMethodBuilder::Create()    // ← Task가 아닌 void 빌더

✅ 올바른 패턴

C#
public class GameManager : MonoBehaviour
{
    // 이벤트 핸들러는 어쩔 수 없이 async void
    async void Start()
    {
        try
        {
            await LoadConfigAsync();
        }
        catch (Exception ex)
        {
            Debug.LogException(ex);        // 반드시 try-catch로 감싼다
        }
    }

    // 일반 메서드는 절대 async void 금지
    async Task RunBackgroundAsync() => await DoWork();
}

규칙: async void는 Unity의 Start·이벤트 핸들러처럼 호출자가 void 시그니처를 강제하는 경우에만 사용하고, 그때조차 본체를 통째로 try-catch로 감싼다.

함정 3: Task.Run으로 I/O 메서드 감싸기

C#
// ❌ 안티패턴 — 이미 비동기인 메서드를 Task.Run으로 감쌈
string html = await Task.Run(() => httpClient.GetStringAsync(url));

// ✅ I/O는 그냥 await
string html = await httpClient.GetStringAsync(url);

GetStringAsync는 이미 비동기 I/O라 스레드를 점유하지 않는다. Task.Run으로 감싸면 ThreadPool 워커 1개가 잠깐 깨어나서 GetStringAsync 호출만 하고 즉시 잠들었다가, 응답 도착 시 또 깨어난다. 순수한 낭비다.

함정 4: Unity 메인 스레드 외에서 Unity API 호출

C#
// ❌ Task.Run 안에서 Unity API 호출 — 예외 발생
await Task.Run(() =>
{
    transform.position = Vector3.zero;     // UnityException: 메인 스레드 아님
});

// ✅ 백그라운드는 계산만, UI 변경은 await 이후에 (자동으로 메인 스레드 복귀)
Vector3 newPos = await Task.Run(() => CalculateNextPosition());
transform.position = newPos;               // await 이후는 메인 스레드

UnityEngine.Object를 다루는 모든 API(Transform, GameObject, Time 등)는 메인 스레드 전용이다. Task.Run 람다는 ThreadPool 워커에서 실행되므로 그 안에서는 절대 호출하면 안 된다. await 이후 코드는 (Unity의 UnitySynchronizationContext 덕분에) 자동으로 메인 스레드에서 재개되므로, 계산은 백그라운드에서, 결과 적용은 await 이후에가 표준 패턴이다.

함정 5: 발사 후 망각(fire-and-forget) — Task를 버린다

C#
// ❌ Task를 받지 않고 호출 — 예외 발생 시 조용히 사라짐
SaveLogAsync(data);                                  // 컴파일러 경고도 안 남는 경우 있음

// ✅ 명시적으로 무시한다는 의도 표현 + 예외 로깅
_ = SaveLogAsync(data).ContinueWith(t =>
{
    if (t.IsFaulted) Debug.LogException(t.Exception);
}, TaskScheduler.Default);

.NET 4.0 시절에는 관찰되지 않은(Unobserved) Task 예외가 GC 종료자에서 다시 던져져 프로세스를 죽였다. .NET 4.5부터는 기본 동작이 "조용히 삼키기"로 바뀌었지만, 이건 에러를 더 찾기 어렵게 만든 것일 뿐이다. fire-and-forget을 의도적으로 쓸 때는 항상 예외 로깅 continuation을 붙여야 한다.


6. C# 버전별 변화 — Task가 진화한 길

.NET 4.0 (2010) — Task 도입과 ContinueWith

처음에는 async/await 없이 ContinueWith로 Task를 이어붙였다.

C#
// .NET 4.0 스타일 — ContinueWith 콜백 체인
Task<int> task = Task.Factory.StartNew(() => Compute());
task.ContinueWith(t =>
{
    if (t.IsFaulted) HandleError(t.Exception);
    else             UseResult(t.Result);
}, TaskScheduler.FromCurrentSynchronizationContext());
IL
// ContinueWith 호출 — Task 객체에 콜백 등록
callvirt instance class Task`1 Task`1::ContinueWith(class Action`1<class Task`1<!0>>, class TaskScheduler)

콜백이 두세 개 중첩되면 가독성이 빠르게 무너졌고, 예외 처리도 t.Exception 수동 체크가 필요했다.

C# 5.0 / .NET 4.5 (2012) — async/await 도입

C#
// C# 5.0 — 동기 코드처럼 작성
try
{
    int result = await Task.Factory.StartNew(() => Compute());
    UseResult(result);
}
catch (Exception ex)
{
    HandleError(ex);
}
IL
// async 메서드 본체 — 상태 머신 시작 호출만
call AsyncTaskMethodBuilder`1::Start<...>(!!0&)
call AsyncTaskMethodBuilder`1::get_Task()
ret

내부적으로는 컴파일러가 ContinueWith와 똑같은 일을 해주지만, 코드가 동기 흐름처럼 위에서 아래로 읽힌다. try-catch도 자연스럽게 동작한다.

C# 5.0 — Task.Run의 단순화

Task.Factory.StartNew는 옵션이 많고 기본값이 위험했다(예: 자식 Task 자동 attach). Task.Run은 안전한 기본값으로 단순화된 헬퍼다.

C#
// Before: Task.Factory.StartNew
Task<int> t1 = Task.Factory.StartNew(() => 42, CancellationToken.None,
    TaskCreationOptions.DenyChildAttach, TaskScheduler.Default);

// After: Task.Run (위와 동일한 안전한 기본값)
Task<int> t2 = Task.Run(() => 42);

.NET 5 (2020) — IsCompletedSuccessfully

IsCompleted && !IsFaulted && !IsCanceled 조합을 한 속성으로 줄였다.

C#
// Before
if (task.IsCompleted && !task.IsFaulted && !task.IsCanceled)
    UseResult(task.Result);

// After
if (task.IsCompletedSuccessfully)
    UseResult(task.Result);

.NET 6 (2021) — Task.WaitAsync(CancellationToken)

원래 취소 토큰을 받지 않는 Task에 외부 타임아웃·취소를 붙일 수 있게 됐다.

C#
// .NET 6+
Task<string> download = httpClient.GetStringAsync(url);
string html = await download.WaitAsync(TimeSpan.FromSeconds(5));
// 5초 안에 안 끝나면 TimeoutException

핵심은 Task의 본질은 .NET 4.0 시절과 같다는 점이다. async/await도, WaitAsync도, IsCompletedSuccessfully도 전부 같은 Task 객체를 더 편하게 다루는 도구일 뿐이다.


7. 정리

핵심 7가지를 기억한다.

  1. Task는 객체다 — 비동기 작업의 상태·결과·예외를 담는 평범한 객체. async/await은 그 위에 얹힌 문법 설탕.
  2. Task ≠ Thread — Task는 "할 일", ThreadPool 워커는 "그걸 실행하는 사람". I/O Task는 스레드를 점유하지 않는다.
  3. 메서드 선택: CPU 작업은 Task.Run, 대기는 Task.Delay, 캐시 히트는 Task.FromResult, 결과 없는 즉시 완료는 Task.CompletedTask.
  4. 상태는 3종 종료: RanToCompletion / Faulted / Canceled. IsCompleted만으로는 성공·실패를 구분 못 한다 — IsCompletedSuccessfully(.NET 5+)가 안전.
  5. .Result·.Wait()는 데드락 폭탄 — 위에서부터 await을 일관되게 쓴다. Main 외에는 절대 사용 금지.
  6. async void는 이벤트 핸들러 전용 — 그조차 본체를 try-catch로 감싸야 한다. 일반 메서드는 항상 Task 반환.
  7. Unity에서: Task.Run 안에서 Unity API 호출 금지, await 이후는 메인 스레드, fire-and-forget Task는 항상 예외 로깅 continuation 추가.

이 일곱 가지를 지키면 Task는 위험한 도구가 아니라 코드 흐름을 정직하게 표현하는 객체가 된다. 다음 글에서는 그 위에 올라가는 async/await의 컴파일러 변환을 더 깊게 본다.

반응형

+ Recent posts