PHpullh
심층 가이드/C#/비동기/동시성

C# · 심층 가이드

C# 비동기/동시성 완전 정리

async와 await의 실행 흐름을 바탕으로 병렬 처리, 타임아웃, 취소 토큰, 재시도, 프로듀서 컨슈머, 스로틀링까지 C# 비동기 코드 열다섯 가지를 정리했습니다.

주제 15개 · 예제 코드 포함 · 최종 수정 2026-09-03 · 작성 pullh 편집팀

await는 스레드를 만드는 문법이 아닙니다. 대기하는 동안 현재 스레드를 반납하고, 작업이 끝나면 나머지 코드를 이어 실행하도록 컴파일러가 메서드를 상태 기계로 재작성할 뿐입니다. 이 구분을 놓치면 비동기 메서드를 불렀는데 왜 빨라지지 않는지, 왜 순서가 뒤엉키는지 설명이 안 됩니다. 그리고 취소는 언어가 알아서 해 주지 않고 CancellationToken을 인자로 계속 흘려보내는 협조 방식이라는 점도 처음에 잡아 두는 편이 좋습니다.

비동기 함수 시작과 순차 작업 처리를 먼저 보면 await를 줄줄이 붙였을 때 시간이 그대로 더해진다는 사실이 눈에 들어오고, 병렬 작업 처리가 그 대비가 됩니다. timeout 처리와 취소 신호는 사실상 같은 장치를 다른 목적으로 쓰는 예제라 붙여서 읽어야 하고, retry 로직은 그 위에 재시도 횟수와 대기 시간을 얹은 형태입니다. producer consumer와 throttle은 동시 실행 개수를 통제하는 문제를 각각 큐와 세마포어로 접근합니다.

가장 비싼 실수는 비동기 메서드를 .Result나 .Wait()로 동기 호출하는 것입니다. 동기화 컨텍스트가 있는 환경에서는 완료 콜백이 이미 막혀 있는 스레드로 돌아오려다 그대로 교착에 빠집니다. 예외 처리도 짚어 둘 만합니다. 여러 작업을 동시에 기다릴 때 await가 다시 던지는 예외는 그중 하나뿐이라, 나머지 실패는 로그에 남지 않고 조용히 사라집니다. 실패를 전부 봐야 한다면 반환된 작업 객체에서 예외 목록을 직접 꺼내야 합니다.

01비동기 함수 시작

Task.FromResult(1)은 이미 완료된 태스크를 만들고, await가 그 안의 값을 꺼내 출력합니다. 비동기 인터페이스를 구현하면서 실제로는 캐시된 값을 즉시 돌려줄 때 이 형태를 씁니다. 완료된 태스크를 await하면 스레드 전환이 일어나지 않고 호출한 자리에서 그대로 이어집니다.

C# code

// 비동기 함수 시작
using System;
using System.Threading.Tasks;

var value = await Task.FromResult(1);
Console.WriteLine(value);
알아두면 좋은 점

동기적으로 끝나는 경로가 대부분이라면 ValueTask를 반환해 태스크 객체 할당을 아낄 수 있고, 돌려줄 값이 없다면 Task.CompletedTask를 재사용하면 됩니다.

자주 하는 실수

본문에 await가 하나도 없는데 async를 붙이면 상태 머신만 생기고 컴파일러 경고가 뜹니다. 이럴 때는 async를 빼고 완성된 태스크를 그대로 반환해야 합니다.

02순차 작업 처리

두 개의 완료된 태스크를 Task.WhenAll로 묶어 string[]을 받은 뒤 쉼표로 이어 붙입니다. 여러 호출의 결과를 한꺼번에 모아야 할 때 쓰는 형태입니다. 반환 배열의 순서는 완료 순서가 아니라 인자로 넘긴 순서를 따릅니다.

C# code

// 순차 작업 처리
using System;
using System.Threading.Tasks;

var results = await Task.WhenAll(Task.FromResult("a"), Task.FromResult("b"));
Console.WriteLine(string.Join(",", results));
알아두면 좋은 점

순차 실행이 필요하다면 각 호출을 따로 await해야 합니다. Task.WhenAll에 넘기는 태스크는 이미 시작된 상태라 넘기는 순간부터 함께 진행됩니다.

자주 하는 실수

결과 타입이 string[]으로 잡히는 것은 모든 태스크의 결과 타입이 같을 때뿐이며, 타입이 섞이면 값을 돌려주지 않는 비제네릭 오버로드가 선택되어 결과를 받지 못합니다.

03병렬 작업 처리

값 3이 담긴 완료 태스크를 await해서 그대로 콘솔에 찍는, 병렬성이 전혀 없는 최소 코드입니다. 여기서 await 대신 .Result를 쓰고 싶어지는 순간이 오는데, WinForms나 구버전 ASP.NET처럼 동기화 컨텍스트가 있는 환경에서는 그 한 줄이 교착 상태를 만듭니다.

