[PART13.비동기와 스레딩 기초(3/15)] Thread 클래스 — 스레드를 직접 다루는 원시 도구
new Thread(Work).Start() 의 실체 / OS 스레드와 1:1 매핑되는 저수준 API / 왜 현대 코드는 Task 를 쓰고 Thread 를 잘 쓰지 않는가
목차
1. 문제 제기 — "스레드를 만들고 싶다"는 충동, 그리고 그 함정
Unity 모바일 게임 클라이언트를 만들다 보면 메인 스레드가 멈추는 순간을 자주 마주합니다. 서버에서 큰 JSON을 받아 파싱할 때, 세이브 파일을 디스크에 쓸 때, 거대한 맵의 길찾기를 계산할 때 — 이런 작업이 메인 스레드에서 수십 ms 이상 걸리면 프레임이 떨어지고 화면이 끊깁니다. "그러면 스레드를 하나 더 만들어서 거기서 돌리면 되지 않을까?" 라는 자연스러운 발상이 생깁니다.
검색해보면 가장 먼저 나오는 도구가 System.Threading.Thread 입니다. new Thread(work) 한 줄로 백그라운드 작업이 만들어지는 듯 보이고, Sleep, Join, Priority 처럼 직관적인 이름의 메서드가 줄지어 있습니다. 그런데 막상 써보면 결과를 받기가 어렵고, 스레드 안에서 예외가 터지면 게임이 즉시 죽고, 50개 만들면 메모리가 50MB씩 늘어납니다.
이것은 우연이 아닙니다. Thread 는 OS(운영체제) 스레드와 거의 1:1 로 매핑되는 가장 저수준의 동시성 도구이며, 본래 이 정도 거친 제어가 필요한 사람을 위해 만들어졌습니다. 우리가 보통 원하는 것 — "백그라운드에서 함수 하나 돌리고 결과 받기" — 은 그 위에 쌓인 ThreadPool·Task 가 훨씬 잘 해줍니다.
이 글에서는 Thread 가 정확히 무엇이고, 어떻게 동작하며, 왜 대부분의 경우 직접 쓰면 안 되는지, 그리고 그럼에도 불구하고 직접 써야 하는 좁은 사례가 무엇인지 IL 레벨까지 내려가 살펴봅니다.
2. 개념 정의 — Thread 는 OS 스레드의 얇은 래퍼다
2.1. 비유: Thread 는 "정직원 채용", Task 는 "파견 인력 요청"
회사에 일이 하나 들어왔다고 해봅시다. 두 가지 방식이 있습니다.
- 정직원 채용: 사람을 한 명 고용합니다. 책상·컴퓨터·계정을 준비하고, 일이 끝나면 다시 정리합니다. 이름표를 달고, 부서 우선순위도 매길 수 있습니다. 그러나 일 하나 시키자고 채용·해고를 반복하면 인사팀이 폭발합니다.
- 파견 인력 요청: 외주 회사에 "결과 좀 뽑아주세요" 라고 부탁합니다. 그쪽 회사가 이미 대기하고 있는 인력 풀에서 한 명을 배정해주고, 일이 끝나면 그 인력은 다른 일을 받으러 갑니다. 결과는 그쪽이 알아서 가져다 줍니다.
Thread 는 정직원, Task(ThreadPool 위에 올라간) 는 파견 인력에 가깝습니다. Thread 는 OS 에 진짜 스레드를 하나 더 만들어 달라고 요청하고, Task 는 이미 만들어져 있는 스레드 풀에서 빌려 쓰는 도구입니다. 둘 다 "동시 실행" 이라는 결과는 같지만 비용·관리 부담·조합 가능성이 완전히 다릅니다.
2.2. Thread 의 구조 한눈에 보기

