CodeGym /행동 /C# SELF /클래스 Task

클래스 TaskTask<TResult>

C# SELF
레벨 60 , 레슨 0
사용 가능

1. 소개

비동기 프로그래밍 세계에서는 "피드백이 있는 위임" 원칙으로 살아요. 예를 들어: 큰 파일을 다운받거나, 수기가바이트 로그를 분석하거나, 먼 서버에 요청을 보내야 할 때가 있죠. 마치 석상처럼 멈춰 기다리느니 시스템에 이렇게 말합니다: "이거 처리해줘, 난 다른 일을 할게. 끝나면 꼭 알려줘!"

바로 여기서 Task가 등장합니다 — 이 접근 방식의 우아한 구현체예요. 단순한 기술적 추상이 아니라, 일을 맡아서 결과가 디지털 허공에 사라지지 않도록 책임지는 '스마트 중개자' 같은 존재예요.

Task는 개인 비서처럼 동작해요. 중요한 일을 맡기면 고개를 끄덕이고 노트에 적은 뒤 말하죠: "당신은 평소 하던 일 하세요, 제가 끝나면 알려드릴게요." 그리고 정말 결과를 들고 찾아옵니다 — 성공 결과든, 왜 실패했는지에 대한 솔직한 설명이든.

좀 더 일상적인 비유를 들면, Task는 온라인으로 진료 예약하는 현대 시스템과 비슷해요: 온라인으로 등록하고 확인을 받으면 대기열에서 몇 시간씩 앉아 있을 필요가 없죠. 시스템이 알아서 예약 시간을 알려주니까요. 그동안 우린 다른 일을 할 수 있어요.

클래스 Task

Task는 .NET 비동기 프로그래밍의 기본 빌딩 블록이에요. 미래에 사용 가능한 실행 중이거나 곧 실행될 연산을 나타냅니다. 메서드가 아무것도 반환하지 않아야 한다면 단순히 Task를 씁니다.

public async Task BackupToCloudAsync()
{
    // 백업 마법을 수행, 아무것도 반환하지 않음
}

클래스 Task<TResult>

결과(예: 문자열, 숫자, 객체…)를 반환해야 하면 Task<TResult>를 사용합니다:

public async Task<string> DownloadHtmlAsync(string url)
{
    // 페이지를 다운로드하고 HTML 코드를 반환
    return "<html>...</html>";
}

Task이지, Thread가 아니지?

Thread는 스레드 자체를 관리합니다(무겁고 위험할 수 있어요). 반면 Task는 더 높은 수준의 추상화라서 스레드 풀에서 실행되거나(또는 I/O 연산처럼 새 스레드를 만들지 않고) 비동기적으로 동작할 수 있고 저수준 세부사항을 신경 쓸 필요가 없습니다.

Task는 "이 작업을 실행하고 싶다"라고 표현하면, .NET이 어떻게 실행할지는 알아서 결정하게 하는 방식이에요.

2. Task 객체 구조

알아야 할 Task의 속성과 메서드

속성 / 메서드 설명
Status
작업의 현재 상태
Result
Task<TResult>의 결과 (스레드를 블록)
IsCompleted
작업이 완료되었는지 여부
IsFaulted
작업에서 예외가 발생했는지 여부
IsCanceled
작업이 취소되었는지 여부
Wait()
현재 스레드를 완료까지 블록함 (위험)
ContinueWith()
완료 후 다른 작업을 실행
Exception
Task가 오류로 종료되었을 때 예외에 접근
Id
작업의 고유 식별자

Task와 함께하는 비동기 메서드 동작 방식

sequenceDiagram
    participant Main as Main 스레드
    participant Task as Task (백그라운드 작업)
    Main->>Task: Task.Run(() => ...) 실행
    Note right of Task: 백그라운드에서 실행
(CPU 또는 I/O) alt 작업 완료 Task->>Main: await가 완료되어 계속 진행 else 오류 Task->>Main: await가 예외를 던짐 end

3. 작업 생성 및 실행: Task 동작 원리

async인 비동기 메서드

가장 흔한 경우는 메서드를 async로 선언하고 Task 또는 Task<TResult>를 반환하는 것입니다(이미 본 대로).

Task.Run: 스레드 풀에서 실행

무거운 작업(예: 큰 수 계산이나 비디오 인코딩)을 백그라운드에서 실행하려면 Task.Run을 사용할 수 있습니다:

Task work = Task.Run(() =>
{
    // 무거운 계산 — UI 스레드를 블록하지 않음!
    Console.WriteLine("백그라운드 계산 시작...");
    Thread.Sleep(2000); // 긴 작업 에뮬레이션
    Console.WriteLine("백그라운드 계산 완료!");
});

결과를 얻고 싶다면:

Task<int> calculateTask = Task.Run(() =>
{
    // 예: 처음 100개의 합을 계산
    int sum = 0;
    for (int i = 1; i <= 100; i++) sum += i;
    return sum;
});

Task.Factory.StartNew

더 낮은 수준의 유연한 방법으로, 스케줄러 지정이나 파라미터 전달 같은 세부 설정이 가능합니다. 하지만 현대 코드에서는 보통 Task.Run을 권장합니다 — 더 간단하고 실수를 줄여주니까요.

4. 데모 앱: 우리의 도서 색인

예를 들어 도서 색인 앱이 있고 "클라우드" 소스에서 도서를 불러오는 기능을 추가해야 한다고 합시다 — 이건 I/O-bound 작업(느린 HTTP 요청이나 파일 읽기)이 될 거예요.

지연을 에뮬레이션하면서 비동기적으로 도서를 "로드"하는 메서드를 추가해봅시다:

public class Book
{
    public string Title { get; set; }
    public string Author { get; set; }
}

public class BookCatalog
{
    public List<Book> Books { get; set; } = new();

    public async Task LoadBooksAsync()
    {
        Console.WriteLine("도서 로드 중...");
        await Task.Delay(2000); // 긴 로드 에뮬레이션 (예: HTTP 또는 파일)
        Books = new List<Book>
        {
            new Book { Title = "CLR via C#", Author = "Jeffrey Richter" },
            new Book { Title = "C# in Depth", Author = "Jon Skeet" }
        };
        Console.WriteLine("도서 로드 완료.");
    }
}

Main에서 비동기 로드를 호출해봅시다 (await 사용):

var catalog = new BookCatalog();
await catalog.LoadBooksAsync();
Console.WriteLine($"카탈로그에 {catalog.Books.Count}권의 책이 있습니다.");

표: Task 생성 및 시작 주요 방법

생성 방법 사용법 결과 용도
async-메서드
async Task / async Task<T>
비동기 연산 보통 I/O, 사용 편의성
Task.Run
Task.Run(() => { ... })
백그라운드 작업 CPU-bound (계산)
TaskCompletionSource<T>
직접 Task를 만들고 완료시킴 프로그래머가 완전 제어 드물게, 저수준 작업용

5. Task의 생명주기

Task는 여러 상태에 있을 수 있어요:

  • Created — Task가 생성되었지만 시작되지 않음(명시적 시작이 필요한 경우).
  • WaitingToRun — 스레드 풀 대기열에서 대기 중.
  • Running — 실행 중.
  • WaitingForActivation — 시작 또는 외부 활성화를 기다림.
  • RanToCompletion — 성공적으로 완료됨.
  • Faulted — 예외로 종료됨.
  • Canceled — 취소됨(취소 지원 시).

다이어그램

flowchart LR
    Start -->|작업 시작| Running
    Running -->|성공| Completed
    Running -->|오류| Faulted
    Running -->|취소| Canceled

실습으로 확인

Task task = Task.Run(() =>
{
    Thread.Sleep(1000);
});
Console.WriteLine(task.Status); // 보통: Running 또는 WaitingToRun
await task;
Console.WriteLine(task.Status); // 완료 후 RanToCompletion

6. Task<TResult>에서 결과를 얻는 법

Task<TResult>는 미래에 나타날 결과를 감싼 래퍼입니다. 결과를 기다려야 할 때는 await를 사용합니다:

Task<int> sumTask = Task.Run(() =>
{
    int sum = 0;
    for (int i = 1; i <= 5; i++) sum += i;
    return sum;
});

int result = await sumTask;
Console.WriteLine(result); // 15

만약 await를 쓰는 걸 깜빡하면, 당신은 결과가 아니라 Task(약속)를 얻습니다. 전형적인 "비동기 함정"이에요.

대안: 동기적으로 결과 가져오기 (UI에서 절대 하지 마세요!)

때때로(예: 테스트에서) await 없이 결과가 필요할 수 있습니다. 그럴 땐 .Result를 사용할 수 있습니다:

int result = sumTask.Result;

하지만 Task가 아직 완료되지 않았다면 이 코드는 스레드를 블록하고, 특히 UI 스레드라면 앱이 멈춥니다! 그러니 가능한 한 항상 await를 선호하세요.

