C#에서 Channel<T>BlockingCollection<T> 비교

동시성 프로그래밍에서 생산자-소비자 패턴은 매우 일반적인 구조이며, C#에서는 이를 구현하기 위한 여러 추상화가 존재한다. 특히 BlockingCollection<T>Channel<T>는 대표적인 메시지 큐이며, 유사한 목적을 가지지만 구조, 성능, 사용성 면에서 뚜렷한 차이를 지닌다. 두 구조의 가장 본질적인 공통점은 “생산자 또는 소비자가 준비되지 않았을 때 대기(BLOCKING 또는 AWAITING) 를 통해 동기화된다”는 점입니다.
즉, 생산자-소비자 간 속도 차이를 자동으로 조율해주는 구조라는 점이 가장 중요한 공통점이며, 이게 일반 Queue나 ConcurrentQueue와 구분되는 지점이기도 합니다.

  1. 자동 대기(Blocking 또는 Awaiting) 기반 흐름 제어
    소비자가 큐에서 데이터를 꺼내려 할 때 데이터가 없다면, 소비자는 자동으로 대기하며, 데이터가 추가되면 즉시 깨어난다.
    반대로, 큐가 가득 찼을 경우 생산자도 차단(block) 또는 비동기 대기(await) 를 통해 흐름을 제어한다.

  2. 생산자-소비자의 느슨한 결합 구조 지원
    생산 속도와 소비 속도가 달라도 버퍼와 대기 메커니즘을 통해 균형 있게 처리되므로, 비동기적 흐름 제어가 용이하다.

  3. 스레드 안전성 보장
    동시 접근 시 내부적으로 적절한 동기화를 수행하여, 생산자와 소비자가 다중 스레드 환경에서도 안전하게 데이터를 주고받을 수 있다.

이번 글에서는 이 두 컬렉션의 차이점을 명확히 짚고, 언제 어떤 것을 선택해야 하는지 기준을 제시합니다.

요약

항목 BlockingCollection Channel
기본 구현체 ConcurrentQueue<T> 등 래핑 커스텀 구현 (Channel<T>)
생산자/소비자 모델 단순 모델 다중 생산자/다중 소비자까지 유연
완료 신호 CompleteAdding() channel.Writer.Complete()
대기 방법 차단(block) 비동기 대기(await)
비동기 지원 불완전 (GetConsumingEnumerable) 완전한 async/await 지원
용도 간단한 동기 큐 고성능 비동기 스트리밍 파이프

내부 구조 비교

BlockingCollection

  • 내부적으로 IProducerConsumerCollection<T> 구현체를 감싼 래퍼이다.
  • Add(), Take() 같은 메서드를 제공하며, CompleteAdding() 호출로 생산 완료를 명시할 수 있다.
  • Blocking의 구현은 Monitor.Wait 또는 SemaphoreSlim 기반이다.

Channel

  • System.Threading.Channels 네임스페이스에 있으며, .NET Core/Standard 이후 등장.
  • 비동기 처리를 기본으로 설계되어 있고, ChannelWriter<T>, ChannelReader<T>로 역할이 명확히 분리된다.
  • 내부적으로는 RingBuffer 기반의 Lock-Free Queue를 활용하며, 동시성 처리에 강력하다.

다음은 두 구조를 비교한 시각적 다이어그램이다:

graph TD
  subgraph BlockingCollection
    A[Producer Thread] --> |Add| B[BlockingCollection]
    B --> |Take| C[Consumer Thread]
  end

  subgraph Channel
    D[Producer Thread] -->|WriteAsync| E[Channel.Writer]
    E --> F[Internal Buffer]
    F --> |ReadAsync| G[Channel.Reader]
    G --> H[Consumer Thread]
  end

API 사용 예시

BlockingCollection

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
var collection = new BlockingCollection<int>(boundedCapacity: 100);

Task producer = Task.Run(() =>
{
for (int i = 0; i < 1000; i++)
collection.Add(i); // 큐가 가득 차면 BLOCK
collection.CompleteAdding();
});

Task consumer = Task.Run(() =>
{
while (!collection.IsCompleted)
{
try
{
int item = collection.Take(); // 큐가 비어 있으면 BLOCK
Console.WriteLine(item);
}
catch (InvalidOperationException)
{
// CompleteAdding 이후 Take 시 발생 가능
break;
}
}
});

Channel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
var channel = Channel.CreateBounded<int>(100);

Task producer = Task.Run(async () =>
{
for (int i = 0; i < 1000; i++)
await channel.Writer.WriteAsync(i); // 큐가 가득 차면 await으로 대기
channel.Writer.Complete();
});

Task consumer = Task.Run(async () =>
{
while (await channel.Reader.WaitToReadAsync())
{
// 버퍼에 남아있는 모든 아이템을 즉시 꺼냄
while (channel.Reader.TryRead(out int item))
{
Console.WriteLine(item); // 항목 하나씩 처리
}
}
});

// 혹은 아래의 consumer (위: 한번에 다꺼냄, 아래: 한번에 하나씩 꺼냄)
Task consumer = Task.Run(async () => {
while (!channel.Reader.Completion.IsCompleted)
{
try
{
int item = await channel.Reader.ReadAsync(); // 하나씩 읽어옴
Console.WriteLine(item);
}
catch (ChannelClosedException)
{
break; // 채널이 닫혔으면 반복 종료
}
}
});

BlockingCollection vs. Channel

항목 BlockingCollection Channel
생산 Add() WriteAsync()
소비 Take() TryRead() + WaitToReadAsync()
완료 신호 CompleteAdding() Writer.Complete()

성능과 선택 기준

  • BlockingCollection<T>는 단순한 시나리오에 적합: 스레드 기반 동기 소비자가 주를 이루는 경우 빠르게 구현할 수 있다.

  • Channel<T>는 고성능 비동기 스트림 처리에 유리: 성능 측면에서 Channel<T>가 대부분 이점이 있으며, 다수의 생산자-소비자, 비동기 await 흐름이 필요한 현대적인 아키텍처에 적합하다.


결론

BlockingCollection<T>는 레거시 환경이나 단순한 생산자-소비자 구조에 여전히 유효하다. 그러나 새로운 비동기 기반 아키텍처나 고성능 시스템에서는 Channel<T>가 더 적절한 선택이며, 설계 관점에서도 명확한 역할 분리와 비동기 친화적 인터페이스를 제공한다.

실제 시스템의 요구사항에 따라 적절한 도구를 선택하는 것이 중요하고, 최근에는 비동기 파이프라인 아키텍처가 점점 주목 받고 있다.