← C# 강의 목록으로
모던 C#
모던 C# · 선수: 델리게이트

19. async / await

I/O 작업을 논블로킹으로 다루는 핵심 키워드가 async/await 입니다. Task·Task<T>·Task.WhenAll·예외 처리·취소까지 한 단원에서 정리합니다.

C#.NET 8async비동기
소요 시간
약 1~1.5시간
난이도
📊 중급
선수 조건
🎯 델리게이트와 람다
결과물
I/O 작업을 논블로킹으로 다루는 핵심 키워드가 async/await 입니다. Task·Task<T>·Task.WhenAll·예외 처리·취소까지 한 단원에서 정리합니다.

이 강의에서 배우는 것

  • 1`Task`/`Task<T>` 와 `async`/`await` 의 관계를 이해한다
  • 2`Task.WhenAll` 로 병렬 대기, `Task.WhenAny` 로 첫 완료 처리
  • 3`CancellationToken` 으로 비동기 작업을 안전하게 취소한다
  • 4async 메서드의 예외가 `await` 시점에 던져진다는 사실을 안다
  • 5`ConfigureAwait(false)` 의 용도를 큰 그림으로 파악한다

소개

I/O 를 기다리는 동안 스레드를 점유하지 않게 해 주는 것이 **비동기 프로그래밍**입니다. C# 의 `async`/`await` 는 콜백 지옥 없이 동기 코드처럼 비동기를 쓰게 해 줍니다. 이 단원에서는 .NET 의 `Task` 모델과 함께 핵심 패턴을 익힙니다.

핵심 개념

1) `Task` 는 "미래의 결과"

`Task` 는 진행 중인 작업, `Task<T>` 는 끝나면 `T` 를 돌려줄 작업입니다. `await` 는 그 결과를 기다렸다가 꺼내 줍니다.

csharp
async Task<int> GetAsync()
{
    await Task.Delay(1000);   // 1초간 스레드를 놓아준다
    return 42;
}

int n = await GetAsync();   // 비동기처럼 보이지만 동기처럼 읽힌다

2) `async` 가 붙은 메서드의 시그니처

  • 반환 타입은 `Task`, `Task<T>`, 또는 `ValueTask`/`ValueTask<T>`
  • 이벤트 핸들러 한정 예외로 `async void` — 일반 코드에서는 **쓰지 말 것** (예외를 잡을 수 없음)
  • 관례적으로 메서드 이름 끝에 `Async` 를 붙임 (`SaveAsync`, `FetchAsync`)

3) 병렬 대기 — `Task.WhenAll` / `Task.WhenAny`

`await` 를 연달아 쓰면 순차 대기지만, `Task` 를 모아서 한 번에 `await` 하면 **동시에** 진행됩니다.

csharp
Task<int> a = FetchAsync("A");
Task<int> b = FetchAsync("B");
int[] all = await Task.WhenAll(a, b);   // 모두 끝날 때까지
Task<int> first = await Task.WhenAny(a, b);   // 하나만 끝나도 됨

4) 취소 — `CancellationToken`

협동적 취소(cooperative cancellation) 모델입니다. **토큰이 받아 주는 쪽에서 확인** 해야 실제로 중단됩니다.

csharp
using CancellationTokenSource cts = new();
cts.CancelAfter(2000);
await Task.Delay(5000, cts.Token);   // 2초에 OperationCanceledException

`Task.Delay`, `HttpClient.GetAsync`, `Stream.ReadAsync` 등 BCL 메서드는 대부분 `CancellationToken` 매개변수를 받습니다.

5) 예외는 `await` 에서 다시 던져진다

async 메서드의 예외는 `Task` 안에 보관되었다가, **`await` 하는 시점에** 다시 던져집니다. 따라서 `try`/`catch` 는 호출 쪽에 두면 됩니다.

csharp
try { await DoAsync(); }
catch (InvalidOperationException ex) { /* ... */ }

`Task.WhenAll` 에서 여러 예외가 나면 첫 번째만 throw, 나머지는 `Task.Exception` (`AggregateException`) 에 모입니다.

6) `ConfigureAwait(false)` — 라이브러리 코드에서만

WinForms/WPF/ASP.NET Framework 처럼 **UI/요청 컨텍스트가 있는** 환경에서 await 후 같은 컨텍스트로 돌아오는 비용을 피하고 싶을 때 씁니다. 콘솔 앱이나 ASP.NET Core 에서는 거의 무의미합니다.

csharp
await stream.ReadAsync(buf).ConfigureAwait(false);

라이브러리 작성자라면 반복적으로 붙이는 게 안전, 앱 코드는 신경 쓰지 않아도 OK.

핵심 예제

