반응형

[PART13.비동기와 스레딩 기초(2/15)] 프로세스 · 스레드 · Task — 용어 정리

OS가 다루는 단위 vs CPU 위에서 실행되는 흐름 vs .NET이 추상화한 "할 일" — 셋의 경계를 정확히 긋습니다


1. 문제 제기 — "Task 100개 만들면 스레드 100개가 뜨나요?"

Unity 모바일 클라이언트 신입 개발자가 비동기 코드를 처음 만질 때 거의 반드시 거치는 혼란이 있습니다. "Task가 스레드인 줄 알았다"는 오해입니다. 이 오해 위에 코드를 쌓으면 다음과 같은 사고가 생깁니다.

시나리오 1. 인벤토리에서 아이템 100개의 아이콘을 동시에 불러오려고 Task.Run을 100번 호출했습니다. 프로파일러를 켜 보니 스레드는 8개만 떠 있는데, 그러면 92개의 작업은 어디로 간 걸까요?

시나리오 2. UI에 로딩 스피너를 띄우고 Task.Delay(3000)await 했습니다. 의도한 대로 3초간 화면이 멈추지 않았는데, 도대체 어떤 스레드가 그 3초를 기다린 걸까요? 새 스레드가 만들어졌다면 비싸지 않을까요?

시나리오 3. 백그라운드에서 무거운 계산을 돌리려고 new Thread(...)Task.Run(...) 중 무엇을 쓸지 고민하다가, 두 코드가 거의 똑같이 보여 그냥 검색에서 본 대로 베껴 썼습니다. 한쪽은 GC 스파이크를 만들고 다른 한쪽은 만들지 않는데 그 이유를 모릅니다.

이 세 시나리오의 답은 모두 "프로세스 · 스레드 · Task가 서로 다른 추상화 계층에 있다" 는 사실에서 출발합니다. 셋을 같은 차원의 단어로 다루면 비동기 코드는 영영 마법처럼 느껴집니다. 셋이 어느 계층에 속하고 누가 누구를 빌려 쓰는지를 한 번 정리해 두면, 그 뒤로는 Task.Run·async/await·ConfigureAwait까지의 모든 개념이 일관된 모델 위에서 풀립니다.

이 글의 목표는 단 하나입니다. "Task 하나 = 스레드 하나가 아니다"라는 문장을 머리가 아닌 멘탈 모델로 받아들이는 것입니다.


2. 개념 정의 — 세 단어가 가리키는 서로 다른 계층

비유: 레스토랑

세 단어의 차이를 한 번에 잡는 가장 빠른 비유는 레스토랑입니다.

  • 프로세스레스토랑 건물 한 채입니다. 독립된 주방, 식자재 창고, 자금이 있고 옆 가게(다른 프로세스)와 격리되어 있습니다.
  • 스레드는 그 안에서 실제로 요리를 하는 요리사입니다. 한 레스토랑에 요리사는 여러 명일 수 있고, 같은 주방 설비(코드)와 식자재 창고(힙 메모리)를 공유합니다.
  • Task는 손님이 적어 낸 주문서 한 장입니다. "파스타 한 그릇 만들어 주세요"라고 적힌 종이일 뿐, 그 자체로는 요리하지 않습니다. 누가 만들지(어느 요리사가 잡을지)는 주방장이 정합니다.

핵심 통찰은 마지막 줄입니다. 주문서는 요리사가 아닙니다. 주문서가 100장 쌓여도 요리사는 8명일 수 있습니다. 8명이 100장을 돌려가며 처리합니다.

한 장으로 보는 계층 구조

프로세스 (Process) — OS가 관리하는 실행 인스턴스

이 그림에서 읽어야 할 사실은 셋입니다.

  1. 프로세스 한 채 안에 스레드가 여러 개 있고, 그 위로 Task 큐가 따로 떠 있습니다.
  2. Task 개수와 스레드 개수는 같지 않습니다. Task 가 N 개여도 스레드는 풀 크기만큼만 존재합니다.
  3. Task 는 "큐에 들어 있다 → 스레드가 잠시 빌려 가서 실행한다 → 끝나면 스레드는 풀로 복귀한다"는 식으로 처리됩니다.

기본 코드로 확인 — 스레드 ID는 같을 수도, 다를 수도 있다

