C# async/await 실무 판단표 - Task.Run과 ConfigureAwait
· 업데이트: · Go Komura · 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. |
목차
- 먼저 결론(한 줄로)
- 이 글에서 쓰는 말
- 2.1. 먼저 구별하고 싶은 말
- 2.2. 자주 나오는 말
- 먼저 보는 판단표
- 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.1. 반환값은 우선 Task / Task<T>
- 4.2. async void는 이벤트 핸들러만
- 4.3. CancellationToken을 받아 하위 호출로 넘긴다
- 4.4. 비동기 API는 끝까지 비동기로 잇는다
- 4.5. LINQ로 태스크를 만들 때는 ToArray / ToList로 확정한다
- 자주 있는 안티패턴
- 리뷰 시 체크리스트
- 대략적인 구분
- 정리
- 참고 자료
이 글의 지식 맵
이 기사는 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
flowchart LR
accTitle: C# async/await 실무 판단의 지식 맵
accDescr: I/O-bound인지 CPU-bound인지의 구별이 Task.Run이나 ConfigureAwait(false)의 사용 구분, WhenAll·WhenAny·Channel 등의 선택과 어떻게 이어지는지 보여주는 그림입니다.
io_bound_operation["I/O-bound 처리"]
cpu_bound_operation["CPU-bound 작업"]
taskrun_dotnet["Task.Run"]
ui_thread_context["UI 스레드 컨텍스트"]
asp_net_core_request_thread["ASP.NET Core의 요청 처리"]
sync_over_async["동기 대기 혼입(sync-over-async)"]
threadpool_starvation["스레드 풀 기아(starvation)"]
synchronizationcontext["SynchronizationContext"]
configureawait_false["ConfigureAwait(false)"]
generic_library_code["범용 라이브러리 코드"]
cancellationtoken_dotnet["CancellationToken(.NET)"]
async_void["async void"]
event_handler_method["이벤트 핸들러 메서드"]
regular_async_method["일반 비동기 메서드"]
task_whenall["Task.WhenAll"]
task_whenany["Task.WhenAny"]
parallel_foreachasync["Parallel.ForEachAsync"]
semaphoreslim["SemaphoreSlim"]
channel_t["Channel<T>"]
backpressure["backpressure"]
backgroundservice["BackgroundService"]
ihostedservice["IHostedService"]
fire_and_forget["fire-and-forget"]
decoupled_background_work["호출자의 수명과 분리된 백그라운드 작업"]
periodictimer["PeriodicTimer"]
iasyncenumerable["IAsyncEnumerable<T>"]
await_using["await using"]
valuetask["ValueTask"]
taskrun_dotnet -->|"사용은 비권장"| io_bound_operation
taskrun_dotnet -->|"권장되는 대응"| cpu_bound_operation
taskrun_dotnet -->|"권장되는 대응"| ui_thread_context
taskrun_dotnet -->|"사용은 비권장"| asp_net_core_request_thread
sync_over_async -->|"원인이 될 수 있다"| threadpool_starvation
ui_thread_context -->|"이용한다"| synchronizationcontext
configureawait_false -->|"권장되는 대응"| generic_library_code
configureawait_false -->|"사용은 비권장"| ui_thread_context
io_bound_operation -.->|"이용한다"| cancellationtoken_dotnet
async_void -->|"권장되는 대응"| event_handler_method
async_void -->|"사용은 비권장"| regular_async_method
task_whenall -->|"권장되는 대응"| io_bound_operation
task_whenany -.->|"이용한다"| cancellationtoken_dotnet
parallel_foreachasync -->|"권장되는 대응"| io_bound_operation
semaphoreslim -.->|"권장되는 대응"| io_bound_operation
channel_t -.->|"이용한다"| backpressure
backgroundservice -->|"권장되는 대응"| channel_t
backgroundservice -->|"구현을 담당한다"| ihostedservice
fire_and_forget -->|"사용은 비권장"| decoupled_background_work
channel_t -->|"권장되는 대응"| decoupled_background_work
periodictimer -->|"이용한다"| cancellationtoken_dotnet
iasyncenumerable -->|"권장되는 대응"| io_bound_operation
await_using -.->|"전제로 한다"| io_bound_operation
valuetask -->|"사용은 비권장"| regular_async_method
event_handler_method -.->|"전제로 한다"| ui_thread_context
ihostedservice -->|"권장되는 대응"| asp_net_core_request_thread
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 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」로 가지 않는 일입니다.
우선
- 그 처리는 무엇을 기다리고 있는가
- 누가 그 처리의 수명을 갖는가
- 동시 실행 수를 어디서 제어하는가
이 세 가지를 보면 헷갈림이 꽤 줄어듭니다.
flowchart TB
accTitle: 헷갈림을 줄이는 세 가지 질문
accDescr: 그 처리는 무엇을 기다리고 있는가, 누가 그 처리의 수명을 갖는가, 동시 실행 수를 어디서 제어하는가라는 세 가지를 차례로 보면 async/await 주변의 작성 방식에 대한 헷갈림이 줄어든다는 것을 나타낸다.
q1["무엇을 기다리고 있는가"] --> q2["누가 수명을 갖는가"]
q2 --> q3["동시 실행 수를 어디서 제어하는가"]
q3 --> less["작성 방식의 헷갈림이 꽤 줄어든다"]
q1 -.-> avoid["일단 Task.Run을 피한다"]
그림 1: 「일단」으로 고르지 말고, 대기의 종류·수명·동시 실행 수 세 가지를 먼저 본다.
2. 이 글에서 쓰는 말
2.1. 먼저 구별하고 싶은 말
처음에 이 둘을 나누면 혼란이 꽤 줄어듭니다.
| 말 | 여기에서의 의미 |
|---|---|
| I/O-bound | HTTP, DB, 파일, 소켓 등 외부의 완료 대기가 중심인 처리 |
| CPU-bound | 압축, 이미지 처리, 해시 계산, 무거운 변환 등 CPU 계산 그 자체가 중심인 처리 |
async / await가 특히 효과를 내는 것은 I/O 대기 쪽이며, 기다리는 동안 스레드를 다른 일로 돌려줄 수 있습니다. 한편 CPU 계산은 「대기」가 아니라 실제로 계산하고 있는 시간이므로 어느 스레드에서 돌릴지와 병렬 수를 어떻게 정할지가 주제가 됩니다.
flowchart TB
accTitle: I/O-bound와 CPU-bound의 차이
accDescr: 외부의 완료 대기가 중심인 I/O-bound는 await로 기다리는 동안 스레드를 다른 일로 돌려줄 수 있는 반면, 계산 그 자체가 중심인 CPU-bound는 어느 스레드에서 돌릴지와 병렬 수가 주제가 된다는 구별을 나타낸다.
io["I/O-bound(외부의 완료 대기)"] --> e1["기다리는 동안 스레드를 다른 곳으로 돌려줄 수 있다"]
e1 --> fit["async/await가 특히 효과를 낸다"]
cpu["CPU-bound(계산 그 자체)"] --> e2["어느 스레드에서 돌릴지가 주제"]
e2 --> par["병렬 수의 정하는 방식도 주제가 된다"]
그림 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)는 이렇게 잡은 문맥으로 돌아가지 않고 나머지를 실행해도 된다는 지정입니다
flowchart TB
accTitle: await의 continuation이 돌아가는 곳의 차이
accDescr: await는 대기에 들어간 시점의 SynchronizationContext를 잡아 continuation을 그곳으로 되돌리므로, WinForms/WPF에서는 UI 스레드로 돌아가 컨트롤을 만질 수 있지만 ASP.NET Core에는 돌아갈 곳이 없어 continuation은 스레드 풀에서 동작한다는 것을 나타낸다.
aw["await가 문맥을 잡는다"] --> ui["WinForms나 WPF의 UI 스레드"]
aw --> asp["ASP.NET Core는 돌아갈 곳이 없다"]
ui --> touch["await 뒤에 UI를 만질 수 있다"]
asp --> pool["나머지는 스레드 풀에서 동작한다"]
그림 3: ConfigureAwait(false) 이야기는 await의 나머지가 어디로 돌아가는가라는 한 점으로 모인다.
여기서 「UI 코드에서는 붙이지 않는 편이 자연스럽다」「ASP.NET Core의 앱 코드에서는 붙여도 안 붙여도 큰 차이가 없다」「어느 쪽에서 실행될지 모르는 범용 라이브러리에서는 붙일 가치가 있다」는 3.12의 결론이 나옵니다. 자세한 배경은 9. 참고 자료의 ConfigureAwait FAQ가 가장 잘 정리되어 있습니다.
특히 중요한 것은 비동기와 병렬은 별개라는 점입니다.
- 비동기: 기다리는 방식의 이야기
- 병렬: 동시에 진행하는 이야기
이 둘이 섞이면 Task.Run을 어디서나 쓰고 싶어집니다.
여기가 첫 갈림길입니다.
flowchart TB
accTitle: 비동기와 병렬은 별개
accDescr: 비동기는 기다리는 방식의 이야기, 병렬은 동시에 진행하는 이야기이며, 이 둘이 섞이면 Task.Run을 어디서나 쓰고 싶어지므로 여기가 첫 갈림길이 된다는 것을 나타낸다.
a["비동기(기다리는 방식의 이야기)"] -.-> mix["섞이면 Task.Run 남용으로"]
p["병렬(동시에 진행하는 이야기)"] -.-> mix
mix --> fork["여기가 첫 갈림길"]
그림 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 / 앱 고유 컨텍스트에 의존하지 않는다 |
flowchart TD
start["하고 싶은 처리"] --> q1{"외부 I/O를 기다리는가?"}
q1 -- "예" --> p1["async API를 그대로 await한다"]
q1 -- "아니오" --> q2{"CPU 계산이 무거운가?"}
q2 -- "예" --> q3{"어디서 돌리는가?"}
q3 -- "UI 이벤트 / 데스크톱" --> p2["Task.Run을 검토한다"]
q3 -- "ASP.NET Core의 요청" --> p3["Task.Run으로 감싸지 않는다<br/>필요하면 다른 워커나 큐로 보낸다"]
q3 -- "worker / 백그라운드" --> p4["그 자리에서 실행하거나<br/>병렬도를 명시한다"]
q2 -- "아니오" --> q4{"여러 일을 다루는가?"}
q4 -- "전부 끝날 때까지 기다린다" --> p5["Task.WhenAll"]
q4 -- "먼저 끝난 것을 쓴다" --> p6["Task.WhenAny"]
q4 -- "건수가 많다" --> p7["Parallel.ForEachAsync<br/>또는 SemaphoreSlim"]
q4 -- "순서대로 흐르게 한다" --> p8["Channel<T>"]
q4 -- "일정 간격" --> p9["PeriodicTimer"]
q4 -- "순차 스트림" --> p10["IAsyncEnumerable<T>"]
그림 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을 받았다면 그대로 하위 호출로 넘깁니다
여기는 상당히 정석입니다.
flowchart TB
accTitle: I/O 대기의 기본 형태
accDescr: HTTP나 DB나 파일의 대기에서는 먼저 async 판 API를 찾아 그대로 await하는 것이 기본이며, 이미 async인 I/O를 Task.Run으로 감싸는 것은 다른 스레드로 다시 던지고 있을 뿐 이득이 없음을 나타낸다.
need["HTTP·DB·파일의 대기"] --> find["먼저 async 판 API를 찾는다"]
find --> aw["그대로 await한다"]
wrap["Task.Run으로 감싼다"] -.-> bad["다시 던지고 있을 뿐 이득이 없다"]
그림 6: I/O 대기의 기본은 「async API를 그대로 await」. Task.Run으로 감싸는 것은 피한다.
3.3. CPU 부하가 무거우면, Task.Run을 쓸 장소를 고른다
Task.Run이 효과를 내는 때는 CPU 계산을 지금 스레드에서 빼고 싶을 때입니다.
예를 들어 UI 이벤트 핸들러에서 무거운 계산을 그대로 돌리면 화면이 멈춥니다.
이런 때는 Task.Run이 자연스럽습니다.
flowchart TB
accTitle: UI에서 Task.Run이 효과를 내는 방식
accDescr: UI 이벤트 핸들러에서 무거운 계산을 그대로 돌리면 화면이 멈추므로, Task.Run으로 CPU 계산을 UI 스레드에서 빼면 화면의 응답이 유지된다는, Task.Run이 효과를 내는 전형 장면을 나타낸다.
heavy["UI 이벤트에서 무거운 계산을 돌린다"] -.-> freeze["화면이 멈춘다"]
run["Task.Run으로 UI 스레드에서 뺀다"] --> keep["화면은 계속 응답한다"]
그림 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 스레드)입니다. 서버 측에는 그 하나가 없습니다. 원래 스레드는 분명히 해방되지만, 대신 다른 스레드가 같은 시간만큼 계산으로 채워지므로 상쇄되면 제로입니다
남는 것은 큐로의 투입과 스레드 전환 비용, 그리고 「어느 스레드에서 동작하고 있는지」가 한 단 알기 어려워지는 일입니다. 그래서 피합니다.
flowchart TB
accTitle: ASP.NET Core에서 Task.Run을 피하는 이유
accDescr: 요청 처리에서 Task.Run을 끼워도 계산의 총량은 바뀌지 않고, UI처럼 비워야 할 특별한 스레드도 없으므로 상쇄되면 제로이며, 남는 것은 스레드 전환 비용과 읽기 어려움뿐임을 나타낸다.
tr["요청 처리에서 Task.Run을 끼운다"] --> r1["계산의 총량은 바뀌지 않는다"]
tr --> r2["비워야 할 특별한 스레드가 없다"]
r1 --> zero["상쇄되면 제로"]
r2 --> zero
zero --> cost["전환 비용과 읽기 어려움이 남는다"]
그림 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()로 일단 확정해 두면, 모든 태스크가 그 시점에 시작됩니다.
sequenceDiagram
participant Caller as 호출 측
participant T1 as Task 1
participant T2 as Task 2
participant T3 as Task 3
Caller->>T1: 시작
Caller->>T2: 시작
Caller->>T3: 시작
Caller->>Caller: await Task.WhenAll(...)
T1-->>Caller: 완료
T2-->>Caller: 완료
T3-->>Caller: 완료
그림 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보다 설계가 조금 늘어납니다.
「처음 하나만 되면 된다」는 경우에만 고르면 이해하기 쉽습니다.
flowchart TB
accTitle: WhenAny에서 승자를 확인한 뒤에 나머지를 멈추는 흐름
accDescr: Task.WhenAny가 반환하는 것은 먼저 완료된 태스크이지 성공한 태스크가 아니므로, 완료를 하나씩 await해서 성공했을 때만 나머지를 취소하고, 실패라면 후보에서 빼고 다음 완료를 기다리며, 전부 실패하면 실패를 모아 던지는 흐름을 나타낸다.
any["WhenAny로 첫 완료를 얻는다"] --> chk{"그 태스크는 성공했는가"}
chk -->|"성공"| win["나머지를 취소하고 반환한다"]
chk -->|"실패"| next["후보에서 빼고 실패를 기록한다"]
next --> rest{"후보는 남아 있는가"}
rest -->|"예"| any
rest -->|"아니오"| agg["실패를 모아 던진다"]
그림 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.ForEachAsync나SemaphoreSlim
이라는 구분으로 크게 빗나가지 않습니다.
flowchart TB
accTitle: 건수에 따른 병렬 묶는 방식의 구분
accDescr: 몇 건의 독립 처리라면 Task.WhenAll로 전부 동시에 돌려도 되지만, 건수가 많으면 연결이나 메모리나 외부 부하가 한꺼번에 늘어나므로 Parallel.ForEachAsync나 SemaphoreSlim으로 동시 실행 수의 상한을 정한다는 것을 나타낸다.
q{"대상의 건수는 많은가"}
q -->|"몇 건 정도"| all["Task.WhenAll로 일제히"]
q -->|"많다"| limit["병렬 수의 상한을 정한다"]
limit --> pfe["Parallel.ForEachAsync"]
limit --> sem["SemaphoreSlim으로 자유롭게 제어"]
그림 11: 전건을 일제히 던져도 되는지가 갈림길. 많다면 동시 실행 수를 명시한다.
3.7. 순서대로 흐르게 하고 싶다면, Channel<T>
「지금 당장 끝나지 않아도 되지만 확실히 처리하고 싶은」 일을 호출 측에서 분리하고 싶어지는 경우가 있습니다. 메일 송신, 로그 전송, Webhook 후처리, 파일 변환 등입니다.
이런 때 Task.Run을 던져 버리면
- 예외를 어디서 볼 것인가
- 종료 시에 기다릴 것인가
- 건수가 늘었을 때 어디까지 받을 것인가
가 애매해집니다.
이런 종류의 일은 큐에 쌓고, 전용 컨슈머가 순서대로 처리하는 편이 관리하기 쉽습니다.
flowchart LR
p["producer"] --> w["WriteAsync"]
w --> q{"큐에 빈자리가 있는가?"}
q -- "예" --> c["Channel에 들어간다"]
q -- "아니오" --> b["빌 때까지 기다린다"]
c --> d["consumer가 ReadAsync"]
d --> e["순서대로 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」보다 이쪽이 예외, 중지, 병렬 수, 상한을 다루기 쉬워집니다.
flowchart TB
accTitle: 던져 버리기와 큐 관리의 대비
accDescr: Task.Run의 던져 버리기에서는 예외나 종료나 수용 건수가 애매해지는 반면, Channel에 쌓고 BackgroundService의 컨슈머가 순서대로 처리하는 형태라면 예외·중지·병렬 수·상한을 다루기 쉽다는 것을 나타낸다.
ff["그냥 Task.Run으로 던져 버린다"] -.-> vague["예외·종료·상한이 애매하다"]
ch["Channel의 큐에 쌓는다"] --> bs["BackgroundService가 소비한다"]
bs --> mng["예외·중지·병렬 수를 관리할 수 있다"]
그림 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를 날리지 않는다는 전제로 씁니다.
또한 처리 시간이 주기보다 길면 그 지연은 설계로서 다룰 필요가 있습니다.
타이머가 알아서 병렬화해서 따라잡아 주는 것은 아닙니다.
flowchart TB
accTitle: PeriodicTimer의 주기 루프
accDescr: WaitForNextTickAsync로 다음 주기를 기다리고, 처리를 await로 실행한 뒤 다시 대기로 돌아가는 루프이며, 중지는 CancellationToken으로 하고, 처리가 주기보다 길 때의 지연은 설계로서 다룰 필요가 있음을 나타낸다.
tick["WaitForNextTickAsync로 기다린다"] --> proc["처리를 await로 실행"]
proc --> tick
stop["CancellationToken으로 중지"] -.-> tick
proc -.-> warn["주기보다 긴 처리의 지연은 설계로 다룬다"]
그림 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>로 할지는
결과를 전부 모은 뒤에 쓸지, 도착한 순으로 쓸지로 정하면 이해하기 쉽습니다.
flowchart TB
accTitle: 결과의 사용 방식으로 정하는 반환값의 형태
accDescr: 결과를 전부 모은 뒤에 쓴다면 Task로 리스트 전체를 반환하고, 도착한 순으로 한 건씩 쓴다면 IAsyncEnumerable을 반환해 await foreach로 처리한다는 정하는 방식을 나타낸다.
q{"결과를 어떻게 쓰는가"}
q -->|"전부 모은 뒤에"| list["Task로 리스트 전체를 반환한다"]
q -->|"도착한 순으로"| ae["IAsyncEnumerable로 흘려보낸다"]
ae --> each["await foreach로 한 건씩 처리"]
ae -.-> mem["메모리에 전건을 담지 않는다"]
그림 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로 들어간다Release는finally에서 반드시 호출한다
는 두 가지입니다.
「동시에 한 건만 넣고 싶다」「외부 API 호출을 동시에 3건까지로 하고 싶다」
는 장면에서는 SemaphoreSlim은 상당히 실용적입니다.
flowchart TB
accTitle: await를 넘는 상호 배제의 형태
accDescr: await를 넘는 코드에서는 lock 대신 SemaphoreSlim을 쓰고, WaitAsync로 들어가 await를 포함하는 처리를 수행한 뒤 finally에서 반드시 Release를 호출한다는 두 가지가 중요함을 나타낸다.
lk["lock은 await를 넘지 못한다"] -.-> alt["대신 SemaphoreSlim"]
wait["WaitAsync로 들어간다"] --> crit["await를 포함하는 처리를 수행한다"]
crit --> rel["finally에서 반드시 Release"]
그림 16: 입구는 WaitAsync, 출구는 finally의 Release. 이 쌍을 깨지 않는 것이 요점.
3.12. UI / 앱 코드 / 라이브러리에서 await 작성 방식을 나눈다
ConfigureAwait(false)는 언제든 붙이면 되는 것이 아닙니다.
대략적인 나누는 방식은 이렇습니다.
flowchart LR
a["UI / 앱 코드"] --> b["await someAsync()"]
b --> c["원래 컨텍스트로 돌아가 계속"]
d["범용 라이브러리"] --> e["await someAsync().ConfigureAwait(false)"]
e --> f["특정 컨텍스트로 돌아간다는 전제를 갖지 않는다"]
그림 17: 앱 측 코드는 plain await로 원래 문맥으로 돌아가고, 범용 라이브러리는 ConfigureAwait(false)를 검토한다.
- UI / 앱 코드
- 우선 보통의
await로 충분합니다 - await 뒤에 UI 갱신이나 앱 측 컨텍스트에 의존하는 처리를 한다면
ConfigureAwait(false)는 붙이지 않는 편이 자연스럽습니다
- 우선 보통의
- ASP.NET Core의 앱 코드
- 보통은 보통의
await로 충분합니다 ConfigureAwait(false)를 전체 관례로 무리하게 철저히 하지 않아도 됩니다
- 보통은 보통의
- 범용 라이브러리 코드
- UI나 앱 모델에 의존하지 않는다면
ConfigureAwait(false)는 유력합니다
- UI나 앱 모델에 의존하지 않는다면
즉
- 앱 측 코드는 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.CompletedTask나 Task.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 측으로 돌려보내는 곳까지 직접 쓴다는 의식이 중요합니다.
flowchart TB
accTitle: async void를 피하는 이유와 유일한 예외
accDescr: async void는 호출 측이 await할 수 없고 완료를 기다릴 수 없으며 예외 처리와 테스트가 어려워지므로 일반적인 메서드에서는 피하고, 시그니처상 void가 필요한 이벤트 핸들러에서만 쓰며 그 안에서 try/catch로 예외를 UI 측으로 돌려보낸다는 것을 나타낸다.
av["async void 메서드"] --> p1["await할 수 없다"]
av --> p2["완료를 기다릴 수 없다"]
av --> p3["예외와 테스트가 어렵다"]
ev["이벤트 핸들러는 예외"] -.-> duty["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의 전파
이 차이는 나중에 장애가 되기 쉬우므로 처음에 정해 두면 안정적입니다.
flowchart TB
accTitle: CancellationToken의 전파
accDescr: 상위에서 받은 CancellationToken을 하위 API로 그대로 넘기면 도중에서도 멈추지만, 받기만 하고 넘기지 않으면 취소할 수 있어 보이는데 도중에 멈추지 않는 코드가 된다는 것을 나타낸다.
up["상위에서 token을 받는다"] --> pass["하위 API로 그대로 넘긴다"]
pass --> stop["도중에서도 제대로 멈춘다"]
nopass["받기만 하고 넘기지 않는다"] -.-> fake["멈추는 것처럼 보이지만 멈추지 않는다"]
그림 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()도 쓸 수 있으므로, 콘솔 앱에서도 무리하게 동기화할 이유는 상당히 줄어 있습니다.
flowchart TB
accTitle: 동기 대기를 섞지 않는 치환
accDescr: async/await를 쓴다면 끝까지 비동기로 잇고, Result나 Wait는 await로, Thread.Sleep은 Task.Delay로 바꾼다. 동기적으로 기다리는 작성 방식이 섞이면 막히는 방식이 읽기 어려워진다는 것을 나타낸다.
chain["끝까지 비동기로 잇는다"] --> r1["Result나 Wait는 await로"]
chain --> r2["Thread.Sleep은 Task.Delay로"]
mix["동기적인 대기가 섞인다"] -.-> clog["막히는 방식이 읽기 어려워진다"]
그림 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 |
이 표 안에서 특히 실무에서 자주 보이는 것은 다음 세 가지입니다.
- I/O인데
Task.Run - 실은 독립인데 직렬 await
- fire-and-forget의 수명 관리가 없다
이 세 가지만 고쳐도 코드의 가독성은 상당히 개선됩니다.
flowchart TB
accTitle: 실무에서 특히 자주 보는 세 가지 고치는 방식
accDescr: I/O인데 Task.Run으로 감싸고 있는 것, 실은 독립인데 직렬로 await하고 있는 것, fire-and-forget의 수명 관리가 없는 것이라는 실무에서 특히 자주 보는 세 가지를 각각의 치환처로 고치기만 해도 가독성이 개선된다는 것을 나타낸다.
a1["I/O인데 Task.Run"] --> f1["async API를 직접 await"]
a2["독립 처리의 직렬 await"] --> f2["먼저 시작해 WhenAll"]
a3["fire-and-forget의 수명 방치"] --> f3["Channel이나 BackgroundService로"]
f1 --> better["가독성이 상당히 개선된다"]
f2 --> better
f3 --> better
그림 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을 쓰고 있다면Release가finally에 들어가 있는가ValueTask를 쓰고 있다면 측정상의 이유가 있는가, 한 번 await 전제로 되어 있는가ConfigureAwait(false)의 유무가 그 코드의 종류와 맞는가- UI / 앱 코드라면 plain
await - 범용 라이브러리라면
ConfigureAwait(false)를 검토
- UI / 앱 코드라면 plain
이 체크리스트는 팀에서 리뷰 관점을 맞추는 데도 쓰기 쉽습니다.
7. 대략적인 구분
구분 목록은 3.1의 판단표에 모아 두었습니다. 같은 표를 다시 싣기보다 필요할 때 그곳으로 돌아가는 편이 찾기 쉬우므로, 여기에는 표를 두지 않습니다.
- 상황별로 「먼저 무엇을 쓸지」를 보고 싶다 → 3.1의 판단표
- 각 패턴의 작성 방식을 보고 싶다 → 3.2 〜 3.12(3.1 표의 행과 대응합니다)
3.1 표에 들어 있지 않은 판단이 하나만 있습니다. 반환값의 형입니다. 여기는 상황이 아니라 메서드 설계의 이야기이므로 4.1에 정리해 두었습니다. 결론만 쓰면, 우선 Task / Task<T>를 고르고, ValueTask는 측정해서 필요성이 보인 뒤입니다.
8. 정리
async / await의 베스트 프랙티스는 세세한 기법을 여러 개 외운다기보다
처리의 종류에 맞춰 형을 고른다는 정리가 실무에서는 효과가 있습니다.
보는 순서는 대략 이렇게 됩니다.
- I/O 대기인지 CPU 계산인지를 나눈다
- I/O라면 async API를 그대로
await한다 - CPU 계산이라면 어디서 돌려야 하는지를 정한다
- 여러 처리라면
WhenAll/WhenAny/ 병렬 수 제한을 고른다 - 요청 수명에서 빼려면 그냥 fire-and-forget이 아니라 큐화한다
- 반환값, 취소, 예외, 상호 배제, 컨텍스트의 다루기를 맞춘다
async / await는 작성 방식 자체가 간결한 만큼, 대충 쓰면 방침이 보이기 어려워집니다. 반대로
- I/O는 I/O로 다룬다
- CPU는 CPU로 다룬다
- 백그라운드 처리는 백그라운드 처리로서 수명을 관리한다
이 세 가지를 나누기만 해도 상당히 읽기 쉬워집니다.
9. 참고 자료
- 이 글의 샘플 코드 세트(라이브러리, 데모, 유닛 테스트) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/csharp-async-await-best-practices
- Asynchronous programming scenarios - C#
- Asynchronous programming with async and await
- Task-based Asynchronous Pattern (TAP) in .NET
- ConfigureAwait FAQ
- Parallel.ForEachAsync Method
- Task.WaitAsync Method
- System.Threading.Channels library
- Create a Queue Service
- Background tasks with hosted services in ASP.NET Core
- Generate and consume async streams
- Implement a DisposeAsync method
- ValueTask Struct
- CA2012: Use ValueTasks correctly
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
멀티스레드 실무 베스트 프랙티스 .NET 편 ── 스레드를 늘리기 전에 정해 둘 것
「스레드를 만들었더니 가끔 죽거나 멈춘다」를 막는 설계의 정석을 .NET/C# 대상으로 정리합니다. 스레드를 직접 만들지 않고 Task에 맡기기, 공유 가변 상태 줄이기, 락의 규율, CancellationToken으로 정지 설계하기, UI 스레...
업무 시스템의 코드 설계 ── 상품 코드·고객 코드를 정하는 법과 check digit
상품 코드·고객 코드 등 업무 시스템의 코드 체계를 정하는 실무 가이드. 유의미 코드와 무의미 일련번호 판단표, JAN·Luhn 등의 check digit 산식과 C# 구현, Excel의 선행 0 소실 대책, 자릿수 초과와 이행까지 정리합니다.
Windows 세션 분리를 어떻게 이해할 것인가 ── Session 0·RDP·다중 사용자 동시 실행
Windows 앱 개발자가 혼동하기 쉬운 「세션」 개념을 정리합니다. 서비스가 UI를 띄울 수 없는 Session 0 분리의 이유, RDP 연결 시 세션의 동작, named object의 세션 분리, 공유 PC·RDS 환경에서 자주 나오는 설계 ...
Windows의 프로세스 간 통신을 어떻게 고를까 ── Named Pipe / TCP / gRPC / 공유 메모리 / COM 판단표
Windows 앱끼리의 연동 수단을 어떻게 고를지 정리합니다. Named Pipe, 로컬 TCP, gRPC, 공유 메모리, 파일 연동, COM의 강점과 함정을 판단표로 정리하고, 정석 구성과 Named Pipe 구현 예까지 설명합니다.
Windows 앱 데이터 저장 위치 고르기 ── SQLite / JSON / 레지스트리 / Access 판단표
Windows 데스크톱 앱의 데이터를 어디에, 무엇으로 저장할지. AppData/ProgramData 구분, SQLite·JSON 파일·레지스트리·Access(.accdb) 각각의 강점과 함정을 판단표와 함께 정리하고, 손상 대책과 비트 수 문제...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
UI 스레드 & 타이머
WPF / WinForms UI 스레드, async 흐름, Dispatcher 사용, 타이머 판단을 정리한 토픽 페이지입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
UI, 백그라운드 처리, I/O를 포함하는 Windows 앱에서는 async/await의 구분이 구현 품질에 직결됩니다.
기술 상담 & 설계 리뷰
Task.Run이나 ConfigureAwait의 판단을 책임 분리와 함께 정리하고 싶을 때는 기술 상담·설계 리뷰로 이어집니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 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 측으로 돌려보내는 곳까지 직접 쓴다는 의식이 중요합니다.