C# code

// 병렬 작업 처리
using System;
using System.Threading.Tasks;

var value = await Task.FromResult(3);
Console.WriteLine(value);
알아두면 좋은 점

콘솔 앱과 ASP.NET Core에는 동기화 컨텍스트가 없어 .Result가 교착되지는 않지만, 스레드 풀 스레드를 통째로 묶어 두므로 부하가 오르면 스레드 고갈로 이어집니다.

자주 하는 실수

진짜 병렬로 돌리려면 태스크를 모두 시작한 뒤에 await해야 합니다. 반복문 안에서 호출마다 즉시 await하면 앞 작업이 끝나야 다음이 시작되어 순차 실행이 됩니다.

04timeout 처리

WhenAll이 두 태스크가 모두 끝나기를 기다린 결과를 출력합니다. 여기에는 시간 제한이 없고, WhenAll 자체도 타임아웃 인자를 받지 않습니다. 제한을 걸려면 Task.WhenAny로 Task.Delay와 경쟁시키는데, 이때 진 쪽 작업은 취소되지 않고 계속 돕니다.

C# code

// timeout 처리
using System;
using System.Threading.Tasks;

var results = await Task.WhenAll(Task.FromResult("a"), Task.FromResult("b"));
Console.WriteLine(string.Join(",", results));
알아두면 좋은 점

Task.WaitAsync(TimeSpan)을 쓰면 Delay와 WhenAny를 직접 조합하지 않고도 제한 시간을 넘겼을 때 TimeoutException을 받을 수 있습니다.

자주 하는 실수

경쟁에서 진 Task.Delay를 취소하지 않으면 그 타이머가 만료될 때까지 살아 있습니다. 요청마다 반복되면 타이머와 콜백이 계속 쌓입니다.

05retry 로직

값 5를 담은 태스크 하나를 await할 뿐, 재시도 루프는 들어 있지 않습니다. 실제 재시도를 붙일 때 흔한 함정은 태스크 객체를 변수에 담아 두고 다시 await하는 것입니다. 태스크는 이미 끝난 결과를 그대로 돌려줄 뿐 다시 실행되지 않으므로, 시도마다 호출을 새로 만들어야 합니다.

C# code

// retry 로직
using System;
using System.Threading.Tasks;

var value = await Task.FromResult(5);
Console.WriteLine(value);
알아두면 좋은 점

재시도의 대상은 태스크가 아니라 태스크를 만들어 내는 Func<Task<T>>여야 합니다. 그래야 시도할 때마다 새 호출이 실제로 발생합니다.

자주 하는 실수

인증 실패나 잘못된 요청처럼 다시 보내도 결과가 같은 오류까지 재시도하면 시간만 버리고 상대 서버의 차단을 부릅니다. 재시도할 예외 종류를 좁혀야 합니다.

06스트림 또는 채널 소비

WhenAll은 모든 항목이 끝난 뒤에야 배열을 한 번에 넘겨줍니다. 결과가 순차적으로 흘러들어오는 스트림이나 채널을 다룰 때는 이 방식이 맞지 않습니다. 그때는 IAsyncEnumerable<T>와 await foreach로 도착하는 대로 처리해야 메모리가 한 번에 부풀지 않습니다.

C# code

// 스트림 또는 채널 소비
using System;
using System.Threading.Tasks;

var results = await Task.WhenAll(Task.FromResult("a"), Task.FromResult("b"));
Console.WriteLine(string.Join(",", results));
알아두면 좋은 점

IAsyncEnumerable<T>를 돌려주는 메서드에는 [EnumeratorCancellation]을 붙인 토큰 매개변수를 두어야 WithCancellation으로 넘긴 토큰이 실제로 전달됩니다.

자주 하는 실수

GetAsyncEnumerator를 직접 부른 뒤 DisposeAsync를 빠뜨리면 생산자 쪽 반복자의 정리 코드가 실행되지 않아 열어 둔 자원이 그대로 남습니다.

07producer consumer

생산자와 소비자를 나누는 대신, 값 7 하나를 await해 출력하는 단일 흐름입니다. 백그라운드 생산 작업과 소비 루프를 실제로 분리할 때는 System.Threading.Channels가 표준 선택지입니다. 반환 타입도 함께 고민할 만한데, 대부분 동기적으로 끝나는 경로라면 ValueTask가 태스크 할당을 아껴 줍니다.

C# code

// producer consumer
using System;
using System.Threading.Tasks;

var value = await Task.FromResult(7);
Console.WriteLine(value);
알아두면 좋은 점