async / await — 비동기 메서드 / 비동기 대기 (Asynchronous method / await) async는 메서드 안에서 await를 쓸 수 있게 해 주는 표식입니다. await는 그 자리에서 작업이 끝나기를 기다리되, 현재 스레드를 차단하지 않고 호출자에게 제어권을 돌려줍니다. 작업이 완료되면 이어서 실행할 코드를 콜백처럼 등록해 두는 셈입니다.
예시: await Task.Delay(100); 100ms 동안 스레드를 점유하지 않고 기다린 뒤 다음 줄로 진행
C#
using System;
using System.Threading;
using System.Threading.Tasks;

public class Demo
{
    public static async Task Main()
    {
        Console.WriteLine($"메인 시작 — 스레드 ID = {Thread.CurrentThread.ManagedThreadId}");

        // Task 5개를 거의 동시에 던진다
        var tasks = new Task[5];
        for (int i = 0; i < 5; i++)
        {
            int idx = i;
            tasks[i] = Task.Run(() =>
            {
                Console.WriteLine($"Task {idx} 실행 — 스레드 ID = {Thread.CurrentThread.ManagedThreadId}");
            });
        }
        await Task.WhenAll(tasks);
    }
}

콘솔 환경에서 이 코드를 돌리면 출력이 대략 이렇게 찍힙니다(실행할 때마다 순서·ID가 다릅니다).

메인 시작 — 스레드 ID = 1
Task 0 실행 — 스레드 ID = 4
Task 2 실행 — 스레드 ID = 5
Task 1 실행 — 스레드 ID = 4
Task 3 실행 — 스레드 ID = 6
Task 4 실행 — 스레드 ID = 5

읽어 낼 부분은 두 가지입니다. 첫째, Task 5개에 대해 스레드 ID 가 5종류가 아닙니다. 같은 ID(4, 5)가 재사용됩니다. 풀의 작업자 스레드가 한 Task 를 끝낸 뒤 다음 Task 를 잡았기 때문입니다. 둘째, 메인의 ID(1)와 Task 들의 ID 는 다릅니다. Task.Run은 호출하는 즉시 작업을 풀로 보냈다는 뜻입니다.

세 단어의 정확한 정의

용어 추상화 계층 누가 관리 핵심 자원
프로세스 OS 운영체제 커널 가상 주소 공간 · 핸들 · 보안 토큰
스레드 OS / CLR 운영체제 커널 (CLR이 매핑) 스택(기본 1MB) · 레지스터 · 실행 컨텍스트
Task .NET 라이브러리 TPL(Task Parallel Library) 상태 · 결과값 · 연속 작업 콜백

스레드는 하드웨어/OS 층의 "실제 일꾼"이고, Task 는 .NET 층의 "할 일 영수증"입니다. 영수증은 일꾼을 만들지 않습니다. 일꾼이 영수증을 잡아 처리할 뿐입니다.


3. 내부 동작 — 누가 누구를 빌려 쓰는가

프로세스 메모리 공간 — 스레드가 공유하는 전제

프로세스 가상 주소 공간 (스레드끼리 공유)

스레드는 메모리 비용이 작지 않습니다. 운영체제 입장에서 스레드 하나는 보통 1MB짜리 유저 모드 스택과 별도의 커널 모드 스택, 그리고 컨텍스트(레지스터 묶음)를 가집니다. 그래서 "동시 작업 1만 개 = 스레드 1만 개" 라는 발상은 메모리만으로도 깨집니다. 1만 × 1MB = 10GB. 모바일 게임 클라이언트에서 절대 통과하지 못합니다.

여기에 컨텍스트 스위칭 비용이 더해집니다. CPU 코어 수보다 활성 스레드가 많으면 OS 가 스레드 사이를 빠르게 오가며 시간을 분배하는데, 매번 레지스터를 저장·복원하고 CPU 캐시 라인이 무효화됩니다. 이는 순수 오버헤드입니다.

이 두 비용 때문에 CLR 은 스레드를 직접 만들지 못하게 막지는 않지만, 풀로 재사용하는 모델(ThreadPool)을 기본으로 깔아 두고, 그 위에 Task 라는 가벼운 추상화를 얹었습니다.

Thread vs Task.Run — IL 레벨에서 보이는 비용 차이

