---
title: "MagicOnion 은 .proto 대신 C# 인터페이스를 스키마로 쓴다"
description: "gRPC 는 .proto 와 코드 생성을 요구합니다. MagicOnion 은 서버와 클라이언트가 같은 C# 인터페이스를 컴파일하는 것으로 그 자리를 채우고, 전송은 gRPC(HTTP/2)에 맡깁니다. 대신 이름이 곧 와이어 계약입니다."
author: "유니얼"
pubDate: 2026-10-02
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "네트워크"]
canonical: https://blog.unialgames.com/posts/csharp-magiconion-why/
---
```csharp
public interface IMathService : IService<IMathService>
{
    UnaryResult<int> SumAsync(int x, int y);
}
```

C# 서버와 C# 클라이언트가 gRPC 로 이야기하려면 보통 `.proto` 파일을 쓰고 `protoc` 로 양쪽 코드를 만듭니다. 같은 언어끼리인데 스키마 언어와 생성 단계를 하나 더 거치는 셈입니다.

MagicOnion 은 **서버와 클라이언트가 같이 참조하는 C# 인터페이스를 스키마로 쓰고, 전송만 gRPC 에 맡깁니다.** 위 인터페이스가 `.proto` 의 자리입니다. 기준 판본은 7.11.0 입니다.

## IDL 이 하던 일을 인터페이스에서 읽는다

`.proto` 는 서비스 이름, 메서드 이름, 요청과 응답 메시지를 정합니다. MagicOnion 은 셋을 리플렉션으로 인터페이스에서 뽑습니다. 서비스 이름은 인터페이스의 짧은 이름, 메서드 이름은 메서드 이름, 요청은 인자 목록, 응답은 `UnaryResult<T>` 의 `T` 입니다.

<x-compare>
<script type="application/json">
{
  "label": "두 수를 더하는 서비스 하나를 정의하기",
  "tabs": [
    {
      "label": ".proto 로 쓴 gRPC",
      "code": "// math.proto\nservice MathService {\n  rpc Sum (SumRequest) returns (SumReply);\n}\nmessage SumRequest { int32 x = 1; int32 y = 2; }\nmessage SumReply   { int32 result = 1; }\n\n// 빌드 때 protoc 가 이 파일에서 C# 메시지 클래스와\n// 서비스 바인딩 코드를 만든다",
      "stats": [["스키마", ".proto 파일"], ["요청과 응답", "메서드마다 메시지를 따로 선언"], ["서버 등록", "protoc 가 만든 바인딩 코드"], ["코드 생성", "빌드 단계에서 protoc"]]
    },
    {
      "label": "MagicOnion",
      "code": "// 서버와 클라이언트가 같이 참조하는 C# 파일\npublic interface IMathService : IService<IMathService>\n{\n    UnaryResult<int> SumAsync(int x, int y);\n}\n\n// 서버는 이 인터페이스를 구현하고,\n// 클라이언트는 이 인터페이스로 프록시를 받는다",
      "stats": [["스키마", "C# 인터페이스"], ["요청과 응답", "인자 목록이 곧 요청, 반환 타입의 T 가 응답"], ["서버 등록", "리플렉션으로 인터페이스를 읽어 등록"], ["라우트", "/IMathService/SumAsync"]],
      "tone": "good"
    }
  ]
}
</script>
</x-compare>

그래서 `SumAsync` 의 gRPC 경로는 `/IMathService/SumAsync` 입니다. 인터페이스 이름과 메서드 이름이 **그대로 와이어에 실립니다.**

## 계약은 양쪽에 똑같이 컴파일된 소스가 지킨다

`.proto` 가 없으면 "양쪽이 같은 계약을 본다" 는 보장을 누가 할까요. gRPC 메서드 객체와 마샬러를 만드는 `GrpcMethodHelper` 가 든 `MagicOnion.Internal` 은 패키지로 나가지 않고, 서버 DLL 과 클라이언트 DLL 에 **소스째** 들어갑니다. 서버의 바인더도 클라이언트의 호출기도 같은 함수로 메서드와 마샬러를 만듭니다.

