---
title: "MagicOnion 은 MapMagicOnionService 안에서 인터페이스를 gRPC 메서드로 등록한다"
description: ".proto 도 생성 코드도 없는데 /IMathService/SumAsync 가 생깁니다. MapMagicOnionService 가 로드된 어셈블리를 훑어 grpc-dotnet 확장점에 메서드를 넣기 때문이고, 그래서 규칙에 안 맞는 서비스는 경고 없이 빠집니다."
author: "유니얼"
pubDate: 2026-10-02
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "네트워크"]
canonical: https://blog.unialgames.com/posts/csharp-magiconion-binding/
---
```csharp
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 이 리플렉션으로 답합니다.

<x-wiring>
<script type="application/json">
{
  "label": "MapMagicOnionService() 한 번이 MathService 를 등록하는 순서",
  "width": 1100,
  "height": 470,
  "nodes": [
    { "id": "app", "title": "Program", "sub": "app.MapMagicOnionService()", "x": 0, "y": 10, "w": 150, "h": 54, "aside": true },
    { "id": "map", "title": "매핑 컨텍스트", "sub": "MagicOnionGrpcServiceMappingContext", "x": 300, "y": 10, "w": 205, "h": 54 },
    { "id": "dyn", "title": "동적 메서드 공급자", "sub": "DynamicMagicOnionMethodProvider", "x": 760, "y": 10, "w": 185, "h": 54 },
    { "id": "grpc", "title": "grpc-dotnet", "sub": "MapGrpcService<T>", "x": 300, "y": 210, "w": 150, "h": 54 },
    { "id": "prov", "title": "서비스 메서드 공급자", "sub": "IServiceMethodProvider<T> 구현", "x": 760, "y": 210, "w": 170, "h": 54 },
    { "id": "binder", "title": "메서드 바인더", "sub": "MagicOnionGrpcMethodBinder<T>", "x": 760, "y": 408, "w": 180, "h": 54 }
  ],
  "edges": [
    { "from": "app", "to": "map", "label": "MapMagicOnionService()", "bend": 0 },
    { "from": "map", "to": "dyn", "label": "MapAllSupportedServiceTypes", "bend": 16 },
    { "from": "dyn", "to": "map", "label": "Map(typeof(MathService))", "bend": 16 },
    { "from": "map", "to": "grpc", "label": "MapGrpcService<MathService>()", "bend": 0 },
    { "from": "grpc", "to": "prov", "label": "OnServiceMethodDiscovery", "bend": 0 },
    { "from": "prov", "to": "dyn", "label": "GetGrpcMethods<MathService>()", "bend": 16 },
    { "from": "dyn", "to": "prov", "label": "Unary 메서드 객체들", "bend": 16 },
    { "from": "prov", "to": "binder", "label": "method.Bind(binder)", "bend": 0 },
    { "from": "binder", "to": "grpc", "label": "AddUnaryMethod(Method, 메타데이터, 핸들러)", "bend": -10 }
  ],
  "steps": [
    { "edge": 0, "why": "Program 이 라우트를 구성하면서 MapMagicOnionService() 를 부릅니다." },
    { "edge": 1, "why": "매핑 컨텍스트는 DI 에 있는 메서드 공급자마다 '네가 아는 서비스를 다 매핑해 달라' 고 넘깁니다." },
    { "edge": 2, "why": "동적 공급자는 로드된 어셈블리를 훑어 찾은 타입마다 Map(Type) 을 부릅니다." },
    { "edge": 3, "why": "Map 은 MapGrpcService<T> 를 리플렉션으로 호출합니다. grpc-dotnet 에게는 평범한 gRPC 서비스 매핑입니다." },
    { "edge": 4, "why": "grpc-dotnet 은 DI 의 IServiceMethodProvider<MathService> 에게 메서드 목록을 묻습니다. 열린 제네릭으로 끼워 둔 MagicOnion 의 공급자가 받습니다." },
    { "edge": 5, "why": "공급자는 메서드 공급자들을 동적 공급자가 맨 뒤에 오게 줄 세우고, 차례로 메서드를 달라고 합니다." },
    { "edge": 6, "why": "동적 공급자는 인터페이스를 리플렉션으로 읽어 메서드마다 MagicOnionUnaryMethod 객체를 만들어 돌려줍니다." },
    { "edge": 7, "why": "메서드 객체가 자기 제네릭 인자를 아는 채로 바인더를 부릅니다. 바인더 쪽에서 MakeGenericMethod 를 또 할 필요가 없습니다." },
    { "edge": 8, "why": "바인더가 GrpcMethodHelper 로 Method 를 만들고 핸들러와 함께 grpc-dotnet 에 등록합니다. /IMathService/SumAsync 가 여기서 생깁니다." }
  ],
  "note": "처음부터 끝까지 MapMagicOnionService() 호출 하나 안에서 동기로 일어납니다."
}
</script>
</x-wiring>

탐색과 바인딩 로그는 전부 `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]` 가 없는 것을 고릅니다.

<x-flow>
<script type="application/json">
{
  "label": "자동 탐색 사슬을 지나가는 타입 넷",
  "lines": [
    "AppDomain.CurrentDomain.GetAssemblies()",
    "    .Where(a => !ShouldIgnoreAssembly(a.GetName().Name))",
    "    .SelectMany(a => a.GetTypes())",
    "    .Where(t => typeof(IServiceMarker).IsAssignableFrom(t))",
    "    .Where(t => t.GetCustomAttribute<IgnoreAttribute>(false) == null)",
    "    .Where(t => t is { IsPublic: true, IsAbstract: false, IsGenericTypeDefinition: false })",
    "// 남은 타입마다 Map(t)"
  ],
  "cases": [
    {
      "label": "MathService",
      "state": "public class MathService : ServiceBase<IMathService>, IMathService",
      "path": [
        {
          "line": 0,
          "why": "지금 AppDomain 에 올라와 있는 어셈블리를 모두 봅니다. 시작 어셈블리만이 아닙니다."
        },
        {
          "line": 1,
          "verdict": "true",
          "why": "앱 어셈블리 이름은 무시 목록에 없습니다."
        },
        {
          "line": 2
        },
        {
          "line": 3,
          "verdict": "true",
          "why": "ServiceBase<T> 가 IService<T> 를 통해 IServiceMarker 를 구현합니다."
        },
        {
          "line": 4,
          "verdict": "true",
          "why": "[Ignore] 가 없습니다."
        },
        {
          "line": 5,
          "verdict": "true",
          "why": "최상위 public, 추상 아님, 열린 제네릭 아님."
        },
        {
          "line": 6,
          "out": "/IMathService/SumAsync 등록"
        }
      ]
    },
    {
      "label": "Outer.NestedService",
      "state": "public class Outer { public class NestedService : ServiceBase<INestedService>, INestedService { } }",
      "path": [
        {
          "line": 0
        },
        {
          "line": 1,
          "verdict": "true"
        },
        {
          "line": 2
        },
        {
          "line": 3,
          "verdict": "true"
        },
        {
          "line": 4,
          "verdict": "true"
        },
        {
          "line": 5,
          "verdict": "false",
          "why": "Type.IsPublic 은 최상위 public 타입에만 true 입니다. 중첩 public 타입은 IsNestedPublic 쪽이 true 이고 IsPublic 은 false 입니다.",
          "out": "등록 안 됨. 경고도 없음"
        }
      ],
      "skip": [
        6
      ]
    },
    {
      "label": "클라이언트 프록시",
      "state": "MagicOnion.Client.DynamicClient 어셈블리의 IMathServiceClient (IMathService 를 구현)",
      "path": [
        {
          "line": 0,
          "why": "같은 프로세스에서 MagicOnionClient.Create 를 부르면 이 동적 어셈블리도 AppDomain 에 올라옵니다."
        },
        {
          "line": 1,
          "verdict": "false",
          "why": "무시 목록의 MagicOnion.Client.* 에 걸립니다. 안 걸렀다면 클라이언트 프록시가 서버 서비스로 등록됐을 것입니다.",
          "out": "등록 안 됨"
        }
      ],
      "skip": [
        2,
        3,
        4,
        5,
        6
      ]
    }
  ],
  "note": "사슬 어디서 빠지든 로그는 남지 않습니다. 탐색 대상이 아니었던 것이기 때문입니다."
}
</script>
</x-flow>

"올라와 있는" 이 조건입니다. 서비스를 별도 클래스 라이브러리에 두고 앱은 `ProjectReference` 만 건 채 그 타입을 코드에서 쓰지 않았더니, 어셈블리가 로드되지 않아 `/ILibService/...` 엔드포인트가 없었고 경고도 없었습니다. `MapMagicOnionService(typeof(LibService).Assembly)` 로 넘기면 생겼습니다. (이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release 에서 매핑 뒤 엔드포인트를 열거.)

## 경로의 메서드 이름은 구현 쪽 이름이다

경로의 서비스 이름은 인터페이스의 짧은 이름(`[ServiceName]` 이 있으면 그 값)이고, 메서드 이름은 **구현 클래스의** `MethodInfo.Name` 입니다. 명시적 인터페이스 구현의 CLR 이름에는 인터페이스 이름이 붙습니다.

<x-compare>
<script type="application/json">
{
  "label": "같은 Ping 을 두 방식으로 구현했을 때 (한 프로세스 Kestrel HTTP/2, net10.0)",
  "tabs": [
    {
      "label": "암묵적 구현",
      "code": "public class ExplicitService : ServiceBase<IExplicitService>, IExplicitService\n{\n    public UnaryResult<int> Ping() => UnaryResult.FromResult(1);\n}",
      "stats": [["구현 쪽 MethodInfo.Name", "Ping"], ["서버 등록 경로", "/IExplicitService/Ping"], ["클라이언트가 부르는 경로", "/IExplicitService/Ping"], ["결과", "두 경로가 같다"]],
      "tone": "good"
    },
    {
      "label": "명시적 구현",
      "code": "public class ExplicitService : ServiceBase<IExplicitService>, IExplicitService\n{\n    UnaryResult<int> IExplicitService.Ping() => UnaryResult.FromResult(1);\n}",
      "stats": [["구현 쪽 MethodInfo.Name", "IExplicitService.Ping"], ["서버 등록 경로", "/IExplicitService/IExplicitService.Ping"], ["클라이언트가 부르는 경로", "/IExplicitService/Ping"], ["결과", "Unimplemented: Method is unimplemented."]],
      "tone": "bad"
    }
  ]
}
</script>
</x-compare>

시작할 때 오류도 경고도 없이 엔드포인트가 엉뚱한 이름으로 생기고, 클라이언트 호출은 전부 `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 입니다.