Channel.CreateBounded로 용량을 정해 두면 소비가 느릴 때 생산자가 자연스럽게 대기하게 되어, 큐가 무한정 커지는 것을 막는 배압이 걸립니다.

자주 하는 실수

생산이 끝난 뒤 writer.Complete()를 부르지 않으면 소비 루프는 더 올 데이터가 없다는 사실을 알 수 없어 영원히 대기합니다.

08취소 신호

두 태스크의 완료를 함께 기다리는 코드이며 취소 토큰은 쓰이지 않았습니다. 취소를 붙이려면 CancellationTokenSource를 만들어 각 호출에 토큰을 넘겨야 합니다. 주의할 점은 WhenAll이 하나가 실패하거나 취소돼도 나머지를 멈춰 주지 않는다는 것입니다.

C# code

// 취소 신호
using System;
using System.Threading.Tasks;

var results = await Task.WhenAll(Task.FromResult("a"), Task.FromResult("b"));
Console.WriteLine(string.Join(",", results));
알아두면 좋은 점

CancellationTokenSource는 IDisposable이라 타임아웃을 걸어 둔 경우 특히 정리가 필요하고, CreateLinkedTokenSource로 요청 토큰과 시간 제한 토큰을 하나로 묶을 수 있습니다.

자주 하는 실수

취소를 catch (Exception)으로 뭉뚱그려 잡으면 정상적인 취소가 장애 로그로 쌓입니다. OperationCanceledException은 따로 구분해 처리해야 합니다.

09비동기 에러 처리

예외 처리 없이 완료 태스크를 await하고 값 9를 출력합니다. 비동기에서 던져진 예외는 태스크 안에 담겼다가 await 시점에 다시 튀어나옵니다. await는 원래 예외를 그대로 던지지만 .Wait()나 .Result는 AggregateException으로 감싸서 catch 조건이 달라집니다.

C# code

// 비동기 에러 처리
using System;
using System.Threading.Tasks;

var value = await Task.FromResult(9);
Console.WriteLine(value);
알아두면 좋은 점

WhenAll이 만든 태스크의 Exception 속성에는 모든 실패가 모여 있으므로, await로 받은 첫 예외만 보고 넘어가면 나머지 실패를 놓치게 됩니다.

자주 하는 실수

await하지 않고 버려진 태스크에서 난 예외는 아무도 보지 못한 채 사라집니다. 실행만 하고 잊는 호출에도 예외를 기록하는 연결을 붙여야 합니다.

10배치 처리

완료된 태스크 두 개를 한 번에 기다리는 형태로, 배치 크기 제어는 없습니다. 항목이 수천 개인 목록에 이 패턴을 그대로 적용하면 모든 작업이 동시에 시작돼 상대 서버나 커넥션 풀을 밀어 버립니다. Chunk로 나눠 배치마다 WhenAll을 돌리거나 SemaphoreSlim으로 동시 실행 수를 묶어야 합니다.

C# code

// 배치 처리
using System;
using System.Threading.Tasks;

var results = await Task.WhenAll(Task.FromResult("a"), Task.FromResult("b"));
Console.WriteLine(string.Join(",", results));
알아두면 좋은 점

SemaphoreSlim으로 동시 실행 수를 묶을 때는 WaitAsync를 쓰고 Release를 finally에 두어야, 예외가 나도 자리가 반환되어 뒤 작업이 멈추지 않습니다.

자주 하는 실수

Chunk로 나눠 배치마다 기다리면 한 배치의 가장 느린 항목이 끝날 때까지 다음 배치가 시작되지 않아 전체 시간이 예상보다 크게 늘어납니다.

11debounce

값 11을 즉시 반환하는 태스크를 await하므로 지연이나 디바운스 동작은 없습니다. 마지막 입력만 처리하려면 대기 구간이 필요한데, Thread.Sleep은 스레드를 점유하므로 비동기 코드에서는 쓰지 않습니다. Task.Delay에 취소 토큰을 넘겨 새 입력이 오면 이전 대기를 끊는 방식이 일반적입니다.

C# code

// debounce
using System;
using System.Threading.Tasks;

var value = await Task.FromResult(11);
Console.WriteLine(value);
알아두면 좋은 점

대기 중이던 Task.Delay가 취소되면 TaskCanceledException이 올라오므로, 디바운스에서는 이 예외를 정상 흐름으로 보고 조용히 넘기도록 감싸야 합니다.

자주 하는 실수

이전 CancellationTokenSource를 새 것으로 바꾸기만 하고 Cancel을 부르지 않으면 앞선 대기가 살아남아, 마지막 입력만이 아니라 모든 입력이 처리됩니다.

12throttle

