MagicOnion 의 Unity 패키지는 채널과 허브를 한 곳에서 닫는다
Esc

    MagicOnion 의 Unity 패키지는 채널과 허브를 한 곳에서 닫는다

    –C#라이브러리네트워크
    var channel = GrpcChannel.ForAddress("http://localhost:5000", options);
    var hub = await StreamingHubClient.ConnectAsync<IChatHub, IChatReceiver>(channel, receiver);
    // 플레이 모드를 끄면 이 채널과 허브는 누가 닫나?

    보통의 .NET 앱은 프로세스가 끝나면 운영체제가 소켓과 스레드를 거둬 갑니다. Unity 에디터는 플레이 모드를 꺼도 프로세스가 그대로 살아서, 닫지 않은 HTTP/2 연결과 허브 수신 루프, 하트비트 타이머가 다음 플레이까지 남아 에디터를 멈추게 합니다.

    MagicOnion.Client.Unity 는 채널을 GrpcChannelx 로 만들게 해서 전부 레지스트리 하나에 모으고, 그 레지스트리를 Unity 수명에 매달아 종료 때 허브, 채널, 핸들러 순으로 한 번에 닫습니다. 기준 판본은 7.11.0 입니다.

    HTTP/2 는 핸들러 하나로 꽂는다

    MagicOnion 7 클라이언트는 Grpc.Net.Client 를 쓰고, Unity 의 HttpClient 는 HTTP/2 를 못 합니다. 그래서 HTTP/2 를 말하는 YetAnotherHttpHandler 를 채널 옵션에 꽂습니다.

    new GrpcChannelOptions
    {
        HttpHandler = new YetAnotherHttpHandler { Http2Only = true },   // http:// 평문이면 필요
        DisposeHttpClient = true,                                       // 채널을 닫을 때 핸들러까지
    }

    평문에는 TLS 협상이 없어 처음부터 HTTP/2 로 말하겠다고 정해야 하므로 Http2Only = true 가 필요합니다. DisposeHttpClient = true 는 아래 레지스트리가 채널을 닫을 때 핸들러까지 닫게 하는 스위치입니다.

    채널을 닫는 책임을 레지스트리 하나에 모은다

    GrpcChannelProviderHost.Initialize(provider) 는 DontDestroyOnLoad 게임 오브젝트를 만들어 프로바이더를 정적 기본값으로 등록하고, OnApplicationQuit 와 OnDestroy 에서 ShutdownAllChannels() 를 부릅니다. GrpcChannelx.ForAddress 는 그 프로바이더에 채널을 만들어 달라고 하고, 프로바이더는 만든 채널을 목록에 넣습니다. 호스트를 초기화하지 않고 부르면 “The default GrpcChannelProvider is not configured yet” 예외가 나서 등록을 건너뛸 수 없습니다.

    허브가 이 목록에 들어가는 것은 GrpcChannelx 가 ChannelBase 를 상속하고 IMagicOnionAwareGrpcChannel 을 구현하기 때문입니다. 클라이언트 본체는 채널이 그 인터페이스면 “허브가 생겼다” 고 알리기만 합니다. 뒤집어 말하면 GrpcChannel 을 직접 쓰면 허브 자동 정리는 없습니다. 에디터의 Window → MagicOnion → gRPC Channels 창이 이 목록을 보여 주는데, 송신 바이트는 SetPayloadLength 를 부르는 마샬러만 세서 Unary byte[1024] 10회가 0 B 로 찍혔고 허브 호출은 크기만큼 늘었습니다.

    채널 옵션은 값이 아니라 팩토리로 받는다

    프로바이더 생성자 중 옵션 인스턴스를 받는 쪽은 [Obsolete] 이고, 메시지가 이유를 적어 둡니다. “HttpClient/HttpHandler may be reused unintentionally.”

    같은 구성을 .NET 콘솔의 GrpcChannel 과 SocketsHttpHandler 로 재현했습니다. 핸들러 하나로 채널 A, B 를 만들어 A 를 Dispose 한 뒤 B 로 호출했습니다.

    구성 A.Dispose() 뒤 B 호출
    핸들러 공유, DisposeHttpClient = true RpcException(Internal, ObjectDisposedException … SocketsHttpHandler)
    핸들러 공유, DisposeHttpClient = false 성공
    채널마다 새 핸들러(팩토리), DisposeHttpClient = true 성공

    문제는 공유와 DisposeHttpClient = true 의 조합입니다. 팩토리로 받는 대가로 채널마다 HTTP/2 연결과 핸들러가 따로 생깁니다. (이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release.)

    채널 주소에는 호스트와 포트만 남는다

    GrpcChannelx 는 주소를 GrpcChannelTarget 으로 들고 다니는데, 이 구조체에는 호스트, 포트, 평문 여부만 있습니다. 채널을 만들 때 이 셋으로 주소를 다시 조립합니다.

    역방향 프록시 뒤에서 경로 접두사로 서버를 나누는 배치는 GrpcChannelx 로 쓸 수 없습니다.

    주의할 점

    1. 채널은 GrpcChannelx 로 만들고 호스트를 먼저 초기화한다

    안 지키면 생기는 문제. GrpcChannel.ForAddress 로 만든 채널과 그 위의 허브는 아무도 닫지 않습니다. 플레이 모드를 끈 뒤에도 연결과 수신 루프가 살아 있다가 다음 플레이나 스크립트 재컴파일 때 에디터가 멈추는 원인이 됩니다.

    이렇게 쓴다. [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] 정적 메서드에서 GrpcChannelProviderHost.Initialize(...) 를 부르고, 모든 채널을 GrpcChannelx.ForAddress 로 만듭니다. 허브도 그 채널로 연결해야 정리 목록에 들어갑니다.

    2. 채널 옵션은 팩토리로 넘긴다

    안 지키면 생기는 문제. 옵션 인스턴스 하나를 넘기면 모든 채널이 핸들러 하나를 나눠 쓰고, 채널 하나를 닫는 순간 그 핸들러가 해제됩니다. 로그인 서버 채널을 닫았더니 게임 서버 채널의 다음 호출이 실패하는 식으로, 원인과 증상이 다른 채널에서 나타납니다.

    이렇게 쓴다. new GrpcNetClientGrpcChannelProvider(() => new GrpcChannelOptions { HttpHandler = new YetAnotherHttpHandler { ... }, DisposeHttpClient = true }) 처럼 람다로 넘겨 채널마다 핸들러를 새로 만듭니다.

    3. 경로 접두사가 붙은 주소를 GrpcChannelx 에 넘기지 않는다

    안 지키면 생기는 문제. http://host:5198/prefix 를 넘기면 /prefix 가 버려지고 요청은 /IService/Method 로 갑니다. 서버에 그 경로가 없으니 Unimplemented(HTTP 404)로 실패하고, 채널을 만들 때는 아무 경고도 없습니다.

    이렇게 쓴다. Unity 클라이언트가 붙는 서버는 경로가 아니라 호스트나 포트로 나눕니다.

    정리

    MagicOnion 의 Unity 패키지는 채널과 허브를 닫는 책임을 레지스트리 하나에 모읍니다.

    • HTTP/2 는 YetAnotherHttpHandler 를 GrpcChannelOptions.HttpHandler 에 꽂아 해결하고, 평문이면 Http2Only = true 입니다.
    • GrpcChannelProviderHost 가 Unity 수명에 매달려 종료 때 모든 채널을 허브, 채널, 목록 순으로 닫습니다. GrpcChannel 을 직접 쓰면 이 정리가 없습니다.
    • 옵션을 팩토리로 받는 것은 DisposeHttpClient 와 짝이고, 채널 주소에는 호스트와 포트만 남습니다.