new Thread(...) vs Task.Run(...) 키워드 박스 new Thread(delegate) 는 OS 에 새 스레드를 직접 요청합니다. Task.Run(delegate) 은 ThreadPool 큐에 작업 하나를 밀어 넣고 풀에 있는 스레드 한 명에게 처리를 맡깁니다. 똑같이 백그라운드 실행이지만 비용·재사용성이 다릅니다.
C#
using System;
using System.Threading;
using System.Threading.Tasks;

public class Program
{
    // Before: Thread 직접 생성 — 새 OS 스레드 만들고 끝나면 버림
    public static void RunWithThread()
    {
        var thread = new Thread(() => Console.WriteLine("hello"));
        thread.Start();
    }

    // After: Task.Run — ThreadPool 의 기존 스레드를 빌림
    public static void RunWithTask()
    {
        Task.Run(() => Console.WriteLine("hello"));
    }
}

두 메서드를 컴파일해서 IL 을 비교하면 차이가 깔끔하게 드러납니다.

IL
// RunWithThread
IL_0014: newobj instance void [System.Threading.Thread]System.Threading.ThreadStart::.ctor(object, native int)
IL_001f: newobj instance void [System.Threading.Thread]System.Threading.Thread::.ctor(class ...ThreadStart)   // ← Thread 객체 newobj
IL_0024: callvirt instance void [System.Threading.Thread]System.Threading.Thread::Start()                     // ← OS 스레드 생성·시작

// RunWithTask
IL_0014: newobj instance void [System.Runtime]System.Action::.ctor(object, native int)
IL_001f: call class ...Task [System.Runtime]System.Threading.Tasks.Task::Run(class ...Action)                 // ← static Task.Run 호출 (Thread 객체 없음)
IL_0024: pop

RunWithThread 쪽 IL 에서는 Thread::.ctorThread::Start 가 명시적으로 호출됩니다. 이는 OS 스레드가 새로 만들어진다는 뜻입니다. 작업이 끝나면 그 스레드는 그대로 사라집니다. 다음번 호출에는 또 새로 만듭니다.

반면 RunWithTask 쪽은 Task::Run 이라는 정적 메서드 한 번 호출이 전부입니다. Thread::.ctor 가 없습니다. 내부적으로 Task.RunThreadPool.QueueUserWorkItem 계열 경로를 타고, 이미 살아 있는 풀 스레드 중 한 명에게 작업을 위임합니다. 작업이 끝나면 그 스레드는 풀로 돌아가 다음 작업을 기다립니다.

언제 new Thread 를 써야 하나? 거의 없습니다. 단, 장수명 백그라운드 루프(예: 자체 게임 서버에서 평생 도는 워커)나 STA 같은 특수 아파트먼트가 필요한 코드에는 Thread 가 적합합니다. 짧은 단발성 작업은 모두 Task.Run을 씁니다.

Task 가 스레드를 차지하지 않는 또 하나의 길 — async/await 의 상태 기계

Task.Run은 "CPU 작업 → 스레드 풀에 던지기"입니다. 그런데 비동기의 진짜 마법은 "기다리는 동안 스레드를 점유하지 않는다" 는 부분에 있습니다. 이를 가능하게 하는 게 컴파일러가 생성하는 상태 기계(state machine) 입니다.

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

public class Program
{
    public static async Task<int> FetchAsync()
    {
        await Task.Delay(10);
        return 42;
    }
}

이 짧은 메서드를 컴파일하면 컴파일러는 별도의 중첩 구조체 <FetchAsync>d__0 을 생성하고, 원래의 FetchAsync 메서드 본문을 MoveNext() 라는 상태 기계 메서드로 변환합니다.

