MagicOnion 은 StreamingHub 에서만 자기 몫의 할당을 없앴다
await client.EchoAsync(1); // Unary
await hub.EchoAsync(1); // StreamingHub
두 줄 모두 int 하나를 보내고 받습니다. 호출마다 힙에 남는 것 중 MagicOnion 이 만든 것은 얼마이고, 그 밑의 gRPC 와 Kestrel 이 만든 것은 얼마일까요.
MagicOnion 없이 gRPC 에 서비스를 손으로 등록한 기준선과 비교하면 답이 갈립니다. StreamingHub 는 모든 복사를 풀에서 빌려 자기 몫의 할당이 0 이고, Unary 는 호출마다 객체와 상자를 새로 만들고 마샬러가 길이를 몰라 페이로드만큼 한 벌을 더 씁니다. 기준 판본은 7.11.0 입니다.
호출 하나가 힙에 남기는 바이트
값은 서버와 클라이언트 할당의 합입니다.
| 시나리오 | MessagePack | MemoryPack | 손으로 쓴 gRPC | MagicOnion 몫 |
|---|---|---|---|---|
Unary int → int |
8,650 B | 8,652 B | 7,065 B | 약 +1,585 B |
Unary byte[10KB] → int |
29,491 B | 28,968 B | 17,491 B | +12,000 / +11,477 B |
Hub 요청/응답 int → int |
2,136 B | 2,136 B | 2,136 B (양방향 스트림 핑퐁) | 약 0 |
Hub byte[10KB] → int |
12,405 B | 12,404 B | +10,269 B = 받은 byte[] 하나 |
허브의 페이로드 비용은 서버 메서드가 인자로 받은 배열 하나뿐입니다. Unary 는 크기와 상관없이 1.6 KB 가 붙고 크기에 비례해 또 붙어, 10 KB 에서 MagicOnion 몫이 페이로드보다 큽니다. 직렬화기를 바꿔도 거의 같아서 비용의 주인은 직렬화기가 아니라 전송 쪽입니다. (Windows 11, .NET SDK 10.0.401, net10.0, 한 프로세스 Kestrel HTTP/2, 워크스테이션 GC, 콘솔 로거 끔. 서버 GC 에서는 라운드마다 수 KB 씩 튀어 차이 계측이 흔들렸습니다.)
Unary 는 값 하나를 네 번 상자에 담는다
gRPC 의 호출 API 는 요청과 응답에 참조 타입을 요구하고, MagicOnion 은 UnaryResult<int> Echo(int x) 를 허용해야 하니 값 타입을 Box<T> 로 한 번 싸서 넘깁니다. 서버는 필터가 값을 바꿔치기할 수 있게 요청과 결과를 object 로 들고 있습니다.
그 밖에 호출마다 RequestContext, ServiceContext, 여러 async 상태 머신이 새로 생깁니다. 서버가 UnaryResult 에서 값을 꺼낼 때 Task.FromResult 를 거쳐, BCL 이 캐시하지 않는 값이면 72 B 가 더 붙습니다. 서비스 메서드가 실제로 기다리면(await Task.Yield()) 호출당 약 1.1 KB 가 늘었는데, 서비스 상태 머신 하나가 아니라 MagicOnion, grpc-dotnet, Kestrel 의 층이 줄줄이 상태 머신을 힙에 올린 값입니다.
Unary 마샬러는 길이를 몰라 한 벌을 더 쓴다
gRPC 직렬화 컨텍스트에는 쓸 길이를 먼저 알리는 SetPayloadLength 가 있습니다. 손으로 쓴 마샬러에서 이 한 줄만 빼 보았습니다.
MagicOnion 의 Unary 마샬러는 두 번째 모양입니다. 중간 버퍼 없이 gRPC 버퍼에 곧장 직렬화하니 길이는 다 쓴 뒤에야 알고, 미리 알릴 수 없습니다. 응답도 같은 마샬러라서, 필터가 미리 직렬화한 바이트를 SetRawBytesResponse 로 넘겨도 그 한 벌은 남았습니다(1 KB 와 10 KB 응답의 차이가 9 KB 의 두 배). StreamingHub 마샬러는 이미 직렬화해 둔 봉투를 넘기므로 길이를 먼저 알립니다.
StreamingHub 는 복사를 늘려 할당을 없앴다
허브 요청 하나의 바이트는 한 방향에 네 번 복사됩니다. 복사 대상은 전부 ArrayPool<byte>.Shared 에서 빌린 배열이나 gRPC 버퍼이고, 봉투 객체도 풀에서 옵니다.
연결마다 StreamingHubContext 하나를 호출마다 다시 채우고, 할당을 피하려고 소비 루프에 CancellationToken 도 넘기지 않습니다. 봉투 풀은 서버가 DefaultObjectPool(최대 256), 클라이언트가 네 칸짜리 자체 풀이고 한 프로세스에 두 벌입니다. 대가는 지연입니다. 바이트는 양방향 스트림 핑퐁과 같은데 스레드 풀을 건너는 채널 홉이 넷이라 왕복 중앙값이 15 µs 쯤 길었습니다. 그래도 Unary 보다는 빨라서, 루프백에서 Unary Echo(int) 는 중앙값 210.2 µs, Hub 는 134.8 µs 였습니다. (이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release.)
주의할 점
1. 큰 바이트 덩어리를 Unary 로 자주 보내지 않는다
안 지키면 생기는 문제. Unary 마샬러가 길이를 알리지 않아 메시지 크기만큼의 byte[] 가 한 번 더 생깁니다. 10 KB 요청 하나에 MagicOnion 몫이 12 KB 였습니다. 스크린샷이나 저장 데이터를 Unary 로 자주 올리면 요청마다 그만큼의 가비지가 두 벌씩 생깁니다.
이렇게 쓴다. 큰 데이터는 StreamingHub 로 보냅니다. 허브는 받은 byte[] 하나만 새로 만들었습니다. Unary 를 써야 하면 호출 빈도를 낮춥니다.
2. 잦고 작은 호출은 StreamingHub 로 보낸다
안 지키면 생기는 문제. Unary int → int 하나가 8,650 B, 허브 왕복이 2,136 B 로 네 배 차이입니다. 위치나 입력처럼 초당 수십 번 오가는 메시지를 Unary 로 보내면 양쪽 GC 압력이 그만큼 커집니다.
이렇게 쓴다. 연결을 유지하며 자주 주고받는 것은 허브 메서드로 만들고, 응답이 필요 없으면 void 로 선언합니다. 응답 없는 호출은 한 방향 메시지 하나(1,361 B)로 줄었습니다. Unary 는 로그인, 구매처럼 드물고 독립적인 요청에 씁니다.
3. 응답 없는 호출을 끝없이 쏘지 않는다
안 지키면 생기는 문제. 클라이언트의 쓰기 큐는 무제한입니다. 네트워크보다 빨리 void 메서드를 쏘면 메시지가 클라이언트 메모리에 쌓이고, 빌린 봉투가 돌아오지 않아 네 칸 풀도 비어 새 봉투가 만들어집니다. 20,000번 연사에서 봉투가 호출당 약 48 B 씩 새로 잡혔습니다.
이렇게 쓴다. 초당 보낼 양을 정하고 그 이상은 합치거나 버립니다. 가끔 응답 있는 호출을 섞어 왕복을 기다리면 그 자리가 속도 조절이 됩니다.
4. 서버 메트릭 수집기는 서버를 띄울 때부터 붙인다
안 지키면 생기는 문제. 계측기의 켜짐 여부를 호출마다 읽지 않으려고 ServiceContext 를 만들 때 한 번 읽어 MetricsContext 구조체에 담습니다. 허브의 ServiceContext 는 Connect 때 하나라서, 도는 중에 dotnet-counters 나 OpenTelemetry 를 붙이면 이미 붙어 있던 연결의 허브 호출은 끊길 때까지 한 건도 세지지 않았습니다.
이렇게 쓴다. 수집기는 서버 기동 코드에서 함께 켭니다. 도중에 붙였다면 그 뒤에 새로 붙은 연결만 보고 있다는 것을 알고 읽습니다.
정리
MagicOnion 은 StreamingHub 에서만 자기 몫의 할당을 풀로 없앴습니다.
- 허브 왕복은 손으로 쓴 gRPC 양방향 스트림과 같은 2,136 B 이고, Unary 는 손으로 쓴 gRPC Unary 보다 약 1.6 KB 많습니다.
- Unary 는 값 타입을
Box<T>로 네 번,object로 두 번 담고, 마샬러가SetPayloadLength를 못 불러 페이로드 1바이트마다 1바이트를 더 씁니다. - 허브는 한 방향 네 번의 복사를 전부 풀에서 빌리는 대신 채널 홉 넷만큼 지연을 냅니다.