.NET 동시성: 올바른 도구 선택하기
이 스킬을 사용해야 하는 경우
다음과 같은 상황에서 이 스킬을 사용하세요:
- .NET에서 동시 작업을 처리하는 방법을 결정해야 할 때
- async/await, Channels, Akka.NET 또는 다른 추상화를 사용할지 평가할 때
- 락, 세마포어 또는 다른 동기화 기본 요소를 사용하고 싶은 유혹을 느낄 때
- 역압력, 배치 처리, 디바운싱이 필요한 데이터 스트림을 처리해야 할 때
- 여러 동시 엔터티 간의 상태를 관리해야 할 때
참조 파일
- advanced-concurrency.md: Akka.NET Streams, Reactive Extensions, Akka.NET Actors(엔터티-퍼-액터, 상태 머신, 클러스터 샤딩) 및 비동기 로컬 함수 패턴
철학
단순하게 시작하고, 필요할 때만 확장하세요.
대부분의 동시성 문제는 async/await으로 해결할 수 있습니다. async/await로 깔끔하게 해결할 수 없는 특정 요구 사항이 있을 때만 더 정교한 도구를 사용하세요.
공유 가변 상태를 피하려고 노력하세요. 동시성을 처리하는 가장 좋은 방법은 동시성 자체를 설계에서 없애는 것입니다. 불변 데이터, 메시지 전달, 격리된 상태(예: 액터)는 버그의 전체 범주를 제거합니다.
락은 예외여야 하며, 규칙이 되어서는 안 됩니다. 공유 가변 상태를 피할 수 없을 때:
- 첫 번째 선택: 공유 상태를 피하도록 재설계(불변성, 메시지 전달, 액터 격리)
- 두 번째 선택:
System.Collections.Concurrent사용(ConcurrentDictionary 등) - 세 번째 선택:
Channel<T>를 사용하여 메시지 전달을 통해 접근 직렬화 - 최후의 수단: 간단하고 수명이 짧은 임계 구역에
lock사용
의사 결정 트리
무엇을 하려고 하시나요?
│
├─► I/O 대기 (HTTP, 데이터베이스, 파일)?
│ └─► async/await 사용
│
├─► 컬렉션을 병렬로 처리 (CPU 바운드)?
│ └─► Parallel.ForEachAsync 사용
│
├─► 생산자/소비자 패턴 (작업 큐)?
│ └─► System.Threading.Channels 사용
│
├─► UI 이벤트 처리 (디바운스, 스로틀, 결합)?
│ └─► Reactive Extensions (Rx) 사용
│
├─► 서버 측 스트림 처리 (역압력, 배치 처리)?
│ └─► Akka.NET Streams 사용
│
├─► 복잡한 전이를 가진 상태 머신?
│ └─► Akka.NET Actors (Become 패턴) 사용
│
├─► 많은 독립 엔터티의 상태 관리?
│ └─► Akka.NET Actors (엔터티-퍼-액터) 사용
│
├─► 여러 비동기 작업 조정?
│ └─► Task.WhenAll / Task.WhenAny 사용
│
└─► 위 중 어느 것도 해당되지 않습니까?
└─► 스스로에게 물어보세요: "정말로 공유 가변 상태가 필요한가?"
├─► 예 → 공유 상태를 피하도록 재설계 고려
└─► 정말 피할 수 없음 → Channels 또는 Actors를 사용하여 접근 직렬화
레벨 1: async/await (기본 선택)
용도: I/O 바운드 작업, 비차단 대기, 대부분의 일상적인 동시성.
// 간단한 비동기 I/O
public async Task<Order> GetOrderAsync(string orderId, CancellationToken ct)
{
var order = await _database.GetAsync(orderId, ct);
var customer = await _customerService.GetAsync(order.CustomerId, ct);
return order with { Customer = customer };
}
// 병렬 비동기 작업 (독립적인 경우)
public async Task<Dashboard> LoadDashboardAsync(string userId, CancellationToken ct)
{
var ordersTask = _orderService.GetRecentOrdersAsync(userId, ct);
var notificationsTask = _notificationService.GetUnreadAsync(userId, ct);
var statsTask = _statsService.GetUserStatsAsync(userId, ct);
await Task.WhenAll(ordersTask, notificationsTask, statsTask);
return new Dashboard(
Orders: await ordersTask,
Notifications: await notificationsTask,
Stats: await statsTask);
}
핵심 원칙: 항상 CancellationToken을 받아들이세요. 라이브러리 코드에서는 ConfigureAwait(false)를 사용하세요. 비동기 코드를 차단하지 마세요.
레벨 2: Parallel.ForEachAsync (CPU 바운드 병렬 처리)
용도: 작업이 CPU 바운드이거나 제어된 동시성이 필요할 때 컬렉션을 병렬로 처리.
public async Task ProcessOrdersAsync(
IEnumerable<Order> orders,
CancellationToken ct)
{
await Parallel.ForEachAsync(
orders,
new ParallelOptions
{
MaxDegreeOfParallelism = Environment.ProcessorCount,
CancellationToken = ct
},
async (order, token) =>
{
await ProcessOrderAsync(order, token);
});
}
사용하지 말아야 할 경우: 순수 I/O 작업, 순서가 중요한 경우, 역압력이 필요한 경우.
레벨 3: System.Threading.Channels (생산자/소비자)
용도: 작업 큐, 생산자/소비자 패턴, 생산자와 소비자 분리.
public class OrderProcessor
{
private readonly Channel<Order> _channel;
public OrderProcessor()
{
_channel = Channel.CreateBounded<Order>(new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.Wait
});
}
// 생산자
public async Task EnqueueOrderAsync(Order order, CancellationToken ct)
{
await _channel.Writer.WriteAsync(order, ct);
}
// 소비자 (백그라운드 작업으로 실행)
public async Task ProcessOrdersAsync(CancellationToken ct)
{
await foreach (var order in _channel.Reader.ReadAllAsync(ct))
{
await ProcessOrderAsync(order, ct);
}
}
public void Complete() => _channel.Writer.Complete();
}
Channels이 적합한 경우: 속도 분리, 역압력이 있는 버퍼링, 작업자에게 팬아웃, 백그라운드 큐.
Channels이 부적합한 경우: 복잡한 스트림 작업(배치 처리, 윈도잉), 엔터티별 상태 저장 처리, 정교한 감독.
레벨 4+: Akka.NET Streams, Reactive Extensions, Actors
스트림 처리, UI 이벤트 구성 또는 상태 저장 엔터티 관리가 필요한 고급 시나리오는 advanced-concurrency.md를 참조하세요.
Akka.NET Streams는 서버 측 배치 처리, 스로틀링 및 역압력에 탁월합니다. Reactive Extensions는 UI 이벤트 구성에 이상적입니다. Akka.NET Actors는 엔터티-퍼-액터 패턴, Become()을 사용한 상태 머신 및 Cluster Sharding을 통한 분산 시스템을 처리합니다.
안티 패턴: 피해야 할 것
비즈니스 로직을 위한 락
// BAD: 공유 상태를 보호하기 위해 락 사용
private readonly object _lock = new();
private Dictionary<string, Order> _orders = new();
public void UpdateOrder(string id, Action<Order> update)
{
lock (_lock) { if (_orders.TryGetValue(id, out var order)) update(order); }
}
// GOOD: 액터 또는 Channel을 사용하여 접근 직렬화
수동 스레드 관리
// BAD: 수동으로 스레드 생성
var thread = new Thread(() => ProcessOrders());
thread.Start();
// GOOD: Task.Run 또는 더 나은 추상화 사용
_ = Task.Run(() => ProcessOrdersAsync(cancellationToken));
비동기 코드에서 차단
// BAD: 비동기 차단 - 교착 상태 위험!
var result = GetDataAsync().Result;
// GOOD: 모든 곳에서 비동기 사용
var result = await GetDataAsync();
보호 없는 공유 가변 상태
// BAD: 여러 작업이 공유 상태를 변경
var results = new List<Result>();
await Parallel.ForEachAsync(items, async (item, ct) =>
{
var result = await ProcessAsync(item, ct);
results.Add(result); // 경쟁 조건!
});
// GOOD: ConcurrentBag 사용
var results = new ConcurrentBag<Result>();
빠른 참조: 언제 어떤 도구를 사용할까?
| 필요 | 도구 | 예시 |
|---|---|---|
| I/O 대기 | async/await | HTTP 호출, 데이터베이스 쿼리 |
| 병렬 CPU 작업 | Parallel.ForEachAsync | 이미지 처리, 계산 |
| 작업 큐 | Channel<T> | 백그라운드 작업 처리 |
| 디바운스/스로틀 UI 이벤트 | Reactive Extensions | 입력 시 검색, 자동 저장 |
| 서버 측 배치 처리/스로틀링 | Akka.NET Streams | 이벤트 집계, 속도 제한 |
| 상태 머신 | Akka.NET Actors | 결제 흐름, 주문 생명 주기 |
| 엔터티 상태 관리 | Akka.NET Actors | 주문 관리, 사용자 세션 |
| 여러 비동기 작업 실행 | Task.WhenAll | 대시보드 데이터 로딩 |
| 여러 비동기 작업 경쟁 | Task.WhenAny | 폴백이 있는 타임아웃 |
| 주기적 작업 | PeriodicTimer | 상태 확인, 폴링 |
확장 경로
async/await (여기서 시작)
│
├─► 병렬 처리가 필요하십니까? → Parallel.ForEachAsync
│
├─► 생산자/소비자가 필요하십니까? → Channel<T>
│
├─► UI 이벤트 구성이 필요하십니까? → Reactive Extensions
│
├─► 서버 측 스트림 처리가 필요하십니까? → Akka.NET Streams
│
└─► 상태 머신 또는 엔터티 관리가 필요하십니까? → Akka.NET Actors
구체적인 필요가 있을 때만 확장하세요. "만약을 대비해" 액터나 스트림을 사용하지 마세요.