C# Async Patterns
async/await, Task, ValueTask, async streams 및 cancellation 패턴을 사용하여 C# 비동기 프로그래밍을 마스터합니다. 이 SKILL은 반응성이 뛰어나고 확장이 용이한 애플리케이션을 구축하기 위해 C# 8-12의 모던 비동기 패턴을 다룹니다.
Async/Await Fundamentals
async/await 패턴은 동기 코드처럼 보이고 동작하는 비동기 코드를 작성하는 간단한 방법을 제공합니다.
Basic Async Method
public async Task<string> FetchDataAsync(string url) { using var client = new HttpClient(); string result = await client.GetStringAsync(url); return result; }
// 비동기 메서드 호출 public async Task ProcessAsync() { string data = await FetchDataAsync("https://api.example.com/data"); Console.WriteLine(data); }
Async Method Signature Rules
// ✅ 올바름 - Task 반환 public async Task ProcessDataAsync() { await Task.Delay(1000); }
// ✅ 올바름 - Task<T> 반환 public async Task<int> CalculateAsync() { await Task.Delay(1000); return 42; }
// ⚠️ 이벤트 핸들러 전용 - void 반환 public async void Button_Click(object sender, EventArgs e) { await ProcessDataAsync(); }
// ❌ 잘못됨 - async가 아니지만 Task 반환 public Task WrongAsync() { // async를 사용하거나 Task.FromResult를 사용해야 함 return Task.CompletedTask; }
Task and Task
Task는 비동기 작업을 나타냅니다. Task는 값을 반환하는 작업을 나타냅니다.
Creating Tasks
// CPU 집약적 작업을 위한 Task.Run public async Task<int> CalculateSumAsync(int[] numbers) { return await Task.Run(() => numbers.Sum()); }
// 이미 계산된 값을 위한 Task.FromResult public Task<string> GetCachedValueAsync(string key) { if (_cache.TryGetValue(key, out var value)) { return Task.FromResult(value); } return FetchFromDatabaseAsync(key); }
// void 비동기 메서드를 위한 Task.CompletedTask public Task ProcessIfNeededAsync(bool condition) { if (!condition) { return Task.CompletedTask; } return DoActualWorkAsync(); }
Task Composition
public async Task<Result> ProcessOrderAsync(Order order) { // 순차적 실행 (Sequential execution) await ValidateOrderAsync(order); await ChargePaymentAsync(order); await ShipOrderAsync(order);
return new Result { Success = true };
}
public async Task<Result> ProcessOrderParallelAsync(Order order) { // 병렬 실행 (Parallel execution) var validationTask = ValidateOrderAsync(order); var inventoryTask = CheckInventoryAsync(order); var pricingTask = CalculatePricingAsync(order);
await Task.WhenAll(validationTask, inventoryTask, pricingTask);
return new Result
{
IsValid = await validationTask,
InStock = await inventoryTask,
Price = await pricingTask
};
}
ValueTask and ValueTask
ValueTask는 결과가 동기적으로 사용 가능한 경우가 많을 때 사용하는 성능 최적화 수단입니다.
When to Use ValueTask
public class CachedRepository { private readonly Dictionary<int, User> _cache = new(); private readonly IDatabase _database;
// ✅ ValueTask 사용이 적절한 사례 - 캐시에서 동기적으로 반환되는 경우가 많음
public ValueTask<User> GetUserAsync(int id)
{
if (_cache.TryGetValue(id, out var user))
{
return ValueTask.FromResult(user);
}
return new ValueTask<User>(FetchUserFromDatabaseAsync(id));
}
private async Task<User> FetchUserFromDatabaseAsync(int id)
{
var user = await _database.QueryAsync<User>(id);
_cache[id] = user;
return user;
}
}
ValueTask Best Practices
public class BufferedReader { private readonly byte[] _buffer = new byte[4096]; private int _position; private int _length;
// Hot path 최적화를 위한 ValueTask
public async ValueTask<byte> ReadByteAsync()
{
if (_position < _length)
{
// 동기 경로 - 할당 없음 (No allocation)
return _buffer[_position++];
}
// 비동기 경로 - 데이터 추가 읽기
await FillBufferAsync();
return _buffer[_position++];
}
private async Task FillBufferAsync()
{
_length = await _stream.ReadAsync(_buffer);
_position = 0;
}
}
// ⚠️ ValueTask 규칙 public async Task ConsumeValueTaskAsync() { var reader = new BufferedReader();
// ✅ 올바름 - 한 번만 await
byte b = await reader.ReadByteAsync();
// ❌ 잘못됨 - ValueTask를 저장하지 마세요
var task = reader.ReadByteAsync();
await task; // 잠재적 이슈 발생 가능
// ❌ 잘못됨 - 여러 번 await 하지 마세요
var vt = reader.ReadByteAsync();
await vt;
await vt; // 절대 하지 마세요
}
Async Void vs Async Task
async void (드물게 발생)와 async Task (거의 항상 사용)를 언제 사용할지 이해합니다.
The Async Void Problem
// ❌ 나쁨 - await 불가, 예외 처리 안 됨 public async void ProcessDataBadAsync() { await Task.Delay(1000); throw new Exception("Unhandled!"); // 앱 크래시 발생 }
// ✅ 좋음 - await 가능, 예외 처리 가능 public async Task ProcessDataGoodAsync() { await Task.Delay(1000); throw new Exception("Handled!"); // catch 가능 }
// 사용 예시 public async Task CallerAsync() { try { // async void는 await 불가 ProcessDataBadAsync(); // Fire and forget - 위험함
// async Task는 await 가능
await ProcessDataGoodAsync(); // 여기서 예외 catch됨
}
catch (Exception ex)
{
Console.WriteLine($"Caught: {ex.Message}");
}
}
The Only Valid Use of Async Void
// ✅ 이벤트 핸들러 - 유일하게 허용되는 사례 public partial class MainWindow : Window { public async void SaveButton_Click(object sender, RoutedEventArgs e) { try { await SaveDataAsync(); MessageBox.Show("Saved successfully!"); } catch (Exception ex) { MessageBox.Show($"Error: {ex.Message}"); } }
private async Task SaveDataAsync()
{
await _repository.SaveAsync(_data);
}
}
ConfigureAwait(false)
라이브러리 코드에서 성능을 위해 synchronization context 캡처를 제어합니다.
Understanding ConfigureAwait
// 라이브러리 코드 - ConfigureAwait(false) 사용 public class DataService { public async Task<Data> GetDataAsync(int id) { // ConfigureAwait(false) - 컨텍스트를 캡처하지 않음 var json = await _httpClient.GetStringAsync($"/api/data/{id}") .ConfigureAwait(false);
var data = await DeserializeAsync(json)
.ConfigureAwait(false);
return data;
}
}
// UI 코드 - ConfigureAwait(false) 사용 금지 public class ViewModel { public async Task LoadDataAsync() { var data = await _dataService.GetDataAsync(42); // 여기서 UI 컨텍스트가 필요함 this.DataProperty = data; // UI 업데이트 } }
ConfigureAwait Patterns
public class AsyncLibrary { // ✅ ConfigureAwait(false)를 사용한 라이브러리 메서드 public async Task<Result> ProcessAsync(string input) { var step1 = await Step1Async(input).ConfigureAwait(false); var step2 = await Step2Async(step1).ConfigureAwait(false); var step3 = await Step3Async(step2).ConfigureAwait(false); return step3; }
// ✅ ASP.NET Core - 어디서나 ConfigureAwait(false) 안전함
[HttpGet]
public async Task<IActionResult> GetData(int id)
{
// ASP.NET Core에는 synchronization context가 없음
var data = await _repository.GetAsync(id).ConfigureAwait(false);
return Ok(data);
}
}
CancellationToken Patterns
오래 실행되는 작업에 대한 적절한 취약점 지원.
Basic Cancellation
public async Task<List<Result>> ProcessItemsAsync( IEnumerable<Item> items, CancellationToken cancellationToken = default) { var results = new List<Result>();
foreach (var item in items)
{
// 취소 요청 확인
cancellationToken.ThrowIfCancellationRequested();
var result = await ProcessItemAsync(item, cancellationToken);
results.Add(result);
}
return results;
}
// Timeout과 함께 사용 public async Task<List<Result>> ProcessWithTimeoutAsync(IEnumerable<Item> items) { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
return await ProcessItemsAsync(items, cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Operation timed out");
throw;
}
}
Advanced Cancellation Patterns
public class BackgroundProcessor { private CancellationTokenSource? _cts;
public async Task StartAsync()
{
_cts = new CancellationTokenSource();
await ProcessLoopAsync(_cts.Token);
}
public void Stop()
{
_cts?.Cancel();
}
private async Task ProcessLoopAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
await ProcessBatchAsync(cancellationToken);
await Task.Delay(1000, cancellationToken);
}
catch (OperationCanceledException)
{
// 취소 시 예상되는 상황
break;
}
}
}
// 연결된 cancellation tokens (Linked cancellation tokens)
public async Task ProcessWithMultipleTokensAsync(
CancellationToken userToken,
CancellationToken systemToken)
{
using var linkedCts = CancellationTokenSource
.CreateLinkedTokenSource(userToken, systemToken);
await DoWorkAsync(linkedCts.Token);
}
}
Async Streams (IAsyncEnumerable)
IAsyncEnumerable를 사용하여 비동기적으로 데이터를 스트리밍합니다 (C# 8+).
Basic Async Streams
public async IAsyncEnumerable<LogEntry> ReadLogsAsync( string filePath, [EnumeratorCancellation] CancellationToken cancellationToken = default) { await using var stream = File.OpenRead(filePath); using var reader = new StreamReader(stream);
string? line;
while ((line = await reader.ReadLineAsync(cancellationToken)) != null)
{
if (TryParseLog(line, out var entry))
{
yield return entry;
}
}
}
// 비동기 스트림 소비 public async Task ProcessLogsAsync(string filePath) { await foreach (var log in ReadLogsAsync(filePath)) { Console.WriteLine($"{log.Timestamp}: {log.Message}"); } }
Advanced Async Stream Patterns
public class DataStreamProcessor { // 필터링이 포함된 비동기 스트림 public async IAsyncEnumerable<Event> GetEventsAsync( DateTime startDate, [EnumeratorCancellation] CancellationToken cancellationToken = default) { int page = 0;
while (true)
{
var events = await FetchPageAsync(page++, cancellationToken);
if (events.Count == 0)
yield break;
foreach (var evt in events.Where(e => e.Date >= startDate))
{
yield return evt;
}
}
}
// 비동기 스트림에 대한 LINQ 스타일 작업
public async IAsyncEnumerable<TResult> SelectAsync<TSource, TResult>(
IAsyncEnumerable<TSource> source,
Func<TSource, TResult> selector)
{
await foreach (var item in source)
{
yield return selector(item);
}
}
// 비동기 스트림 버퍼링 (Buffering)
public async IAsyncEnumerable<List<T>> BufferAsync<T>(
IAsyncEnumerable<T> source,
int bufferSize)
{
var buffer = new List<T>(bufferSize);
await foreach (var item in source)
{
buffer.Add(item);
if (buffer.Count >= bufferSize)
{
yield return buffer;
buffer = new List<T>(bufferSize);
}
}
if (buffer.Count > 0)
{
yield return buffer;
}
}
}
Parallel Async Operations
여러 비동기 작업을 동시에 실행합니다.
Task.WhenAll and Task.WhenAny
public async Task<Summary> GetDashboardDataAsync() { // 모든 작업을 동시에 시작 var userTask = GetUserDataAsync(); var ordersTask = GetOrdersAsync(); var analyticsTask = GetAnalyticsAsync();
// 모두 완료될 때까지 대기
await Task.WhenAll(userTask, ordersTask, analyticsTask);
return new Summary
{
User = await userTask,
Orders = await ordersTask,
Analytics = await analyticsTask
};
}
// 일부 실패 처리 (Partial failures) public async Task<Results> ProcessWithPartialFailuresAsync() { var tasks = new[] { ProcessTask1Async(), ProcessTask2Async(), ProcessTask3Async() };
await Task.WhenAll(tasks.Select(async t =>
{
try
{
await t;
}
catch (Exception ex)
{
// 로그를 남기되 throw 하지 않음
Console.WriteLine($"Task failed: {ex.Message}");
}
}));
// 성공한 결과 수집
var results = tasks
.Where(t => t.IsCompletedSuccessfully)
.Select(t => t.Result)
.ToList();
return new Results { Successful = results };
}
Task.WhenAny for Timeouts and Racing
public async Task<T> WithTimeoutAsync<T>(Task<T> task, TimeSpan timeout) { var delayTask = Task.Delay(timeout); var completedTask = await Task.WhenAny(task, delayTask);
if (completedTask == delayTask)
{
throw new TimeoutException("Operation timed out");
}
return await task;
}
// 여러 소스 간 레이싱 (Racing multiple sources) public async Task<Data> GetFastestDataAsync() { var primaryTask = GetFromPrimaryAsync(); var secondaryTask = GetFromSecondaryAsync(); var cacheTask = GetFromCacheAsync();
var completedTask = await Task.WhenAny(primaryTask, secondaryTask, cacheTask);
return await completedTask;
}
// Throttled parallel processing (동시성 제한 병렬 처리) public async Task<List<Result>> ProcessWithThrottlingAsync( IEnumerable<Item> items, int maxConcurrency) { var semaphore = new SemaphoreSlim(maxConcurrency); var tasks = items.Select(async item => { await semaphore.WaitAsync(); try { return await ProcessItemAsync(item); } finally { semaphore.Release(); } });
return (await Task.WhenAll(tasks)).ToList();
}
Exception Handling in Async Code
비동기 메서드에 대한 적절한 예외 처리 패턴.
Basic Exception Handling
public async Task<Result> ProcessWithErrorHandlingAsync() { try { var data = await FetchDataAsync(); return await ProcessDataAsync(data); } catch (HttpRequestException ex) { _logger.LogError(ex, "Network error occurred"); throw; } catch (Exception ex) { _logger.LogError(ex, "Unexpected error occurred"); return Result.Failed(ex.Message); } }
// Task.WhenAll과 함께 사용하는 예외 처리 public async Task ProcessMultipleAsync() { var tasks = new[] { Task1Async(), Task2Async(), Task3Async() };
try
{
await Task.WhenAll(tasks);
}
catch (Exception ex)
{
// 첫 번째 예외만 throw됨
_logger.LogError(ex, "At least one task failed");
// 모든 예외를 가져오려면:
var exceptions = tasks
.Where(t => t.IsFaulted)
.Select(t => t.Exception)
.ToList();
foreach (var exception in exceptions)
{
_logger.LogError(exception, "Task failed");
}
}
}
AggregateException Handling
public async Task HandleAllExceptionsAsync() { var tasks = Enumerable.Range(1, 10) .Select(i => ProcessItemAsync(i)) .ToArray();
try
{
await Task.WhenAll(tasks);
}
catch
{
// 모든 예외 조사
var aggregateException = new AggregateException(
tasks.Where(t => t.IsFaulted)
.SelectMany(t => t.Exception?.InnerExceptions ?? Array.Empty<Exception>())
);
aggregateException.Handle(ex =>
{
if (ex is HttpRequestException)
{
_logger.LogWarning(ex, "Network error - retrying");
return true; // 처리됨 (Handled)
}
return false; // 다시 throw (Rethrow)
});
}
}
Deadlock Prevention
비동기 코드에서 흔히 발생하는 데드락 상황을 피합니다.
Common Deadlock Patterns
// ❌ DEADLOCK - 비동기 코드에서 blocking 발생 public void DeadlockExample() { // UI 또는 ASP.NET 컨텍스트에서 데드락 발생 var result = GetDataAsync().Result;
// 이것 또한 데드락 발생 가능
GetDataAsync().Wait();
}
// ✅ 올바름 - 끝까지 비동기 유지 (async all the way) public async Task CorrectExample() { var result = await GetDataAsync(); }
// ✅ 올바름 - 라이브러리 코드에서 ConfigureAwait(false) 사용 public async Task<Data> LibraryMethodAsync() { var data = await FetchAsync().ConfigureAwait(false); return ProcessData(data); }
Avoiding Deadlocks
public class DeadlockFreeService { // ✅ 끝까지 비동기 유지 public async Task<Result> ProcessAsync() { var data = await GetDataAsync(); var processed = await ProcessDataAsync(data); return processed; }
// ✅ 부득이하게 block 해야 한다면 Task.Run 사용
public Result ProcessSync()
{
return Task.Run(async () => await ProcessAsync()).GetAwaiter().GetResult();
}
// ✅ 비동기 disposal 사용 (Async disposal)
public async Task UseResourceAsync()
{
await using var resource = new AsyncDisposableResource();
await resource.ProcessAsync();
}
}
Async in ASP.NET Core
ASP.NET Core 애플리케이션의 비동기 코드 모범 사례.
Controller Async Patterns
[ApiController] [Route("api/[controller]")] public class ProductsController : ControllerBase { private readonly IProductRepository _repository;
// ✅ Async 액션 메서드
[HttpGet("{id}")]
public async Task<ActionResult<Product>> GetProduct(
int id,
CancellationToken cancellationToken)
{
var product = await _repository.GetByIdAsync(id, cancellationToken);
if (product == null)
return NotFound();
return Ok(product);
}
[HttpPost]
public async Task<ActionResult<Product>> CreateProduct(
[FromBody] CreateProductRequest request,
CancellationToken cancellationToken)
{
var product = await _repository.CreateAsync(request, cancellationToken);
return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
}
// ✅ IAsyncEnumerable을 사용한 응답 스트리밍
[HttpGet("stream")]
public async IAsyncEnumerable<Product> StreamProducts(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var product in _repository.GetAllStreamAsync(cancellationToken))
{
yield return product;
}
}
}
Background Services
public class DataProcessorService : BackgroundService { private readonly IServiceProvider _serviceProvider; private readonly ILogger<DataProcessorService> _logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Data processor service starting");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ProcessDataBatchAsync(stoppingToken);
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
catch (OperationCanceledException)
{
// 중지 시 예상되는 상황
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing data batch");
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
_logger.LogInformation("Data processor service stopped");
}
private async Task ProcessDataBatchAsync(CancellationToken cancellationToken)
{
using var scope = _serviceProvider.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IDataRepository>();
await repository.ProcessBatchAsync(cancellationToken);
}
}
Best Practices
-
Async All the Way: .Result나 .Wait()를 사용하여 비동기 코드를 block 하지 마세요.
-
Use CancellationToken: 오래 실행되는 작업에는 항상 CancellationToken을 받도록 하세요.
-
ConfigureAwait in Libraries: 라이브러리 코드에서는 ConfigureAwait(false)를 사용하세요.
-
Avoid Async Void: 이벤트 핸들러용으로만 async void를 사용하세요.
-
Return Task Directly: 가능하면 await 없이 Task를 직접 반환하세요.
-
Use ValueTask for Hot Paths: 자주 호출되거나 동기적으로 실행되는 경우가 많은 메서드에는 ValueTask를 고려하세요.
-
Handle All Exceptions: 비동기 메서드에서는 항상 예외를 처리하세요.
-
Don't Mix Blocking and Async: 하나의 호출 체인에는 하나의 패러다임만 선택하세요.
-
Dispose Async Resources: IAsyncDisposable에는 await using을 사용하세요.
-
Test with Cancellation: 취소가 올바르게 작동하는지 테스트하세요.
Common Pitfalls
-
Blocking on Async Code: .Result나 .Wait() 사용은 데드락을 유발합니다.
-
Forgetting ConfigureAwait: 라이브러리에서 성능 문제를 일으킬 수 있습니다.
-
Async Void Methods: await가 불가능하며 예외를 삼켜버립니다.
-
Not Handling Cancellation: CancellationToken 파라미터를 무시하는 것.
-
Over-using Task.Run: 이미 비동기인 코드를 Task.Run으로 감싸지 마세요.
-
Capturing Context Unnecessarily: 컨텍스트가 필요 없는 상황에서 리소스를 낭비합니다.
-
Fire and Forget: await 없이 비동기 작업을 시작하는 것.
-
Mixing Sync and Async: 혼란을 야기하고 잠재적인 데드락을 만듭니다.
-
Not Using ValueTask Correctly: ValueTask를 여러 번 await 하는 것.
-
Ignoring Exceptions in Task.WhenAll: 첫 번째 예외만 catch 하는 것.
When to Use
다음을 수행할 때 이 SKILL을 사용합니다:
-
C#에서 비동기 코드 작성
-
I/O 바운드 작업 구현 (데이터베이스, 네트워크, 파일 시스템)
-
반응형 UI 애플리케이션 구축
-
확장 가능한 웹 서비스 구축
-
데이터 스트림 처리
-
취소 지원(Cancellation support) 구현
-
ValueTask를 통한 비동기 성능 최적화
-
병렬 비동기 작업 처리
-
비동기 코드의 데드락 방지
-
ASP.NET Core 비동기 패턴 작업
Resources
-
Async/Await Best Practices
-
ConfigureAwait FAQ
-
Async Streams Tutorial
-
ValueTask Overview
-
Task-based Asynchronous Pattern (TAP)