WhenAll은 넘긴 작업을 제한 없이 동시에 진행시킵니다. 초당 호출 수를 조절하려면 그 앞단에 SemaphoreSlim 같은 게이트를 따로 둬야 합니다. 또 하나 알아 둘 점은, 여러 태스크가 동시에 실패해도 await는 그중 첫 예외만 던지므로 전체 목록은 task.Exception에서 확인해야 합니다.

C# code

// throttle
using System;
using System.Threading.Tasks;

var results = await Task.WhenAll(Task.FromResult("a"), Task.FromResult("b"));
Console.WriteLine(string.Join(",", results));
알아두면 좋은 점

속도 제한은 동시 실행 수 제한과 다릅니다. 초당 호출 수를 맞추려면 세마포어가 아니라 시간 창을 기준으로 허가를 내주는 System.Threading.RateLimiting의 제한기가 필요합니다.

자주 하는 실수

게이트를 두고도 넘길 태스크를 미리 전부 만들어 두면, 태스크를 만드는 시점에 이미 호출이 시작되어 제한이 아무 의미가 없어집니다.

13파일 읽기 작업

파일 접근 코드는 없고, 값 13을 담은 완료 태스크를 await할 뿐입니다. 실제 파일 읽기는 File.ReadAllTextAsync나 스트림 기반 API로 대체합니다. FileStream을 직접 만들 때 useAsync: true를 빼면 이름만 Async일 뿐 내부적으로 동기 I/O가 스레드를 잡아먹습니다.

C# code

// 파일 읽기 작업
using System;
using System.Threading.Tasks;

var value = await Task.FromResult(13);
Console.WriteLine(value);
알아두면 좋은 점

File.ReadAllTextAsync에는 취소 토큰을 받는 오버로드가 있어 큰 파일 읽기를 중간에 끊을 수 있고, 줄 단위로 흘려 처리할 때는 StreamReader.ReadLineAsync 반복이 메모리를 덜 씁니다.

자주 하는 실수

로컬 디스크의 작은 파일에서는 비동기 API가 오히려 느립니다. 비동기의 이득은 대기가 긴 네트워크 파일이나 동시 요청이 많은 서버에서 나옵니다.

14요청 작업 감싸기

요청 두 건을 흉내 낸 완료 태스크를 WhenAll로 감싸 결과를 출력합니다. HTTP 호출로 바꾼다면 HttpClient를 요청마다 새로 만들지 말고 재사용해야 소켓 고갈을 피할 수 있습니다. 라이브러리 코드라면 각 await에 ConfigureAwait(false)를 붙여 호출자의 컨텍스트로 되돌아가는 비용을 없앱니다.

C# code

// 요청 작업 감싸기
using System;
using System.Threading.Tasks;

var results = await Task.WhenAll(Task.FromResult("a"), Task.FromResult("b"));
Console.WriteLine(string.Join(",", results));
알아두면 좋은 점

HttpClient를 하나로 오래 재사용하면 DNS 변경이 반영되지 않는 문제가 있어, 서버 앱에서는 수명을 관리해 주는 IHttpClientFactory로 만드는 편이 좋습니다.

자주 하는 실수

응답을 받고 EnsureSuccessStatusCode를 부르지 않으면 404나 500 응답의 본문을 정상 결과로 착각해 파싱하게 되고, 오류가 엉뚱한 곳에서 드러납니다.

15스케줄 작업

주기 실행 로직 없이 값 15를 한 번 await하고 끝나는 코드입니다. 타이머나 백그라운드 루프로 확장할 때는 콜백의 반환 타입을 조심해야 합니다. async void로 만든 메서드에서 터진 예외는 호출자가 잡을 수 없고 프로세스를 그대로 내리므로, 이벤트 핸들러가 아니면 쓰지 않습니다.

C# code

// 스케줄 작업
using System;
using System.Threading.Tasks;

var value = await Task.FromResult(15);
Console.WriteLine(value);
알아두면 좋은 점

장시간 도는 백그라운드 루프는 BackgroundService로 만들고 ExecuteAsync가 받은 토큰을 대기 지점마다 넘겨야, 앱이 종료될 때 루프가 제때 빠져나옵니다.

자주 하는 실수

주기 실행 루프 안에서 예외를 잡지 않으면 한 번의 실패로 루프가 끝나 버립니다. 프로세스는 살아 있는데 아무 일도 하지 않는 상태라 장애를 알아채기 어렵습니다.

정리하며

  • await는 스레드를 새로 만드는 게 아니라 대기 중 스레드를 돌려주는 장치입니다
  • 비동기 코드를 .Result로 동기 호출하면 환경에 따라 교착이 발생합니다
  • 여러 작업을 함께 기다릴 때 await는 예외 하나만 던지므로 나머지를 따로 확인합니다
  • 취소는 자동이 아니라 CancellationToken을 끝까지 전달해야 동작합니다

더 깊이 들어가고 싶다면 C# 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.