MagicOnion 의 StreamingHub 는 한 연결의 호출을 한 줄로 세운다
public interface IChatHub : IStreamingHub<IChatHub, IChatReceiver>
{
ValueTask JoinAsync(string room);
ValueTask<int> AddAsync(int x, int y);
void Notify(int value);
}
클라이언트가 같은 연결로 SlowAsync 와 AddAsync 를 동시에 부르면 AddAsync 는 바로 끝날까요. 허브 메서드 안에서 클라이언트의 답을 기다리는 동안 요청이 몰리면 어떻게 될까요.
StreamingHub 는 연결 하나를 gRPC 양방향 스트림 하나로 삼고, 그 위의 호출을 용량 10 짜리 큐와 소비 루프 하나로 도착 순서대로 한 줄 세웁니다. 다른 연결끼리는 병렬입니다. 기준 판본은 7.11.0 입니다.
허브 메서드는 스트림 안의 메시지다
서버가 gRPC 에 등록하는 것은 DuplexStreaming 메서드 /IChatHub/Connect 하나이고, 요청과 응답 타입은 풀에서 빌린 바이트 봉투 StreamingHubPayload 입니다. 허브 메서드는 메서드 ID → 핸들러 표에 따로 들어갑니다. 메시지는 전부 MessagePack 배열 하나이고, 종류를 적는 칸 없이 배열의 원소 수가 종류를 가릅니다.
메서드 ID 는 메서드 이름의 FNV-1a 32비트 해시입니다. 문자열 경로 대신 최대 5바이트 정수 하나가 붙고, 이름만 넣으니 오버로드는 같은 ID 가 되어 등록 때 막힙니다. (Windows 11, .NET SDK 10.0.401, net10.0, 한 프로세스 Kestrel HTTP/2 에서 잡은 바이트, gRPC 길이 접두사 제외.)
연결 하나에 루프가 셋 돈다
연결 하나가 곧 Connect 호출 하나이고 허브 인스턴스 하나입니다. 그 안에서 받은 메시지를 가르는 수신 루프, 요청을 꺼내 허브 메서드를 부르는 소비 루프, 응답 스트림에 쓰는 쓰기 루프가 돕니다.
쓰기에 큐가 필요한 것은 gRPC 응답 스트림을 여러 스레드가 동시에 쓸 수 없는데, 이 연결의 허브 메서드, 다른 연결의 브로드캐스트, 하트비트 타이머가 모두 다른 스레드에서 쓰려 하기 때문입니다.
같은 연결의 호출은 앞 메서드가 끝나야 시작한다
소비 루프는 하나이고, 꺼낸 요청의 허브 메서드가 돌려준 ValueTask 가 끝나야 다음을 꺼냅니다. 메서드 안의 await Task.Delay(300) 도 “끝” 에 들어갑니다. 응답 없는 void 호출도 같은 줄에 섭니다.
순서가 보장되니 서버는 StreamingHubContext 를 연결마다 하나만 만들어 호출마다 다시 채웁니다. 순서의 값은 지연입니다. 자기 일은 바로 끝나는 FastAsync 가 앞의 300 ms 를 기다려 305 ms 가 걸렸습니다.
요청 큐가 차면 수신 루프가 멈춘다
요청 큐는 Channel.CreateBounded(capacity: 10, FullMode = Wait) 입니다. 차면 넣는 쪽인 수신 루프가 기다리고, HTTP/2 흐름 제어가 클라이언트를 늦춥니다. 여기까지는 맞는 역압입니다. 문제는 수신 루프가 요청만 읽지 않는다는 데 있습니다. 허브 메서드가 await Client.AskAsync() 로 기다리는 클라이언트의 답도 수신 루프가 읽습니다.
뒤에 쌓인 요청이 0, 5, 10개일 때는 502~509 ms 에 성공했고 11개와 12개에서는 5,012 ms, 5,007 ms 에 실패했습니다. 5초는 클라 결과를 기다리는 기본 제한 시간이고, 교착은 이 제한 시간 덕에 풀립니다. 쌓인 요청이 클라 결과와 무관한 호출이어도 똑같습니다. 같은 수신 루프가 하트비트 응답도 읽으므로 이 큐가 차면 하트비트도 막힙니다.
주의할 점
1. 허브 메서드가 클라 결과를 기다리는 동안 같은 연결에 요청이 쌓이지 않게 한다
안 지키면 생기는 문제. await Client.X() 로 답을 기다리는 동안 같은 연결에서 요청이 11개 이상 들어오면, 클라이언트는 500 ms 에 답을 보냈는데도 아무도 읽지 않아 5초 뒤 TaskCanceledException 으로 풀립니다. 입력을 응답 없는 호출로 계속 쏘는 게임 클라이언트면 쉽게 넘습니다.
이렇게 쓴다. 클라 결과는 요청이 드문 흐름(방 입장 확인처럼 한 번 묻는 것)에서만 씁니다. 게임 루프 중에는 리시버로 알리고 클라이언트가 별도 허브 메서드로 답을 보내게 합니다.
2. OnDisconnected 에서 치운 것을 아직 도는 메서드가 쓸 수 있다
안 지키면 생기는 문제. 연결이 끝나면 서버는 소비 루프를 RequestQueueShutdownTimeout, 1초만 기다리고 OnDisconnected 를 부릅니다. 기다리기를 포기할 뿐 메서드를 멈추지는 않아서, SlowAsync(2000) 도중에 끊었더니 OnDisconnected 가 먼저 불리고 메서드는 0.9초 뒤에 끝났습니다. OnDisconnected 에서 Dispose 한 필드를 그 메서드가 쓰면 ObjectDisposedException 이 납니다.
이렇게 쓴다. 오래 걸리는 허브 메서드는 Context.CallContext.CancellationToken 을 받아 중간중간 확인하게 하고, OnDisconnected 에서는 “끝났다” 표시만 남깁니다. 공유 자원은 메서드 쪽에서 표시를 확인한 뒤 씁니다.
3. 보낸 호출이 끊기기 전에 실행됐다고 가정하지 않는다
안 지키면 생기는 문제. 소비 루프는 요청을 꺼낼 때마다 취소를 확인합니다. 클라이언트가 SlowAsync(500) 과 Notify 다섯 개를 보내고 100 ms 에 끊었더니 서버에는 Slow 만 실행되고 Notify 는 하나도 실행되지 않았습니다. “클라이언트 호출은 유실되지 않는다” 는 연결이 살아 있는 동안의 말입니다.
이렇게 쓴다. 반드시 서버에 닿아야 하는 명령은 응답 있는 메서드로 보내고 결과를 받은 뒤에 완료로 칩니다. 재접속 뒤에는 서버에서 현재 상태를 받아 다시 맞춥니다.
정리
StreamingHub 는 연결 하나의 호출을 용량 10 짜리 큐와 소비 루프 하나로 한 줄 세웁니다.
- 허브 메서드는
Connect스트림 위의 MessagePack 배열이고, 원소 수가 메시지 종류를, 이름의 해시가 메서드를 가립니다. - 같은 연결의 호출은 앞 메서드의
await까지 끝나야 시작하고, 다른 연결끼리는 병렬입니다. - 큐가 차면 수신 루프가 멈춰 클라 결과 응답과 하트비트까지 못 읽습니다. 11개가 쌓이자 5초 교착이 났습니다.