[PART 11.비동기와 동시성(12/12)] Channel<T> — 생산자-소비자 패턴의 현대적 구현
ConcurrentQueue 대비 장점 / BoundedChannel vs UnboundedChannel / async 파이프라인 구성
목차
1. 네트워크 스레드에서 받은 데이터를 메인 스레드에 어떻게 넘기는가
Unity 모바일 온라인 게임을 만든다고 하자. 서버에서 패킷이 초당 수십 개 날아오고, 수신은 별도 네트워크 스레드에서 처리한다. 하지만 게임 상태를 바꾸는 일은 반드시 메인 스레드에서 해야 한다(유니티 API는 거의 전부 메인 스레드 전용이다). 그래서 "수신 스레드가 패킷을 큐에 넣고, 메인 스레드가 Update에서 꺼낸다"는 구조를 짜게 된다.
처음에는 ConcurrentQueue<T>로 시작한다.
// 네트워크 스레드
_queue.Enqueue(packet);
// 메인 스레드의 Update()
while (_queue.TryDequeue(out var p)) Handle(p);
그런데 여기서 백그라운드 비동기 처리 파이프라인을 하나 추가하고 싶다고 해보자. 예를 들어 "수신된 패킷을 백그라운드에서 압축 해제한 뒤 메인 스레드로 올리기". 이때 소비자는 Unity의 Update가 아니라 백그라운드의 async 메서드다. 소비자는 큐가 비어있으면 일이 생길 때까지 기다려야 한다.
// 문제: 비어있을 때 기다리는 표준적인 방법이 없다
while (!_queue.TryDequeue(out var p))
await Task.Delay(10); // busy-wait — CPU 낭비 + 응답성 저하
Task.Delay(10)은 10ms마다 깨어나 큐를 살피는 바쁜 대기(busy-wait, CPU를 계속 돌며 조건이 만족되기를 기다리는 방식)다. 저전력을 지향해야 할 모바일 환경에서 이 패턴은 독이다. BlockingCollection<T>을 쓰면 스레드를 블로킹해 대기할 수 있지만, 이번엔 async의 핵심 장점(스레드를 놓아주고 물리 스레드를 재활용하는 것)이 사라진다.
우리가 정말 원하는 것은 "비어있으면 await으로 조용히 기다리다가, 데이터가 들어오는 그 순간 자연스럽게 깨어나는 큐"다. 이것이 바로 Channel<T>다.
2. Channel<T> — 락 없이 await으로 기다리는 큐
2.1 비유: 공장의 컨베이어 벨트와 감지 센서
공장에서 생산라인(생산자)과 포장라인(소비자)을 잇는 컨베이어 벨트를 상상하자. 벨트 위에 제품이 있으면 포장라인은 집어서 박스에 넣는다. 없으면 불이 꺼지고 포장 직원은 쉰다. 생산라인에서 제품이 하나 올라오는 순간 센서가 자동으로 불을 켜고 포장 직원을 깨운다. 벨트가 꽉 차면 반대로 생산라인이 잠시 멈춘다.
Channel<T>는 정확히 이 컨베이어 벨트다. 큐와 비동기 신호 메커니즘이 한 몸으로 묶여 있어서, 비어있을 때 await ReadAsync()는 스레드를 점유하지 않고 잠들고, 생산자가 WriteAsync 하는 순간 continuation이 자동으로 깨어난다. 중간에 개발자가 lock, Monitor.Wait, AutoResetEvent 같은 도구를 엮을 필요가 없다.
Channel<T>— 생산자-소비자 채널 (Producer-Consumer Channel) 스레드 안전한 큐에 비동기 신호를 결합한 자료구조. 한쪽은ChannelWriter<T>로 값을 넣고, 다른 쪽은ChannelReader<T>로 값을 꺼낸다. 비어있으면 reader가await으로 대기하고, 가득 차면 writer가await으로 대기한다.System.Threading.Channels네임스페이스에 있다.
예시:var ch = Channel.CreateUnbounded<int>();용량 제한이 없는 채널을 만든다.
2.2 구조 시각화
생산자와 소비자가 서로를 직접 참조하지 않는다. 오직 ChannelWriter<T>와 ChannelReader<T>라는 두 개의 얇은 인터페이스로만 소통한다. 생산자에게는 Writer만, 소비자에게는 Reader만 넘기면 된다 — 인터페이스 분리 원칙(ISP, Interface Segregation Principle)을 구조적으로 강제한다.
2.3 기본 코드 — 가장 간단한 생산자-소비자
using System.Threading.Channels;
var channel = Channel.CreateUnbounded<int>();
// 생산자
_ = Task.Run(async () =>
{
for (int i = 0; i < 3; i++)
await channel.Writer.WriteAsync(i);
channel.Writer.Complete(); // 더 이상 없음을 알린다
});
// 소비자
await foreach (var item in channel.Reader.ReadAllAsync())
Console.WriteLine(item); // 0, 1, 2
Channel.CreateUnbounded<int>()는 용량 무제한 채널을 만든다. 생산자는 WriteAsync로 값을 넣고 끝나면 Complete()로 "끝"을 알린다. 소비자는 ReadAllAsync()를 await foreach로 돌리면 채널이 닫히는 순간 루프가 자동 종료된다.
await foreach— 비동기 반복 (Asynchronous iteration)IAsyncEnumerable<T>를 구현한 시퀀스를 하나씩 비동기로 꺼내는 루프. 각 반복 사이에await이 섞일 수 있다. C# 8.0에서 도입되었다.
예시:await foreach (var x in reader.ReadAllAsync()) { ... }채널에 값이 도착할 때마다 한 번씩 루프가 실행된다.
2.4 IL 분석 — Channel.CreateUnbounded는 어떤 객체를 돌려주는가
Channel.CreateUnbounded<int>() 호출부를 컴파일하면 아래와 같은 IL이 나온다.
// var channel = Channel.CreateUnbounded<int>();
call class [System.Threading.Channels]System.Threading.Channels.Channel`1<!!0>
[System.Threading.Channels]System.Threading.Channels.Channel::CreateUnbounded<int32>()
stloc.0 // channel
// await channel.Writer.WriteAsync(i);
ldloc.0
callvirt instance class ChannelWriter`1<!0>
Channel`1<int32>::get_Writer() // Writer 프로퍼티 getter
ldloc.1 // i
ldc.i4.0
newobj instance void CancellationToken::.ctor(bool)
callvirt instance valuetype ValueTask ChannelWriter`1<int32>::WriteAsync(!0, CancellationToken)
핵심은 두 가지다. 첫째, CreateUnbounded가 반환하는 것은 Channel<int> 인스턴스(참조 타입)다 — 한 번 생성되면 생산자와 소비자 모두가 같은 객체를 공유한다. 둘째, WriteAsync는 Task가 아니라 valuetype ValueTask를 반환한다. 즉, 동기 완료 경로(큐에 빈 공간이 있는 일반 케이스)에서 힙 할당 없이 끝난다. 초당 수천 번의 WriteAsync 호출에도 GC 압력이 붙지 않는 것은 이 ValueTask 설계 덕분이다.
3. UnboundedChannel과 BoundedChannel은 서로 다른 자료구조다
Channel.CreateUnbounded와 Channel.CreateBounded는 이름만 비슷할 뿐 내부 구현이 다르다. 선택을 잘못하면 앱이 먹통이 되거나 메모리가 터진다.
3.1 두 채널의 내부 구조
Unbounded의 저장소는 ConcurrentQueue<T>다. 락을 쓰지 않는 대신 쓰기가 항상 즉시 끝난다. Bounded의 저장소는 내부 Deque<T> + 단일 lock이다. 락이 있지만 용량이 차면 Writer도 대기하는 backpressure(역압)를 구현할 수 있다.
Backpressure — 역압 소비자의 처리 속도가 생산자보다 느릴 때, 생산자가 자발적으로 느려지도록 강제하는 메커니즘. 생산자의 WriteAsync를 블로킹(실제론 비동기 대기)해서 시스템 전체가 소비 속도에 맞춰 흐르게 한다. 메모리 폭주를 막는 기본 패턴이다.
3.2 BoundedChannelFullMode — 가득 찼을 때의 정책
BoundedChannelOptions.FullMode는 4가지 값을 가진다.
var opts = new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.Wait, // (기본) 공간 생길 때까지 대기
// FullMode = BoundedChannelFullMode.DropNewest, // 직전에 들어온 것을 밀어내고 새 것 삽입
// FullMode = BoundedChannelFullMode.DropOldest, // 가장 오래된 것을 버리고 새 것 삽입
// FullMode = BoundedChannelFullMode.DropWrite, // 이번 쓰기를 그냥 버린다
};
var ch = Channel.CreateBounded<int>(opts);
어느 것을 쓸지는 "버려도 되는 데이터"인지로 결정한다.
| FullMode | 언제 쓰는가 | Unity 실전 예 |
|---|---|---|
Wait |
모든 데이터가 중요함 | 결제 트랜잭션, 저장 이벤트 |
DropOldest |
오래된 것은 쓸모 없음 | 다른 플레이어 위치 업데이트 |
DropNewest |
이미 처리 대기 중인 것 우선 | 이벤트 큐에서 연속 중복 버리기 |
DropWrite |
최신 1개만 유지 (사실상 덮어쓰기) | 임시 디버그 로그, 프레임 통계 |
3.3 IL로 보는 "Writer가 대기하는 경로"
BoundedChannel이 가득 찼을 때 WriteAsync는 어떻게 되는가. 코드 자체는 동일하다.
await writer.WriteAsync(item);
그런데 IL의 수준에서는 writer의 실제 타입이 UnboundedChannelWriter냐 BoundedChannelWriter냐에 따라 실행 경로가 갈라진다.
// 컴파일 결과는 공통 — 둘 다 가상 호출
callvirt instance valuetype ValueTask ChannelWriter`1<!0>::WriteAsync(!0, CancellationToken)
callvirt(가상 메서드 호출)로 런타임에 실제 writer의 WriteAsync가 디스패치된다. UnboundedChannelWriter.WriteAsync는 항상 완료된 ValueTask를 돌려주고, BoundedChannelWriter.WriteAsync는 큐가 가득 차면 AsyncOperation<VoidResult>라는 IValueTaskSource 구현 객체를 꺼내 ValueTask로 감싸 반환한다. 여기서 처음으로 힙 할당이 발생한다 — 동기 완료 경로에서는 ValueTask가 힙 없이 끝나지만, 실제 대기가 필요한 순간에만 객체를 만든다. Unity 핫패스에서 Bounded를 써도 대부분의 쓰기는 할당 없이 지나간다는 뜻이다.
3.4 SingleReader / SingleWriter 최적화
옵션으로 "reader가 하나뿐"이거나 "writer가 하나뿐"임을 채널에게 약속하면 내부 동기화를 상당 부분 없앨 수 있다.
var opts = new UnboundedChannelOptions
{
SingleReader = true, // reader는 오직 1개 (예: Unity 메인 스레드)
SingleWriter = true, // writer도 오직 1개 (예: 네트워크 스레드)
AllowSynchronousContinuations = false, // 핫패스에서 false 권장
};
var ch = Channel.CreateUnbounded<Packet>(opts);
| 옵션 | 약속하는 것 | 깨지면 |
|---|---|---|
SingleReader = true |
ReadAsync/TryRead를 단 하나의 consumer가 호출 |
데이터가 유실되거나 내부 상태 손상 |
SingleWriter = true |
WriteAsync/TryWrite를 단 하나의 producer가 호출 |
같은 위의 증상 |
AllowSynchronousContinuations |
continuation을 호출자 스레드에서 inline 실행 | 기본 false가 안전, true는 스택 오버플로우 위험 |
Unity 모바일에서 "네트워크 스레드 1개 → 메인 스레드 1개" 구조라면 둘 다 true로 두는 것이 제값을 뽑는 설정이다.
4. 로딩 파이프라인·이벤트 큐·네트워크 수신을 Channel로 재작성하기
개념은 설명했다. 이제 실제 Unity 모바일 게임에서 자주 쓰는 세 가지 패턴을 Before/After로 비교한다.
4.1 사례 ① — 이벤트 큐: 백그라운드에서 메인 스레드로 안전하게 건네기
상황: 네트워크 수신 스레드가 패킷을 받아 게임 이벤트로 변환한 뒤, Unity 메인 스레드에서 처리해야 한다.
Before — ConcurrentQueue + Update 폴링
// 네트워크 스레드
public class NetworkReceiver
{
readonly ConcurrentQueue<GameEvent> _q = new();
public ConcurrentQueue<GameEvent> Queue => _q;
void OnReceive(byte[] packet)
{
_q.Enqueue(Deserialize(packet)); // 락프리 푸시
}
}
// Unity 메인 스레드
public class EventDispatcher : MonoBehaviour
{
[SerializeField] NetworkReceiver _recv;
void Update()
{
while (_recv.Queue.TryDequeue(out var evt))
Dispatch(evt);
}
}
얼핏 깔끔해 보이지만 두 가지 약점이 있다. 첫째, Update에서 폴링하므로 매 프레임 빈 큐를 한 번씩 건드린다. 대부분 frames에서 아무 일도 없어도 TryDequeue 호출 비용이 발생한다. 둘째, 메인 스레드 말고 다른 async 메서드가 이벤트를 구독하고 싶다면 별도의 ManualResetEventSlim 같은 신호 객체를 덧대야 한다.
After — Channel<T> + await foreach
public class NetworkReceiver
{
readonly Channel<GameEvent> _ch = Channel.CreateUnbounded<GameEvent>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
public ChannelReader<GameEvent> Reader => _ch.Reader; // 외부엔 Reader만 공개
void OnReceive(byte[] packet)
{
_ch.Writer.TryWrite(Deserialize(packet)); // 논블로킹 동기 쓰기
}
public void Stop() => _ch.Writer.Complete();
}
// Unity 메인 스레드 — 여전히 TryRead를 쓸 수 있다 (폴링 유지 옵션)
public class EventDispatcherPolling : MonoBehaviour
{
[SerializeField] NetworkReceiver _recv;
void Update()
{
while (_recv.Reader.TryRead(out var evt))
Dispatch(evt);
}
}
// 또는 백그라운드 async 소비자
async Task ConsumeAsync(ChannelReader<GameEvent> reader, CancellationToken ct)
{
await foreach (var evt in reader.ReadAllAsync(ct))
Dispatch(evt);
}
Before와 After의 차이는 세 가지다.
- 외부로는 Reader만 노출.
NetworkReceiver에Queue프로퍼티 대신Reader프로퍼티만 공개해 "외부에서 실수로 Enqueue할 수 없게" 만들 수 있다. - async 소비자를 바로 붙일 수 있다. 백그라운드 스레드에서
await foreach로 돌리는 처리가 공짜로 생긴다. - Complete 신호가 자연스럽게 흐른다.
Stop()을 부르는 순간ReadAllAsync루프가 종료되어 리소스 정리가 깔끔해진다.
메인 스레드 폴링 패턴(TryRead 루프)은 그대로 유지할 수 있다 — Channel은 동기 경로도 모두 지원한다.
IL로 본 차이 — TryRead는 ValueTask가 아니다
// _recv.Reader.TryRead(out var evt)
callvirt instance bool ChannelReader`1<!0>::TryRead(!0&)
brfalse.s EXIT_LOOP
TryRead는 ValueTask도 Task도 반환하지 않는 순수 동기 bool 메서드다. 즉 async 상태 머신이 전혀 만들어지지 않는다. Unity Update()에서 매 프레임 불러도 할당이 0바이트다. "async 채널이지만 메인 스레드에선 동기 API로도 쓸 수 있다"는 점이 핵심 실전 팁이다.
4.2 사례 ② — 로딩 파이프라인: 동시 로드 수 제한하기
상황: 씬 전환 시 에셋번들 100개를 로드해야 한다. 모두 동시에 로드하면 메모리가 터진다. 최대 4개씩만 동시에 로드하고 싶다.
Before — SemaphoreSlim으로 수동 제어
async Task LoadAllAsync(string[] names)
{
using var sem = new SemaphoreSlim(4);
var tasks = names.Select(async name =>
{
await sem.WaitAsync();
try { await LoadAsync(name); }
finally { sem.Release(); }
});
await Task.WhenAll(tasks);
}
잘 동작하지만 100개의 Task를 한 번에 만들어 놓고 세마포어로 대기 줄을 세운다. 100개의 상태 머신이 전부 힙에 깔린다.
After — BoundedChannel로 작업 큐 만들기
async Task LoadAllAsync(string[] names, CancellationToken ct)
{
var jobs = Channel.CreateBounded<string>(new BoundedChannelOptions(4)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = false, // 워커 4개
SingleWriter = true, // 생산자 1개
});
// 워커 4개 — 같은 reader를 공유
var workers = Enumerable.Range(0, 4).Select(_ => Task.Run(async () =>
{
await foreach (var name in jobs.Reader.ReadAllAsync(ct))
await LoadAsync(name);
})).ToArray();
// 생산자 — 채널이 가득 차면 여기서 자동으로 대기한다 (backpressure)
foreach (var name in names)
await jobs.Writer.WriteAsync(name, ct);
jobs.Writer.Complete();
await Task.WhenAll(workers);
}
차이의 핵심은 100개의 Task를 미리 만들지 않는다는 점이다. 생산자는 채널이 받아줄 때만 작업을 넣는다. 메모리에 공존하는 상태 머신이 항상 4개 + α로 제한되므로 Unity 모바일의 제한된 RAM을 예측 가능하게 쓸 수 있다.
IL로 본 "backpressure의 실체"
// await jobs.Writer.WriteAsync(name, ct);
callvirt instance valuetype ValueTask ChannelWriter`1<string>::WriteAsync(!0, CancellationToken)
stloc 'task'
ldloca.s 'task'
call instance valuetype ValueTaskAwaiter ValueTask::GetAwaiter()
...
callvirt instance bool ValueTaskAwaiter::get_IsCompleted()
brtrue.s SKIP_AWAIT
// 여기서 상태 머신이 중단되고, 채널에 공간이 생길 때 resume
ValueTaskAwaiter.IsCompleted가 false이면(= 채널이 꽉 찼으면) 상태 머신이 일시 중단되어 스택을 해제한다. 이 지점이 바로 backpressure가 실제로 동작하는 순간이다. SemaphoreSlim 버전은 100개의 상태 머신이 동시에 메모리에 살아있지만, Channel 버전은 대기 중인 생산자는 1개뿐이다.
4.3 사례 ③ — 다단계 파이프라인: 수신 → 압축 해제 → 디스패치
상황: 서버가 GZip으로 압축한 패킷을 보낸다. 수신 스레드는 바이트만 받아 넣고, 백그라운드 스레드 풀이 압축을 풀고, 메인 스레드가 이벤트를 디스패치한다.
// 2단계 파이프라인: raw → 해제된 이벤트
var raw = Channel.CreateBounded<byte[]>(new BoundedChannelOptions(32) { SingleWriter = true });
var evts = Channel.CreateBounded<GameEvent>(new BoundedChannelOptions(64) { SingleReader = true });
// 1단계: 네트워크 수신 스레드
void OnPacket(byte[] bytes) => raw.Writer.TryWrite(bytes);
// 2단계: 백그라운드 워커 — 여러 개 가능
async Task DecompressWorkerAsync(CancellationToken ct)
{
await foreach (var bytes in raw.Reader.ReadAllAsync(ct))
{
var evt = Deserialize(Decompress(bytes));
await evts.Writer.WriteAsync(evt, ct);
}
}
// 3단계: 메인 스레드 디스패처 (Unity Update에서 드레인)
void Update()
{
while (evts.Reader.TryRead(out var evt))
Dispatch(evt);
}
raw 채널과 evts 채널이 서로 다른 용량으로 설정되어 있다는 점을 보자. 수신은 뾰족한 스파이크가 있을 수 있어 버퍼 32개, 메인 스레드가 한 프레임에 처리할 수 있는 이벤트는 더 많으니 64개로 둔다. 용량은 각 단계의 생산/소비 특성에 맞춰 따로 튜닝해야 한다.
5. 함정과 주의사항
실무에서 Channel이 조용히 앱을 먹통으로 만드는 패턴 4가지를 살펴보자.
5.1 함정 ① — Complete() 누락 → 영원한 대기
// ❌ 잘못된 코드
async Task Produce(ChannelWriter<int> w)
{
for (int i = 0; i < 10; i++)
await w.WriteAsync(i);
// w.Complete(); ← 빠짐!
}
// 소비자는 영원히 기다린다
await foreach (var x in reader.ReadAllAsync()) // 10개 받고… 그대로 정지
Console.WriteLine(x);
생산자가 값을 다 넣었어도 Complete()를 호출하지 않으면 채널은 "아직 값이 더 올 수 있다"고 판단한다. ReadAllAsync의 await foreach는 영원히 반환되지 않는다. Unity 모바일에서는 백그라운드 Task가 살아있어 앱 종료가 지연되거나 메모리가 회수되지 않는다.
// ✅ 올바른 코드
async Task Produce(ChannelWriter<int> w)
{
try
{
for (int i = 0; i < 10; i++)
await w.WriteAsync(i);
}
finally
{
w.Complete(); // 예외 중에도 반드시 호출
}
}
try/finally로 예외 상황에서도 반드시 Complete를 호출한다. 예외를 전파하고 싶다면 w.Complete(ex)로 예외 객체를 담아 닫으면 소비자 쪽에서 그대로 throw된다.
IL로 본 finally의 중요성
.try
{
// WriteAsync 루프 본체
leave.s END_FINALLY
}
finally
{
ldarg.0
callvirt instance void ChannelWriter`1<int32>::Complete(class [mscorlib]System.Exception)
endfinally
}
END_FINALLY:
ret
finally 블록은 예외/정상 어느 경로에서도 Complete 호출이 보장되는 구조다. 이것이 없으면 네트워크 예외 한 번에 채널이 영원히 열린 채 남는다.
5.2 함정 ② — SingleReader=true인 채널에 여러 reader
// ❌ 잘못된 코드
var opts = new UnboundedChannelOptions { SingleReader = true };
var ch = Channel.CreateUnbounded<int>(opts);
// 워커 두 개가 같은 reader를 읽는다 → 계약 위반
_ = Task.Run(async () =>
{
await foreach (var x in ch.Reader.ReadAllAsync()) Handle(x);
});
_ = Task.Run(async () =>
{
await foreach (var x in ch.Reader.ReadAllAsync()) Handle(x); // ☠
});
SingleReader = true는 "내부 동기화를 건너뛰어도 안전하다"는 약속이다. 여러 reader가 동시에 접근하면 큐의 헤드 포인터가 손상되어 값이 중복 소비되거나 누락된다. 심지어 예외도 안 나고 조용히 틀리게 동작한다.
// ✅ 올바른 코드 — 워커가 여러 개면 SingleReader = false
var opts = new UnboundedChannelOptions { SingleReader = false, SingleWriter = true };
var ch = Channel.CreateUnbounded<int>(opts);
// 이제 여러 워커가 동시에 읽어도 안전
옵션은 실제 코드의 접근 패턴과 정확히 일치해야 한다. 의심스러우면 false로 두는 편이 안전하다(성능 손실은 미미하다).
5.3 함정 ③ — CancellationToken을 안 넘겨 앱 종료가 안 끝남
// ❌ 잘못된 코드
async Task ConsumeAsync(ChannelReader<int> reader)
{
await foreach (var x in reader.ReadAllAsync()) // 토큰 없음
Handle(x);
}
Unity에서 씬을 전환하거나 앱을 종료할 때 이 Task를 멈추려면 어떻게 할까? 생산자가 Complete()를 안 부르면 영원히 끝나지 않는다. 토큰을 전달해야 외부에서 취소할 수 있다.
// ✅ 올바른 코드
async Task ConsumeAsync(ChannelReader<int> reader, CancellationToken ct)
{
try
{
await foreach (var x in reader.ReadAllAsync(ct))
Handle(x);
}
catch (OperationCanceledException) { /* 정상 종료 */ }
}
// 호출 측
var cts = new CancellationTokenSource();
var task = ConsumeAsync(reader, cts.Token);
// ... OnDestroy에서
cts.Cancel();
await task; // 즉시 깔끔하게 종료
ReadAllAsync(ct)는 ct.IsCancellationRequested가 되는 순간 OperationCanceledException을 던진다. Unity에서는 MonoBehaviour.destroyCancellationToken(Unity 2022.2+)이나 직접 만든 CancellationTokenSource를 연결해 둔다.
IL로 본 취소의 전파
// await reader.ReadAllAsync(ct)
ldarg.1 // reader
ldarg.2 // ct
callvirt instance class [mscorlib]IAsyncEnumerable`1<!0>
ChannelReader`1<int32>::ReadAllAsync(CancellationToken)
// 상태 머신이 iterator를 돌며 각 MoveNextAsync에서 ct 검사
내부적으로 ReadAllAsync는 IAsyncEnumerable을 만들고 각 MoveNextAsync 호출에 토큰을 전달한다. 따라서 Cancel() 한 번으로 모든 대기가 즉시 풀린다. 토큰 없이 호출했다면 이 연결이 끊겨 외부에서 멈출 방법이 없다.
5.4 함정 ④ — Unbounded를 기본값처럼 쓰기
// ❌ 잘못된 코드 — 모바일에서 위험
var ch = Channel.CreateUnbounded<LogEntry>();
// 백그라운드 로그 생산자
void OnLog(LogEntry e) => ch.Writer.TryWrite(e);
"제한이 없어서 간단하다"는 이유로 Unbounded를 기본으로 고르면, 앱이 백그라운드로 내려가거나 소비자가 느릴 때 큐가 무한 증가한다. 모바일 RAM(특히 저사양 안드로이드 기기)에서는 수백 MB만 잡혀도 OS가 앱을 킬한다.
// ✅ 올바른 코드 — 용량 + drop 정책
var ch = Channel.CreateBounded<LogEntry>(new BoundedChannelOptions(1024)
{
FullMode = BoundedChannelFullMode.DropOldest, // 오래된 로그는 버려도 OK
});
원칙: 용량 상한이 없는 Unbounded는 소비가 "절대 뒤처지지 않음"이 증명될 때만 쓴다. 의심스러우면 Bounded + DropOldest가 모바일 환경에서 가장 안전한 기본값이다.
6. C# 버전별 변화
Channel은 언어 기능이라기보다 BCL(Base Class Library) 라이브러리라서 C# 버전보다는 .NET 런타임 버전을 따른다.
6.1 이전 시대 — .NET Framework와 NuGet 프리뷰 (2018 이전)
Channel<T>가 BCL에 들어오기 전에는 생산자-소비자가 필요하면 ConcurrentQueue + AutoResetEvent 또는 BlockingCollection 또는 TPL Dataflow(BufferBlock<T>)를 조립해야 했다.
// 옛날 방식 — BlockingCollection
var q = new BlockingCollection<int>(boundedCapacity: 10);
// 생산자
Task.Run(() => { for (int i = 0; i < 100; i++) q.Add(i); q.CompleteAdding(); });
// 소비자 — 스레드를 블로킹해 대기 (async 아님)
foreach (var x in q.GetConsumingEnumerable())
Console.WriteLine(x);
GetConsumingEnumerable은 스레드를 블로킹한다. 이는 async 기반 코드와 근본적으로 궁합이 맞지 않는다. 소비자 스레드가 그냥 놀고 있는 셈이다.
6.2 .NET Core 3.0 (2019) — System.Threading.Channels 정식 도입
System.Threading.Channels 네임스페이스가 .NET Core 3.0에서 BCL에 기본 포함되었다. API 구조는 이때 확정되어 지금까지 거의 그대로 유지된다.
// .NET Core 3.0 이후 — 공식 API
var ch = Channel.CreateBounded<int>(10);
await ch.Writer.WriteAsync(1);
await foreach (var x in ch.Reader.ReadAllAsync()) { ... }
가장 중요한 변화는 ReadAsync/WriteAsync가 ValueTask를 반환한다는 점이다. BlockingCollection은 Task도 ValueTask도 없는 동기 API였고, 그 이후 구현체들은 Task를 썼다. Channel은 처음부터 ValueTask를 선택해 힙 할당을 최소화했다.
6.3 .NET 5 / 6 / 8 — 성능 튜닝
API는 추가되지 않았다. 런타임이 꾸준히 JIT 인라이닝, ValueTask 내부 구현 개선, AsyncOperation<T> 재활용 최적화를 다듬었다. 같은 코드가 .NET 8에서는 .NET Core 3.0 대비 수십 % 빠르게 동작한다(마이크로소프트의 annual perf blog 참조).
.NET 6에서는 ChannelReader<T>.ReadAllAsync(CancellationToken) 오버로드가 정식으로 들어와 취소 전파가 깔끔해졌다.
6.4 Before/After — BlockingCollection에서 Channel로
// Before — .NET Framework 4.x 시대
var q = new BlockingCollection<Packet>(100);
new Thread(() =>
{
foreach (var p in q.GetConsumingEnumerable()) // 블로킹
Handle(p);
}).Start();
q.Add(packet);
// After — 현대 C# (.NET 6+)
var ch = Channel.CreateBounded<Packet>(100);
_ = Task.Run(async () =>
{
await foreach (var p in ch.Reader.ReadAllAsync()) // 비동기 대기
Handle(p);
});
await ch.Writer.WriteAsync(packet);
두 버전의 코드 외형은 비슷하지만, After는 소비자 스레드가 대기 중일 때 스레드 풀에 반환된다. 서버·멀티플레이 백엔드에서는 스레드 한 개를 덜 쓰는 것이 수천 배 누적되면 동시 접속 허용치의 차이가 된다. 클라이언트 모바일 환경에서도 "깨어있는 물리 스레드 개수"가 줄면 배터리 소모가 준다.
7. 정리
핵심 체크리스트
- Channel<T>는 "비동기 대기 + 생산자-소비자"가 한 몸인 자료구조다. ConcurrentQueue의 락프리 성능에 await 지원을 더한 것.
- Unbounded는 OOM 위험이 있다. 모바일 기본값은
Bounded + FullMode를 고르는 것. - BoundedChannelFullMode: 모든 데이터 보존 →
Wait, 최신 우선 →DropOldest, 중복 억제 →DropNewest, 덮어쓰기 →DropWrite. - SingleReader/SingleWriter는 계약이다. 실제 접근 패턴과 옵션을 반드시 일치시킨다. 의심되면
false. ChannelReader<T>/ChannelWriter<T>로만 외부에 노출해 ISP를 강제한다. 생산자에게는 Writer, 소비자에게는 Reader만.- Complete()는 반드시 try/finally로 보장한다. 누락되면 소비자가 영원히 대기.
- CancellationToken을 항상 함께 넘긴다. Unity에서는 씬 전환·앱 종료에서 자원을 깔끔히 풀 수 있어야 한다.
- Unity 메인 스레드 소비는
TryRead루프가 정석이다 — 할당 없는 동기 경로를 Channel이 그대로 제공한다. WriteAsync/ReadAsync가ValueTask반환이므로 동기 완료 경로에서 GC 압력이 없다. 핫패스에 적합하다.- .NET Core 3.0 이후 BCL 내장. NuGet 추가 패키지 불필요.
직접 확인해보기
UnboundedChannelOptions의AllowSynchronousContinuations = true를 켜서BenchmarkDotNet으로 측정하면 스레드 스위칭이 줄어 throughput이 얼마나 올라가는가? 대신 어떤 위험(스택 오버플로우·메인 스레드 블로킹)이 생기는가?- 2단계 파이프라인(
raw → decompressed)에서 중간 채널의 용량을 1, 4, 32, 256으로 바꿔가며 latency와 메모리 사용량이 어떻게 변하는지 Unity Profiler로 비교해보라. SingleReader = true를 켠 채 reader를 2개 돌리면 어떤 증상이 나오는가? 값이 유실될까, 중복 처리될까, 예외가 날까? (힌트: "조용히 틀린다"가 현실이다.)
'C# 심화' 카테고리의 다른 글
| [PART12.메모리 관리와 성능(2/10)] IDisposable — 왜 Dispose 패턴이 필요한가 (0) | 2026.04.14 |
|---|---|
| [PART12.메모리 관리와 성능(1/10)] 가비지 컬렉터 — 어떻게 동작하는가 (0) | 2026.04.14 |
| [PART11.비동기와 동시성(11/12)] volatile과 Interlocked — lock 없이 스레드 안전을 확보하는 법 (1) | 2026.04.14 |
| [PART11.비동기와 동시성(10/12)] Mutex vs SemaphoreSlim vs lock — 동기화 도구 선택 기준 (0) | 2026.04.14 |
| [PART11.비동기와 동시성(9/12)] lock — 임계 구역을 보호하는 원리 (0) | 2026.04.14 |