라이브러리
34편
MagicOnion 은 StreamingHub 에서만 자기 몫의 할당을 없앴다
MagicOnion 을 빼고 같은 일을 하는 gRPC 와 비교하면, StreamingHub 왕복은 할당이 같고 Unary 는 호출당 약 1.6 KB 에 페이로드 1바이트마다 1바이트를 더 씁니다. 상자, 마샬러, 풀이 그 차이를 만듭니다.
MagicOnion 의 Unity 패키지는 채널과 허브를 한 곳에서 닫는다
Unity 에디터는 플레이 모드를 꺼도 프로세스가 살아서, 닫지 않은 채널과 허브 수신 루프가 남습니다. MagicOnion 의 Unity 패키지는 모든 채널을 레지스트리 하나에 모아 종료 때 허브, 채널 순서로 닫습니다.
MagicOnion 의 필터는 메서드를 바인딩할 때 한 번 조립된다
필터는 요청마다 불리는 것처럼 보이지만, 사슬과 필터 인스턴스는 MapMagicOnionService 가 메서드를 바인딩할 때 한 번 만들어집니다. 그래서 요청당 할당은 0 이고, 대신 필터 필드와 생성자 주입은 모든 요청이 나눠 씁니다.
MagicOnion 의 하트비트는 상대의 메시지 처리 루프를 잰다
네트워크는 멀쩡한데 하트비트가 연결을 끊습니다. MagicOnion 의 하트비트는 PING 이 아니라 허브 메시지라서, 답이 상대의 처리 루프를 지나야 나오기 때문입니다. 메인 스레드가 멈추거나 서버 요청 큐가 차면 끊깁니다.
MagicOnion 의 그룹 브로드캐스트는 한 번 직렬화하고 연결마다 복사한다
방에 50명이 있어도 room.All.OnMessage 한 번은 직렬화를 한 번만 하고, 받는 연결마다 바이트를 복사해 응답 큐에 넣고 돌아옵니다. 대신 Single 을 돌리면 직렬화가 사람 수만큼이고, 느린 수신자는 알림 없이 끊깁니다.
MagicOnion 의 StreamingHub 클라이언트는 리시버를 연결한 곳의 스레드에서 부른다
같은 허브 연결인데 await 뒤 코드와 리시버가 다른 스레드에서 돕니다. 응답은 기다린 곳의 컨텍스트로, 리시버는 ConnectAsync 때 잡은 컨텍스트로 가고, 컨텍스트가 없으면 리시버가 수신 전체를 막습니다.
MagicOnion 의 StreamingHub 는 한 연결의 호출을 한 줄로 세운다
허브 메서드는 gRPC 메서드가 아니라 Connect 스트림 하나 위의 메시지입니다. 서버는 연결마다 용량 10 짜리 큐와 소비 루프 하나로 호출을 순서대로 처리하고, 이 큐가 차면 수신 루프가 멈춰 교착이 납니다.
MagicOnion 클라이언트는 인터페이스 구현을 Emit 이나 소스 생성기로 만든다
MagicOnionClient.Create 는 인터페이스만 받는데 구현 객체를 돌려줍니다. JIT 에서는 Reflection.Emit, IL2CPP 에서는 소스 생성기가 같은 모양의 클래스를 만들고, 생성기를 켜면 동적 경로는 사라집니다.
MagicOnion 의 Unary 는 요청마다 ServiceContext 와 서비스 인스턴스를 새로 만든다
서비스 코드는 this.Context 로 요청 정보를 읽습니다. MagicOnion 은 호출마다 인스턴스를 받아 Context 속성을 채우므로, 서비스를 싱글턴으로 등록하면 동시 요청 200건 중 199건이 남의 컨텍스트를 봤습니다.
MagicOnion 은 MapMagicOnionService 안에서 인터페이스를 gRPC 메서드로 등록한다
.proto 도 생성 코드도 없는데 /IMathService/SumAsync 가 생깁니다. MapMagicOnionService 가 로드된 어셈블리를 훑어 grpc-dotnet 확장점에 메서드를 넣기 때문이고, 그래서 규칙에 안 맞는 서비스는 경고 없이 빠집니다.
MagicOnion 은 .proto 대신 C# 인터페이스를 스키마로 쓴다
gRPC 는 .proto 와 코드 생성을 요구합니다. MagicOnion 은 서버와 클라이언트가 같은 C# 인터페이스를 컴파일하는 것으로 그 자리를 채우고, 전송은 gRPC(HTTP/2)에 맡깁니다. 대신 이름이 곧 와이어 계약입니다.
MemoryPack 의 byte[] 직렬화는 GC 가 치울 배열을 매번 남기고, 스레드마다 256KB 를 쥔다
MemoryPack 의 byte[] 직렬화는 호출마다 결과 배열을 새로 만들고, 스레드마다 256KB 작업 공간을 둡니다. 큰 결과 배열은 gen2 GC 를 부르고, 작업 공간은 스레드가 사는 동안 남으니 자주 도는 경로는 재사용 버퍼로 보냅니다.
MemoryPack 은 압축 부품만 주고, 압축 경로는 개발자가 직접 짠다
MemoryPack 에는 압축 옵션이 없고 압축기와 멤버 포매터만 있습니다. 압축 경로는 개발자가 짜야 하고, 그때 압축 표시, 압축할 크기, 푸는 크기 상한, 멤버 포매터의 null 을 직접 챙겨야 합니다.
MemoryPack 의 Union 은 받는 쪽이 모르는 구현 타입을 받으면 예외를 던진다
MemoryPack 의 Union 은 인터페이스 필드에 든 구현 타입을 태그 1 B 로 알립니다. 받는 쪽이 모르는 태그와 등록하지 않은 자식 클래스는 예외이고, 양쪽 선언 타입이 어긋나면 예외 없이 빈 객체가 나옵니다.
MemoryPack 은 기본 모드에서 멤버를 더하면 옛 클라이언트가 응답을 읽지 못한다
MemoryPack 기본 모드는 멤버 수만 적어, 멤버를 더한 새 서버의 응답을 옛 클라이언트가 읽지 못합니다. VersionTolerant 로 막지만 타입 변경은 못 잡고, 순환이 생기면 스택 넘침으로 프로세스가 끝납니다.
MemoryPack 은 멤버 순서를 바꾸면 옛 클라이언트와 값이 예외 없이 섞인다
MemoryPack 의 생성 코드는 멤버를 부모부터 선언 순서대로 값만 씁니다. 서버만 순서를 바꾸거나 부모에 멤버를 더하면 옛 클라이언트와 값이 예외 없이 섞이니, 멤버는 끝에만 추가하고 순서 목록 파일을 리뷰합니다.
MemoryPack 의 포매터는 타입마다 static 필드 하나에 등록된다
MemoryPack 은 타입마다 쓸 포매터를 static 필드 하나에 둡니다. 다시 등록하면 기존 포매터를 덮어쓰고, 한 번 실패한 타입은 IsRegistered 가 true 라 등록이 건너뛰어집니다. 유니티와 함께 쓰는 모델은 두 대상으로 빌드합니다.
MemoryPack 의 리더는 조작한 패킷 하나로 서버 프로세스를 끝낼 수 있다
서버가 클라이언트 패킷을 MemoryPack 으로 바로 풀 때 생기는 네 가지 문제와 예방책입니다. 그중 둘은 try/catch 로 잡을 수 없이 프로세스를 끝내므로, 멤버 포매터나 형식 분리로 리더에 닿기 전에 막아야 합니다.
MemoryPackWriter 는 숫자를 바꾸지 않고 메모리 모양 그대로 쓴다
MemoryPackWriter 는 int 200 을 글자로 바꾸거나 줄이지 않고, C# 이 메모리에 들고 있는 C8 00 00 00 을 그대로 옮겨 적습니다. 버퍼에는 모자랄 때와 Flush 때만 알려 빠르지만, writer 를 직접 쓰면 Flush 전까지 버퍼는 0 B 입니다.
MemoryPackSerializer 는 직렬화 도중 다시 부르면 앞부분을 덮어쓴다
패킷을 직렬화하는 도중 포매터 안에서 MemoryPackSerializer.Serialize 를 또 부르면, 스레드마다 하나인 버퍼를 0번부터 다시 써서 패킷 앞부분이 덮입니다. StringBuilder 하나를 두 함수가 같이 쓰는 것과 같고, 받은 writer 에 바로 써야 합니다.
MemoryPack 은 메모리 모양을 그대로 보내서 받는 쪽 타입대로 읽힌다
.NET 서버와 유니티 클라이언트가 같은 메시지를 주고받는데 시각이 어긋나고 튜플 값이 뒤바뀝니다. MemoryPack 바이트에는 타입 표시가 거의 없고, 값이 메모리 모양 그대로 나가서 받는 쪽 타입의 메모리 모양대로 읽히기 때문입니다.
MemoryPack 은 C# 객체를 메모리 그대로 복사해 바이트로 만든다
서버와 클라이언트가 메시지를 주고받으려면 객체를 바이트로 바꿔야 합니다. MemoryPack 은 그 일을 하는 C# 과 Unity 용 라이브러리로, 코드를 소스 생성기가 만들고 값은 변환 없이 메모리 그대로 복사해 JSON 보다 몇 배 빠릅니다.
MessagePack-CSharp 는 워밍업 뒤 결과물만 할당한다
직렬화 한 번이 힙에 남기는 것은 결과 배열 하나, 역직렬화는 만들어지는 객체뿐입니다. 재사용 버퍼면 0 B 입니다. 예외는 첫 호출과, 한 번에 128 KB 넘게 요청하는 큰 원시 배열입니다.
MessagePack-CSharp 의 스트림 리더는 메시지 끝을 건너뛰어 보며 찾는다
한 스트림에 이어 붙은 메시지 사이에는 길이도 구분자도 없습니다. MessagePackStreamReader 는 TrySkip 으로 값 하나의 끝을 할당 없이 찾지만, 조각나 도착하면 매번 처음부터 다시 훑고 크기 한도도 없습니다.
MessagePack-CSharp 의 UntrustedData 는 해시 비교자와 압축 해제 한도만 바꾼다
기본값 TrustedData 에서는 240 KB 요청이 1초를 먹고 4 KB 압축 블록이 100 MB 를 할당시킵니다. UntrustedData 는 이 둘만 막고, 깊이 한도 500 은 기본값에서도 이미 켜져 있습니다.
MessagePack-CSharp 의 Union 봉투는 선언 타입이 인터페이스일 때만 붙는다
MessagePack 바이트에는 타입 이름이 없어 인터페이스 필드를 되읽을 수 없습니다. Union 은 선언 타입에만 두 칸 봉투를 씌우고, 등록 안 된 타입은 예외 없이 nil 이 됩니다. Typeless 는 스트림이 타입을 고릅니다.
MessagePack-CSharp v3 는 포매터를 컴파일할 때 C# 으로 만든다
런타임 IL 생성은 첫 호출이 느리고 NativeAOT 에서 쓸 수 없습니다. v3 는 소스 생성기가 컴파일 때 포매터를 만들지만, List<내 타입> 멤버는 여전히 동적 코드로 떨어져 NativeAOT 에서 실패합니다.
MessagePack-CSharp 는 문자열 키를 문자열로 만들지 않고 8바이트 정수로 비교한다
정수 키는 배열 칸 번호라 키가 바이트에 실리지 않고, 문자열 키는 이름이 값마다 실립니다. 읽을 때 문자열 키는 string 을 만들지 않고 8바이트씩 ulong 으로 잘라 상수와 비교해 할당이 0 입니다.
MessagePack-CSharp 는 타입마다 포매터를 정적 필드에 한 번만 정한다
직렬화마다 멤버 타입의 포매터를 묻는데도 조회는 7 ns 안팎입니다. 리졸버 체인을 타입마다 한 번 돌아 정적 제네릭 필드에 박아 두기 때문이고, 아무도 모르면 런타임에 IL 로 만듭니다.
MessagePack-CSharp 의 리더는 정수를 쓴 폭이 아니라 값으로 읽는다
1바이트로 쓴 200 을 int 로 읽으면 되고 sbyte 로 읽으면 OverflowException 입니다. MessagePackReader 는 64비트로 넓혔다가 checked 로 좁히고, 거짓 헤더와 잘린 글자도 할당 전에 막습니다.
MessagePackWriter 는 최악 크기를 먼저 잡고 쓴 만큼만 전진한다
값마다 IBufferWriter 를 부르면 가상 호출이 쌓이고, 문자열은 헤더 폭을 인코딩 전에 모릅니다. MessagePackWriter 는 최악 크기를 먼저 잡고 Span 을 쥔 채 쓴 만큼만 전진해 할당 0 으로 씁니다.
MessagePack-CSharp 의 Serialize 는 스레드마다 둔 64 KB 배열에 먼저 쓴다
byte[] 를 돌려주는 Serialize 는 결과 크기를 모르면서도 결과 배열 하나만 할당합니다. 스레드마다 둔 64 KB 스크래치에 먼저 쓰고 복사하기 때문이고, 포매터 안에서 다시 부르면 그 배열이 덮입니다.
MessagePack 포맷은 첫 바이트 하나로 종류와 길이를 말한다
MessagePack 바이트에는 전체 길이도 필드 이름도 없습니다. 첫 바이트 하나가 종류와 길이를 함께 말하고, 작은 정수와 짧은 문자열은 그 한 바이트로 끝납니다. DateTime 은 Kind 를 잃습니다.
MessagePack-CSharp 는 필드 이름 대신 번호로 객체를 바이트로 바꾼다
JSON 은 객체마다 필드 이름을 싣고 숫자를 글자로 바꿉니다. MessagePack-CSharp 는 [Key] 번호로 칸을 정하고 타입마다 전용 포매터를 두어, 같은 객체를 23 B 대신 8 B 로 두 배 안팎 빠르게 씁니다.