<x-flow>
<script type="application/json">
{
  "label": "JSON Transcoding 이 요청 하나를 처리하는 순서 (줄인 코드)",
  "lines": [
    "handle = serviceActivator.Create(context.RequestServices);",
    "await context.Request.BodyReader.CopyToAsync(memStream);",
    "request = (typeof(TRequest) == typeof(Nil)) ? Box.Create(Nil.Default)",
    "        : RequestMarshaller.ContextualDeserializer(memStream.ToArray());",
    "var response = await unaryMethodHandler(handle.Instance, request, serverCallContext);",
    "context.Response.StatusCode = 200;  ResponseMarshaller.ContextualSerializer(response, ...);",
    "catch (Exception ex) { await WriteErrorResponseAsync(context, ex); }"
  ],
  "cases": [
    {
      "label": "{\"x\":1,\"y\":2}",
      "state": "POST /webapi/IJsonService/Sum",
      "path": [
        {
          "line": 0
        },
        {
          "line": 1,
          "why": "본문 전체를 메모리로 읽습니다."
        },
        {
          "line": 2,
          "verdict": "false",
          "why": "인자가 있는 메서드입니다."
        },
        {
          "line": 3,
          "why": "키 x, y 가 매개변수 이름과 정확히 같습니다.",
          "state": "x = 1 · y = 2"
        },
        {
          "line": 4,
          "why": "gRPC 경로와 같은 핸들러입니다. 필터 사슬도 여기서 돕니다."
        },
        {
          "line": 5,
          "out": "200 application/json  3"
        }
      ],
      "skip": [
        6
      ]
    },
    {
      "label": "{\"X\":1,\"Y\":2}",
      "state": "POST /webapi/IJsonService/Sum",
      "path": [
        {
          "line": 0
        },
        {
          "line": 1
        },
        {
          "line": 2,
          "verdict": "false"
        },
        {
          "line": 3,
          "why": "인자 이름 비교는 Array.IndexOf 라 대소문자를 가립니다. X, Y 는 모르는 키로 건너뜁니다.",
          "state": "x = 0 · y = 0"
        },
        {
          "line": 4
        },
        {
          "line": 5,
          "out": "200 application/json  0   (오류 없음)"
        }
      ],
      "skip": [
        6
      ]
    },
    {
      "label": "{\"x\":\"abc\"}",
      "state": "POST /webapi/IJsonService/Sum",
      "path": [
        {
          "line": 0
        },
        {
          "line": 1
        },
        {
          "line": 2,
          "verdict": "false"
        },
        {
          "line": 3,
          "why": "문자열을 int 로 읽다가 던집니다. 필터 사슬에 들어가기 전입니다."
        },
        {
          "line": 6,
          "out": "500 {\"Code\":13,...}  전역 필터 통과 0회"
        }
      ],
      "skip": [
        4,
        5
      ]
    }
  ],
  "note": "형식이 틀린 본문이 400 이 아니라 500 이 되는 것은 역직렬화 예외가 일반 예외 경로로 가기 때문입니다."
}
</script>
</x-flow>

인자 이름 키는 대소문자를 가려서, 틀리면 오류 없이 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` 에 한 번 더 등록하는 개발용 경로입니다.