<x-wiring>
<script type="application/json">
{
  "label": "Unary 호출 SumAsync(1, 2) 한 번이 지나가는 층",
  "width": 940,
  "height": 400,
  "nodes": [
    { "id": "user", "title": "사용자 코드", "sub": "await client.SumAsync(1, 2)", "x": 0, "y": 8, "w": 170, "h": 54, "aside": true },
    { "id": "proxy", "title": "클라이언트 프록시", "sub": "Emit 또는 소스 생성", "x": 360, "y": 8, "w": 170, "h": 54 },
    { "id": "rmi", "title": "RawMethodInvoker", "sub": "메서드마다 하나", "x": 720, "y": 140, "w": 170, "h": 54 },
    { "id": "grpc", "title": "grpc-dotnet", "sub": "HTTP/2 · Kestrel", "x": 720, "y": 338, "w": 170, "h": 54 },
    { "id": "handler", "title": "메서드 핸들러", "sub": "MagicOnionGrpcMethodHandler", "x": 360, "y": 338, "w": 180, "h": 54 },
    { "id": "svc", "title": "MathService", "sub": "호출마다 새 인스턴스", "x": 0, "y": 338, "w": 170, "h": 54 }
  ],
  "edges": [
    { "from": "user", "to": "proxy", "label": "SumAsync(1, 2)", "bend": 0 },
    { "from": "proxy", "to": "rmi", "label": "DynamicArgumentTuple(1, 2)", "bend": 0 },
    { "from": "rmi", "to": "grpc", "label": "Box 에 담아 직렬화", "bend": 24 },
    { "from": "grpc", "to": "handler", "label": "역직렬화, 인스턴스 생성", "bend": 16 },
    { "from": "handler", "to": "svc", "label": "Context 를 넣고 호출", "bend": 16 },
    { "from": "svc", "to": "handler", "label": "UnaryResult<int>(3)", "bend": 16 },
    { "from": "handler", "to": "grpc", "label": "Box<int>(3)", "bend": 16 },
    { "from": "grpc", "to": "rmi", "label": "응답 바이트", "bend": 24 },
    { "from": "rmi", "to": "user", "label": "3", "bend": 0 }
  ],
  "steps": [
    { "edge": 0, "why": "사용자는 인터페이스 메서드를 부릅니다. 받은 객체는 MagicOnion 이 인터페이스로 만들어 준 구현 클래스입니다." },
    { "edge": 1, "why": "프록시는 인자 두 개를 DynamicArgumentTuple<int, int> 하나로 묶어 그 메서드 전용 RawMethodInvoker 에 넘깁니다." },
    { "edge": 2, "why": "튜플은 구조체라서 Box<T> 에 담습니다. 서버와 같은 GrpcMethodHelper 가 만든 마샬러가 gRPC 버퍼에 바로 씁니다." },
    { "edge": 3, "why": "Kestrel 이 POST /IMathService/SumAsync 를 받습니다. 같은 마샬러로 튜플을 되살리고, grpc-dotnet 이 서비스 인스턴스를 만듭니다." },
    { "edge": 4, "why": "핸들러는 ServiceContext 를 새로 만들어 인스턴스에 넣고, 필터를 지나 컴파일해 둔 호출자로 SumAsync(1, 2) 를 부릅니다." },
    { "edge": 5, "why": "서비스는 UnaryResult<int> 로 3 을 돌려줍니다. 핸들러는 그 값을 ServiceContext.Result 에서 꺼냅니다." },
    { "edge": 6, "why": "int 는 값 타입이라 다시 Box<int> 에 담아 grpc-dotnet 에 넘기고, 마샬러가 직렬화합니다." },
    { "edge": 7, "why": "응답 바이트가 클라이언트로 돌아와 같은 규칙의 마샬러가 Box<int> 를 되살립니다." },
    { "edge": 8, "why": "상자를 벗긴 3 이 await 의 결과가 됩니다." }
  ],
  "note": "위 줄은 클라이언트 DLL, 아래 줄은 서버 DLL 입니다. 둘 사이의 계약은 양쪽에 똑같이 컴파일된 GrpcMethodHelper 한 파일이 지킵니다."
}
</script>
</x-wiring>

두 DLL 의 `GrpcMethodHelper` 는 이름은 같아도 서로 다른 `internal` 타입이라 부딪히지 않습니다. 공개 API 를 늘리지 않고 와이어 규칙을 한 소스에서 나오게 한 것입니다. 대신 **C# 전용**입니다. 다른 언어가 붙으려면 MessagePack 바이트와 이름 규칙을 손으로 맞춰야 합니다.

## 전송은 grpc-dotnet 에 통째로 맡긴다

서버는 ASP.NET Core 의 한 기능입니다. `AddMagicOnion()` 이 안에서 `AddGrpc()` 를 부르고, grpc-dotnet 이 서비스의 메서드 목록을 묻는 확장점에 MagicOnion 의 공급자를 끼웁니다. Kestrel, 인증, 미들웨어, HTTP/2 멀티플렉싱이 그대로 따라옵니다.