Thread 객체 하나는 OS 스레드 하나와 사실상 1:1 로 묶여 있습니다. 우리가 객체를 생성하면 CLR(Common Language Runtime, .NET 프로그램이 실행되는 가상 머신)이 OS 에 "스레드 만들어 달라" 고 시스템 콜을 던지고, OS 는 약 1MB 의 스택과 TEB(Thread Environment Block, OS 가 스레드별로 갖는 작업장 메타데이터) 같은 자원을 할당합니다.
new— 객체 인스턴스 생성 연산자 클래스의 인스턴스를 힙에 할당하고 생성자를 호출한다.Thread의 경우 객체 자체는 .NET 힙에 만들어지지만,Start()가 호출되는 순간 OS 에 진짜 스레드 생성을 요청한다.
예시:Thread t = new Thread(Work);Work 라는 메서드를 시작점으로 갖는 Thread 객체 생성 (아직 OS 스레드는 만들어지지 않음)
2.3. 가장 단순한 Thread 사용 — new → Start → Join
다음은 Unity 의 Awake 또는 일반 콘솔 앱에서 흔히 보는 Thread 사용 패턴입니다.
using System;
using System.Threading;
class Program
{
static void Main()
{
// 1) 실행할 메서드를 가리키는 Thread 객체를 만든다.
Thread t = new Thread(Work);
// 2) 디버거에서 식별 가능한 이름과 동작 모드 지정
t.Name = "Worker";
t.IsBackground = true; // 메인이 끝나면 강제 종료
t.Priority = ThreadPriority.AboveNormal; // 스케줄러에 대한 '힌트'
// 3) OS 스레드가 실제로 만들어지고 Work 가 새 스레드 위에서 실행된다.
t.Start();
// 4) Worker 가 끝날 때까지 Main 스레드는 여기서 블로킹된다.
t.Join();
}
static void Work()
{
Thread.Sleep(100); // 100ms 동안 OS 가 이 스레드에 CPU 를 안 준다
Console.WriteLine("done");
}
}
위 코드의 IL 을 살펴보면 메서드 본체에 들어 있는 모든 호출이 그저 Thread 의 인스턴스 메서드 호출이라는 것이 드러납니다.
.method private hidebysig static void Main () cil managed
{
// new Thread(Work) — 델리게이트로 ThreadStart 만들기
IL_0009: ldnull
IL_000a: ldftn void Program::Work()
IL_0010: newobj instance void [System.Threading.Thread]System.Threading.ThreadStart::.ctor(object, native int)
// Thread 객체 생성 — 이 시점에는 아직 OS 스레드가 만들어지지 않는다
IL_001b: newobj instance void [System.Threading.Thread]System.Threading.Thread::.ctor(class System.Threading.ThreadStart)
// 속성 설정 — Name, IsBackground, Priority 모두 callvirt 로 setter 호출
IL_0021: ldstr "Worker"
IL_0026: callvirt instance void Thread::set_Name(string)
IL_002c: ldc.i4.1
IL_002d: callvirt instance void Thread::set_IsBackground(bool)
IL_0033: ldc.i4.3 // ThreadPriority.AboveNormal = 3
IL_0034: callvirt instance void Thread::set_Priority(valuetype ThreadPriority)
// ★ 여기서 비로소 OS 에 스레드 생성 요청이 들어간다
IL_003a: callvirt instance void Thread::Start()
// 호출자는 Worker 가 끝날 때까지 블로킹
IL_003f: callvirt instance void Thread::Join()
IL_0044: ret
}
newobj(객체 생성) 와 callvirt(가상 메서드 호출) 를 보면 핵심 사실이 드러납니다.
- OS 스레드는
Thread::.ctor가 아니라Thread::Start()시점에 만들어진다. 객체를 만들어 두기만 하면 자원이 안 잡혀 있고,Start()를 부르는 순간 비로소 OS 콜이 일어납니다. Priority = AboveNormal은ldc.i4.3으로 컴파일됩니다.ThreadPriority가 enum 이고 그 정수값이 그대로 박힌다는 뜻입니다. enum 이름은 컴파일러 친절일 뿐 런타임에 의미가 없습니다.
3. 내부 동작 — Thread.Start 가 실제로 무슨 일을 하는가
3.1. Start 호출의 흐름

