비동기 추가

황현중·2025년 12월 3일

C#

목록 보기
21/24

1. .NET BCL에 추가된 async 메서드

1-1. BCL + async 메서드란?

먼저 BCL(Base Class Library)를 한 줄로 정리하면:

.NET이 기본으로 제공하는 표준 라이브러리 묶음 (예: System.IO, System.Net.Http, System.Data, System.Threading.Tasks 등)

.NET 4.5 이후부터 이 BCL 곳곳에 비동기 메서드들이 대량으로 추가됐다. 특징은:

  • 이름 끝에 Async가 붙고
  • 반환 타입이 Task 또는 Task<T>이고
  • 내부에서 OS의 비동기 IO를 사용해서 스레드를 블록하지 않는다

1-2. 대표적인 BCL async 메서드 예시

  • 파일/스트림 IO
    • Stream.ReadAsync, Stream.WriteAsync
    • FileStream.ReadAsync, FileStream.WriteAsync
  • HTTP/네트워크
    • HttpClient.GetAsync, PostAsync, SendAsync
    • NetworkStream.ReadAsync, WriteAsync
  • DB / Entity Framework
    • DbCommand.ExecuteReaderAsync, ExecuteNonQueryAsync
    • DbContext.SaveChangesAsync
  • 기타
    • Task.Delay
    • Socket.*Async 계열 등

이 메서드들은 “오래 걸리는 IO 작업을 스레드를 점유하지 않고 처리해준다”가 핵심이다.

1-3. 동기 vs 비동기 파일 읽기 간단 비교

// 동기 방식
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read);
byte[] buffer = new byte[1024];
int read = fs.Read(buffer, 0, buffer.Length);  // 여기서 스레드가 블록됨
// 비동기 방식
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true);
byte[] buffer = new byte[1024];
int read = await fs.ReadAsync(buffer, 0, buffer.Length); // await, 스레드는 풀려 있음

동기 Read는 작업이 끝날 때까지 스레드를 묶어 두고, 비동기 ReadAsync는 “이 IO 끝나면 알려줘”라고 OS에 맡겨놓고 스레드는 다른 일도 할 수 있다.


2. Task / Task<TResult> 타입 자세히 보기

2-1. Task는 ‘작업의 상태’를 표현하는 객체

Task = “미래에 끝날 작업 하나”를 표현하는 타입

Task는 내부적으로 상태(state)를 가진다. 예를 들면:

  • Created : 아직 시작 전
  • Running : 실행 중
  • RanToCompletion : 정상 완료
  • Faulted : 예외 발생 후 종료
  • Canceled : 취소됨
Task t = Task.Run(() =>
{
    // 뭔가 작업
});

await t;

Console.WriteLine(t.Status); // RanToCompletion / Faulted / Canceled 등

비동기 작업 안에서 예외가 나면 TaskFaulted 상태가 되고, 그 Task를 await하는 시점에 예외가 다시 던져진다.

Task t = Task.Run(() =>
{
    throw new InvalidOperationException("에러 발생");
});

try
{
    await t;  // 여기서 예외가 터짐
}
catch (Exception ex)
{
    Console.WriteLine(ex.Message);
}

2-2. Task<TResult> = 결과를 담은 Task

Task<int> t = Task.Run(() =>
{
    // 오래 걸리는 계산
    return 42;
});

int result = await t;   // t.Result를 await로 안전하게 꺼내는 셈

Task<T> 안에는 나중에 T 타입의 결과가 들어온다고 생각하면 된다. await는 사실상:

  1. Task가 끝날 때까지 기다리고
  2. 끝나면 .Result 값을 꺼내서
  3. 예외가 있으면 다시 throw 해 주고
  4. 그 값을 좌변 변수에 넣어주는 역할

2-3. Task와 Thread의 차이 (자주 헷갈리는 포인트)

  • Thread는 OS 레벨의 실제 실행 흐름(스레드 하나)
  • Task는 “해야 할 일 하나(작업 단위)”를 표현하는 논리적인 개념

Task는 스레드풀 위에서 돌 수도 있고, IO 대기 중에는 스레드를 점유하지 않고 기다릴 수도 있다. 즉, “비동기 = 항상 새로운 스레드를 만든다”는 개념이 아니다.