IL
// 컴파일러가 자동 생성한 중첩 타입
.class nested private auto ansi sealed beforefieldinit '<FetchAsync>d__0'
    extends [System.Runtime]System.ValueType
    implements [System.Runtime]System.Runtime.CompilerServices.IAsyncStateMachine
{
    .field public int32 '<>1__state'                           // 현재 상태 (-1: 초기 / 0: 대기 / -2: 완료)
    .field public valuetype ...AsyncTaskMethodBuilder`1<int32> '<>t__builder'   // Task 빌더
    .field private valuetype ...TaskAwaiter '<>u__1'           // 대기 중인 awaiter 보관

    .method private final hidebysig newslot virtual instance void MoveNext()
    {
        IL_0000: ldarg.0
        IL_0001: ldfld int32 Program/'<FetchAsync>d__0'::'<>1__state'
        IL_0006: stloc.0
        IL_0007: ldloc.0
        IL_0008: brfalse.s IL_0040          // 상태 0(이미 await 진입)이면 재진입 위치로 점프

        IL_000a: ldc.i4.s 10
        IL_000c: call class ...Task ...Task::Delay(int32)
        IL_0011: callvirt instance valuetype ...TaskAwaiter ...Task::GetAwaiter()
        IL_0016: stloc.2
        IL_0017: ldloca.s 2
        IL_0019: call instance bool ...TaskAwaiter::get_IsCompleted()
        IL_001e: brtrue.s IL_005c           // 이미 끝났으면 결과 가져오는 곳으로 (스레드 안 놓음)

        // 아직 안 끝났으면 — 상태를 0 으로 저장하고 빌더에 콜백 등록 후 함수에서 빠져나간다
        IL_0020-IL_003e: ...AwaitUnsafeOnCompleted(...)
        IL_003e: leave.s IL_0093            // ← 여기서 스레드 반환! 이후엔 풀의 다른 스레드가 MoveNext 를 다시 부른다

        IL_0040: ...                         // 재진입 지점 — Task.Delay 가 끝났을 때 여기서부터 다시 실행
        IL_005e: call instance void ...TaskAwaiter::GetResult()
        IL_0063: ldc.i4.s 42
        IL_0066: leave.s IL_007f

        IL_008e: ldloc.1
        IL_008f: call instance void ...AsyncTaskMethodBuilder`1<int32>::SetResult(!0)   // 최종 결과를 Task 에 기록
    }
}

// 우리가 호출하는 FetchAsync 의 IL — 본문이 사라지고 상태 기계 시작 코드만 남아 있다
.method public hidebysig static class ...Task`1<int32> FetchAsync()
{
    .custom instance void ...AsyncStateMachineAttribute::.ctor(class ...Type) = ...   // 어떤 상태 기계를 쓰는지 메타데이터로 명시

    IL_0000-IL_0007: ...AsyncTaskMethodBuilder`1<int32>::Create()    // Task 빌더 준비
    IL_000c-IL_000f: '<>1__state' = -1                                // 초기 상태
    IL_001b-IL_001d: ...AsyncTaskMethodBuilder::Start<...>(...)       // MoveNext() 한 번 첫 호출
    IL_0029-IL_002e: get_Task() → ret                                  // 호출자에게 Task 반환
}

이 IL 에서 읽어 낼 핵심은 한 줄, leave.s IL_0093 입니다. Task.Delay(10) 이 아직 끝나지 않은 경우, 상태 기계는 현재 진행 위치(<>1__state = 0)와 awaiter 를 필드에 저장한 뒤 메서드에서 그냥 빠져나옵니다. 현재 스레드는 즉시 풀로 반환됩니다. 10ms 가 지나 타이머가 만료되면, ThreadPool 의 어떤(꼭 같은 스레드일 필요 없는) 스레드가 MoveNext() 를 다시 호출해 IL_0040 위치로 점프하여 이어서 실행합니다.

await Task.Delay(10) 는 스레드를 10ms 동안 점유하지 않습니다. 점유하는 것은 작은 구조체(상태 기계)와 타이머 콜백 등록뿐입니다.

I/O 바운드 작업이 스레드를 거의 쓰지 않는 이유

Task.Delay 는 시간 대기였지만, 파일/네트워크 같은 I/O 작업도 같은 모델을 따릅니다. 핵심은 OS 가 비동기 I/O 를 직접 지원한다는 사실입니다.

  • Windows: I/O Completion Port (IOCP)
  • Linux: epoll / io_uring
  • macOS: kqueue

HttpClient.GetStringAsync(...) 같은 호출은 운영체제에 "이 요청 보내고, 응답이 오면 알려줘" 만 등록한 뒤 호출 스레드를 즉시 반환합니다. 응답이 도착하면 OS 가 .NET 의 IOCP 스레드(매우 적은 수)를 깨우고, 그 스레드가 등록된 콜백을 실행해 우리의 상태 기계를 이어 갑니다.

요약하면 다음과 같습니다.

작업 종류 누가 기다리는가 우리가 쓰는 스레드 수
CPU 바운드 계산 풀 스레드가 직접 계산 CPU 코어 수 정도
시간 대기 (Task.Delay) OS 타이머 큐 0 (콜백 시점에만 잠깐)
네트워크/디스크 I/O OS I/O 서브시스템(IOCP/epoll) 0 (콜백 시점에만 잠깐)

