MagicOnion 은 MapMagicOnionService 안에서 인터페이스를 gRPC 메서드로 등록한다
builder.Services.AddMagicOnion();
var app = builder.Build();
app.MapMagicOnionService();
이 세 줄 뒤에 /IMathService/SumAsync 같은 gRPC 엔드포인트가 생깁니다. .proto 도 protoc 가 만든 바인딩 코드도 없는데, 누가 IMathService 를 찾아 gRPC 에 알렸을까요.
MapMagicOnionService() 가 그 호출 안에서 로드된 어셈블리를 리플렉션으로 훑고, grpc-dotnet 의 서비스 메서드 확장점에 인터페이스 메서드를 끼워 넣습니다. 이 방식 때문에 규칙에서 벗어난 서비스는 오류 없이 빠지거나 엉뚱한 이름으로 등록됩니다. 기준 판본은 7.11.0 입니다.
등록은 MapMagicOnionService 호출 하나 안에서 끝난다
AddMagicOnion() 은 DI 에 인프라만 넣고 서비스를 모릅니다. 그중 핵심은 열린 제네릭으로 등록한 IServiceMethodProvider<> 입니다. grpc-dotnet 은 MapGrpcService<T>() 가 불리면 이 공급자에게 “T 의 gRPC 메서드가 뭐냐” 고 묻고, .proto 로 쓴 gRPC 라면 생성 코드가 답할 자리에 MagicOnion 이 리플렉션으로 답합니다.
탐색과 바인딩 로그는 전부 MapMagicOnionService() 앞뒤에 찍은 표시 사이에 있었습니다. app.Run() 도 첫 요청도 아닌, 라우트를 구성하는 그 호출 안입니다. 호출자는 인터페이스 메서드마다 Expression.Lambda(...).Compile() 로 한 번 만들어 두어 요청 경로에는 리플렉션 호출이 없고, 대신 서버는 Native AOT 로 내보낼 수 없습니다. (Windows 11, .NET SDK 10.0.401, net10.0, 한 프로세스 Kestrel HTTP/2 에서 트레이스 로그로 확인.)
자동 탐색에서 빠지는 서비스는 경고가 없다
인자 없는 MapMagicOnionService() 는 AppDomain 에 올라와 있는 어셈블리에서 무시 목록을 빼고, IServiceMarker 를 구현한 최상위 public, 비추상, 비제네릭 클래스 중 [Ignore] 가 없는 것을 고릅니다.
“올라와 있는” 이 조건입니다. 서비스를 별도 클래스 라이브러리에 두고 앱은 ProjectReference 만 건 채 그 타입을 코드에서 쓰지 않았더니, 어셈블리가 로드되지 않아 /ILibService/... 엔드포인트가 없었고 경고도 없었습니다. MapMagicOnionService(typeof(LibService).Assembly) 로 넘기면 생겼습니다. (이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release 에서 매핑 뒤 엔드포인트를 열거.)
경로의 메서드 이름은 구현 쪽 이름이다
경로의 서비스 이름은 인터페이스의 짧은 이름([ServiceName] 이 있으면 그 값)이고, 메서드 이름은 구현 클래스의 MethodInfo.Name 입니다. 명시적 인터페이스 구현의 CLR 이름에는 인터페이스 이름이 붙습니다.
시작할 때 오류도 경고도 없이 엔드포인트가 엉뚱한 이름으로 생기고, 클라이언트 호출은 전부 Unimplemented 가 됩니다. 같은 이유로 오버로드도 안 됩니다. 경로가 이름뿐이라 구분할 수 없고, 조회가 구현 클래스의 모든 인스턴스 메서드를 이름으로 찾으므로 인터페이스 밖의 private 헬퍼라도 이름이 같으면 MapMagicOnionService() 에서 “Service does not support method overloading.” 으로 멈춥니다.
JSON Transcoding 은 같은 메서드를 한 번 더 등록한다
AddMagicOnion().AddJsonTranscoding() 은 두 번째 IServiceMethodProvider 를 등록합니다. grpc-dotnet 이 공급자를 전부 부르므로 같은 Unary 메서드가 /webapi/IJsonService/Sum 으로 한 번 더 바인딩되고, 핸들러도 gRPC 경로와 같은 것에 직렬화기만 System.Text.Json 으로 바꿔 씁니다. 허브는 건너뛰어 404, 스트리밍 메서드는 501 입니다.
인자 이름 키는 대소문자를 가려서, 틀리면 오류 없이 0 이 들어간 200 이 나갑니다. 형식이 틀린 본문은 필터에 들어가기 전에 500 이 됩니다. (이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release, Development 환경.)
주의할 점
1. 서비스 메서드를 명시적 인터페이스 구현으로 쓰지 않는다
안 지키면 생기는 문제. 서버는 /IExplicitService/IExplicitService.Ping 으로 등록하고 클라이언트는 /IExplicitService/Ping 을 부릅니다. 서버 로그에는 정상 등록으로 나오고, 호출은 전부 Unimplemented: Method is unimplemented. 로 떨어집니다.
이렇게 쓴다. 서비스 메서드는 public 암묵적 구현으로 씁니다. 두 인터페이스의 같은 이름 때문에 명시적 구현이 필요하다면 인터페이스를 나눕니다.
2. 서비스가 든 어셈블리는 매핑할 때 넘긴다
안 지키면 생기는 문제. 자동 탐색은 이미 로드된 어셈블리만 봅니다. 서비스를 클래스 라이브러리로 뺐는데 시작 코드가 그 타입을 건드리지 않으면 서비스 전체가 조용히 빠집니다. 반대로 참조한 외부 라이브러리에 서비스 클래스가 있으면 의도하지 않은 서비스가 열립니다. 다른 클래스 안에 넣은 서비스 클래스도 IsPublic 이 false 라 빠집니다.
이렇게 쓴다. 운영 서버는 app.MapMagicOnionService(typeof(MathService).Assembly) 처럼 어셈블리를 지정하고, 서비스 클래스는 네임스페이스 바로 아래에 둡니다. 시작 직후 EndpointDataSource.Endpoints 를 한 번 로그로 남기면 빠진 엔드포인트를 배포 전에 봅니다.
3. 서비스 구현 클래스에 같은 이름 메서드를 두지 않는다
안 지키면 생기는 문제. 인터페이스 메서드 Ping() 옆에 private int Ping(int x) 헬퍼만 두어도 MapMagicOnionService() 가 TargetInvocationException 에 싸인 오버로드 예외를 던져 서버가 뜨지 않습니다. 이 예외를 잡고 넘기면 서버는 뜨지만 그 서비스 전체가 Unimplemented 입니다.
이렇게 쓴다. 헬퍼는 다른 이름을 붙이거나 별도 클래스로 뺍니다. 인터페이스에서도 오버로드 대신 이름을 달리합니다.
4. JSON Transcoding 은 Development 환경에서만 켠다
안 지키면 생기는 문제. AddJsonTranscoding() 이 등록하는 가드는 !hostEnvironment.IsDevelopment() 를 봅니다. 문서는 “Production 에서 막힌다” 고 적지만 Staging 을 포함해 Development 가 아닌 모든 환경에서 StartAsync() 가 InvalidOperationException 으로 실패합니다.
이렇게 쓴다. 로컬에서 서비스를 찔러 보는 용도로만 켭니다. 다른 환경에서 꼭 필요하면 AllowEnableInNonDevelopmentEnvironment 를 명시적으로 켜고 외부에 열려 있지 않은지 따로 확인합니다. 요청 본문의 키는 C# 매개변수 이름을 대소문자까지 그대로 씁니다.
정리
MagicOnion 서버는 MapMagicOnionService() 호출 안에서 인터페이스를 리플렉션으로 읽어 grpc-dotnet 에 gRPC 메서드로 등록합니다.
- 대상은 로드된 어셈블리의 최상위 public 서비스 클래스입니다. 규칙 밖의 클래스는 경고 없이 빠집니다.
- 경로는
/{인터페이스 짧은 이름}/{구현 쪽 메서드 이름}이라 명시적 구현과 오버로드가 조용히 깨집니다. - JSON Transcoding 은 두 번째 공급자로 같은 Unary 메서드를
/webapi에 한 번 더 등록하는 개발용 경로입니다.