핵심은 Start() 한 번이 곧바로 OS 시스템 콜로 이어진다는 점입니다. CLR 은 매니지드 런타임이지만 스레드 생성에서는 단순한 중개자에 불과합니다. 그래서 짧은 작업 1000 개에 new Thread().Start() 를 1000 번 부르면 1000 번의 시스템 콜과 1000MB 의 스택 가상주소 예약이 발생합니다.
3.2. 포그라운드 vs 백그라운드 — 프로세스 종료 조건
Thread 는 기본적으로 포그라운드 스레드 로 만들어집니다. 포그라운드 스레드가 단 하나라도 살아 있으면 프로세스는 종료되지 않습니다. 반면 IsBackground = true 로 바꾸면 메인 스레드(엄밀히는 모든 포그라운드 스레드)가 끝나는 순간 OS 가 백그라운드 스레드를 무자비하게 강제 종료합니다.
// ❌ 의도와 다른 동작 — 작업이 끝나기도 전에 프로세스가 죽는다
static void Main()
{
Thread t = new Thread(() =>
{
Thread.Sleep(5000);
Console.WriteLine("이 줄이 출력되지 않을 수 있다");
});
t.IsBackground = true;
t.Start();
// Main 종료 → 백그라운드 t 도 같이 사망
}
// ✅ 실수로 깜빡 잊은 정리 작업도 반드시 끝낸다
static void Main()
{
Thread t = new Thread(() =>
{
Thread.Sleep(5000);
Console.WriteLine("끝까지 실행 보장");
});
// IsBackground 를 건드리지 않음 → 기본값 false (포그라운드)
t.Start();
// Main 이 끝나도 t 가 끝날 때까지 프로세스는 살아 있는다고 보장된다
}
ThreadPool 에서 빌려오는 스레드, Task.Run 으로 만든 스레드는 모두 백그라운드 스레드 입니다. 이것이 콘솔 앱에서 Task.Run(...) 만 던져 두고 Main 이 끝나면 작업이 사라지는 흔한 함정의 정체입니다.
IsBackground— 백그라운드 여부 속성 (bool)true면 백그라운드 스레드,false(기본) 면 포그라운드 스레드. 프로세스 종료 조건과 직결되며, 반드시Start()호출 전에 설정해야 안전하다.
예시:t.IsBackground = true;백그라운드 워커로 만들어 메인 종료 시 같이 정리되게 한다.
3.3. Sleep / Yield / Priority — 자주 오해받는 세 가지
Thread.Sleep(ms) 는 단순히 "지정 시간 동안 CPU 를 안 받는다" 가 아니라 다음과 같은 정밀한 동작을 합니다.
- 호출 즉시 스레드를 Wait 상태 로 바꾸고 OS 스케줄러에 양보합니다.
ms시간이 지나면 다시 Ready 상태 로 복귀하지만, 즉시 실행되는 것은 아닙니다 — CPU 코어가 비어 있어야 비로소 실행됩니다.- 따라서 실제 깨어나는 시간은 항상
ms이상이며, Windows 의 기본 타이머 해상도(약 15.6ms) 때문에Sleep(1)도 사실상 16ms 가까이 걸릴 수 있습니다.
Thread.Yield() 는 같은 코어에서 대기 중인 다른 스레드가 있다면 한 번 양보하고, 없으면 즉시 자신이 다시 실행됩니다. 이는 스핀 락에서 잠깐 양보할 때 외에는 거의 쓰이지 않습니다.
Priority 는 스케줄러에 대한 힌트 일 뿐 보장이 아닙니다. Highest 로 올린다고 해서 다른 스레드보다 먼저 실행된다는 보장이 없으며, 잘못 사용하면 낮은 우선순위 스레드가 절대 실행되지 못하는 기아(starvation) 나 우선순위 역전(priority inversion) 을 만들 수 있습니다. 모바일에서는 OS 가 거의 무시하는 경향이 있어 더더욱 의존하면 안 됩니다.
4. 실전 적용 — Thread vs Task, 그리고 Unity 실전 패턴
4.1. 결과 반환과 예외 처리 — Thread 의 약점이 그대로 드러난다
같은 "값 하나 계산해서 받기" 를 Thread 와 Task 로 구현해 비교해 봅니다.
// ❌ Thread 버전 — 결과 받기가 꽤 번거롭다
static int ThreadVersion()
{
int result = 0; // 캡처되는 지역 변수
Thread t = new Thread(() => { result = Compute(); });
t.Start();
t.Join(); // 호출자 스레드를 블로킹
return result; // 예외가 터지면 못 살림
}
// ✅ Task 버전 — 결과·예외·취소가 모두 Task 객체에 담긴다
static async Task<int> TaskVersion()
{
int result = await Task.Run(() => Compute());
return result;
}
이 두 코드의 IL 은 완전히 다른 구조를 보여줍니다.
// ❌ ThreadVersion — 클로저(클래스)에 result 를 캡처해 공유 상태로 만든다
.class nested private auto ansi sealed beforefieldinit '<>c__DisplayClass0_0'
{
.field public int32 result // ← result 가 여기로 승격됨
.method assembly hidebysig instance void '<ThreadVersion>b__0' () cil managed
{
// result = Compute()
IL_0000: ldarg.0
IL_0001: call int32 Program::Compute()
IL_0006: stfld int32 ...::result
}
}
// → 람다가 result 를 쓰는 순간 컴파일러가 클래스를 만들고 result 를 그 필드로 옮긴다.
// "지역 변수처럼 보이지만 실제로는 힙 객체의 필드" — 동기화 없는 공유 상태의 시작
// ✅ TaskVersion — 컴파일러가 비동기 상태머신을 자동 생성한다
.class nested private auto ansi sealed beforefieldinit '<TaskVersion>d__1'
extends [System.Runtime]System.ValueType
implements [System.Runtime]System.Runtime.CompilerServices.IAsyncStateMachine
{
.field public int32 '<>1__state' // 진행 상태
.field public valuetype AsyncTaskMethodBuilder`1<int32> '<>t__builder'
.field private valuetype TaskAwaiter`1<int32> '<>u__1'
.method instance void MoveNext () cil managed
{
// ... Task.Run → GetAwaiter → 완료 안 됐으면 콜백 등록하고 빠져나감
// 완료되면 GetResult() 로 결과를 꺼낸다 (예외도 여기서 다시 던져짐)
IL_0029: call class Task`1<int32> Task::Run<int32>(class Func`1<int32>)
IL_002e: callvirt instance valuetype TaskAwaiter`1<!0> Task`1<int32>::GetAwaiter()
IL_007b: call instance !0 valuetype TaskAwaiter`1<int32>::GetResult()
}
}
Thread 버전의 IL 이 보여주는 진실:
result는 더 이상 지역 변수가 아닙니다.<>c__DisplayClass0_0라는 컴파일러 생성 클래스의 필드 로 승격(promotion)되어 힙에 올라갑니다. 두 스레드(Main 과 Worker)가 같은 힙 객체의 같은 필드를 동시에 읽고 씁니다. 변수가 두 개라면 동기화 문제가 안 보이지만 실제로는 여기서 시작됩니다.- 람다 안에서 예외가 던져져도
Main스레드의try-catch는 절대 잡지 못합니다 — 다른 스레드의 콜 스택이기 때문입니다. .NET Framework 2.0 부터 Thread 위에서 처리되지 않은 예외는 즉시 프로세스를 종료시킵니다.
Task 버전의 IL 이 보여주는 진실:
- 컴파일러는
async메서드를 상태 머신(state machine) 클래스로 변환합니다.<>1__state필드가 진행 단계를 나타내고,MoveNext()가 단계마다 호출됩니다. TaskAwaiter::GetResult()가 결과를 꺼내는 동시에 Task 안에서 발생한 예외를 호출자 스레드에서 다시 던집니다. 그래서await TaskVersion()을 감싼try-catch가 정상적으로 잡습니다.
async/await— 비동기 메서드를 만드는 키워드 쌍async는 메서드를 비동기 상태 머신으로 컴파일하라는 표시이며,await는 Task 가 끝날 때까지 호출자 스레드를 블로킹하지 않고 잠깐 양보한 뒤 결과·예외를 꺼내 돌아오게 한다.
예시:int x = await Task.Run(() => Compute());Compute 가 끝날 때까지 호출자 스레드는 다른 일을 할 수 있고, 끝나면 여기서 이어서 실행된다.
4.2. Unity 핫패스 — Thread 가 지옥을 부르는 흔한 사례
Unity 의 Update() 같은 매 프레임 실행되는 메서드(핫패스)에서 짧은 작업마다 Thread 를 만드는 코드는 가장 위험한 패턴입니다.
// ❌ 매 프레임 새 OS 스레드를 만든다 — GC 스파이크와 시스템 콜 폭주
public class EnemyAI : MonoBehaviour
{
void Update()
{
Thread t = new Thread(() =>
{
float dist = Vector3.Distance(transform.position, target.position);
if (dist < 5f) /* ... */;
});
t.IsBackground = true;
t.Start();
// 그리고 transform.position 은 메인 스레드 외에서 접근 → InvalidOperationException
}
}
이 코드는 두 가지 치명적 문제가 있습니다.
- 매 프레임 OS 스레드 생성 — 60FPS 면 초당 60 번의 시스템 콜과 1MB × 60 = 60MB 의 스택 예약·해제. Boehm GC(Unity 의 IL2CPP 가 사용하는 보수적 가비지 컬렉터)는 Thread 객체 자체와 클로저까지 회수해야 해서 GC 스파이크(GC 가 한 번에 많은 메모리를 회수하면서 프레임이 길어지는 현상)가 폭발합니다.
- 워커 스레드에서
transform.position접근 — UnityEngine API 의 거의 전부는 스레드 안전하지 않습니다. 메인 스레드 외에서 호출하면 즉시 예외가 발생하거나 더 나쁘게는 정의되지 않은 동작(undefined behavior)으로 이어집니다.
Unity 에서 진짜로 백그라운드가 필요한 패턴은 다음과 같습니다.
// ✅ 한 번만 만든 워커 스레드 + 결과를 메인 스레드 큐로 전달
using System.Collections.Concurrent;
using System.Threading;
using UnityEngine;
public class FileLoader : MonoBehaviour
{
private Thread _worker;
private readonly ConcurrentQueue<string> _results = new();
private volatile bool _running = true;
void Start()
{
_worker = new Thread(WorkerLoop)
{
Name = "FileLoaderWorker",
IsBackground = true // 게임 종료 시 자동 정리
};
_worker.Start();
}
void WorkerLoop()
{
while (_running)
{
// 파일 IO 같은 Unity 와 무관한 작업만
string content = System.IO.File.ReadAllText("save.json");
_results.Enqueue(content);
Thread.Sleep(1000);
}
}
void Update()
{
// 메인 스레드에서 결과 소비 — UnityEngine API 사용 가능
while (_results.TryDequeue(out var json))
{
// GameObject 생성·UI 갱신 등은 여기서만
}
}
void OnDestroy() => _running = false;
}
이 패턴의 핵심은 워커 스레드는 평생 한 번만 생성 하고, 메인 스레드와의 통신은 스레드 안전 큐(ConcurrentQueue<T>)를 통해서만 한다는 것입니다. UnityEngine API 호출은 전부 Update() 같은 메인 스레드 콜백 안에서만 일어납니다.
4.3. Coroutine 과의 차이 — "스레드 같지만 스레드 아님"
Unity 의 Coroutine 은 이름은 비슷하지만 스레드가 아닙니다. 메인 스레드 위에서 시간을 잘게 쪼개 쓰는 협력적 멀티태스킹 입니다. yield return null 은 "다음 프레임에 이어서 실행해달라" 는 표시일 뿐, 새 스레드를 만들지 않습니다.
| 항목 | Thread | Coroutine | Task |
|---|---|---|---|
| 진짜 병렬 실행 | O (다른 코어에서) | X (메인 스레드에서) | O (스레드 풀) |
| UnityEngine API | 메인 스레드 외 호출 금지 | 자유롭게 사용 가능 | 메인 스레드 외 호출 금지 |
| CPU 무거운 계산 | 적합 | 부적합 (메인 막힘) | 적합 |
| 매 프레임 잠깐 양보 | 부자연스러움 | 가장 적합 | await 로 흉내 가능 |
| 생성 비용 | 매우 큼 | 매우 작음 | 작음 (풀 재사용) |
Unity 에서 "GameObject 를 천천히 페이드 인" 같은 작업에는 Coroutine, "파일 IO·압축 해제·길찾기 계산" 같은 진짜 무거운 작업에는 Thread/Task 를 씁니다.
4.4. Thread 를 직접 써도 괜찮은 좁은 경우
대부분의 경우 Task.Run 으로 충분하지만, 다음 상황에서는 Thread 직접 생성이 정당합니다.
- 앱 수명 내내 도는 단일 워커 루프 — 위의
FileLoader처럼 한 번 만들고 평생 도는 백그라운드 루프.TaskCreationOptions.LongRunning도 비슷한 효과지만Thread가 의도를 더 명확히 드러냅니다. - STA(Single-Threaded Apartment) 가 필요한 경우 — WPF·일부 COM 객체 호출 등.
ThreadPool스레드는 모두 MTA 라서SetApartmentState가 통하지 않습니다. - 포그라운드 스레드가 필요한 경우 — 콘솔 앱에서 메인 종료 후에도 반드시 마무리되어야 하는 작업.
Task는 백그라운드라 메인이 끝나면 사라집니다. - 디버깅용 이름이 중요한 장기 워커 —
Name이 디버거의 스레드 창에 그대로 표시되어 식별이 쉽습니다.
이 외에 "백그라운드에서 한 번 실행" 정도의 작업이라면 Task.Run 이 거의 항상 정답입니다.
5. 함정과 주의사항
5.1. 처리되지 않은 예외 = 프로세스 사망
가장 자주 깨지는 함정입니다.
// ❌ 게임이 그대로 죽는 코드
static void Main()
{
Thread t = new Thread(() =>
{
throw new InvalidOperationException("Worker 폭발");
});
t.Start();
try
{
t.Join(); // Join 은 단순 대기일 뿐, 예외를 다시 던지지 않는다
}
catch (Exception ex)
{
// 절대 여기 도달하지 못한다 — Worker 의 예외는 다른 콜 스택이라 못 잡는다
Console.WriteLine(ex);
}
// → AppDomain.UnhandledException 만 한 번 울리고 프로세스 종료
}
// ✅ Worker 안에서 try-catch 로 직접 잡고 결과를 안전한 채널로 전달
static void Main()
{
Exception captured = null;
Thread t = new Thread(() =>
{
try
{
throw new InvalidOperationException("Worker 폭발");
}
catch (Exception ex)
{
captured = ex; // 호출자가 볼 수 있는 곳에 저장
}
});
t.Start();
t.Join();
if (captured != null)
Console.WriteLine($"Worker 실패: {captured.Message}");
}
// ❌ 잡히지 않는 예외의 IL — Main 의 try 블록은 Worker 안에 도달하지 않는다
.method instance void '<Main>b__0_0' () cil managed
{
IL_0000: ldstr "Worker 폭발"
IL_0005: newobj instance void [System.Runtime]System.InvalidOperationException::.ctor(string)
IL_000a: throw // ← 이 throw 는 Worker 콜 스택에서 끝
}
// Main 의 .try 블록은 Join 을 감싸지만, throw 가 일어난 콜 스택은 별개라 catch 가 안 잡는다
Task 는 이 점이 자동으로 해결됩니다 — 내부에서 발생한 예외는 Task 객체에 저장되어 await 또는 task.Result 시점에 호출자 스레드에서 다시 던져집니다. Thread 를 직접 쓸 때는 워커의 최상위 코드를 항상 try-catch 로 감싸는 것이 강제됩니다.
5.2. Thread.Abort 는 더 이상 존재하지 않는다
옛날 코드에서 t.Abort() 를 본 적이 있을지 모릅니다. 이 API 는 .NET Core/.NET 5+ 에서 호출 시 PlatformNotSupportedException 을 던지도록 명시적으로 제거되었습니다. 안전하게 스레드를 멈추는 방법은 다음 두 가지뿐입니다.
// ❌ 동작하지 않는 코드 (런타임 예외)
t.Abort(); // PlatformNotSupportedException
// ✅ 협력적 취소 — 워커가 자발적으로 종료 신호를 본다
private volatile bool _stopRequested;
void WorkerLoop()
{
while (!_stopRequested)
{
// ...
}
}
void StopWorker() => _stopRequested = true;
volatile— 메모리 모델 한정자 이 필드의 모든 읽기·쓰기를 다른 스레드에서도 즉시 보이게 한다(컴파일러·CPU 의 재배치를 막는다). 단순한 플래그 변수에는 적합하지만 복잡한 동기화에는 부족하니 그땐lock이나Interlocked를 써야 한다.
예시:private volatile bool _stopRequested;한 스레드가 true 로 쓰면 다른 스레드가 다음 읽기에서 즉시 본다는 보장.
5.3. Sleep 으로 대기하지 말 것
Thread.Sleep 은 다음 두 경우 모두에서 흔한 실수의 원천입니다.
// ❌ "이 작업이 끝날 때까지 기다려야지" — 시간이 정해져 있지 않다
while (!IsReady())
{
Thread.Sleep(10); // CPU 양보는 되지만 즉시 깨지 않음 + 응답성 떨어짐
}
// ✅ 정확한 깨우기 신호 사용
private readonly ManualResetEventSlim _ready = new();
void Wait() => _ready.Wait();
void NotifyReady() => _ready.Set();
Sleep 은 본질적으로 "정해진 시간만큼 기다린다" 가 의미일 때만 사용해야 합니다. "조건 만족 시까지 기다린다" 는 ManualResetEventSlim·SemaphoreSlim·Task 같은 신호 메커니즘이 정답입니다.
5.4. Unity 의 메인 스레드 검증 함정
// ❌ Unity 에디터에서는 동작하는 척하다가 모바일에서 크래시
new Thread(() =>
{
var go = GameObject.Find("Player"); // 워커 스레드에서 호출 — 정의되지 않은 동작
go.transform.position = Vector3.zero;
}).Start();
// ✅ 워커는 데이터만 다루고 메인 스레드 디스패처로 위임
new Thread(() =>
{
Vector3 nextPos = Heavy.ComputeNextPosition(); // 순수 수학만
MainThreadDispatcher.Enqueue(() =>
{
var go = GameObject.Find("Player"); // 메인 스레드에서만
go.transform.position = nextPos;
});
}).Start();
Unity 의 메인 스레드 검사는 빌드 종류에 따라 차이가 있고, 에디터에서 통과한다고 모바일 디바이스에서 안전하다고 보장하지 않습니다. 워커 스레드는 항상 "Unity API 호출 0 회" 라고 가정 하고 코드를 작성해야 합니다.
6. C# 버전별 변화
Thread 자체는 .NET Framework 1.0 부터 존재했고 클래스 시그니처는 거의 변하지 않았습니다. 다만 Thread 를 직접 쓰지 않게 만드는 상위 도구의 진화가 있었습니다.
| 시기 | 변화 | 영향 |
|---|---|---|
| .NET Framework 1.0 | Thread, ThreadStart 등장 |
직접 스레드 제어가 유일한 방법 |
| .NET Framework 1.0 | ThreadPool 등장 |
풀 재사용으로 짧은 작업 비용 절감 |
| .NET Framework 2.0 | 처리되지 않은 예외 → 프로세스 종료 로 동작 변경 | "Thread 안에서 try-catch 누락 = 사망" 의 시작 |
| .NET Framework 4.0 | Task, Task<T> 등장 (TPL) |
결과 반환·예외 전파·조합이 가능해짐 |
| C# 5.0 / .NET 4.5 | async/await 추가 |
Task 위에서 비동기 코드를 동기처럼 작성 |
| .NET Core 1.0 / .NET 5+ | Thread.Abort, Suspend, Resume 제거 |
강제 종료 불가 → 협력적 취소 모델 강제 |
| .NET 6+ | Thread 에 UnsafeStart 등 일부 보강 |
일반 사용에는 영향 적음 |
C# 언어 차원의 변화보다는 런타임 차원의 변화 가 큽니다. 특히 Abort 의 제거로 "외부에서 스레드를 강제로 죽이는" 동작은 더 이상 존재하지 않으며, 이는 "어차피 안전하게 멈출 방법이 없으니 처음부터 협력적 취소를 설계해야 한다" 는 사고의 변화를 강제했습니다.
// ❌ .NET Framework 시절 코드 — .NET 5+ 에서는 던져진다
try { workerThread.Abort(); }
catch (ThreadAbortException) { /* ... */ }
// → PlatformNotSupportedException
// ✅ 현대 코드 — CancellationToken 으로 협력적 취소
var cts = new CancellationTokenSource();
var task = Task.Run(() => Work(cts.Token), cts.Token);
// 잠시 후
cts.Cancel();
위와 같은 흐름 때문에 신규 코드에서는 Thread 를 직접 만들기보다는 Task 와 CancellationToken 조합이 표준이 되었습니다.
7. 정리
Thread 는 잘 알아야 하지만 거의 쓰지 않는 도구입니다. "스레드를 이해한다" 는 것은 "Task 안에서 무엇이 돌아가는지 이해한다" 와 같은 말이며, 그 이해가 있어야 Task 의 함정을 피할 수 있습니다.
핵심 체크리스트
- [ ]
Thread는 OS 스레드와 1:1 매핑되는 저수준 API 다 — 매 인스턴스마다 약 1MB 스택과 시스템 콜 비용이 든다. - [ ]
Start()시점에 비로소 OS 스레드가 만들어진다. 객체 생성만으로는 자원이 잡히지 않는다. - [ ]
IsBackground = false(기본) 면 포그라운드,true면 백그라운드 — 프로세스 종료 조건이 갈린다. - [ ]
Priority는 OS 스케줄러에 대한 힌트일 뿐 보장이 아니다. 모바일에서는 거의 무시된다. - [ ] 워커 스레드 안의 처리되지 않은 예외는 프로세스를 즉시 죽인다.
Thread를 쓰면 워커 최상위try-catch가 필수다. - [ ] 결과 반환·예외 전파·취소·조합이 필요하면
Thread가 아니라Task를 쓴다 — IL 레벨에서도Task는 상태 머신과 awaiter 가 자동 생성된다. - [ ]
Thread.Abort는 .NET 5+ 에서 제거되었다. 취소는CancellationToken같은 협력적 모델로만 가능하다. - [ ] Unity 에서 워커 스레드 안의 UnityEngine API 호출은 금지. 워커는 순수 계산·IO 만, 결과는 메인 스레드 큐로 전달.
- [ ] 매 프레임
new Thread()는 GC 스파이크와 시스템 콜 폭주의 원인. 한 번 만들고 평생 도는 패턴이 안전하다. - [ ]
Thread를 직접 써도 좋은 정당한 경우: 앱 수명 동안 도는 단일 워커, STA 필요, 포그라운드 보장, 디버깅용 이름이 핵심인 장기 워커.