이래서 수천 개의 동시 네트워크 요청을 ThreadPool 16개 정도로 처리할 수 있습니다.

ExecutionContext — 스레드를 넘나들어도 보안 컨텍스트는 따라간다

await 는 자주 스레드를 갈아탑니다. 그러면 사용자 ID·문화권 설정·로깅 컨텍스트 같은 암묵적 환경 정보는 어떻게 유지될까요. .NET 은 await 직전 호출 스레드의 ExecutionContext 를 캡처해 두었다가 재개 시 새 스레드에 복원합니다. 이 덕분에 Thread.CurrentPrincipal, CultureInfo.CurrentCulture, 로거가 의존하는 AsyncLocal<T> 값이 비동기 흐름 내내 일관되게 유지됩니다.


4. 실전 적용 — Unity 클라이언트에서 셋을 어떻게 다루는가

Unity 는 일반 .NET 콘솔 앱과 결정적인 차이가 하나 있습니다. 거의 모든 Unity API 가 메인 스레드(렌더링 스레드)에서만 호출 가능하다는 점입니다. transform.position, gameObject.SetActive, UI Toolkit 의 모든 메서드, 코루틴 시작 등 — 백그라운드 스레드에서 만지면 즉시 예외가 납니다.

따라서 Unity 에서 셋을 다루는 규칙은 다음과 같이 정리됩니다.

어디서 무엇을
게임 오브젝트·UI 변경 메인 스레드 기본 코드
무거운 계산 (경로 탐색·압축·해싱) 풀 스레드 await Task.Run(() => ...)
네트워크 / 파일 I/O 대기 스레드 없음 (OS) await SomethingAsync()
일정 시간 대기 (게임 루프 안) 메인 스레드 await Task.Delay(...) 또는 코루틴

Before/After — 메인 스레드 블로킹

Before — Thread.Sleep 으로 메인을 막아 버린 코드

C#
using System.Threading;
using UnityEngine;

public class FreezeOnLoad : MonoBehaviour
{
    public void OnClickStart()
    {
        // 3초간 로딩 분위기를 내고 싶다 ...
        Thread.Sleep(3000);   // 메인 스레드를 3초간 완전히 정지시킴
        SceneManager.LoadScene("Stage1");
    }
}

Thread.Sleep 의 IL 은 충격적일 정도로 단순합니다.

IL
.method public hidebysig static void BlockingWait()
{
    IL_0000: ldc.i4 1000
    IL_0005: call void ...Thread::Sleep(int32)    // ← 호출 스레드를 OS 레벨에서 정지
    IL_000a: ret
}

이 호출은 메인 스레드 자체를 OS 차원에서 잠재웁니다. 그동안 게임 루프는 한 프레임도 진행하지 않습니다. 화면은 얼고 입력도 받지 않습니다. ANR(앱 응답 없음)에 가까운 상태가 됩니다.

After — Task.Delay 로 스레드를 풀어 주는 코드

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

public class SmoothLoad : MonoBehaviour
{
    public async void OnClickStart()
    {
        // 3초 동안 게임 루프를 정상 진행시키며 기다림
        await Task.Delay(3000);
        SceneManager.LoadScene("Stage1");
    }
}

NonBlockingWaitAsync 의 IL 은 앞에서 본 FetchAsync 처럼 별도의 상태 기계 구조체를 생성하고, AsyncTaskMethodBuilder::Start 를 호출하는 형태입니다. 메인 스레드는 await 시점에 즉시 풀려 게임 루프를 계속 돌립니다. 3초 후 타이머 콜백이 메인 스레드(Unity 의 UnitySynchronizationContext)로 다시 돌아와 LoadScene 을 실행합니다.

주의: OnClickStart 의 반환형이 async void 입니다. UI 이벤트 핸들러라서 어쩔 수 없는 경우인데, 함정이 따로 있습니다. 6번 섹션에서 다룹니다.

무거운 계산을 백그라운드로 — Task.Run 의 정확한 사용법

Before — 메인에서 큰 계산을 그대로 돌림

C#
public class ItemFilter : MonoBehaviour
{
    [SerializeField] List<Item> items;

    public void OnSearch(string keyword)
    {
        // items 가 5만 개일 때 메인 스레드에서 직접 정렬 → 프레임 드랍
        var filtered = items
            .Where(i => i.Name.Contains(keyword))
            .OrderBy(i => i.Score)
            .ToList();

        UpdateUI(filtered);
    }
}

