19. async / await
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` 는 그 결과를 기다렸다가 꺼내 줍니다.
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` 하면 **동시에** 진행됩니다.
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) 모델입니다. **토큰이 받아 주는 쪽에서 확인** 해야 실제로 중단됩니다.
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` 는 호출 쪽에 두면 됩니다.
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 에서는 거의 무의미합니다.
await stream.ReadAsync(buf).ConfigureAwait(false);라이브러리 작성자라면 반복적으로 붙이는 게 안전, 앱 코드는 신경 쓰지 않아도 OK.
핵심 예제
예제 1 — `AsyncBasics` : 기본 흐름
Console.WriteLine("작업 시작");
int result = await DoWorkAsync();
Console.WriteLine($"결과: {result}");
static async Task<int> DoWorkAsync()
{
await Task.Delay(1000);
await Task.Delay(1000);
return 42;
}**실행 결과**
작업 시작
...1초 대기
...1초 더 대기
결과: 42
작업 종료**메모:** `await Task.Delay` 동안 스레드는 다른 일을 할 수 있습니다. UI 가 멈추지 않는 핵심 이유입니다.
예제 2 — `WhenAll` : 동시에 시작, 한 번에 기다리기
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);**실행 결과**
[A] 시작 (1000ms)
[B] 시작 (1500ms)
[C] 시작 (800ms)
[C] 완료
[A] 완료
[B] 완료
결과 합계: 3300
총 시간: 1500 ms (순차였다면 3300ms 정도)**메모:** 가장 오래 걸린 작업의 시간만큼만 걸립니다. I/O 호출 여러 건을 묶을 때 최고의 패턴.
예제 3 — `WhenAny` : 가장 빠른 응답만 채택
Task<string> winner = await Task.WhenAny(mirrorA, mirrorB, mirrorC);
string result = await winner;**실행 결과**
가장 먼저 응답: Tokyo(800ms)**메모:** `WhenAny` 는 가장 먼저 끝난 `Task` 자체를 돌려주므로, 값을 꺼내려면 **한 번 더 `await`** 해야 합니다.
예제 4 — `Cancellation` : 외부에서 중단하기
using CancellationTokenSource cts = new();
cts.CancelAfter(TimeSpan.FromMilliseconds(1500));
try { await LongJobAsync(cts.Token); }
catch (OperationCanceledException) { Console.WriteLine("작업이 취소되었습니다"); }**실행 결과**
단계 1 완료
단계 2 완료
작업이 취소되었습니다**메모:** 토큰을 전달받은 메서드 안쪽에서 `Task.Delay(ms, token)` 처럼 토큰을 함께 넘겨야 합니다. 토큰을 무시하면 절대 안 멈춥니다.
예제 5 — `AsyncException` : 예외 흐름
try { await FailingAsync(); }
catch (InvalidOperationException ex) { Console.WriteLine($"잡았다: {ex.Message}"); }**실행 결과**
잡았다: 의도된 실패
WhenAll 에서 잡음: 의도된 실패
t1 상태: Faulted, t2 상태: Faulted**메모:** async 메서드 안에서 던진 예외는 Task 객체에 담겨 있다가, await 하는 순간 그대로 throw 됩니다. 평소 동기 코드처럼 try/catch 로 잡으면 됩니다.
전체 예제 코드 (src/)
src/AsyncBasics/AsyncBasics.csproj
<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
// .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
<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
// 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
<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
// 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
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
<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
// 가장 먼저 완료된 작업만 처리한다 — "어느 서버가 먼저 응답하는가" 패턴.
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
<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>
자주 하는 실수
- `async void` 사용 — 호출자가 예외를 못 잡고, 앱이 죽습니다. 이벤트 핸들러 외에는 쓰지 말 것.
- `task.Result` / `task.Wait()` 호출 — UI 컨텍스트에서 데드락의 주범. 항상 `await`.
- `Task.Run(async () => ...)` 으로 단순 I/O 를 감싸기 — I/O 는 이미 비동기라 스레드 풀에 던질 필요 없습니다.
- `WhenAll` 의 결과를 모은 뒤에야 첫 예외만 보고 끝내기 — 다른 작업의 예외도 검사해야 누락 안 됨.
- `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초 후 취소 신호를 보내, 카운트다운이 도중에 멈추도록 한다.
- 취소되면 "취소되었습니다" 를 출력한다.
예상 출력
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` 로 측정해서 출력한다 (가장 긴 작업과 비슷해야 함).
예상 출력 (수치 예시)
[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
<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
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
<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
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;
}
직접 해 보기
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` 의 정체를 컴파일 타임에 잡아 줍니다.