3. async 메서드의 반환 타입 규칙

3-1. 기본 패턴 3가지

async를 메서드에 붙일 때, 반환 타입은 보통 이 셋 중 하나다.

  1. async Task → 비동기 작업인데 리턴값이 필요 없는 경우
  2. async Task<T> → 비동기 작업인데 결과 값을 돌려줘야 하는 경우
  3. async void → 이벤트 핸들러용 특수 케이스 (WinForms, WPF의 Button_Click 등)
// 반환값 없음
async Task DoWorkAsync()
{
    await Task.Delay(1000);
}

// int 결과 반환
async Task<int> GetNumberAsync()
{
    await Task.Delay(1000);
    return 42;
}

// UI 이벤트 핸들러 (WinForms, WPF 등)
async void Button_Click(object sender, EventArgs e)
{
    await Task.Delay(1000);
    MessageBox.Show("완료");
}

3-2. async void가 위험한 이유 (이벤트 핸들러 제외)

async void DoSomething()
{
    await Task.Delay(1000);
    throw new Exception("에러");
}
  • async void 메서드에서 발생한 예외는 호출자가 try/catch로 잡기 어렵다.
  • 호출자 입장에서 이 메서드가 끝났는지, 실패했는지, 취소됐는지를 알 방법이 없다.

그래서 규칙은 간단하다.

  • 일반 메서드: 웬만하면 async void 쓰지 말고 async Task로 만들기
  • 이벤트 핸들러: 프레임워크가 void 시그니처를 요구하니 어쩔 수 없이 async void

3-3. async 메서드는 내부적으로 상태 머신으로 변환된다

async Task<int> GetNumberAsync()
{
    await Task.Delay(1000);
    return 10;
}

컴파일러는 위 코드를 컴파일할 때, 내부적으로 “상태 머신(state machine)” 코드로 변환한다.

  • await를 만날 때마다 “어디까지 실행했는지” 상태를 저장해 두고
  • 비동기 작업이 끝나면 그 상태를 다시 불러와서 이어서 실행한다

개발자 입장에서는 동기 코드처럼 작성하지만, 실제로는 콜백 지옥을 컴파일러가 자동으로 펼쳐서 관리해 주는 셈이다.


4. async 메서드가 아닌 경우의 비동기 처리

4-1. async 없이 Task 직접 반환하기

비동기 메서드를 만들 때 꼭 async를 써야만 하는 건 아니다. Task 또는 Task<T>만 반환해 주면 호출자는 await로 사용할 수 있다.

Task<int> GetNumberAsync()
{
    // async 키워드 없음
    return Task.Run(() =>
    {
        // 무거운 작업이라고 가정
        Task.Delay(1000).Wait();
        return 7;
    });
}
// 호출하는 쪽
int n = await GetNumberAsync();

호출자는 이 메서드가 async인지 아닌지 알 필요가 없다. Task<int>만 있으면 그냥 await 하면 끝이다.

다만, 직접 Task를 조립하다 보면 예외/취소 처리 등에서 실수하기 쉽기 때문에 웬만하면 async + await 조합으로 작성하는 편이 더 안전하다.

4-2. 옛날 비동기 패턴(APM/EAP) 한 줄만 짚고 가기

async/await 이전에는 이런 패턴을 썼다.

  • APM (Asynchronous Programming Model)
    • BeginXXX / EndXXX 콤보
    • 예: BeginRead, EndRead
  • EAP (Event-based Asynchronous Pattern)
    • XXXAsync 메서드 + XXXCompleted 이벤트
    • 예: WebClient.DownloadStringAsync + DownloadStringCompleted

요즘은 이 패턴들을 TaskCompletionSource 등으로 감싸서 Task 기반(TAP, Task-based Asynchronous Pattern)으로 바꾸고, async/await로 사용하는 경우가 많다.

4-3. 동기 메서드를 비동기처럼 감싸는 Task.Run

Task<string> ReadFileAsyncWrapper(string path)
{
    return Task.Run(() =>
    {
        // 원래 동기 메서드
        return File.ReadAllText(path);
    });
}

이렇게 하면 동기 메서드를 백그라운드 스레드에서 실행하게 만들 수 있다. 호출자는 다음처럼 쓸 수 있다.