예제 1 — `AsyncBasics` : 기본 흐름

csharp
Console.WriteLine("작업 시작");
int result = await DoWorkAsync();
Console.WriteLine($"결과: {result}");

static async Task<int> DoWorkAsync()
{
    await Task.Delay(1000);
    await Task.Delay(1000);
    return 42;
}

**실행 결과**

text
작업 시작
  ...1초 대기
  ...1초 더 대기
결과: 42
작업 종료

**메모:** `await Task.Delay` 동안 스레드는 다른 일을 할 수 있습니다. UI 가 멈추지 않는 핵심 이유입니다.

예제 2 — `WhenAll` : 동시에 시작, 한 번에 기다리기

csharp
Task<int> t1 = FetchAsync("A", 1000);
Task<int> t2 = FetchAsync("B", 1500);
Task<int> t3 = FetchAsync("C", 800);
int[] results = await Task.WhenAll(t1, t2, t3);

**실행 결과**

text
  [A] 시작 (1000ms)
  [B] 시작 (1500ms)
  [C] 시작 (800ms)
  [C] 완료
  [A] 완료
  [B] 완료
결과 합계: 3300
총 시간: 1500 ms  (순차였다면 3300ms 정도)

**메모:** 가장 오래 걸린 작업의 시간만큼만 걸립니다. I/O 호출 여러 건을 묶을 때 최고의 패턴.

예제 3 — `WhenAny` : 가장 빠른 응답만 채택

csharp
Task<string> winner = await Task.WhenAny(mirrorA, mirrorB, mirrorC);
string result = await winner;

**실행 결과**

text
가장 먼저 응답: Tokyo(800ms)

**메모:** `WhenAny` 는 가장 먼저 끝난 `Task` 자체를 돌려주므로, 값을 꺼내려면 **한 번 더 `await`** 해야 합니다.

예제 4 — `Cancellation` : 외부에서 중단하기

csharp
using CancellationTokenSource cts = new();
cts.CancelAfter(TimeSpan.FromMilliseconds(1500));

try { await LongJobAsync(cts.Token); }
catch (OperationCanceledException) { Console.WriteLine("작업이 취소되었습니다"); }

**실행 결과**

text
  단계 1 완료
  단계 2 완료
작업이 취소되었습니다

**메모:** 토큰을 전달받은 메서드 안쪽에서 `Task.Delay(ms, token)` 처럼 토큰을 함께 넘겨야 합니다. 토큰을 무시하면 절대 안 멈춥니다.

예제 5 — `AsyncException` : 예외 흐름

csharp
try { await FailingAsync(); }
catch (InvalidOperationException ex) { Console.WriteLine($"잡았다: {ex.Message}"); }

**실행 결과**

text
잡았다: 의도된 실패
WhenAll 에서 잡음: 의도된 실패
  t1 상태: Faulted, t2 상태: Faulted

**메모:** async 메서드 안에서 던진 예외는 Task 객체에 담겨 있다가, await 하는 순간 그대로 throw 됩니다. 평소 동기 코드처럼 try/catch 로 잡으면 됩니다.

전체 예제 코드 (src/)

src/AsyncBasics/AsyncBasics.csproj

xml
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern19</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

src/AsyncBasics/Program.cs

csharp
// .NET 8 의 top-level statement 는 await 를 그대로 쓸 수 있다.
Console.WriteLine("작업 시작");
int result = await DoWorkAsync();
Console.WriteLine($"결과: {result}");
Console.WriteLine("작업 종료");

// async 메서드는 Task / Task<T> 를 반환한다.
// await 만난 시점에서 호출자에게 반환했다가, 작업이 끝나면 이어서 실행한다.
static async Task<int> DoWorkAsync()
{
    Console.WriteLine("  ...1초 대기");
    await Task.Delay(1000);
    Console.WriteLine("  ...1초 더 대기");
    await Task.Delay(1000);
    return 42;
}

src/AsyncException/AsyncException.csproj

xml
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern19</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

src/AsyncException/Program.cs

csharp
// async 메서드에서 던진 예외는 Task 안에 "포장" 되어 있다가, await 시점에 다시 던져진다.
try
{
    await FailingAsync();
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"잡았다: {ex.Message}");
}

// Task.WhenAll 의 경우 첫 예외만 throw 되고, 나머지 예외는 Task.Exception 안에 모인다.
Task t1 = FailingAsync();
Task t2 = FailingAsync();
try
{
    await Task.WhenAll(t1, t2);
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"WhenAll 에서 잡음: {ex.Message}");
    Console.WriteLine($"  t1 상태: {t1.Status}, t2 상태: {t2.Status}");
}

static async Task FailingAsync()
{
    await Task.Delay(200);
    throw new InvalidOperationException("의도된 실패");
}

src/Cancellation/Cancellation.csproj

xml
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern19</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

src/Cancellation/Program.cs

csharp
// CancellationToken : 비동기 작업을 외부에서 중단할 수 있게 해 주는 신호.
using CancellationTokenSource cts = new();

// 1.5초 후에 취소 신호를 보낸다.
cts.CancelAfter(TimeSpan.FromMilliseconds(1500));

try
{
    await LongJobAsync(cts.Token);
    Console.WriteLine("정상 완료");
}
catch (OperationCanceledException)
{
    Console.WriteLine("작업이 취소되었습니다");
}

// 토큰을 매 단계마다 점검하거나, Task.Delay 에 그대로 넘겨 자동 취소시킨다.
static async Task LongJobAsync(CancellationToken token)
{
    for (int i = 1; i <= 5; i++)
    {
        await Task.Delay(500, token);   // 취소되면 OperationCanceledException 던짐
        Console.WriteLine($"  단계 {i} 완료");
    }
}

src/WhenAll/Program.cs

csharp
using System.Diagnostics;

// 3 개의 작업을 동시에 시작하고 모두 끝나길 기다린다.
Stopwatch sw = Stopwatch.StartNew();

Task<int> t1 = FetchAsync("A", 1000);
Task<int> t2 = FetchAsync("B", 1500);
Task<int> t3 = FetchAsync("C", 800);

int[] results = await Task.WhenAll(t1, t2, t3);

sw.Stop();
Console.WriteLine($"결과 합계: {results.Sum()}");
Console.WriteLine($"총 시간: {sw.ElapsedMilliseconds} ms  (순차였다면 3300ms 정도)");

// 네트워크 호출을 흉내내는 비동기 메서드 — 실제로는 HttpClient 등을 await 한다.
static async Task<int> FetchAsync(string name, int delayMs)
{
    Console.WriteLine($"  [{name}] 시작 ({delayMs}ms)");
    await Task.Delay(delayMs);
    Console.WriteLine($"  [{name}] 완료");
    return delayMs;
}

src/WhenAll/WhenAll.csproj

xml
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern19</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

src/WhenAny/Program.cs

csharp
// 가장 먼저 완료된 작업만 처리한다 — "어느 서버가 먼저 응답하는가" 패턴.
Task<string> mirrorA = MirrorAsync("Seoul", 1500);
Task<string> mirrorB = MirrorAsync("Tokyo", 800);
Task<string> mirrorC = MirrorAsync("Singapore", 1200);

Task<string> winner = await Task.WhenAny(mirrorA, mirrorB, mirrorC);
string result = await winner;   // 한 번 더 await 해서 값을 꺼낸다.
Console.WriteLine($"가장 먼저 응답: {result}");

// 나머지는 그대로 백그라운드에서 끝나도록 두거나, 취소하면 된다(다음 예제 참고).
static async Task<string> MirrorAsync(string region, int delayMs)
{
    await Task.Delay(delayMs);
    return $"{region}({delayMs}ms)";
}

src/WhenAny/WhenAny.csproj

xml
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern19</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

자주 하는 실수

  1. `async void` 사용 — 호출자가 예외를 못 잡고, 앱이 죽습니다. 이벤트 핸들러 외에는 쓰지 말 것.
  2. `task.Result` / `task.Wait()` 호출 — UI 컨텍스트에서 데드락의 주범. 항상 `await`.
  3. `Task.Run(async () => ...)` 으로 단순 I/O 를 감싸기 — I/O 는 이미 비동기라 스레드 풀에 던질 필요 없습니다.
  4. `WhenAll` 의 결과를 모은 뒤에야 첫 예외만 보고 끝내기 — 다른 작업의 예외도 검사해야 누락 안 됨.
  5. `CancellationToken` 을 받기만 하고 다음 호출에 전달하지 않기 — 취소가 전파되지 않습니다.

정리

  • `async` 가 붙은 메서드는 `Task`/`Task<T>` 를 반환하고, 호출자는 `await` 로 결과를 꺼낸다
  • 여러 작업을 묶을 땐 `Task.WhenAll`, 가장 빠른 하나만 쓸 땐 `Task.WhenAny`
  • 외부 취소는 `CancellationToken` 으로, 호출 체인을 따라 끝까지 전파한다
  • 예외는 `await` 시점에 던져지므로 평소처럼 `try`/`catch` 로 다루면 된다
  • 콘솔/서버 앱은 `ConfigureAwait` 거의 신경 안 써도 됨, UI 라이브러리는 챙기자

과제

**과제 - 19. async / await**

문제 1 — 취소 가능한 카운트다운

  • 프로젝트 폴더: `Homework01/`
  • 핵심 개념: `async Task`, `Task.Delay`, `CancellationTokenSource`

요구사항

  • `CountdownAsync(int seconds, CancellationToken token)` 메서드를 만든다.
  • 1초 간격으로 남은 초를 출력한다 ("5초 남음", "4초 남음", ...).
  • 0 에 도달하면 "발사!" 를 출력한다.
  • 메인에서 `CancellationTokenSource` 를 만들고 2.5초 후 취소 신호를 보내, 카운트다운이 도중에 멈추도록 한다.
  • 취소되면 "취소되었습니다" 를 출력한다.

예상 출력

text
5초 남음
4초 남음
3초 남음
취소되었습니다

힌트

  • `await Task.Delay(1000, token);` 처럼 토큰을 같이 넘기면 자동으로 `OperationCanceledException` 이 던져진다.
  • `cts.CancelAfter(2500);` 로 지정 시간 후 취소.
  • 카운트다운은 `Console.WriteLine($"{i}초 남음");` → `await Task.Delay(1000, token);` 순서.

문제 2 — 세 작업 병렬 처리

  • 프로젝트 폴더: `Homework02/`
  • 핵심 개념: `Task.WhenAll`, `async Task<T>`

요구사항

  • 3 개의 비동기 작업 `LoadAsync(string name, int delayMs)` 를 만든다 — 지정 시간만큼 대기 후 `delayMs` 를 그대로 반환한다.
  • 메인에서 3 개의 작업을 동시에 시작 (예: 700ms / 1200ms / 900ms).
  • `Task.WhenAll` 로 모두 기다린 뒤, 반환된 값들의 합을 출력한다.
  • 전체 소요 시간도 `Stopwatch` 로 측정해서 출력한다 (가장 긴 작업과 비슷해야 함).

예상 출력 (수치 예시)

text
[A] 시작
[B] 시작
[C] 시작
[A] 완료 (700ms)
[C] 완료 (900ms)
[B] 완료 (1200ms)
합계: 2800
소요: 1200ms 근처

힌트

  • `System.Diagnostics.Stopwatch` 사용.
  • `int[] results = await Task.WhenAll(t1, t2, t3);` → `results.Sum()`.
  • 출력 순서는 작업 완료 순서대로(짧은 것부터) 나옵니다.

정답 확인

직접 풀어 본 후 [`answer/`](./answer/) 폴더의 정답과 비교해 보세요.

정답 (answer/)

homework/answer/Homework01/Homework01.csproj

xml
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern19</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

homework/answer/Homework01/Program.cs

csharp
using CancellationTokenSource cts = new();
cts.CancelAfter(TimeSpan.FromMilliseconds(2500));   // 2.5초 후 자동 취소

try
{
    await CountdownAsync(5, cts.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("취소되었습니다");
}

static async Task CountdownAsync(int seconds, CancellationToken token)
{
    for (int i = seconds; i > 0; i--)
    {
        Console.WriteLine($"{i}초 남음");
        await Task.Delay(1000, token);   // 토큰 전달 — 취소되면 여기서 throw
    }
    Console.WriteLine("발사!");
}

homework/answer/Homework02/Homework02.csproj

xml
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern19</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

homework/answer/Homework02/Program.cs

csharp
using System.Diagnostics;

Stopwatch sw = Stopwatch.StartNew();

// 동시에 시작 — 가장 긴 작업(1200ms) 만큼만 걸린다.
Task<int> t1 = LoadAsync("A", 700);
Task<int> t2 = LoadAsync("B", 1200);
Task<int> t3 = LoadAsync("C", 900);

int[] results = await Task.WhenAll(t1, t2, t3);
sw.Stop();

Console.WriteLine($"합계: {results.Sum()}");
Console.WriteLine($"소요: {sw.ElapsedMilliseconds}ms 근처");

static async Task<int> LoadAsync(string name, int delayMs)
{
    Console.WriteLine($"[{name}] 시작");
    await Task.Delay(delayMs);
    Console.WriteLine($"[{name}] 완료 ({delayMs}ms)");
    return delayMs;
}

직접 해 보기

bash
cd src/AsyncBasics && dotnet run
cd ../WhenAll && dotnet run
cd ../WhenAny && dotnet run
cd ../Cancellation && dotnet run
cd ../AsyncException && dotnet run

다음 단원

[20_Nullable_참조_타입](../20_Nullable_참조_타입/) — `null` 의 정체를 컴파일 타임에 잡아 줍니다.

예제 코드 / 강의 자료

전체 강의 자료와 예제 코드는 GitHub에서 자유롭게 받아볼 수 있습니다.

GitHub에서 보기 ↗