After — 계산은 풀 스레드, UI 변경은 메인 스레드

C#
public class ItemFilter : MonoBehaviour
{
    [SerializeField] List<Item> items;

    public async void OnSearch(string keyword)
    {
        // 1) 무거운 LINQ 계산은 풀 스레드에서 — UI 와 분리
        List<Item> filtered = await Task.Run(() =>
            items
                .Where(i => i.Name.Contains(keyword))
                .OrderBy(i => i.Score)
                .ToList()
        );

        // 2) await 이후엔 UnitySynchronizationContext 덕분에 다시 메인 스레드로 복귀
        UpdateUI(filtered);
    }
}

여기서 두 가지가 중요합니다. 첫째, Task.Run 안의 코드는 백그라운드 스레드에서 도므로 절대 Unity API 를 만지면 안 됩니다. transform.position 한 줄만 들어가도 예외입니다. 둘째, await 이 끝나면 자동으로 메인 스레드로 돌아옵니다. Unity 가 깔아 둔 UnitySynchronizationContextawait 의 컨텍스트 캡처를 받아 콜백을 메인 큐에 등록하기 때문입니다. 그래서 UpdateUI(filtered) 는 안전합니다.

동시 다운로드 100개 — Task 와 스레드 수의 진짜 관계

C#
public async Task LoadAllIconsAsync(IReadOnlyList<string> urls)
{
    using var http = new HttpClient();

    // Task 100개를 동시에 시작 — 그렇다고 스레드 100개가 뜨는 게 아니다
    Task<byte[]>[] tasks = urls
        .Select(url => http.GetByteArrayAsync(url))
        .ToArray();

    byte[][] results = await Task.WhenAll(tasks);
    // 모든 다운로드가 끝난 시점에 도달
}

이 코드를 돌리는 동안 모니터링 도구로 스레드를 확인하면, 활성 .NET 스레드는 ThreadPool.GetAvailableThreads 로 얻는 풀 사이즈 정도(보통 코어 수의 한 자리 배수)에서 머무릅니다. 100개 요청이 동시에 진행되는데도 그렇습니다. 이유는 앞에서 본 그대로입니다. HTTP 응답을 기다리는 99개의 Task 는 OS 의 I/O 큐에서 자고 있고, .NET 스레드를 점유하지 않기 때문입니다.

만약 같은 일을 new Thread(...) × 100 으로 했다면 100개의 OS 스레드(× 1MB 스택)가 떴을 겁니다. 모바일에서 이는 곧장 OOM 위험으로 이어집니다.


5. 함정과 주의사항 — 신입이 자주 밟는 지뢰

함정 1 — async void

나쁜 예

C#
public class Bad : MonoBehaviour
{
    public async void StartHeavyWork()
    {
        await Task.Run(() => LoadHugeAssetBundle());  // 여기서 예외가 나면 ?
    }
}

async void 는 호출자에게 Task 를 반환하지 않습니다. 따라서 호출자가 await 할 수 없고, 메서드 내부에서 던진 예외는 호출 스택을 타고 올라가지 못한 채 SynchronizationContext 위에서 곧바로 던져집니다. Unity 에서는 보통 메인 스레드 예외 → 게임 강제 종료(또는 콘솔의 빨간 에러로 끝)로 이어집니다. try/catch 로 감쌀 곳이 없습니다.

좋은 예

C#
public class Good : MonoBehaviour
{
    public async Task StartHeavyWorkAsync()
    {
        await Task.Run(() => LoadHugeAssetBundle());
    }

    // 이벤트 핸들러처럼 void 가 강제될 때만 async void 를 쓰되, 안에서 try/catch 로 감싼다
    public async void OnClickStart()
    {
        try { await StartHeavyWorkAsync(); }
        catch (Exception ex) { Debug.LogException(ex); }
    }
}

규칙은 한 줄입니다. async void 는 UI 이벤트 핸들러처럼 반환형이 강제될 때 외에는 쓰지 않습니다. 그 경우에도 본문 전체를 try/catch 로 감쌉니다.

함정 2 — .Result / .Wait() 으로 동기 대기 → 데드락

나쁜 예 (UI 컨텍스트 환경에서 데드락)

C#
public void OnClick()
{
    string html = DownloadAsync().Result;   // 메인 스레드가 자기 자신을 기다림
    UpdateUI(html);
}

