MagicOnion 의 Unary 는 요청마다 ServiceContext 와 서비스 인스턴스를 새로 만든다
Esc

    MagicOnion 의 Unary 는 요청마다 ServiceContext 와 서비스 인스턴스를 새로 만든다

    –C#라이브러리네트워크
    public class MathService : ServiceBase<IMathService>, IMathService
    {
        public UnaryResult<int> SumAsync(int x, int y) => UnaryResult.FromResult(x + y);
    }

    서비스 메서드는 인자 x, y 만 받는데도 this.Context 로 헤더, 인증 정보, 요청 원본을 읽을 수 있습니다. 그 컨텍스트가 언제 생기고 누구의 것인지 모르면, 싱글턴 등록 한 줄로 다른 사용자의 요청을 읽게 됩니다.

    MagicOnion 의 Unary 는 필터 사슬과 호출자는 바인딩 때 한 번 만들고, ServiceContext 와 서비스 인스턴스는 요청마다 새로 만들어 속성으로 꽂습니다. 인자 목록은 Nil, 값 그대로, 튜플 중 하나로 감싸 gRPC 메시지 하나가 됩니다. 기준 판본은 7.11.0 입니다.

    조립은 한 번, 컨텍스트는 호출마다

    서버가 시작할 때 메서드마다 BuildUnaryMethod 가 핸들러 하나를 만듭니다. 이 메서드는 필터를 모아 델리게이트 사슬로 감싼 뒤 로컬 함수 InvokeAsync 를 돌려주고, 요청마다 도는 것은 그 로컬 함수뿐입니다.

    바인딩 때 메서드마다 한 번 요청마다
    필터 정렬, 필터 인스턴스, 필터 사슬 ServiceContext (Guid.NewGuid(), DateTime.UtcNow)
    직렬화기 서비스 인스턴스 (grpc-dotnet 이 넘겨준다)
    인터페이스 메서드 호출자(식 트리) 값 타입 요청과 응답의 Box<T>
    특성 조회 표 InvokeAsync 의 async 상태 머신

    ServiceContext 는 풀링하지 않습니다. 호출 세 번의 ContextId 가 전부 달랐습니다.

    Context 는 인스턴스의 속성이라 싱글턴이면 덮인다

    MagicOnion 은 서비스 인스턴스를 만들지 않습니다. grpc-dotnet 이 넘겨준 인스턴스의 Context 속성에 방금 만든 컨텍스트를 넣을 뿐입니다. DI 에 등록하지 않은 기본 상태에서는 인스턴스가 호출마다 새로 생기니 문제가 없고, AddSingleton 으로 등록하면 인스턴스 하나의 같은 필드를 요청마다 덮어씁니다.

    await 뒤에 Context.GetRawRequest() 가 자기 요청인지 보는 메서드에 동시 호출 200건을 넣었더니 199건이 남의 것이었고, 예외도 경고도 없었습니다. (Windows 11, .NET SDK 10.0.401, net10.0, 한 프로세스 Kestrel HTTP/2.)

    예외는 grpc-dotnet 에 넘겨 상태 코드가 된다

    서비스 메서드는 필터 사슬 안에서 불리고, 핸들러의 catch 가 예외를 gRPC 상태로 바꿉니다.

    서버에서 한 일 클라이언트가 받은 것
    일반 예외 (기본 옵션) Unknown(2) "Exception was thrown by handler."
    일반 예외 (IsReturnExceptionStackTraceInErrorDetail = true) Unknown(2), 예외 메시지와 서버 소스 파일 절대 경로
    throw new ReturnStatusException(NotFound, "no such user") NotFound(5) "no such user"
    Context.CallContext.Status = FailedPrecondition 후 정상 반환 FailedPrecondition(9), 본문은 버려짐

    기본값에서 서버 예외 메시지는 새지 않습니다. 상태 코드는 열거형 밖의 값(1001)도 그대로 전달됩니다.

    인자 목록은 메시지 하나로 감싸진다

    gRPC 는 요청 메시지 하나, 그것도 참조 타입을 요구합니다. MagicOnion 은 인자 0개를 Nil, 1개를 값 그대로, 2~15개를 DynamicArgumentTuple<...> 구조체로 묶고, 값 타입이면 Box<T> 에 담아 넘깁니다. 선 위의 바이트는 직렬화기에 따라 이렇게 갈립니다.

    MemoryPack 쪽 마지막 줄은 값 타입만 든 튜플을 구조체 메모리째 쓰기 때문입니다. DynamicArgumentTuple 은 LayoutKind.Auto 라서 필드 배치를 런타임이 정하고, 그 배치가 런타임마다 달랐습니다.

    보낸 쪽 IntLong(7, 1234567890123) 의 바이트 CoreCLR 서버가 읽은 값
    CoreCLR CB 04 FB 71 1F 01 00 00 07 00 00 00 00 00 00 00 a=7 b=1234567890123
    Unity (에디터 Mono, Mono·IL2CPP 플레이어) 07 00 00 00 00 00 00 00 CB 04 FB 71 1F 01 00 00 a=1912276171 b=7

    크기가 같아 역직렬화는 성공했고, 서버는 뒤섞인 값으로 메서드를 실행했습니다. 끝에 인자를 더하는 변경은 MessagePack 서버만 흡수합니다. 서버가 MethodInfo 의 기본값으로 빈 칸을 채우는 폴백 포매터를 끼우기 때문이고, MemoryPack 공급자에는 이 폴백이 없어 구버전 클라이언트 호출이 Unknown 으로 실패했습니다.

    주의할 점

    1. Unary 서비스를 DI 싱글턴으로 등록하지 않는다

    안 지키면 생기는 문제. 핸들러는 호출마다 service.Context = serviceContext 를 합니다. 인스턴스가 하나면 await 뒤의 Context 가 다른 요청의 것이 됩니다. 인증 정보를 Context 에서 읽는 서비스라면 다른 사용자의 권한으로 동작합니다.

    이렇게 쓴다. 서비스 클래스는 DI 에 등록하지 않고 호출마다 새로 만들어지게 둡니다. 공유 상태는 별도 싱글턴 객체에 두고 생성자로 받습니다. 한 요청 안에서만 쓸 값은 Context.Items 에 둡니다.

    2. 운영 서버에서 스택 트레이스 옵션을 켜지 않는다

    안 지키면 생기는 문제. IsReturnExceptionStackTraceInErrorDetail = true 면 처리하지 않은 예외의 메시지, 메서드 이름, 서버 소스 파일 절대 경로가 상태 문자열로 클라이언트에 갑니다. 게임 클라이언트는 사용자 기기에서 돕니다.

    이렇게 쓴다. 개발 환경에서만 켭니다. 클라이언트에 알릴 실패는 ReturnStatusException 이나 CallContext.Status 로 상태 코드와 문구를 직접 정합니다.

    3. 배포한 메서드의 인자를 하나에서 둘로 바꾸지 않는다

    안 지키면 생기는 문제. 인자 하나는 값 그대로, 둘부터는 배열로 나갑니다. 1개에서 2개로 바꾸면 와이어 모양이 바뀌어 옛 클라이언트의 요청을 읽지 못합니다.

    이렇게 쓴다. 인자는 끝에 기본값 있는 선택적 매개변수로 더하고 직렬화기는 MessagePack 을 씁니다. 인자 하나짜리를 늘려야 하면 새 이름의 메서드를 만들어 한동안 같이 둡니다.

    4. MemoryPack 직렬화기는 양쪽 런타임이 같을 때만 쓴다

    안 지키면 생기는 문제. 값 타입 인자 여럿은 메모리 배치째 나가서 Unity 와 CoreCLR 사이에서 예외 없이 값이 뒤섞였습니다. 서버와 클라이언트의 직렬화기가 다르면 MessagePack 서버가 MemoryPack 클라이언트의 07 00 00 00 을 7 로 읽고 메서드를 실행한 뒤 클라이언트는 실패로 보는 경우도 있었습니다.

    이렇게 쓴다. Unity 클라이언트와 .NET 서버라면 기본 MessagePack 을 씁니다. 직렬화기를 바꾸는 배포는 서버와 클라이언트를 한 번에 내보냅니다.

    정리

    MagicOnion 의 Unary 는 사슬은 한 번 조립하고, 컨텍스트와 인스턴스는 요청마다 새로 꽂습니다.

    • ServiceContext 는 풀링 없이 호출마다 생기고, 서비스 인스턴스의 Context 속성으로 들어갑니다. 싱글턴 등록은 이 필드를 덮어씁니다.
    • 처리하지 않은 예외는 Unknown 이 되어 메시지가 새지 않고, 상태는 ReturnStatusException 이나 CallContext.Status 로 정합니다.
    • 인자 목록은 Nil, 값, DynamicArgumentTuple 로 감싸지고, 값 타입은 Box<T> 에 담깁니다. MemoryPack 은 이 튜플을 메모리 배치째 씁니다.