치른 값은 **HTTP/2 가 필수**라는 것입니다. 7.0 에서 Unity 용 C-core gRPC 지원을 걷어냈고, Unity 에서는 YetAnotherHttpHandler 를 `GrpcChannelOptions.HttpHandler` 로 꽂아 HTTP/2 를 씁니다.

## 호출 방식은 둘이고, 인프라에는 다르게 보인다

| | Unary | StreamingHub |
| --- | --- | --- |
| gRPC 에서 무엇인가 | 메서드마다 Unary 하나 | 허브마다 DuplexStreaming 하나 (`Connect`) |
| 인터페이스 | `IService<T>`, 반환 `UnaryResult<T>` | `IStreamingHub<THub, TReceiver>` |
| 서버가 먼저 보내기 | 없음 | `TReceiver` 호출 |
| 인프라에서 보이는 것 | 메서드마다 HTTP POST 한 번 | 긴 요청 하나 |

허브에 메서드가 몇 개든 gRPC 라우트는 하나입니다. 엔드포인트를 열거하면 이렇게 나옵니다.

```text
/IMathService/SumAsync
/IChatHub/Connect          ← JoinAsync · SendAsync · EchoAsync 가 있지만 라우트는 하나
```

허브 메서드는 그 스트림 안에서 메서드 이름의 해시(FNV-1a 32비트)로 구분합니다. 로드 밸런서와 액세스 로그가 보는 것은 `/IChatHub/Connect` 하나뿐입니다. (Windows 11, .NET SDK 10.0.401, net10.0, 한 프로세스의 Kestrel HTTP/2 서버와 클라이언트.)

## 주의할 점

### 1. 배포한 인터페이스와 메서드의 이름을 바꾸지 않는다

**안 지키면 생기는 문제.** Unary 라우트는 `/{인터페이스 짧은 이름}/{메서드 이름}` 이고 허브 메서드 ID 는 메서드 이름의 해시입니다. 리팩터링 도구로 이름을 바꾸면 양쪽 코드가 같이 바뀌어 컴파일은 되지만, 이미 배포된 옛 클라이언트는 `Unimplemented` 를 받거나 허브에서 없는 메서드 ID 오류를 받습니다.

**이렇게 쓴다.** 배포한 이름은 계약으로 봅니다. 바꿔야 하면 공유 인터페이스에 `[ServiceName("옛이름")]`, 허브 메서드면 `[MethodId(옛값)]` 을 붙여 와이어의 이름을 고정합니다. 한쪽에만 붙이면 경로가 어긋납니다.

### 2. 같은 짧은 이름의 인터페이스 둘을 한 서버에 올리지 않는다

**안 지키면 생기는 문제.** 네임스페이스는 경로에 들어가지 않습니다. `A.IMyService` 와 `B.IMyService` 는 둘 다 `/IMyService/...` 가 되어 경로가 겹칩니다.

**이렇게 쓴다.** 인터페이스 이름을 다르게 짓거나 한쪽에 `[ServiceName("B.IMyService")]` 처럼 겹치지 않는 이름을 줍니다.

### 3. 허브 메서드 단위의 부하 분산과 로그를 기대하지 않는다

**안 지키면 생기는 문제.** StreamingHub 의 호출은 전부 `Connect` 스트림 안의 메시지입니다. 로드 밸런서로 메서드별 라우팅을 하거나 액세스 로그로 메서드별 호출 수를 보려 하면 아무것도 나오지 않습니다.

**이렇게 쓴다.** 서버가 먼저 보내야 하거나 잦은 양방향 교환이 필요할 때만 허브를 쓰고, 나머지는 Unary 로 둡니다. 공식 문서도 Unary 를 기본으로 권합니다.

## 정리

MagicOnion 은 **`.proto` 자리에 공유 C# 인터페이스를 두고 전송은 gRPC 에 맡긴** RPC 프레임워크입니다.

- 서비스 이름, 메서드 이름, 요청과 응답 타입을 인터페이스에서 리플렉션으로 읽습니다.
- 와이어 규칙은 서버와 클라이언트에 소스째 컴파일되는 `MagicOnion.Internal` 한 벌이 지킵니다. 그래서 C# 전용이고 이름이 곧 계약입니다.
- Unary 는 메서드마다 gRPC Unary 하나, StreamingHub 는 허브마다 `Connect` 스트림 하나입니다.