private async Task<string> DownloadAsync()
{
    using var http = new HttpClient();
    return await http.GetStringAsync("https://example.com");
    // 기본 동작: await 이후 코드를 메인 스레드로 복귀시키려 함 → 그런데 메인은 .Result 로 막혀 있음 → 영원히 기다림
}

좋은 예 — 그냥 await 하기

C#
public async void OnClick()    // UI 핸들러는 async void 허용
{
    try
    {
        string html = await DownloadAsync();
        UpdateUI(html);
    }
    catch (Exception ex) { Debug.LogException(ex); }
}

라이브러리 코드라면 ConfigureAwait(false)

C#
public async Task<string> DownloadAsync()
{
    using var http = new HttpClient();
    return await http.GetStringAsync("https://example.com").ConfigureAwait(false);
    // 메인 스레드로 복귀할 필요 없음 → 데드락 위험 제거 + 컨텍스트 전환 비용 절감
}
ConfigureAwait(false) — 컨텍스트 복귀 끄기 기본적으로 await 는 호출 시점의 동기화 컨텍스트(Unity 면 메인 스레드)로 복귀합니다. ConfigureAwait(false) 를 붙이면 "어디서 깨어나도 상관없다" 는 신호이며, 풀의 아무 스레드에서 이어 갑니다. UI 를 만지지 않는 라이브러리 비동기 메서드는 거의 항상 이걸 붙입니다.

함정 3 — Task.Run 안에서 Unity API 호출

C#
public async void Bad()
{
    await Task.Run(() =>
    {
        // 백그라운드 스레드에서 Unity API 접근 → UnityException
        transform.position = Vector3.zero;
    });
}

Unity API 는 메인 전용입니다. Task.Run 람다 안에서 만지면 즉시 예외입니다. 이 함정의 회피 방법은 두 가지입니다.

  1. 계산만 백그라운드에서 하고, 결과만 가지고 나와 await 이후에 적용한다.
  2. UniTask 를 쓰는 경우 UniTask.SwitchToMainThread() 같은 명시적 컨텍스트 전환을 활용한다.

함정 4 — Update 루프에서 Task 남발 → GC 스파이크

Task<T>참조 타입(class) 입니다. 매 프레임 만들면 매 프레임 힙 할당입니다. Unity 의 IL2CPP 환경에서도 GC 압박은 그대로 이어져 모바일에서 16~33ms 단위의 스파이크를 만듭니다.

Before — 매 프레임 Task 할당

C#
private static readonly int Cached = 42;

public Task<int> GetTask()
{
    return Task.FromResult(Cached);   // 호출마다 Task<int> 인스턴스 생성
}

After — 동기 완료 시 ValueTask 로 할당 회피

C#
public ValueTask<int> GetValueTask()
{
    return new ValueTask<int>(Cached);    // struct — 힙 할당 없음
}

IL 비교를 보면 차이가 명확합니다.

IL
// Task<int> 반환 — 호출마다 정적 메서드 통해 Task 생성
.method public hidebysig static class ...Task`1<int32> GetTask()
{
    IL_0000: ldsfld int32 Program::Cached
    IL_0005: call class ...Task`1<!!0> ...Task::FromResult<int32>(!!0)   // ← static factory 가 Task 인스턴스 만든다
    IL_000a: ret
}

// ValueTask<int> 반환 — newobj 는 있지만 valuetype 이라 스택에 만들어진다
.method public hidebysig static valuetype ...ValueTask`1<int32> GetValueTask()
{
    IL_0000: ldsfld int32 Program::Cached
    IL_0005: newobj instance void valuetype ...ValueTask`1<int32>::.ctor(!0)   // ← struct 의 ctor — 힙 할당 아님
    IL_000a: ret
}

Task.FromResult 는 static 메서드이지만 내부에서 Task<int> 인스턴스를 만들어 반환합니다. ValueTask<int> 는 struct 라 같은 newobj 명령이 보여도 힙이 아닌 스택(또는 호출자 필드)에 생성됩니다. 자주 호출되는 캐시 조회·풀 방식 API 에서는 ValueTask<T> 가 정답에 가깝습니다.

주의: ValueTask<T>두 번 await 하거나 결과를 보관해 두면 동작이 정의되지 않습니다. 한 번만 await 하고 즉시 소비합니다.

6. .NET 버전별 변천 — 스레드에서 Task 까지

