[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장을 돌려가며 처리합니다.
한 장으로 보는 계층 구조

이 그림에서 읽어야 할 사실은 셋입니다.
- 프로세스 한 채 안에 스레드가 여러 개 있고, 그 위로 Task 큐가 따로 떠 있습니다.
- Task 개수와 스레드 개수는 같지 않습니다. Task 가 N 개여도 스레드는 풀 크기만큼만 존재합니다.
- Task 는 "큐에 들어 있다 → 스레드가 잠시 빌려 가서 실행한다 → 끝나면 스레드는 풀로 복귀한다"는 식으로 처리됩니다.
기본 코드로 확인 — 스레드 ID는 같을 수도, 다를 수도 있다
async/await— 비동기 메서드 / 비동기 대기 (Asynchronous method / await)async는 메서드 안에서await를 쓸 수 있게 해 주는 표식입니다.await는 그 자리에서 작업이 끝나기를 기다리되, 현재 스레드를 차단하지 않고 호출자에게 제어권을 돌려줍니다. 작업이 완료되면 이어서 실행할 코드를 콜백처럼 등록해 두는 셈입니다.
예시:await Task.Delay(100);100ms 동안 스레드를 점유하지 않고 기다린 뒤 다음 줄로 진행
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(...)vsTask.Run(...)키워드 박스new Thread(delegate)는 OS 에 새 스레드를 직접 요청합니다.Task.Run(delegate)은 ThreadPool 큐에 작업 하나를 밀어 넣고 풀에 있는 스레드 한 명에게 처리를 맡깁니다. 똑같이 백그라운드 실행이지만 비용·재사용성이 다릅니다.
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 을 비교하면 차이가 깔끔하게 드러납니다.
// 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::.ctor 와 Thread::Start 가 명시적으로 호출됩니다. 이는 OS 스레드가 새로 만들어진다는 뜻입니다. 작업이 끝나면 그 스레드는 그대로 사라집니다. 다음번 호출에는 또 새로 만듭니다.
반면 RunWithTask 쪽은 Task::Run 이라는 정적 메서드 한 번 호출이 전부입니다. Thread::.ctor 가 없습니다. 내부적으로 Task.Run 은 ThreadPool.QueueUserWorkItem 계열 경로를 타고, 이미 살아 있는 풀 스레드 중 한 명에게 작업을 위임합니다. 작업이 끝나면 그 스레드는 풀로 돌아가 다음 작업을 기다립니다.
언제new Thread를 써야 하나? 거의 없습니다. 단, 장수명 백그라운드 루프(예: 자체 게임 서버에서 평생 도는 워커)나 STA 같은 특수 아파트먼트가 필요한 코드에는Thread가 적합합니다. 짧은 단발성 작업은 모두Task.Run을 씁니다.
Task 가 스레드를 차지하지 않는 또 하나의 길 — async/await 의 상태 기계
Task.Run은 "CPU 작업 → 스레드 풀에 던지기"입니다. 그런데 비동기의 진짜 마법은 "기다리는 동안 스레드를 점유하지 않는다" 는 부분에 있습니다. 이를 가능하게 하는 게 컴파일러가 생성하는 상태 기계(state machine) 입니다.
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() 라는 상태 기계 메서드로 변환합니다.
// 컴파일러가 자동 생성한 중첩 타입
.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 으로 메인을 막아 버린 코드
using System.Threading;
using UnityEngine;
public class FreezeOnLoad : MonoBehaviour
{
public void OnClickStart()
{
// 3초간 로딩 분위기를 내고 싶다 ...
Thread.Sleep(3000); // 메인 스레드를 3초간 완전히 정지시킴
SceneManager.LoadScene("Stage1");
}
}
Thread.Sleep 의 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 로 스레드를 풀어 주는 코드
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 — 메인에서 큰 계산을 그대로 돌림
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 변경은 메인 스레드
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 가 깔아 둔 UnitySynchronizationContext 가 await 의 컨텍스트 캡처를 받아 콜백을 메인 큐에 등록하기 때문입니다. 그래서 UpdateUI(filtered) 는 안전합니다.
동시 다운로드 100개 — Task 와 스레드 수의 진짜 관계
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
❌ 나쁜 예
public class Bad : MonoBehaviour
{
public async void StartHeavyWork()
{
await Task.Run(() => LoadHugeAssetBundle()); // 여기서 예외가 나면 ?
}
}
async void 는 호출자에게 Task 를 반환하지 않습니다. 따라서 호출자가 await 할 수 없고, 메서드 내부에서 던진 예외는 호출 스택을 타고 올라가지 못한 채 SynchronizationContext 위에서 곧바로 던져집니다. Unity 에서는 보통 메인 스레드 예외 → 게임 강제 종료(또는 콘솔의 빨간 에러로 끝)로 이어집니다. try/catch 로 감쌀 곳이 없습니다.
✅ 좋은 예
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 컨텍스트 환경에서 데드락)
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 하기
public async void OnClick() // UI 핸들러는 async void 허용
{
try
{
string html = await DownloadAsync();
UpdateUI(html);
}
catch (Exception ex) { Debug.LogException(ex); }
}
✅ 라이브러리 코드라면 ConfigureAwait(false)
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 호출
public async void Bad()
{
await Task.Run(() =>
{
// 백그라운드 스레드에서 Unity API 접근 → UnityException
transform.position = Vector3.zero;
});
}
Unity API 는 메인 전용입니다. Task.Run 람다 안에서 만지면 즉시 예외입니다. 이 함정의 회피 방법은 두 가지입니다.
- 계산만 백그라운드에서 하고, 결과만 가지고 나와
await이후에 적용한다. - UniTask 를 쓰는 경우
UniTask.SwitchToMainThread()같은 명시적 컨텍스트 전환을 활용한다.
함정 4 — Update 루프에서 Task 남발 → GC 스파이크
Task<T> 는 참조 타입(class) 입니다. 매 프레임 만들면 매 프레임 힙 할당입니다. Unity 의 IL2CPP 환경에서도 GC 압박은 그대로 이어져 모바일에서 16~33ms 단위의 스파이크를 만듭니다.
❌ Before — 매 프레임 Task 할당
private static readonly int Cached = 42;
public Task<int> GetTask()
{
return Task.FromResult(Cached); // 호출마다 Task<int> 인스턴스 생성
}
✅ After — 동기 완료 시 ValueTask 로 할당 회피
public ValueTask<int> GetValueTask()
{
return new ValueTask<int>(Cached); // struct — 힙 할당 없음
}
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 (콜백 지옥)
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)
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 클래스를 직접 다루는 법 과, 그것이 실제로 필요한 드문 경우들을 살펴봅니다.