1. 소개
이제 본격적으로 알아볼 시간입니다 — Task 와 Thread 는 본질적으로 어떻게 다른가? 왜 C# 은 오랫동안 직접적인 스레드 관리 대신 Task 사용을 권장할까? 어떤 상황에서 수동으로 Thread 를 계속 사용해도 되고, 언제는 Task 스타일로 가는 것이 충분(또는 필요한)한가?
"스레드"랑 "작업"이라는 단어가 머릿속 어딘가에서 슬슬 섞이고 심장이 두근거리면 걱정 마세요 — 혼자만 그런 게 아닙니다. 병렬성과 비동기성을 다룰 때는 숙련된 개발자도 가끔 헷갈립니다.
모든 걸 깔끔하게 정리해봅시다. 시작합시다!
Task 가 등장한 간단한 역사
옛날 옛적 (예: .NET 4.0 이전) 병렬로 또는 "백그라운드"에서 코드를 실행하는 명백한 방법은 새 Thread 를 만드는 것이었습니다. 예를 들어, new Thread(() => { ... }).Start(); Thread 는 단순해서 좋지만 모든 책임이 당신에게 있습니다. 리소스 할당, 라이프사이클, 예외 처리, 동기화, 모니터링, 확장성 — 이 모든 걸 개발자가 관리해야 했습니다. 프로그래밍에서 좀 더 게으를 권리가 있잖아요!
Task(즉, Task) 가 등장하면서 상황이 바뀌었습니다 — 네임스페이스의 System.Threading.Tasks.Task. Task 는 Thread 가 아닙니다. 더 추상적이고 유연한 개념이에요. 언젠가(또는 병렬로) 수행될 작업을 설명합니다.
2. Thread — "맨몸 스레드"
Thread 는 낮은 수준의 실행 단위로, 운영체제에서 할당받는 독립된 리소스 덩어리(자체 스택, 실행 컨텍스트 등)를 의미합니다. 직접 Thread 를 생성하면 시작, 종료와 그 생애의 모든 부분을 책임져야 합니다.
using System;
using System.Threading;
class Program
{
static void Main()
{
Thread thread = new Thread(() => {
Console.WriteLine("스레드에서 인사!");
});
thread.Start();
thread.Join(); // 스레드 종료를 기다립니다
}
}
- 여기서는 람다를 자신의 스택에서 실행하는 스레드를 생성했습니다.
- 스레드를 시작한 뒤에는 작업이 끝날 때까지 기다리기 위해 Join() 을 호출합니다.
단점은?
- 각 스레드는 메모리(스택, 약 1MB 정도)를 차지합니다.
- .NET 에서는 수천 개의 스레드를 수동으로 생성하는 것을 권장하지 않습니다 — 시스템이 고생합니다.
- Join() 를 호출하는 것을 잊으면 메인 스레드가 자식보다 먼저 끝나 프로그램이 "끊길" 수 있습니다.
- 스레드 내부의 예외는 밖으로 자동 전파되지 않으니 별도로 잡아야 합니다!
- 스레드를 실행하면 "우아하게" 취소하기가 어렵습니다 (표준 Stop() 같은 메서드가 없음).
3. Task — "신세대 작업"
Task 는 "언젠가 완료될 일"을 나타내는 더 똑똑한 추상화입니다. 내부적으로 Task 는 ThreadPool 의 스레드에서 실행되는 경우가 많아, 단순히 스레드를 마구잡이로 늘리는 것보다 훨씬 효율적입니다. 직접 스레드를 만들고 관리할 필요 없이 풀에서 스레드를 재활용하고 부하에 따라 수를 조절합니다.
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
Task task = Task.Run(() =>
{
Console.WriteLine("Task에서 인사!");
});
await task; // 작업 완료를 기다립니다
}
}
- 이 경우 Task 가 반드시 별도의 스레드에서 실행된다고 보장되진 않지만, 보통 풀의 스레드에서 실행됩니다.
- 비동기 메서드에서 await 하거나 동기적으로는 task.Wait() 로 완료를 기다릴 수 있습니다.
4. Task 와 Thread 의 차이점은?
차이점을 하나하나 정리해서 언제 무엇에 써야 하고 어떤 함정이 있는지 보겠습니다.
| Thread | Task | |
|---|---|---|
| 추상화 | OS 스레드 | 작업/태스크 (스레드를 사용할 수도 있는 추상화) |
| 시작 | new Thread(...).Start() | Task.Run(...), Task.Factory.StartNew(...), async 메서드 |
| 직접 제어 | 가능 (시작, Join, 우선순위 등) | 아니요, .NET 이 관리합니다 |
| 스레드 풀 | 아니요, 항상 새로 생성됩니다 | 예, 보통 ThreadPool 를 사용합니다 |
| 리소스 관리 | 자체 스택 할당 | 풀에서 리소스를 재사용 |
| 확장성 | 나쁨: 1000개 이상의 스레드에 비효율적 | 우수: 수천 개의 Task 도 잘 처리됨 |
| 상호작용 | OS 관점에서 개별 스레드 | 현재 스레드의 연속일 수도 있고, ThreadPool 의 스레드일 수도 있음 |
| 예외 | 명시적으로 잡아야 함, 그렇지 않으면 "사라질" 수 있음 | 예외는 Task 에 저장됨; await 또는 .Wait() 시 잡을 수 있음 |
| 취소 | 표준 방식 없음 | 지원함: CancellationToken |
| 작업 완료 확인 | Join() 로 대기 | await, .Wait(), .Result |
| 사용 용도 | 특수한 경우 — UI 스레드, 장기 실행 스레드 | 거의 모든 백그라운드/병렬 작업 |
5. 언제 무엇을 사용할까?
언제 Thread 를 사용할까?
솔직히 말하면 현대 .NET 코드에서 수동으로 Thread 를 생성할 일은 매우 드뭅니다. 그래도 정당화되는 경우는 있습니다:
- 매우 오래 동작해야 하는 스레드를 만들어야 할 때(예: 라디오 방송 신호 직렬화 또는 하드웨어에서 오는 데이터 처리) 그리고 그 스레드는 "특별" 해야 할 때: 낮은 우선순위, 별도의 문화(culture), 별칭 이름 등이 필요할 때.
- 수동 스레드 관리가 필요한 저수준 API 와 통합해야 할 때.
- 커스텀 스케줄러 같은 매우 특수한 경우.
그 외의 경우에는 Task 가 더 올바르고 현대적인 선택입니다.
언제 Task 를 사용할까?
거의 항상, 백그라운드나 병렬로 일을 하기 원할 때:
- 스레드 풀에서 실행하면 되는 모든 백그라운드 연산(예: 서버의 요청 처리, 파일 파싱, 메일 발송).
- 비동기 작업 실행(async/await) — 이 메커니즘은 Task 또는 Task<T> 를 반환합니다.
- 작업 결합, continuation 처리, 체인 작업.
- 취소, 대기, 결과 수집의 간편함: Task 는 CancellationToken 을 지원하며 현대 API 와 쉽게 통합됩니다.
- 비동기 I/O 연산: 네트워크 요청, 파일 작업, DB 액세스.
비교
| 시나리오 | Thread | Task |
|---|---|---|
| 장기 실행 스레드(예: 자체 서비스) | 예 | 아니요 |
| 짧은 작업을 대량으로 실행 | 아니요 | 예 |
| 비동기 I/O 연산(await) | 아니요 | 예 |
| 작업 결합, 취소, 체인 | 아니요 | 예 |
| 우선순위·문화의 세밀한 조정 | 가능(하지만 드묾) | 아니요, 기본 Task 에 한정 |
| 간단한 CPU 작업 분배 | 가끔 | 예 |
6. 유용한 팁
Task 는 항상 스레드가 아니다!
가장 강력한 마법 중 하나: 비동기 I/O 에 Task 를 사용하면 새 스레드가 전혀 생성되지 않을 수 있습니다! 모든 게 (IO Completion Ports 나 다른 플랫폼 원시적 메커니즘으로) 처리됩니다. 대기 중일 때 스레드는 점유되지 않습니다.
Task 와 비동기성(I/O-bound) — await 의 마법
using System;
using System.Net.Http;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
// 비동기로 사이트 내용을 다운로드 (I/O-bound)
HttpClient client = new HttpClient();
string data = await client.GetStringAsync("https://www.dotnetfoundation.org");
Console.WriteLine($"수신한 문자 수: {data.Length}");
}
}
- 여기서 Task(Task<string>) 는 비동기 I/O 작업을 캡슐화합니다.
- 스레드는 블로킹되지 않습니다 — 작업이 완료되면 메서드 실행이 이어집니다.
- 이런 작업을 위해 수동으로 스레드를 만드는 것은 과잉이고 비효율적입니다.
Task 와 ThreadPool
Task.Run(...) 을 쓰거나 비동기 API(await) 를 사용하면 .NET 은 보통 ThreadPool 이라는 특수한 스레드 풀을 사용합니다. 이 풀은 미리 생성된 스레드 집합으로, 대기 중인 작업을 빠르게 처리할 준비가 되어 있습니다. 일이 적으면 스레드는 쉬고, 일이 많아지면 새 스레드를 합리적으로 늘립니다. 덕분에 애플리케이션은 많은 Task 를 효율적으로 확장할 수 있습니다.
반면 new Thread 로 만든 스레드는 보통 종료되면 풀로 돌아가지 않고 사라집니다. 그래서 대규모 병렬 처리에는 Task 가 훨씬 효율적입니다.
7. 흔한 실수와 함정
복고풍으로 돌아가서 모든 걸 Thread 로만 작성하고 싶어지면 멋진 모험이 시작됩니다: 메모리 누수, 복잡한 동기화, 작업 취소 불가, "멈춰버린" 유령 스레드(좀비 프로세스), 그리고 예외 처리용 별도 API 로의 골머리.
중요한 점은: "Task" 는 편리하고 안전하며 현대적입니다. 대부분의 경우 오늘날 C# 개발에서 수동 스레드 관리로 돌아갈 이유는 없습니다.
GO TO FULL VERSION