비동기 모델은 .NET 출시 초기부터 점진적으로 발전해 왔습니다.

시기 모델 코드 모양 한계
.NET 1.x (2002) Thread 직접 관리 new Thread(work).Start() 생성·소멸 비용 큼, 동기화 직접
.NET 1.x APM (Asynchronous Programming Model) BeginXxx / EndXxx + IAsyncResult 콜백 지옥, 예외 흐름 복잡
.NET 2.0 (2005) EAP (Event-based Asynchronous Pattern) XxxAsync + XxxCompleted 이벤트 한 객체당 한 작업, 합성 어려움
.NET 4.0 (2010) TPL — Task / Task<T> Task.Factory.StartNew(...) 여전히 콜백 체이닝 (ContinueWith)
.NET 4.5 (2012) / C# 5 TAP — async/await await DoAsync() (현재 표준)
.NET Core 2.1 (2018) / C# 7 ValueTask<T> 동기 완료 시 할당 0 한 번만 await 가능
.NET Core 3.0 (2019) / C# 8 비동기 스트림 (IAsyncEnumerable<T>) await foreach (다음 글에서 다룸)

대표 변환 사례 — APM → TAP:

Before — APM (콜백 지옥)

C#
public void DownloadOldStyle(string url)
{
    var req = WebRequest.Create(url);
    req.BeginGetResponse(ar =>          // 콜백 1
    {
        var resp = req.EndGetResponse(ar);
        var stream = resp.GetResponseStream();
        var buffer = new byte[4096];
        stream.BeginRead(buffer, 0, buffer.Length, ar2 =>   // 콜백 2 (중첩)
        {
            int read = stream.EndRead(ar2);
            // ... 결과 사용
        }, null);
    }, null);
}

After — TAP (async/await)

C#
public async Task DownloadModernAsync(string url)
{
    using var http = new HttpClient();
    using var stream = await http.GetStreamAsync(url);

    var buffer = new byte[4096];
    int read = await stream.ReadAsync(buffer);
    // ... 결과 사용 — 평범한 절차적 코드 모양
}

같은 일을 시키지만, TAP 쪽은 동기 코드와 거의 동일한 모양을 유지하면서 내부적으로는 상태 기계로 변환되어 콜백을 자동 등록합니다. 이 변환 덕분에 우리는 "Task 가 스레드를 따로 차지하지 않는" 효율을 평범한 코드 모양으로 누립니다.


7. 정리 — 멘탈 모델 체크리스트

게시글 한 편을 읽고 끝낼 때 머리에 남기고 가야 할 7개 항목입니다.

  • [ ] 프로세스는 OS 가 만든 격리된 실행 인스턴스다. 가상 주소 공간 · 핸들 · 보안 컨텍스트를 가진다.
  • [ ] 스레드는 프로세스 안에서 실제로 CPU 위에서 명령어를 실행하는 흐름이다. 스택과 레지스터가 자기 것이지만 힙은 같은 프로세스 내 다른 스레드와 공유한다. 비싸다(스택 1MB, 컨텍스트 스위칭).
  • [ ] Task는 ".NET 이 추상화한 할 일 객체" 다. 스레드가 아니다. Task 가 N 개여도 스레드는 풀 크기만큼만 존재한다.
  • [ ] Task.Run 은 ThreadPool 의 기존 스레드를 빌려 쓰는 호출이다. new Thread() 와 달리 OS 스레드를 새로 만들지 않는다.
  • [ ] async/await 는 컴파일러가 메서드를 상태 기계로 바꿔 주는 문법이다. await 시점에 스레드를 풀로 반환했다가, 작업이 끝나면 풀의 아무 스레드가 MoveNext() 를 다시 호출해 이어 간다.
  • [ ] I/O 대기는 스레드를 거의 쓰지 않는다. OS 의 IOCP/epoll 이 직접 처리하고 완료 시점에만 콜백 스레드가 깨어난다.
  • [ ] Unity 에서는 UI · 게임 오브젝트는 메인 스레드 전용 이다. Task.Run 람다 안에서 Unity API 를 만지지 말고, 계산만 백그라운드에서 한 뒤 결과를 들고 await 후 메인으로 돌아와 적용한다.

다음 글에서는 이 위에서 Thread 클래스를 직접 다루는 법 과, 그것이 실제로 필요한 드문 경우들을 살펴봅니다.

반응형

+ Recent posts