string text = await ReadFileAsyncWrapper("test.txt");

다만 이 방식은 결국 스레드를 하나 잡아먹는 구조라서, 파일/네트워크 같은 IO는 가능하면 BCL에서 제공하는 진짜 비동기 메서드를 우선 사용하는 게 좋다.


5. 비동기 호출의 병렬 처리 (여러 작업 동시에)

5-1. 순차 await vs 동시에 실행

나쁜(?) 패턴부터 보자.

string html1 = await http.GetStringAsync(url1);
string html2 = await http.GetStringAsync(url2);
string html3 = await http.GetStringAsync(url3);
  • url1 요청 → 완료
  • 그다음 url2 요청 → 완료
  • 그다음 url3 요청 → 완료

각 요청이 1초씩 걸린다면 총 3초가 걸린다. IO는 대부분 “기다림”인데, 그 기다림을 순서대로 처리하는 셈이다.

5-2. 동시에 보내고 한 번에 기다리기 – Task.WhenAll

Task<string> t1 = http.GetStringAsync(url1);
Task<string> t2 = http.GetStringAsync(url2);
Task<string> t3 = http.GetStringAsync(url3);

// 세 요청을 동시에 출발시킨 뒤, 전부 끝날 때까지 기다리기
string[] results = await Task.WhenAll(t1, t2, t3);

string html1 = results[0];
string html2 = results[1];
string html3 = results[2];

이 패턴에서는:

  1. GetStringAsync 세 번을 빠르게 호출해서 Task<string> 세 개를 받는다.
  2. 각 HTTP 요청이 동시에 진행된다.
  3. Task.WhenAll은 “모든 Task가 끝날 때 완료되는 Task”를 하나 만들어 준다.
  4. await Task.WhenAll(...) 하면, 세 요청이 모두 끝난 뒤에 결과 배열이 한 번에 들어온다.

각 요청이 1초씩 걸려도, 전체적인 시간은 거의 1초 수준으로 줄어든다.

5-3. CPU 병렬 처리 간단 느낌

var tasks = new List<Task<int>>();

for (int i = 0; i < 4; i++)
{
    int capture = i;
    tasks.Add(Task.Run(() => VeryHeavyCalc(capture)));
}

int[] results = await Task.WhenAll(tasks);

VeryHeavyCalc 같은 CPU 중심 계산을 여러 조각으로 나누고, Task.Run으로 여러 스레드에서 동시에 돌리는 예시다.

실제로는 스레드풀이 여러 코어에 작업을 배분해서 병렬로 계산할 수 있고, 더 깊게 들어가면 Parallel.For, Parallel.ForEach, PLINQ(AsParallel()) 같은 것들도 배울 수 있다.

입문 단계에서는 일단:

“여러 비동기 IO 작업을 함께 돌리고 싶으면 Task 여러 개 만들고 → Task.WhenAll로 한 번에 기다린다

이 패턴만 확실하게 익혀둬도 비동기로 할 수 있는 일이 확 넓어진다.


정리

  • BCL async 메서드 → .NET 기본 라이브러리에 붙어 있는 XXXAsync 메서드들. IO 작업을 스레드 블록 없이 처리할 수 있게 해 준다.
  • <li><strong>Task / Task&lt;T&gt;</strong>  
      → “미래에 끝날 작업”을 나타내는 타입.  
      <code>await</code>로 자연스럽게 기다리면서 결과를 받을 수 있다.</li>
    
    <li><strong>async 메서드 반환 타입</strong>  
      → 일반 메서드는 <code>async Task</code>, <code>async Task&lt;T&gt;</code>  
      → 이벤트 핸들러만 예외적으로 <code>async void</code></li>
    
    <li><strong>async 아닌 비동기</strong>  
      → Task를 직접 만들어 반환하거나, 레거시 콜백 기반 비동기를 Task로 감싸서 사용.  
      동기 CPU 작업은 <code>Task.Run</code>으로 백그라운드에서 돌릴 수도 있다.</li>
    
    <li><strong>비동기 병렬 처리</strong>  
      → 순차 <code>await</code> 대신, Task 여러 개를 동시에 시작하고  
      <code>Task.WhenAll</code>로 한 번에 기다리면 IO 작업을 동시에 진행할 수 있다.</li>

0개의 댓글