TaskTask<TResult>로 흔히 하는 실수

Task를 반환하지 않고 메서드를 void로 만들면 안 됩니다. 반환값이 없다면 Task를 반환하세요. void로 하면 오류를 처리할 방법이 없습니다.

await 무시. 그냥 메서드를 호출하고 결과를 기다리지 않으면 태스크가 독자적으로 실행("fire and forget")되어 끝났는지, 실패했는지 알기 어렵습니다.

.Result 또는 .Wait()로 블로킹 대기

UI나 ASP.NET 환경에서는 deadlock을 초래하기 쉬워요. 가능한 한 await만 쓰세요.

7. 고급 기능

작업 체이닝: ContinueWith

작업이 끝난 뒤 실행할 동작을 ContinueWith으로 붙일 수 있습니다:

Task.Run(() => 10)
    .ContinueWith(t =>
    {
        Console.WriteLine($"완료! 결과: {t.Result}");
    });

하지만 현대 C#에선 보통 async/await로 처리하는 편이 더 읽기 쉽습니다.

예: 병렬 및 순차 데이터 로드

예를 들어 서로 다른 소스에서 두 권의 책을 로드해야 한다고 합시다. 두 Task를 병렬로 시작하고 둘 다 기다릴 수 있습니다:

public async Task LoadBooksFromMultipleSourcesAsync()
{
    Task<List<Book>> t1 = LoadFromCloudAsync();
    Task<List<Book>> t2 = LoadFromLocalAsync();

    // 두 작업을 병렬로 대기
    await Task.WhenAll(t1, t2);

    // 결과 합치기
    Books = t1.Result.Concat(t2.Result).ToList();
}

private async Task<List<Book>> LoadFromCloudAsync()
{
    await Task.Delay(2000); // "클라우드"
    return new List<Book> { new Book { Title = "Cloud Book", Author = "Cloud Author" } };
}

private async Task<List<Book>> LoadFromLocalAsync()
{
    await Task.Delay(1000); // "로컬 디스크"
    return new List<Book> { new Book { Title = "Local Book", Author = "Local Author" } };
}

유의할 점: await Task.WhenAll(...)을 사용하면 두 요청이 동시에 시작되어(가능한 경우) 병렬로 실행되고, 둘 다 완료될 때까지 앱이 기다립니다.

8. 유용한 팁

Task와 Fire-and-forget

때론 태스크를 시작하고 끝날 때까지 기다리고 싶지 않을 때가 있어요(예: 로그를 클라우드에 보내거나 간단한 토스트를 띄우는 경우):

async void LogToCloudAsync(string message)
{
    await Task.Run(() =>
    {
        // 긴 로그 전송
        Thread.Sleep(1000);
        Console.WriteLine($"로그 전송됨: {message}");
    });
}

하지만 이런 경우 오류가 발생하면 알아내기 어렵습니다. 가능하면 Task를 반환하고 내부에서 예외를 로깅하세요!

TaskTask<TResult>의 실제 사용 사례

  • 클라이언트(UWP/WPF/WinForms) 앱에서는 UI를 블록하지 말고 파일·네트워크 같은 긴 작업에 Task를 사용하세요.
  • WebAPI/ASP.NET에서는 Task가 네트워크/DB 대기를 위해 스레드를 낭비하지 않게 해줘서 성능을 높입니다.
  • 동시에 다운로드·처리·저장 같은 "병렬" 작업을 잘 조직하세요.
  • 거의 모든 긴 메서드는 Async 버전을 제공합니다: File.ReadAllTextAsync, HttpClient.GetStringAsync 등.

FAQ 및 의외의 상황

질문:Task가 때때로 동기적으로 실행되나요?
답변: 연산이 이미 완료되어 있거나(예: 캐시된 결과) 컴파일러나 스케줄러가 같은 스레드에서 메서드를 동기적으로 완료시킬 수 있어요. 정상적이며 반복 호출을 빠르게 해줍니다.

질문:async void를 사용하면 안 되나요?
답변: 그런 메서드는 기다릴 수 없고, 예외를 잡을 수 없으며 완료를 추적할 수 없어요. Task를 사용하고, async void는 EventHandler(예: Button_Click)용으로만 쓰세요.

질문: 여러 작업을 시작하고 그중 하나만 기다릴 수 있나요?
답변: 네 — Task.WhenAny를 사용하세요.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION