C# async/await 실무 판단표 - Task.Run과 ConfigureAwait

· 업데이트: · · C#, async/await, .NET, 설계

수정 이력(9건, 최종 수정 2026년 09월 03일)

이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.

permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635095)
리뷰 지적을 반영해, 오늘 추가한 그림 중 너비가 너무 컸던 것을 세로 구성으로 고치고, 일부 그림과 캡션 표현을 본문 기술에 맞게 바로잡았습니다. 본문 문장은 바꾸지 않았습니다.
각 패턴의 판단 흐름을 그림으로도 따라갈 수 있도록 Mermaid 그림을 17점 추가했습니다(본문 500〜750자당 1그림 규약에 맞춘 것입니다). 기존 그림에는 일련번호가 있는 캡션을 붙였습니다. 본문 문장은 바꾸지 않았습니다.
글 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
외부 리뷰(1283건)에 대응해 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참조하세요.
미러를 경쟁시키는 예에서, 호출 측의 취소를 미러의 실패로 세고 있던 것을 고쳤습니다. `cancellationToken`이 취소되면 모든 태스크가 `OperationCanceledException`으로 끝나므로, 그것을 `failures`에 쌓으면 마지막에 `AggregateException`이 되어, 이용자의 중단이나 타임아웃이 「전체 미러 장애」로 기록·재시도됩니다. `catch` 맨 앞에서 `ThrowIfCancellationRequested()`를 호출해, 취소는 그대로 밖으로 던지도록 했습니다.
미러를 경쟁시키는 예에서, 승자를 확인하기 전에 나머지를 취소하고 있던 것을 고쳤습니다. `Task.WhenAny`가 반환하는 것은 맨 먼저 완료된 태스크이지, 맨 먼저 성공한 태스크가 아닙니다. 가장 빠른 미러가 404나 연결 끊김으로 실패한 경우에도 승자로 돌아오므로, 아직 살아있는 나머지 미러를 스스로 멈춘 뒤에 실패한 예외를 다시 던지게 됩니다. 미러를 여러 개 준비한 의미가 사라지는 깨지는 방식입니다. 완료된 태스크를 하나씩 꺼내 `await`하고, 성공했을 때만 나머지를 취소하는 형태로 고쳤으며, 전멸했을 때는 개별 실패를 모아 던지도록 했습니다.
중복되어 있던 7장의 판단표를 삭제하고 3.1 참조로 바꿨습니다. ASP.NET Core에 대해 「스레드 풀 위에서 동작하므로 무의미」라고 했던 기술을 「처리량이 늘지 않는다」「지켜야 할 특별한 스레드가 없다」 두 가지로 다시 정리했습니다. 용어표에 `IHostedService`와 `BackgroundService`를 추가하고, `cts.Cancel()`을 try 앞에 두는 이유와 목적별 읽기 안내를 추가했습니다.
최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635094)

이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.

Go Komura (2026). 「C# async/await 실무 판단표 - Task.Run과 ConfigureAwait」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635094 https://comcomponent.com/ko/blog/csharp-async-await-best-practices/

DOI(최신 버전)
10.5281/zenodo.21635094
DOI(이 버전)
10.5281/zenodo.22217413

C#의 async / await는 일상적으로 쓰지만, 실무에서 헷갈리기 쉬운 지점은 문법 자체보다 어떤 장면에서 어떤 작성 방식을 고를지입니다. 특히 검색이 많은 것은 Task.Run을 언제 쓸지, ConfigureAwait(false)를 어디에 붙일지, fire-and-forget을 허용해도 되는지 같은 판단입니다.

  • I/O 대기인데 Task.Run으로 감싸 버리는 경우
  • 독립된 처리인데 한 건씩 직렬로 await하는 경우
  • fire-and-forget을 쉽게 넣어 예외나 종료 타이밍을 놓치는 경우
  • ConfigureAwait(false)를 어디서나 똑같이 붙이는 경우
  • ValueTask를 「가벼워 보이니까」라는 이유만으로 고르는 경우

이 부분은 개별적으로 외우기보다 먼저 처리의 종류를 가려내는 쪽에서 들어가는 편이 덜 헤맵니다.

이 글에서는 주로 .NET 6 이후의 일반적인 C# / .NET 앱 개발을 전제로, async / await 주변의 작성 방식을 판단하기 쉬운 순서로 정리합니다.

상정하는 것은 예를 들어 다음과 같은 개발입니다.

  • WinForms / WPF 등의 데스크톱 앱
  • ASP.NET Core의 Web 앱 / API
  • worker / 백그라운드 서비스
  • 콘솔 앱
  • 재사용 가능한 클래스 라이브러리

덧붙여, 이 글에 나오는 코드는 빌드·실행할 수 있는 샘플 세트(라이브러리, 콘솔 데모, 판단표의 각 패턴을 검증하는 유닛 테스트)으로 GitHub에 공개하고 있습니다.

csharp-async-await-best-practices - komurasoft-blog-samples (GitHub)

이 글의 읽는 법

꽤 긴 글이므로, 목적별 입구를 먼저 둡니다.

목적 읽는 곳
판단표만 보고 싶다 3.1의 표와 그림. 이 글의 중심은 여기입니다
각 패턴의 작성 방식을 알고 싶다 3.2 이후. 3.1 표의 행과 1대1로 대응합니다
내 코드를 다시 보고 싶다 5.의 안티패턴 표
리뷰 관점을 맞추고 싶다 6.의 체크리스트
어쨌든 결론만 1.

목차

  1. 먼저 결론(한 줄로)
  2. 이 글에서 쓰는 말
    • 2.1. 먼저 구별하고 싶은 말
    • 2.2. 자주 나오는 말
  3. 먼저 보는 판단표
    • 3.1. 전체 그림
    • 3.2. I/O 대기라면 async API를 그대로 await한다
    • 3.3. CPU 부하가 무거우면 Task.Run을 쓸 장소를 고른다
    • 3.4. 독립된 여러 처리라면 Task.WhenAll
    • 3.5. 먼저 끝난 것을 쓴다면 Task.WhenAny
    • 3.6. 건수가 많고 병렬 수를 제한하고 싶다면 Parallel.ForEachAsync 또는 SemaphoreSlim
    • 3.7. 순서대로 흐르게 하고 싶다면 Channel<T>
    • 3.8. 일정 간격으로 돌리고 싶다면 PeriodicTimer
    • 3.9. 순차로 도착하는 데이터라면 IAsyncEnumerable<T>
    • 3.10. 비동기로 폐기하고 싶다면 await using
    • 3.11. await를 넘는 상호 배제라면 SemaphoreSlim
    • 3.12. UI / 앱 코드 / 라이브러리에서 await 작성 방식을 나눈다
  4. 작성 방식의 기본 규칙
    • 4.1. 반환값은 우선 Task / Task<T>
    • 4.2. async void는 이벤트 핸들러만
    • 4.3. CancellationToken을 받아 하위 호출로 넘긴다
    • 4.4. 비동기 API는 끝까지 비동기로 잇는다
    • 4.5. LINQ로 태스크를 만들 때는 ToArray / ToList로 확정한다
  5. 자주 있는 안티패턴
  6. 리뷰 시 체크리스트
  7. 대략적인 구분
  8. 정리
  9. 참고 자료

이 글의 지식 맵

이 기사는 C#의 async/await에서 가장 먼저 봐야 할 것은 처리가 I/O-bound인지 CPU-bound인지의 구별이며, I/O 대기는 async API를 그대로 await하고 CPU 계산은 UI라면 Task.Run, ASP.NET Core의 리퀘스트 처리에서는 피해야 한다고 정리합니다. ConfigureAwait(false)는 SynchronizationContext를 가지지 않는 ASP.NET Core보다, UI나 앱 고유 컨텍스트에 의존하지 않는 범용 라이브러리 코드에서 유력해집니다. 독립된 여러 I/O 처리는 Task.WhenAll이나 Task.WhenAny로 묶고, 건수가 많으면 Parallel.ForEachAsync나 SemaphoreSlim으로 병렬 수를 제한하며, 수명을 호출원으로부터 떼어내고 싶은 처리는 fire-and-forget이 아니라 Channel와 BackgroundService로 관리해야 한다고 설명합니다.

C# async/await 실무 판단의 지식 맵I/O-bound인지 CPU-bound인지의 구별이 Task.Run이나 ConfigureAwait(false)의 사용 구분, WhenAll·WhenAny·Channel 등의 선택과 어떻게 이어지는지 보여주는 그림입니다.사용은 비권장권장되는 대응권장되는 대응사용은 비권장원인이 될 수 있다이용한다권장되는 대응사용은 비권장이용한다권장되는 대응사용은 비권장권장되는 대응이용한다권장되는 대응권장되는 대응이용한다권장되는 대응구현을 담당한다사용은 비권장권장되는 대응이용한다권장되는 대응전제로 한다사용은 비권장전제로 한다권장되는 대응I/O-bound 처리CPU-bound 작업Task.RunUI 스레드 컨텍스트ASP.NET Core의 요청 처리동기 대기 혼입(sync-over-async)스레드 풀 기아(starvation)SynchronizationContextConfigureAwait(false)범용 라이브러리 코드CancellationToken(.NET)async void이벤트 핸들러 메서드일반 비동기 메서드Task.WhenAllTask.WhenAnyParallel.ForEachAsyncSemaphoreSlimChannelbackpressureBackgroundServiceIHostedServicefire-and-forget호출자의 수명과 분리된 백그라운드 작업PeriodicTimerIAsyncEnumerableawait usingValueTask

그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 26건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle

1. 먼저 결론(한 줄로)

  • async / await대기 중에 스레드를 막지 않기 위한 작성 방식이지, 무엇이든 자동으로 빠르게 만들거나 알아서 다른 스레드로 옮겨 주는 장치가 아닙니다
  • 먼저 그 처리가 I/O 대기인지 CPU 계산인지를 나눕니다
  • I/O 대기라면 async API를 그대로 await하는 것이 기본입니다
  • CPU 계산이라면 어디서 그 계산을 돌려야 하는지를 생각합니다. UI라면 Task.Run이 도움이 되는 경우가 있지만, ASP.NET Core의 요청 처리에서는 Task.Run을 바로 await하는 작성 방식은 기본적으로 피합니다
  • 독립된 여러 처리는 직렬로 await하기보다 Task.WhenAll을 먼저 검토합니다
  • 건수가 많을 때는 Task.WhenAll로 전부 동시에 던지지 말고 병렬 수의 상한을 정합니다
  • fire-and-forget은 쉬워 보여도 관리가 어렵습니다. 정말로 호출 측과 수명을 분리한다면 Channel이나 HostedService 같은 관리되는 곳으로 보내는 편이 안정적입니다
  • 반환값은 우선 Task / Task<T>입니다. ValueTask는 측정해서 필요성이 보인 뒤에 고릅니다
  • ConfigureAwait(false)범용 라이브러리 코드에서는 유력하지만, UI나 앱 측 코드에서는 우선 보통의 await로 충분합니다
  • async void이벤트 핸들러 이외에서는 쓰지 않습니다

결국 async / await 주변에서 가장 중요한 것은 「일단 Task.Run」「일단 fire-and-forget」「일단 ValueTask」로 가지 않는 일입니다.

우선

  1. 그 처리는 무엇을 기다리고 있는가
  2. 누가 그 처리의 수명을 갖는가
  3. 동시 실행 수를 어디서 제어하는가

이 세 가지를 보면 헷갈림이 꽤 줄어듭니다.

헷갈림을 줄이는 세 가지 질문그 처리는 무엇을 기다리고 있는가, 누가 그 처리의 수명을 갖는가, 동시 실행 수를 어디서 제어하는가라는 세 가지를 차례로 보면 async/await 주변의 작성 방식에 대한 헷갈림이 줄어든다는 것을 나타낸다.무엇을 기다리고 있는가누가 수명을 갖는가동시 실행 수를 어디서 제어하는가작성 방식의 헷갈림이 꽤 줄어든다일단 Task.Run을 피한다

그림 1: 「일단」으로 고르지 말고, 대기의 종류·수명·동시 실행 수 세 가지를 먼저 본다.

2. 이 글에서 쓰는 말

2.1. 먼저 구별하고 싶은 말

처음에 이 둘을 나누면 혼란이 꽤 줄어듭니다.

여기에서의 의미
I/O-bound HTTP, DB, 파일, 소켓 등 외부의 완료 대기가 중심인 처리
CPU-bound 압축, 이미지 처리, 해시 계산, 무거운 변환 등 CPU 계산 그 자체가 중심인 처리

async / await가 특히 효과를 내는 것은 I/O 대기 쪽이며, 기다리는 동안 스레드를 다른 일로 돌려줄 수 있습니다. 한편 CPU 계산은 「대기」가 아니라 실제로 계산하고 있는 시간이므로 어느 스레드에서 돌릴지병렬 수를 어떻게 정할지가 주제가 됩니다.

I/O-bound와 CPU-bound의 차이외부의 완료 대기가 중심인 I/O-bound는 await로 기다리는 동안 스레드를 다른 일로 돌려줄 수 있는 반면, 계산 그 자체가 중심인 CPU-bound는 어느 스레드에서 돌릴지와 병렬 수가 주제가 된다는 구별을 나타낸다.I/O-bound(외부의 완료 대기)기다리는 동안 스레드를 다른 곳으로 돌려줄 수 있다async/await가 특히 효과를 낸다CPU-bound(계산 그 자체)어느 스레드에서 돌릴지가 주제병렬 수의 정하는 방식도 주제가 된다

그림 2: 먼저 나누는 것은 이 둘. 대기가 중심인지, 계산이 중심인지에 따라 생각할 주제가 바뀐다.

2.2. 자주 나오는 말

여기에서의 의미
블로킹 완료를 기다리는 동안 그 스레드를 계속 점유하는 것
fire-and-forget 호출 측이 완료를 기다리지 않는 기동 방법
SynchronizationContext await의 나머지를 어디서 돌릴지」를 갖고 있는 장치. 자세한 내용은 아래 보충을 참조
backpressure 밀어 넣는 속도가 너무 빠를 때 쓰기 측을 기다리게 해서 과도한 증가를 막는 장치
IHostedService .NET의 범용 호스트가 시작 시 StartAsync, 중지 시 StopAsync를 호출해 주는 장치. 앱의 수명에 맞춰 동작하는 상주 처리의 입구입니다
BackgroundService IHostedService를 구현한 추상 클래스. ExecuteAsync(CancellationToken)을 하나 override하기만 하면 상주 루프를 쓸 수 있습니다. AddHostedService<T>()로 등록합니다(3.7)

Channel<T>를 쓸 때 그 소비 측(컨슈머)을 두는 곳이 이 BackgroundService입니다. 3.7에서는 「큐에 쌓고 전용 컨슈머가 순서대로 처리하는」 형태를 다루지만, 그 컨슈머의 수명을 앱의 시작·중지에 맞춰 관리해 주는 곳이 여기라는 관계가 됩니다.

SynchronizationContext 보충

ConfigureAwait(false) 이야기(3.12)는 결국 이 한 단어의 이해로 모입니다.

  • await는 이어지는 코드(continuation)를 실행할 때 대기에 들어간 시점의 SynchronizationContext를 잡아, 그곳으로 되돌려 실행합니다(SynchronizationContext가 설정되어 있지 않으면, 기본이 아닌 TaskScheduler가 쓰이고 있는지를 봅니다)
  • WinForms / WPF는 UI 스레드로 처리를 다시 던지는 SynchronizationContext를 갖고 있습니다. 그래서 await 뒤에 컨트롤을 보통처럼 만질 수 있습니다
  • ASP.NET Core에는 SynchronizationContext가 없습니다. 그래서 「돌아갈 곳」이 없고, await의 나머지는 비어 있는 스레드 풀의 스레드에서 그대로 동작합니다
  • ConfigureAwait(false)는 이렇게 잡은 문맥으로 돌아가지 않고 나머지를 실행해도 된다는 지정입니다
await의 continuation이 돌아가는 곳의 차이await는 대기에 들어간 시점의 SynchronizationContext를 잡아 continuation을 그곳으로 되돌리므로, WinForms/WPF에서는 UI 스레드로 돌아가 컨트롤을 만질 수 있지만 ASP.NET Core에는 돌아갈 곳이 없어 continuation은 스레드 풀에서 동작한다는 것을 나타낸다.await가 문맥을 잡는다WinForms나 WPF의 UI 스레드ASP.NET Core는 돌아갈 곳이 없다await 뒤에 UI를 만질 수 있다나머지는 스레드 풀에서 동작한다

그림 3: ConfigureAwait(false) 이야기는 await의 나머지가 어디로 돌아가는가라는 한 점으로 모인다.

여기서 「UI 코드에서는 붙이지 않는 편이 자연스럽다」「ASP.NET Core의 앱 코드에서는 붙여도 안 붙여도 큰 차이가 없다」「어느 쪽에서 실행될지 모르는 범용 라이브러리에서는 붙일 가치가 있다」는 3.12의 결론이 나옵니다. 자세한 배경은 9. 참고 자료의 ConfigureAwait FAQ가 가장 잘 정리되어 있습니다.

특히 중요한 것은 비동기와 병렬은 별개라는 점입니다.

  • 비동기: 기다리는 방식의 이야기
  • 병렬: 동시에 진행하는 이야기

이 둘이 섞이면 Task.Run을 어디서나 쓰고 싶어집니다. 여기가 첫 갈림길입니다.

비동기와 병렬은 별개비동기는 기다리는 방식의 이야기, 병렬은 동시에 진행하는 이야기이며, 이 둘이 섞이면 Task.Run을 어디서나 쓰고 싶어지므로 여기가 첫 갈림길이 된다는 것을 나타낸다.비동기(기다리는 방식의 이야기)섞이면 Task.Run 남용으로병렬(동시에 진행하는 이야기)여기가 첫 갈림길

그림 4: 비동기는 기다리는 방식, 병렬은 동시에 진행하는 이야기. 이 구별이 무너지면 Task.Run을 남용하기 쉽다.

3. 먼저 보는 판단표

3.1. 전체 그림

먼저 이 표부터 보면 대략의 방침이 정해집니다.

상황 먼저 쓰는 것 볼 포인트
HTTP / DB / 파일 등의 대기 async API를 그대로 await Task.Run으로 감싸지 않는다
UI를 멈추고 싶지 않은 무거운 계산 Task.Run CPU 계산을 UI 스레드에서 뺀다
ASP.NET Core의 요청 처리 plain await Task.Run을 바로 await하지 않는다
독립된 소수의 비동기 처리 Task.WhenAll 먼저 전부 시작한 뒤 한꺼번에 기다린다
먼저 끝난 것만 쓴다 Task.WhenAny 나머지의 취소나 예외 회수를 생각한다
건수가 많고 상한을 두고 싶다 Parallel.ForEachAsync / SemaphoreSlim 병렬 수를 명시한다
순서대로 흘리고 싶은 백그라운드 처리 Channel<T> 경계가 있는 큐와 backpressure를 생각한다
일정 간격의 비동기 처리 PeriodicTimer 1 타이머 1 컨슈머를 지킨다
결과를 조금씩 처리하고 싶다 IAsyncEnumerable<T> / await foreach 전건 완료를 기다리지 않고 진행한다
비동기 폐기가 필요 await using IAsyncDisposable을 쓴다
await를 넘는 상호 배제 SemaphoreSlim.WaitAsync try/finally에서 반드시 Release
범용 라이브러리 코드 ConfigureAwait(false)를 검토 UI / 앱 고유 컨텍스트에 의존하지 않는다
아니오UI 이벤트 / 데스크톱ASP.NET Core의 요청worker / 백그라운드아니오전부 끝날 때까지 기다린다먼저 끝난 것을 쓴다건수가 많다순서대로 흐르게 한다일정 간격순차 스트림하고 싶은 처리외부 I/O를 기다리는가?async API를 그대로 await한다CPU 계산이 무거운가?어디서 돌리는가?Task.Run을 검토한다Task.Run으로 감싸지 않는다필요하면 다른 워커나 큐로 보낸다그 자리에서 실행하거나병렬도를 명시한다여러 일을 다루는가?Task.WhenAllTask.WhenAnyParallel.ForEachAsync또는 SemaphoreSlimChannel&lt;T&gt;PeriodicTimerIAsyncEnumerable&lt;T&gt;

그림 5: 판단의 전체 그림. 먼저 I/O 대기인지 CPU 계산인지를 나누고, 여러 일은 묶는 방식으로 도구를 고른다.

이하, 각 패턴을 순서대로 봅니다.

3.2. I/O 대기라면, async API를 그대로 await한다

가장 기본이 되는 패턴입니다.

예를 들어 HTTP, DB, 파일 읽기/쓰기 등은 먼저 async 판 API가 있는지를 봅니다. 있으면 그것을 그대로 await하는 것이 기본입니다.

public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
    return await File.ReadAllTextAsync(path, cancellationToken);
}

이때 피하고 싶은 것은 이미 async인 I/O를 Task.Run으로 감싸는 일입니다.

// 좋지 않은 예
public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
    return await Task.Run(() => File.ReadAllTextAsync(path, cancellationToken), cancellationToken);
}

이것은 I/O 대기를 다른 스레드로 다시 던지고 있을 뿐이며, 정리가 어려워지는 것에 비해 이득이 없습니다.

  • I/O 대기라면 Task.Run은 필요 없습니다
  • 먼저 async API를 찾습니다
  • token을 받았다면 그대로 하위 호출로 넘깁니다

여기는 상당히 정석입니다.

I/O 대기의 기본 형태HTTP나 DB나 파일의 대기에서는 먼저 async 판 API를 찾아 그대로 await하는 것이 기본이며, 이미 async인 I/O를 Task.Run으로 감싸는 것은 다른 스레드로 다시 던지고 있을 뿐 이득이 없음을 나타낸다.HTTP·DB·파일의 대기먼저 async 판 API를 찾는다그대로 await한다Task.Run으로 감싼다다시 던지고 있을 뿐 이득이 없다

그림 6: I/O 대기의 기본은 「async API를 그대로 await」. Task.Run으로 감싸는 것은 피한다.

3.3. CPU 부하가 무거우면, Task.Run을 쓸 장소를 고른다

Task.Run이 효과를 내는 때는 CPU 계산을 지금 스레드에서 빼고 싶을 때입니다.

예를 들어 UI 이벤트 핸들러에서 무거운 계산을 그대로 돌리면 화면이 멈춥니다. 이런 때는 Task.Run이 자연스럽습니다.

UI에서 Task.Run이 효과를 내는 방식UI 이벤트 핸들러에서 무거운 계산을 그대로 돌리면 화면이 멈추므로, Task.Run으로 CPU 계산을 UI 스레드에서 빼면 화면의 응답이 유지된다는, Task.Run이 효과를 내는 전형 장면을 나타낸다.UI 이벤트에서 무거운 계산을 돌린다화면이 멈춘다Task.Run으로 UI 스레드에서 뺀다화면은 계속 응답한다

그림 7: Task.Run이 효과를 내는 것은 비워야 할 특별한 스레드(UI 스레드)가 있을 때.

public Task<byte[]> HashManyTimesAsync(byte[] data, int repeat, CancellationToken cancellationToken)
{
    return Task.Run(() =>
    {
        cancellationToken.ThrowIfCancellationRequested();

        using var sha256 = System.Security.Cryptography.SHA256.Create();
        byte[] current = data;

        for (int i = 0; i < repeat; i++)
        {
            cancellationToken.ThrowIfCancellationRequested();
            current = sha256.ComputeHash(current);
        }

        return current;
    }, cancellationToken);
}

다만 여기서 중요한 것은 어디서 호출하고 있는가입니다.

  • WinForms / WPF 등의 UI: Task.Run이 유효한 장면이 있습니다
  • ASP.NET Core의 요청 처리: Task.Run을 바로 await하는 작성 방식은 기본적으로 피합니다
  • worker / 백그라운드 처리: 그 자리에서 처리하거나 병렬도를 설계합니다

ASP.NET Core의 요청 처리에 Task.Run을 한 장 끼우고 직후에 await해도, 불필요한 스케줄링만 늘어나기 쉽습니다.

여기는 오해하기 쉬운 곳이므로 이유를 나눠 적어둡니다. 「스레드 풀 위에서 동작하므로 Task.Run은 무의미하다」가 아닙니다(스레드 풀 위에서 동작하는 것 자체는 UI 앱의 백그라운드 처리에서도 같습니다). 요점은 다음 두 가지입니다.

  • 처리량은 늘지 않습니다. CPU 계산의 총량은 바뀌지 않고, 돌리는 장소가 다른 스레드 풀의 스레드로 옮겨질 뿐입니다. 동시에 처리할 수 있는 요청 수가 늘어나는 것은 아닙니다
  • 대기의 해방도 되지 않습니다. UI 앱에서 Task.Run이 효과를 내는 것은 비워야 할 특별한 스레드가 하나 있기 때문(UI 스레드)입니다. 서버 측에는 그 하나가 없습니다. 원래 스레드는 분명히 해방되지만, 대신 다른 스레드가 같은 시간만큼 계산으로 채워지므로 상쇄되면 제로입니다

남는 것은 큐로의 투입과 스레드 전환 비용, 그리고 「어느 스레드에서 동작하고 있는지」가 한 단 알기 어려워지는 일입니다. 그래서 피합니다.

ASP.NET Core에서 Task.Run을 피하는 이유요청 처리에서 Task.Run을 끼워도 계산의 총량은 바뀌지 않고, UI처럼 비워야 할 특별한 스레드도 없으므로 상쇄되면 제로이며, 남는 것은 스레드 전환 비용과 읽기 어려움뿐임을 나타낸다.요청 처리에서 Task.Run을 끼운다계산의 총량은 바뀌지 않는다비워야 할 특별한 스레드가 없다상쇄되면 제로전환 비용과 읽기 어려움이 남는다

그림 8: 서버 측에서 Task.Run을 바로 await해도 처리량도 대기의 해방도 얻지 못한다.

그래서 ASP.NET Core에서는 이렇게 생각하는 편이 맞습니다.

  • I/O 대기라면 plain await
  • 짧은 CPU 처리라면 그 자리에서 실행
  • 긴 처리나 요청 수명에서 분리하고 싶은 처리라면 큐나 HostedService로 보낸다

덧붙여 sync 판만 있는 API를 UI에서 호출할 때 UI 응답성 때문에 Task.Run을 쓰는 경우는 있습니다. 다만 이것은 「비동기 I/O」가 아니라 스레드 하나를 점유해서 우회하고 있는 것일 뿐입니다. ASP.NET Core 같은 서버 측에서는 이 빠져나가는 방식은 기본적으로 잘 늘지 않습니다.

3.4. 독립된 여러 처리라면, Task.WhenAll

독립된 비동기 처리가 여러 개 있는데, 이런 식으로 한 건씩 기다리는 코드는 자주 나옵니다.

// 독립인데 직렬이 되어 있는 예
string a = await _httpClient.GetStringAsync(urlA, cancellationToken);
string b = await _httpClient.GetStringAsync(urlB, cancellationToken);
string c = await _httpClient.GetStringAsync(urlC, cancellationToken);

이들이 서로 의존하지 않는다면 먼저 전부 시작하고, 마지막에 한꺼번에 기다리는 편이 자연스럽습니다.

public async Task<string[]> DownloadAllAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
    Task<string>[] tasks = urls
        .Select(url => _httpClient.GetStringAsync(url, cancellationToken))
        .ToArray();

    return await Task.WhenAll(tasks);
}

포인트는 ToArray()입니다. LINQ는 지연 실행이므로 Select만 해서는 아직 열거되지 않은 경우가 있습니다. ToArray()ToList()로 일단 확정해 두면, 모든 태스크가 그 시점에 시작됩니다.

Task 3Task 2Task 1호출 측Task 3Task 2Task 1호출 측시작시작시작await Task.WhenAll(...)완료완료완료

그림 9: 독립된 태스크는 먼저 전부 시작한 뒤 Task.WhenAll로 한꺼번에 완료를 기다린다.

이 패턴이 맞는 것은

  • 건수가 적거나 중간 정도
  • 전건을 한꺼번에 기다리고 싶다
  • 상한 없이 동시에 돌려도 문제없다

는 경우입니다.

건수가 많다면 다음 3.6처럼 병렬 수의 상한을 두는 편이 안전합니다.

3.5. 먼저 끝난 것을 쓴다면, Task.WhenAny

예를 들어 여러 미러 중 먼저 응답한 것을 쓰고 싶은 장면에서는 Task.WhenAny가 이해하기 쉽습니다.

public async Task<byte[]> DownloadFromFirstMirrorAsync(
    IReadOnlyList<string> urls,
    CancellationToken cancellationToken)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

    List<Task<byte[]>> pending = urls
        .Select(url => _httpClient.GetByteArrayAsync(url, cts.Token))
        .ToList();

    var failures = new List<Exception>();

    try
    {
        while (pending.Count > 0)
        {
            Task<byte[]> finished = await Task.WhenAny(pending);
            pending.Remove(finished);

            try
            {
                byte[] data = await finished;   // 성공했을 때만 여기를 빠져나간다
                cts.Cancel();                   // 승자가 정해진 뒤에 나머지를 멈춘다
                return data;
            }
            catch (Exception ex)
            {
                // 호출 측이 멈춘 것이라면, 그것은 「미러의 실패」가 아니다.
                // 여기를 그냥 지나치면 모든 태스크의 취소가 실패로 쌓여,
                // 마지막에 AggregateException이 되어 장애와 구분이 안 된다
                cancellationToken.ThrowIfCancellationRequested();

                // 이 미러는 안 됐다. 나머지에 아직 희망이 있으므로 계속한다
                failures.Add(ex);
            }
        }
    }
    finally
    {
        cts.Cancel();   // 예외로 빠져나갔을 때도 남아 있는 다운로드는 멈춘다

        try
        {
            await Task.WhenAll(pending);
        }
        catch
        {
            // 승자 이외의 취소나 실패를 회수한다
        }
    }

    throw new AggregateException("모든 미러에서의 가져오기에 실패했습니다.", failures);
}

이 코드에서 순서에 의미가 있는 것은 취소를 「승자가 정해진 뒤에」 내고 있다는 점입니다.

  • Task.WhenAny가 반환하는 것은 먼저 완료된 태스크이지, 먼저 성공한 태스크가 아닙니다. 가장 빠른 미러가 404나 연결 끊김으로 실패했을 때도 그것이 「승자」로 돌아옵니다
  • 여기서 결과를 보기 전에 취소하면 아직 살아있는 나머지 미러를 스스로 멈춘 뒤에, 실패한 승자의 예외를 다시 던지게 됩니다. 미러를 여러 개 준비한 의미가 통째로 사라지는, 가장 나쁜 깨지는 방식입니다
  • 그래서 완료된 태스크를 하나씩 꺼내 await하고, 성공했을 때만 나머지를 취소합니다. 실패라면 그 태스크를 후보에서 빼고 다음 완료를 기다립니다
  • Cancel()요구를 낼 뿐이며, 상대가 멈출 때까지 기다리지 않습니다. 그래서 finally에서 나머지를 기다리고, 취소나 실패의 예외를 여기서 관측합니다. 이것을 빼면 아무도 보지 않는 예외가 태스크 측에 남습니다
  • 전부 실패했을 때는 개별 실패를 모아 던집니다. 첫 번째 예외만 던지면 「어느 미러가 어떻게 안 됐는지」가 사라집니다
  • 호출 측의 취소만은 실패로 세지 않고 그대로 밖으로 던집니다. cancellationToken이 취소되면 모든 태스크가 OperationCanceledException으로 끝나므로, 이것을 failures에 쌓으면 마지막에 AggregateException이 되어 이용자의 중단이나 타임아웃이 「전체 미러 장애」로 기록·재시도됩니다. catch 맨 앞에서 ThrowIfCancellationRequested()를 호출하고, 취소는 OperationCanceledException 그대로 반환합니다

여기서 주의할 것은 WhenAny승자를 하나만 반환할 뿐이라는 점입니다. 나머지 처리는 아무것도 하지 않으면 그대로 계속 동작합니다.

그래서

  • 나머지는 취소하고 싶은가
  • 예외는 관측해 두고 싶은가

를 먼저 정해 둘 필요가 있습니다.

Task.WhenAny는 편리하지만 WhenAll보다 설계가 조금 늘어납니다. 「처음 하나만 되면 된다」는 경우에만 고르면 이해하기 쉽습니다.

WhenAny에서 승자를 확인한 뒤에 나머지를 멈추는 흐름Task.WhenAny가 반환하는 것은 먼저 완료된 태스크이지 성공한 태스크가 아니므로, 완료를 하나씩 await해서 성공했을 때만 나머지를 취소하고, 실패라면 후보에서 빼고 다음 완료를 기다리며, 전부 실패하면 실패를 모아 던지는 흐름을 나타낸다.성공실패아니오WhenAny로 첫 완료를 얻는다그 태스크는 성공했는가나머지를 취소하고 반환한다후보에서 빼고 실패를 기록한다후보는 남아 있는가실패를 모아 던진다

그림 10: 「먼저 완료」는 「먼저 성공」이 아니다. 승자의 결과를 확인한 뒤에 나머지를 멈춘다.

3.6. 건수가 많고 병렬 수를 제한하고 싶다면, Parallel.ForEachAsync 또는 SemaphoreSlim

Task.WhenAll은 만든 태스크를 전부 동시에 돌립니다. 그래서 대상 건수가 많으면 HTTP 연결, DB 연결, 메모리 사용량, 외부 서비스에 대한 부하가 한꺼번에 늘어납니다.

이런 때는 동시에 몇 건까지 돌릴지를 정하는 편이 안정적입니다.

Parallel.ForEachAsync는 그 의도가 상당히 읽기 쉽습니다.

public async Task DownloadAndSaveAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
    var options = new ParallelOptions
    {
        MaxDegreeOfParallelism = 8,
        CancellationToken = cancellationToken
    };

    await Parallel.ForEachAsync(
        urls.Select((url, index) => (url, index)),
        options,
        async (item, token) =>
        {
            string html = await _httpClient.GetStringAsync(item.url, token);
            string path = Path.Combine("cache", $"{item.index}.html");
            await File.WriteAllTextAsync(path, html, token);
        });
}

이 패턴이 맞는 것은

  • 건수가 많다
  • 각 항목의 처리는 독립되어 있다
  • 다만 전건 일제는 피하고 싶다

는 경우입니다.

한편 더 자유롭게 제어하고 싶다면 SemaphoreSlim을 쓰는 방법도 있습니다. 예를 들어 「특정 외부 API에는 동시에 4건까지」 같은 제어입니다.

  • 몇 건이면 Task.WhenAll
  • 대량 건수라면 Parallel.ForEachAsyncSemaphoreSlim

이라는 구분으로 크게 빗나가지 않습니다.

건수에 따른 병렬 묶는 방식의 구분몇 건의 독립 처리라면 Task.WhenAll로 전부 동시에 돌려도 되지만, 건수가 많으면 연결이나 메모리나 외부 부하가 한꺼번에 늘어나므로 Parallel.ForEachAsync나 SemaphoreSlim으로 동시 실행 수의 상한을 정한다는 것을 나타낸다.몇 건 정도많다대상의 건수는 많은가Task.WhenAll로 일제히병렬 수의 상한을 정한다Parallel.ForEachAsyncSemaphoreSlim으로 자유롭게 제어

그림 11: 전건을 일제히 던져도 되는지가 갈림길. 많다면 동시 실행 수를 명시한다.

3.7. 순서대로 흐르게 하고 싶다면, Channel<T>

「지금 당장 끝나지 않아도 되지만 확실히 처리하고 싶은」 일을 호출 측에서 분리하고 싶어지는 경우가 있습니다. 메일 송신, 로그 전송, Webhook 후처리, 파일 변환 등입니다.

이런 때 Task.Run을 던져 버리면

  • 예외를 어디서 볼 것인가
  • 종료 시에 기다릴 것인가
  • 건수가 늘었을 때 어디까지 받을 것인가

가 애매해집니다.

이런 종류의 일은 큐에 쌓고, 전용 컨슈머가 순서대로 처리하는 편이 관리하기 쉽습니다.

아니오producerWriteAsync큐에 빈자리가 있는가?Channel에 들어간다빌 때까지 기다린다consumer가 ReadAsync순서대로 await해서 처리

그림 12: 경계가 있는 Channel의 흐름. 큐가 가득 차면 쓰기 측이 기다림으로써 backpressure가 걸린다.

Channel<T>는 producer / consumer 형태를 상당히 자연스럽게 쓸 수 있습니다.

public sealed class BackgroundTaskQueue
{
    private readonly Channel<Func<CancellationToken, ValueTask>> _queue =
        Channel.CreateBounded<Func<CancellationToken, ValueTask>>(
            new BoundedChannelOptions(100)
            {
                FullMode = BoundedChannelFullMode.Wait
            });

    public ValueTask EnqueueAsync(
        Func<CancellationToken, ValueTask> workItem,
        CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(workItem);
        return _queue.Writer.WriteAsync(workItem, cancellationToken);
    }

    public ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(CancellationToken cancellationToken)
        => _queue.Reader.ReadAsync(cancellationToken);
}

이 예에서의 BoundedChannelFullMode.Wait큐가 가득 차면 쓰기 측을 기다리게 한다는 설정입니다. 이것이 backpressure입니다.

ASP.NET Core라면 이런 큐를 BackgroundService와 조합해 소비하는 형태가 이해하기 쉽습니다. 「진짜 fire-and-forget」보다 이쪽이 예외, 중지, 병렬 수, 상한을 다루기 쉬워집니다.

던져 버리기와 큐 관리의 대비Task.Run의 던져 버리기에서는 예외나 종료나 수용 건수가 애매해지는 반면, Channel에 쌓고 BackgroundService의 컨슈머가 순서대로 처리하는 형태라면 예외·중지·병렬 수·상한을 다루기 쉽다는 것을 나타낸다.그냥 Task.Run으로 던져 버린다예외·종료·상한이 애매하다Channel의 큐에 쌓는다BackgroundService가 소비한다예외·중지·병렬 수를 관리할 수 있다

그림 13: 호출 측과 수명을 분리한다면 fire-and-forget이 아니라 관리되는 곳으로 보낸다.

3.8. 일정 간격으로 돌리고 싶다면, PeriodicTimer

일정 간격의 비동기 처리라면 PeriodicTimer는 상당히 읽기 쉽습니다.

public async Task RunPeriodicAsync(CancellationToken cancellationToken)
{
    using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));

    while (await timer.WaitForNextTickAsync(cancellationToken))
    {
        await RefreshCacheAsync(cancellationToken);
    }
}

이 작성 방식의 좋은 점은

  • 콜백형 Timer보다 흐름을 따라가기 쉽다
  • await 기반으로 쓸 수 있다
  • 중지 시에 CancellationToken을 자연스럽게 쓸 수 있다

는 점입니다.

주의점으로서 PeriodicTimer하나의 타이머에 대해 동시에 여러 WaitForNextTickAsync를 날리지 않는다는 전제로 씁니다. 또한 처리 시간이 주기보다 길면 그 지연은 설계로서 다룰 필요가 있습니다. 타이머가 알아서 병렬화해서 따라잡아 주는 것은 아닙니다.

PeriodicTimer의 주기 루프WaitForNextTickAsync로 다음 주기를 기다리고, 처리를 await로 실행한 뒤 다시 대기로 돌아가는 루프이며, 중지는 CancellationToken으로 하고, 처리가 주기보다 길 때의 지연은 설계로서 다룰 필요가 있음을 나타낸다.WaitForNextTickAsync로 기다린다처리를 await로 실행CancellationToken으로 중지주기보다 긴 처리의 지연은 설계로 다룬다

그림 14: 1 타이머 1 컨슈머로 돌린다. 타이머는 알아서 병렬화해서 따라잡아 주지 않는다.

3.9. 순차로 도착하는 데이터라면, IAsyncEnumerable<T>

전건을 List<T>에 모은 뒤에 반환하기보다 도착한 것부터 순서대로 처리하고 싶은 장면이 있습니다.

  • 페이징된 API를 순서대로 읽는다
  • 파일의 행을 조금씩 읽는다
  • 스트리밍 결과를 그대로 흘려보낸다

이런 때는 IAsyncEnumerable<T>await foreach가 자연스럽습니다.

public async Task ProcessUsersAsync(CancellationToken cancellationToken)
{
    await foreach (User user in _userRepository.StreamUsersAsync(cancellationToken))
    {
        await ProcessUserAsync(user, cancellationToken);
    }
}

이 형태가 맞는 것은

  • 전건이 모일 때까지 기다리고 싶지 않다
  • 한 건씩 처리해 가고 싶다
  • 메모리에 전부 담고 싶지 않다

는 경우입니다.

반환값을 Task<List<T>>로 할지 IAsyncEnumerable<T>로 할지는 결과를 전부 모은 뒤에 쓸지, 도착한 순으로 쓸지로 정하면 이해하기 쉽습니다.

결과의 사용 방식으로 정하는 반환값의 형태결과를 전부 모은 뒤에 쓴다면 Task로 리스트 전체를 반환하고, 도착한 순으로 한 건씩 쓴다면 IAsyncEnumerable을 반환해 await foreach로 처리한다는 정하는 방식을 나타낸다.전부 모은 뒤에도착한 순으로결과를 어떻게 쓰는가Task로 리스트 전체를 반환한다IAsyncEnumerable로 흘려보낸다await foreach로 한 건씩 처리메모리에 전건을 담지 않는다

그림 15: 전건을 모을지, 도착한 순으로 흘려보낼지. 사용 방식이 반환값의 형을 정한다.

3.10. 비동기로 폐기하고 싶다면, await using

폐기 시에 플러시나 통신 종료 등의 비동기 처리가 필요한 형은 IAsyncDisposable을 구현합니다. 그 경우에는 using이 아니라 await using을 씁니다.

public async Task WriteFileAsync(string path, byte[] data, CancellationToken cancellationToken)
{
    await using var stream = new FileStream(
        path,
        FileMode.Create,
        FileAccess.Write,
        FileShare.None,
        bufferSize: 81920,
        useAsync: true);

    await stream.WriteAsync(data, cancellationToken);
}

포인트는

  • IAsyncDisposable이라면 await using
  • 「열기」는 동기여도 「닫기」가 비동기인 일은 흔하다

는 것입니다.

「쓰기는 async로 했는데 마지막 폐기만 동기」라는 어긋남을 피하고 싶을 때 효과가 있습니다.

3.11. await를 넘는 상호 배제라면, SemaphoreSlim

await를 넘는 코드에서는 lock 대신 SemaphoreSlim을 쓰는 장면이 있습니다.

public sealed class CacheRefresher
{
    private readonly SemaphoreSlim _gate = new(1, 1);

    public async Task RefreshAsync(CancellationToken cancellationToken)
    {
        await _gate.WaitAsync(cancellationToken);
        try
        {
            await RefreshCoreAsync(cancellationToken);
        }
        finally
        {
            _gate.Release();
        }
    }

    private static Task RefreshCoreAsync(CancellationToken cancellationToken)
        => Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
}

중요한 것은

  • WaitAsync로 들어간다
  • Releasefinally에서 반드시 호출한다

는 두 가지입니다.

「동시에 한 건만 넣고 싶다」「외부 API 호출을 동시에 3건까지로 하고 싶다」 는 장면에서는 SemaphoreSlim은 상당히 실용적입니다.

await를 넘는 상호 배제의 형태await를 넘는 코드에서는 lock 대신 SemaphoreSlim을 쓰고, WaitAsync로 들어가 await를 포함하는 처리를 수행한 뒤 finally에서 반드시 Release를 호출한다는 두 가지가 중요함을 나타낸다.lock은 await를 넘지 못한다대신 SemaphoreSlimWaitAsync로 들어간다await를 포함하는 처리를 수행한다finally에서 반드시 Release

그림 16: 입구는 WaitAsync, 출구는 finally의 Release. 이 쌍을 깨지 않는 것이 요점.

3.12. UI / 앱 코드 / 라이브러리에서 await 작성 방식을 나눈다

ConfigureAwait(false)는 언제든 붙이면 되는 것이 아닙니다.

대략적인 나누는 방식은 이렇습니다.

UI / 앱 코드await someAsync()원래 컨텍스트로 돌아가 계속범용 라이브러리await someAsync().ConfigureAwait(false)특정 컨텍스트로 돌아간다는 전제를 갖지 않는다

그림 17: 앱 측 코드는 plain await로 원래 문맥으로 돌아가고, 범용 라이브러리는 ConfigureAwait(false)를 검토한다.

  • UI / 앱 코드
    • 우선 보통의 await로 충분합니다
    • await 뒤에 UI 갱신이나 앱 측 컨텍스트에 의존하는 처리를 한다면 ConfigureAwait(false)는 붙이지 않는 편이 자연스럽습니다
  • ASP.NET Core의 앱 코드
    • 보통은 보통의 await로 충분합니다
    • ConfigureAwait(false)를 전체 관례로 무리하게 철저히 하지 않아도 됩니다
  • 범용 라이브러리 코드
    • UI나 앱 모델에 의존하지 않는다면 ConfigureAwait(false)는 유력합니다

  • 앱 측 코드는 plain await
  • 범용 라이브러리는 ConfigureAwait(false)를 검토

라고 기억해 두면 실무에서는 일단 막히지 않습니다.

4. 작성 방식의 기본 규칙

4.1. 반환값은 우선 Task / Task<T>

async 메서드의 반환값은 우선 이 순서로 생각합니다.

반환값 우선의 생각
Task 반환값 없는 async 메서드의 기본
Task<T> 값을 반환하는 async 메서드의 기본
ValueTask / ValueTask<T> 측정해서 필요성이 보인 뒤에 고른다

ValueTask는 편리해 보이지만 항상 Task보다 나은 것은 아닙니다. 구조체이므로 복사 비용이 있고, 사용 방식에도 제약이 있습니다.

특히 중요한 것은 ValueTask는 기본적으로 한 번만 await한다는 전제라는 점입니다. 쉽게 로컬 변수에 보관해 여러 번 기다리는 작성 방식에는 맞지 않습니다.

그래서 일상적인 앱 코드에서는 우선 Task / Task<T>로 충분합니다.

또한 메서드 이름에는 Async 접미사를 붙이는 편이 이해하기 쉽습니다.

public Task SaveAsync(CancellationToken cancellationToken)
{
    return Task.CompletedTask;
}

public Task<int> CountAsync(CancellationToken cancellationToken)
{
    return Task.FromResult(_count);
}

위처럼 await할 처리가 없다면 무리하게 async를 붙이지 않고 Task.CompletedTaskTask.FromResult를 반환하는 편이 자연스럽습니다.

4.2. async void는 이벤트 핸들러만

async void는 이벤트 핸들러 이외에서는 피하는 것이 기본입니다.

이유는 단순합니다.

  • 호출 측이 await할 수 없다
  • 완료를 기다릴 수 없다
  • 예외 처리가 어려워진다
  • 테스트하기 어렵다

때문입니다.

이벤트 핸들러만은 void가 필요하므로 거기서만 씁니다.

private async void SaveButton_Click(object? sender, EventArgs e)
{
    try
    {
        await SaveAsync(_saveCancellation.Token);
        _statusLabel.Text = "저장했습니다.";
    }
    catch (OperationCanceledException)
    {
        _statusLabel.Text = "취소했습니다.";
    }
    catch (Exception ex)
    {
        MessageBox.Show(this, ex.Message, "저장 오류");
    }
}

이벤트 핸들러에서는 안에서 예외를 잡아 UI 측으로 돌려보내는 곳까지 직접 쓴다는 의식이 중요합니다.

async void를 피하는 이유와 유일한 예외async void는 호출 측이 await할 수 없고 완료를 기다릴 수 없으며 예외 처리와 테스트가 어려워지므로 일반적인 메서드에서는 피하고, 시그니처상 void가 필요한 이벤트 핸들러에서만 쓰며 그 안에서 try/catch로 예외를 UI 측으로 돌려보낸다는 것을 나타낸다.async void 메서드await할 수 없다완료를 기다릴 수 없다예외와 테스트가 어렵다이벤트 핸들러는 예외try/catch해서 UI로 돌려보낸다

그림 18: 일반적인 메서드는 Task / Task를 반환한다. async void를 허용하는 것은 이벤트 핸들러뿐이다.

4.3. CancellationToken을 받아 하위 호출로 넘긴다

취소 가능한 조작이라면 CancellationToken을 받아 그대로 하위 호출로 넘깁니다.

public async Task<string> DownloadTextAsync(string url, CancellationToken cancellationToken)
{
    using HttpResponseMessage response = await _httpClient.GetAsync(url, cancellationToken);
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadAsStringAsync(cancellationToken);
}

여기서 자주 있는 것은 상위에서는 token을 받고 있는데 하위 호출로 넘기지 않는 패턴입니다. 이러면 「취소할 수 있어 보이는데 도중에 멈추지 않는」 코드가 되기 쉽습니다.

또한 타임아웃도 「대기만 상한을 두고 싶은지」, 「실제 처리 자체도 멈추고 싶은지」에 따라 의미가 바뀝니다.

  • 대기만 상한을 두고 싶다: WaitAsync
  • 실제 처리 자체도 멈추고 싶다: CancellationTokenSource.CancelAfter와 token의 전파

이 차이는 나중에 장애가 되기 쉬우므로 처음에 정해 두면 안정적입니다.

CancellationToken의 전파상위에서 받은 CancellationToken을 하위 API로 그대로 넘기면 도중에서도 멈추지만, 받기만 하고 넘기지 않으면 취소할 수 있어 보이는데 도중에 멈추지 않는 코드가 된다는 것을 나타낸다.상위에서 token을 받는다하위 API로 그대로 넘긴다도중에서도 제대로 멈춘다받기만 하고 넘기지 않는다멈추는 것처럼 보이지만 멈추지 않는다

그림 19: token은 받았으면 하위 호출로 넘긴다. 넘김 누락은 「멈추지 않는 취소」를 만든다.

4.4. 비동기 API는 끝까지 비동기로 잇는다

async / await를 쓴다면 가능한 한 끝까지 비동기로 잇는 편이 자연스럽습니다.

바꿔 쓰는 기준은 다음과 같습니다.

하고 싶어지는 작성 방식 바꿔 쓸 곳
Task.Result / Task.Wait() await
Task.WaitAll() await Task.WhenAll(...)
Task.WaitAny() await Task.WhenAny(...)
Thread.Sleep(...) await Task.Delay(...)

특히 UI나 ASP.NET Core에서는 동기적으로 기다리는 작성 방식이 섞이면 막히는 방식이 읽기 어려워집니다.

지금 C#에서는 async Task Main()도 쓸 수 있으므로, 콘솔 앱에서도 무리하게 동기화할 이유는 상당히 줄어 있습니다.

동기 대기를 섞지 않는 치환async/await를 쓴다면 끝까지 비동기로 잇고, Result나 Wait는 await로, Thread.Sleep은 Task.Delay로 바꾼다. 동기적으로 기다리는 작성 방식이 섞이면 막히는 방식이 읽기 어려워진다는 것을 나타낸다.끝까지 비동기로 잇는다Result나 Wait는 await로Thread.Sleep은 Task.Delay로동기적인 대기가 섞인다막히는 방식이 읽기 어려워진다

그림 20: async를 쓰기로 했다면 도중에 동기 대기로 떨어뜨리지 말고 끝까지 비동기로 잇는다.

4.5. LINQ로 태스크를 만들 때는 ToArray / ToList로 확정한다

Task.WhenAll이나 Task.WhenAny와 LINQ를 조합할 때는 ToArray() 또는 ToList()로 일단 확정하는 편이 안전합니다.

Task<User>[] tasks = userIds
    .Select(id => _userRepository.GetAsync(id, cancellationToken))
    .ToArray();

User[] users = await Task.WhenAll(tasks);

이유는 LINQ가 지연 실행이기 때문입니다. 「이미 전부 시작된 셈」으로 읽고 있었는데 실은 아직 열거되지 않았다는 것은 은근히 위험합니다.

  • 전건을 한꺼번에 기다린다면 ToArray()
  • 도중에 삭제·교체하고 싶다면 ToList()

라고 기억해 두면 구분하기 쉽습니다.

5. 자주 있는 안티패턴

안티패턴 무엇이 힘든지 우선의 치환
Task.Run(async () => await IoAsync()) I/O 대기를 쓸데없이 다시 던지고 있다 await IoAsync()
Task.Result / Wait() 스레드를 막는다. 막히기 쉽다 await
Thread.Sleep()을 async 흐름에 섞는다 대기 중에도 스레드를 점유한다 Task.Delay()
async void를 보통 메서드에서 쓴다 기다릴 수 없고, 예외 관리가 어렵다 Task / Task<T>
Task.WhenAll해야 할 장면에서 직렬 await 불필요하게 느려진다 먼저 전부 시작해 WhenAll
대량 건수를 WhenAll로 한꺼번에 던진다 부하가 튀어 오른다 Parallel.ForEachAsync / SemaphoreSlim
lock으로 await를 넘으려 한다 목적에 맞지 않는다 SemaphoreSlim.WaitAsync
fire-and-forget을 그냥 Task.Run으로 끝낸다 예외·중지·상한의 관리가 애매하다 Channel<T> / BackgroundService
ConfigureAwait(false)를 UI 코드에 기계적으로 붙인다 await 뒤의 UI 갱신이 깨지기 쉽다 plain await
ValueTask를 표준으로 한다 복잡함에 비해 이득이 나오지 않는 경우가 많다 우선 Task

이 표 안에서 특히 실무에서 자주 보이는 것은 다음 세 가지입니다.

  1. I/O인데 Task.Run
  2. 실은 독립인데 직렬 await
  3. fire-and-forget의 수명 관리가 없다

이 세 가지만 고쳐도 코드의 가독성은 상당히 개선됩니다.

실무에서 특히 자주 보는 세 가지 고치는 방식I/O인데 Task.Run으로 감싸고 있는 것, 실은 독립인데 직렬로 await하고 있는 것, fire-and-forget의 수명 관리가 없는 것이라는 실무에서 특히 자주 보는 세 가지를 각각의 치환처로 고치기만 해도 가독성이 개선된다는 것을 나타낸다.I/O인데 Task.Runasync API를 직접 await독립 처리의 직렬 await먼저 시작해 WhenAllfire-and-forget의 수명 방치Channel이나 BackgroundService로가독성이 상당히 개선된다

그림 21: 안티패턴 표 안에서도 먼저 이 세 가지를 고치는 것이 효과적이다.

6. 리뷰 시 체크리스트

async / await 주변의 코드 리뷰에서는 위에서부터 이 정도를 확인합니다.

  • 그 처리가 I/O-bound인지 CPU-bound인지를 처음에 말로 설명할 수 있는가
  • Task.Result / Task.Wait() / Thread.Sleep()이 남아 있지 않은가
  • I/O 대기를 Task.Run으로 감싸지 않았는가
  • 독립된 처리를 불필요하게 직렬 await하지 않았는가
  • 반대로 대량 건수를 무제한으로 WhenAll하지 않았는가
  • CancellationToken을 받고 있다면 하위 호출로 제대로 넘기고 있는가
  • async void가 이벤트 핸들러 이외에 없는가
  • fire-and-forget을 넣고 있다면 예외·중지·상한을 누가 관리하는지 정해져 있는가
  • SemaphoreSlim을 쓰고 있다면 Releasefinally에 들어가 있는가
  • ValueTask를 쓰고 있다면 측정상의 이유가 있는가, 한 번 await 전제로 되어 있는가
  • ConfigureAwait(false)의 유무가 그 코드의 종류와 맞는가
    • UI / 앱 코드라면 plain await
    • 범용 라이브러리라면 ConfigureAwait(false)를 검토

이 체크리스트는 팀에서 리뷰 관점을 맞추는 데도 쓰기 쉽습니다.

7. 대략적인 구분

구분 목록은 3.1의 판단표에 모아 두었습니다. 같은 표를 다시 싣기보다 필요할 때 그곳으로 돌아가는 편이 찾기 쉬우므로, 여기에는 표를 두지 않습니다.

  • 상황별로 「먼저 무엇을 쓸지」를 보고 싶다 → 3.1의 판단표
  • 각 패턴의 작성 방식을 보고 싶다 → 3.2 〜 3.12(3.1 표의 행과 대응합니다)

3.1 표에 들어 있지 않은 판단이 하나만 있습니다. 반환값의 형입니다. 여기는 상황이 아니라 메서드 설계의 이야기이므로 4.1에 정리해 두었습니다. 결론만 쓰면, 우선 Task / Task<T>를 고르고, ValueTask는 측정해서 필요성이 보인 뒤입니다.

8. 정리

async / await의 베스트 프랙티스는 세세한 기법을 여러 개 외운다기보다 처리의 종류에 맞춰 형을 고른다는 정리가 실무에서는 효과가 있습니다.

보는 순서는 대략 이렇게 됩니다.

  1. I/O 대기인지 CPU 계산인지를 나눈다
  2. I/O라면 async API를 그대로 await한다
  3. CPU 계산이라면 어디서 돌려야 하는지를 정한다
  4. 여러 처리라면 WhenAll / WhenAny / 병렬 수 제한을 고른다
  5. 요청 수명에서 빼려면 그냥 fire-and-forget이 아니라 큐화한다
  6. 반환값, 취소, 예외, 상호 배제, 컨텍스트의 다루기를 맞춘다

async / await는 작성 방식 자체가 간결한 만큼, 대충 쓰면 방침이 보이기 어려워집니다. 반대로

  • I/O는 I/O로 다룬다
  • CPU는 CPU로 다룬다
  • 백그라운드 처리는 백그라운드 처리로서 수명을 관리한다

이 세 가지를 나누기만 해도 상당히 읽기 쉬워집니다.

9. 참고 자료

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

C#에서 Task.Run은 언제 써야 하나요?
Task.Run이 효과를 내는 때는 CPU 계산을 지금 스레드에서 빼고 싶을 때입니다. 예를 들어 WinForms / WPF의 UI 이벤트 핸들러에서 무거운 계산을 그대로 돌리면 화면이 멈추므로, Task.Run으로 UI 스레드에서 빼는 편이 자연스럽습니다. 반면 ASP.NET Core의 요청 처리는 원래 ThreadPool 위에서 동작하므로, Task.Run을 끼운 직후에 await해도 불필요한 스케줄링만 늘어나기 쉽고, 기본적으로 피합니다. 긴 처리나 요청 수명에서 분리하고 싶은 처리는 큐나 HostedService로 보내는 편이 맞습니다.
I/O 처리를 await Task.Run()으로 감싸면 안 되나요?
HTTP, DB, 파일 읽기/쓰기 같은 I/O 대기는 async 판 API를 그대로 await하는 것이 기본이며, Task.Run으로 감쌀 필요는 없습니다. 이미 async인 I/O를 Task.Run으로 감싸는 것은 I/O 대기를 다른 스레드로 다시 던질 뿐이고, 읽기만 어려워질 뿐 이득이 없습니다. 다만 sync 판만 있는 API를 UI에서 호출할 때 응답성 때문에 Task.Run을 쓰는 경우는 있습니다. 이것은 비동기 I/O가 아니라 스레드 하나를 점유해서 우회하는 것일 뿐이므로, 서버 측에서는 잘 늘지 않는 빠져나가는 방법입니다.
ConfigureAwait(false)는 어디에 붙여야 하나요?
UI나 앱 측 코드에서는 우선 보통의 await로 충분합니다. await 뒤에 UI 갱신이나 앱 측 컨텍스트에 의존하는 처리를 한다면, ConfigureAwait(false)는 붙이지 않는 편이 자연스럽습니다. ASP.NET Core의 앱 코드도 보통은 보통의 await로 충분하고, 관례로 무리하게 철저히 할 필요는 없습니다. ConfigureAwait(false)가 유력한 곳은 UI나 앱 모델에 의존하지 않는 범용 라이브러리 코드입니다. 「앱 측은 plain await, 범용 라이브러리는 ConfigureAwait(false)를 검토」라고 기억해 두면 실무에서는 일단 막히지 않습니다.
async void는 왜 이벤트 핸들러 이외에서는 피해야 하나요?
async void는 호출 측이 await할 수 없고, 완료를 기다릴 수 없으며, 예외 처리가 어려워지고, 테스트도 하기 어렵기 때문입니다. 보통의 메서드는 Task 또는 Task<T>를 반환하는 것이 기본입니다. 이벤트 핸들러만은 시그니처상 void가 필요하므로 거기서만 쓰고, 그 경우에는 핸들러 안에서 try/catch로 예외를 잡아 UI 측으로 돌려보내는 곳까지 직접 쓴다는 의식이 중요합니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기