---
title: "MagicOnion 클라이언트는 인터페이스 구현을 Emit 이나 소스 생성기로 만든다"
description: "MagicOnionClient.Create 는 인터페이스만 받는데 구현 객체를 돌려줍니다. JIT 에서는 Reflection.Emit, IL2CPP 에서는 소스 생성기가 같은 모양의 클래스를 만들고, 생성기를 켜면 동적 경로는 사라집니다."
author: "유니얼"
pubDate: 2026-10-02
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "네트워크"]
canonical: https://blog.unialgames.com/posts/csharp-magiconion-client/
---
```csharp
IMathService client = MagicOnionClient.Create<IMathService>(channel);
int sum = await client.SumAsync(1, 2);
```

`Create` 는 인터페이스만 받습니다. 구현 클래스는 사용자가 쓴 적이 없는데 `SumAsync` 가 돕니다. 에디터에서 잘 돌던 이 줄이 IL2CPP 빌드에서 처음 예외를 내기도 합니다.

MagicOnion 은 **JIT 가 있으면 `Reflection.Emit` 으로 런타임에, 없으면 소스 생성기로 빌드 때 같은 모양의 구현 클래스를 만듭니다.** 어느 쪽을 쓸지는 공급자 하나가 정하고, 그 클래스의 메서드가 돌려주는 `UnaryResult` 는 `Task` 가 아니라 호출 핸들입니다. 기준 판본은 7.11.0 입니다.

## 기본 공급자는 런타임이 동적 코드를 지원하는지로 갈린다

`Create` 오버로드는 전부 공급자의 `TryGetFactory<T>` 로 모입니다. 인자를 생략하면 공급자는 `MagicOnionClientFactoryProvider.Default` 이고, 그 초기값을 한 줄이 가릅니다.

```csharp
// src/MagicOnion.Client/MagicOnionClientFactoryProvider.cs (netstandard2.1 이상)
public static IMagicOnionClientFactoryProvider Default { get; set; }
    = RuntimeFeature.IsDynamicCodeSupported
        ? DynamicClient.DynamicMagicOnionClientFactoryProvider.Instance
        : DynamicClient.DynamicNotSupportedMagicOnionClientFactoryProvider.Instance;
```

Unity 에서 이 값이 실제로 갈렸습니다.

| | 에디터 플레이 모드 | Mono 플레이어 | IL2CPP 플레이어 |
| --- | --- | --- | --- |
| `IsDynamicCodeSupported` | `True` | `True` | `False` |
| 초기 `Default` | 동적 공급자 | 동적 공급자 | "지원 안 됨" 공급자 |

"지원 안 됨" 공급자는 `false` 를 돌려주지 않고 `NotSupportedException: ... If the application is running on IL2CPP or AOT, ...` 로 원인과 해법을 담아 던집니다. 에디터에서 되던 코드가 IL2CPP 빌드에서 처음 깨지는 자리가 여기입니다.

## 두 경로는 같은 모양의 클래스를 찍는다

동적 빌더와 소스 생성기는 같은 설계도를 씁니다. 생성된 파일을 꺼내 보면 이렇습니다.

```csharp
// 생성된 클라이언트 (줄임)
[MagicOnion.Ignore]
public class Mo0708_Shared_GenServiceClient : MagicOnionClientBase<IGenService>, IGenService
{
    class ClientCore
    {
        public RawMethodInvoker<DynamicArgumentTuple<int, int>, int> SumAsync;   // 메서드마다 하나
        ...
    }
    readonly ClientCore core;

    public UnaryResult<int> SumAsync(int x, int y)
        => this.core.SumAsync.InvokeUnary(this, "IGenService/SumAsync", new DynamicArgumentTuple<int, int>(x, y));
}
```

메서드마다 `RawMethodInvoker` 하나를 `ClientCore` 에 두고, 인자는 서버와 같은 규칙으로 묶어 넘깁니다. `[Ignore]` 는 같은 프로세스의 서버가 이 클래스를 서비스로 오인하지 않게 하는 표시입니다. 동적 경로는 같은 클래스를 `TypeBuilder` 와 `ILGenerator` 로 `T` 마다 한 번 만들고, 생성기는 `typeof(T) ==` 비교와 직접 `new` 로 바꿔 AOT 컴파일러가 볼 수 있게 합니다.

| | 첫 `Create` | 첫 `Create` 할당 | 두 번째 `Create` |
| --- | --- | --- | --- |
| 동적 | 약 8.5 ms | 약 62.5 KB | 약 0.14 ms |
| 생성 | 0.2~0.8 ms | 2,952 B | 0.004~0.009 ms |

동적 경로의 두 번째 `Create` 가 느린 것은 타입은 캐시하지만 인스턴스를 `Activator.CreateInstance` 로 만들기 때문입니다. 생성기가 산 것은 속도보다 AOT 에서 돈다는 것입니다. (Windows 11, .NET SDK 10.0.401, net10.0, 순서를 바꿔 두 번 실행.)

## 생성기를 켜면 동적 공급자가 빠진다

소스 생성기는 `MagicOnion.Client` 패키지 안에 들어 있고, `[MagicOnionClientGeneration(typeof(IGenService))]` 를 붙인 `partial` 클래스 하나로 켭니다. 생성된 초기화기는 `Main` 이나 첫 씬보다 먼저 돌며 기본 공급자를 바꿉니다. 초기값인 동적 공급자는 `ImmutableMagicOnionClientFactoryProvider` 가 아니라서, 뒤에 붙는 것이 아니라 **교체**됩니다.

<x-flow>
<script type="application/json">
{
  "label": "생성 대상에서 빠진 INestedService 로 Create 를 부를 때 (JIT, IsDynamicCodeSupported = True)",
  "lines": [
    "MagicOnionClientFactoryProvider.Default =                         // 모듈 초기화기",
    "    (Default is ImmutableMagicOnionClientFactoryProvider immutable)",
    "        ? immutable.Add(generated)",
    "        : new ImmutableMagicOnionClientFactoryProvider(generated);",
    "var client = MagicOnionClient.Create<INestedService>(channel);   // 사용자 코드",
    "foreach (var provider in providers)",
    "    if (provider.TryGetFactory<INestedService>(out factory)) return true;",
    "return false;   // Create 가 NotSupportedException 을 던진다"
  ],
  "cases": [
    {
      "label": "자동 등록 (기본)",
      "state": "Default = DynamicMagicOnionClientFactoryProvider",
      "path": [
        { "line": 0, "why": "Main 보다 먼저 생성된 초기화기가 돕니다." },
        { "line": 1, "verdict": "false", "why": "지금 Default 는 동적 공급자라 Immutable 이 아닙니다." },
        { "line": 3, "why": "새 합성 공급자로 통째로 바꿉니다. 동적 공급자는 여기서 사라집니다.", "state": "Default = Immutable[Generated]" },
        { "line": 4 },
        { "line": 5, "state": "provider = Generated" },
        { "line": 6, "verdict": "false", "why": "생성 공급자는 모르는 타입에 false 를 돌려줍니다." },
        { "line": 7, "out": "NotSupportedException: Unable to get client factory for service type 'Mo0708.Shared.Outer+INestedService'." }
      ],
      "skip": [2]
    },
    {
      "label": "생성 우선, 동적 폴백을 직접 합성",
      "state": "Default = new ImmutableMagicOnionClientFactoryProvider(Generated, DynamicMagicOnionClientFactoryProvider.Instance)",
      "path": [
        { "line": 4, "why": "자동 등록을 끄고 Main 에서 두 공급자를 직접 이어 둔 상태입니다." },
        { "line": 5, "state": "provider = Generated" },
        { "line": 6, "verdict": "false" },
        { "line": 5, "state": "provider = Dynamic" },
        { "line": 6, "verdict": "true", "why": "동적 공급자는 언제나 true 입니다. 이 자리에서 Emit 으로 클래스를 만듭니다.", "out": "MagicOnion.DynamicallyGeneratedClient.Mo0708.Shared.INestedServiceClient, One() = 1" }
      ],
      "skip": [0, 1, 2, 3, 7]
    }
  ],
  "note": "두 탭 모두 실측 결과입니다. 생성 대상인 IGenService 는 두 구성 모두에서 생성된 클라이언트가 나왔습니다."
}
</script>
</x-flow>

"생성기를 켜도 JIT 에서는 동적으로 폴백되겠지" 는 틀린 기대입니다. 생성 대상에서 빠진 인터페이스, 예를 들어 클래스 안에 중첩한 인터페이스는 JIT 에서도 곧바로 실패합니다. 생성기는 네임스페이스만 따라 훑기 때문입니다. 생성기가 함께 만드는 MessagePack 리졸버는 **정의만** 되고 자동 등록되지 않습니다. JIT 에서는 빠뜨려도 동작해서(enum 이 든 호출이 등록 없이 왕복) AOT 빌드에서야 터집니다.

## UnaryResult 는 Task 가 아니라 호출 핸들이다

`UnaryResult<T>` 는 `T rawValue` 와 `object? valueTask` 두 필드로 셋을 담습니다. 서버의 이미 있는 값, 서버의 `Task<T>`, 그리고 클라이언트의 진행 중인 gRPC 호출(`Task<IResponseContext<T>>`)입니다. 반환 타입이 곧 스키마라 `Task<T>` 로는 Unary 인지 말할 수 없고, 클라이언트에서는 값 말고도 헤더, 상태, 트레일러를 가진 핸들이어야 했습니다.

<x-compare>
<script type="application/json">
{
  "label": "await 하기 전에 GetStatus() 를 부르면 (한 프로세스 Kestrel HTTP/2, net10.0)",
  "tabs": [
    {
      "label": "클라이언트 필터 0개",
      "text": "컨텍스트 Task 는 이미 끝나 있어서 MagicOnion 의 검사는 지나가고, 그 밑의 gRPC 호출 객체가 던집니다.",
      "stats": [["await 전 GetStatus()", "InvalidOperationException: Unable to get the status because the call is not complete."], ["await 전 ResponseHeadersAsync", "x-hdr=h1 (본문보다 먼저 받음)"], ["await 뒤 GetStatus()", "OK, trailers x-trl=t1"]],
      "tone": "bad"
    },
    {
      "label": "클라이언트 필터 1개",
      "text": "컨텍스트 Task 가 아직 안 끝나서 MagicOnion 자신의 검사에서 던집니다. 한 프로세스라 응답이 빨라서 세 번 중 한 번은 예외 없이 OK 가 나왔습니다.",
      "stats": [["await 전 GetStatus()", "InvalidOperationException: UnaryResult request is not yet completed, please await before call this."], ["await 뒤 GetStatus()", "OK"]],
      "tone": "bad"
    },
    {
      "label": "서버 쪽 값",
      "text": "서버에서 FromResult 로 만든 UnaryResult 에는 호출 핸들이 없습니다. 같은 타입이지만 담긴 것이 다릅니다.",
      "stats": [["UnaryResult.FromResult(1).GetStatus()", "InvalidOperationException: UnaryResult has no response."], ["default(UnaryResult<int>) 를 await", "0"]]
    }
  ]
}
</script>
</x-compare>

`[AsyncMethodBuilder]` 로 붙은 빌더는 한 번도 기다리지 않으면 Task 를 만들지 않습니다. 서버에서 `async UnaryResult<int>` 가 기다리지 않고 값을 돌려주면 내부 모양이 `FromResult` 와 같았습니다(`HasRawValue=True valueTask=null`).

## 주의할 점

### 1. 생성기를 켰다면 생성 대상 밖의 인터페이스는 없는 것으로 본다

**안 지키면 생기는 문제.** 생성된 초기화기가 기본 공급자를 교체하므로, 생성 대상에서 빠진 인터페이스는 JIT 에서도 `NotSupportedException: Unable to get client factory for service type ...` 으로 실패합니다. 클래스 안에 중첩한 인터페이스와, 특성 인자로 준 타입과 다른 어셈블리의 인터페이스가 대표적입니다.

**이렇게 쓴다.** 서비스 인터페이스는 네임스페이스 바로 아래에 두고, 인터페이스를 담은 어셈블리마다 그 어셈블리의 타입 하나를 특성에 넣습니다. 개발 중에만 폴백이 필요하면 `DisableAutoRegistration = true` 로 끄고 `new ImmutableMagicOnionClientFactoryProvider(생성 공급자, DynamicMagicOnionClientFactoryProvider.Instance)` 를 직접 겁니다.

### 2. 생성된 직렬화 리졸버를 직접 등록한다

**안 지키면 생기는 문제.** `DynamicArgumentTuple<...>`, `List<T>`, enum 포매터를 모은 리졸버가 만들어지지만 아무도 쓰지 않습니다. JIT 에서는 MessagePack 이 런타임에 포매터를 만들어 주니 개발 중에는 드러나지 않고, IL2CPP 빌드에서 직렬화 오류가 납니다.

**이렇게 쓴다.** 시작 코드에서 생성된 `Resolver` 를 `StaticCompositeResolver` 로 표준 리졸버 앞에 합성합니다. JIT 빌드에도 같은 코드를 넣어 두 환경의 경로를 맞추고, 빌드한 기기에서 모든 메서드를 한 번씩 부르는 스모크 테스트를 둡니다.

### 3. 클라이언트를 호출마다 만들지 않는다

**안 지키면 생기는 문제.** `Create` 는 부를 때마다 `ClientCore` 와 메서드 수만큼의 `RawMethodInvoker`, gRPC `Method`, 직렬화기를 만듭니다. 메서드 넷짜리에서 2,952 B 였고, 동적 경로는 매번 리플렉션 생성자 호출이 붙습니다.

**이렇게 쓴다.** 채널과 클라이언트는 한 번 만들어 들고 씁니다. 요청마다 헤더나 데드라인이 다르면 `client.WithHeaders(...)`, `client.WithDeadline(...)` 을 씁니다. 이 메서드들은 `ClientCore` 를 나눠 쓰는 복제본을 만듭니다.

### 4. GetStatus 와 GetTrailers 는 await 뒤에 부른다

**안 지키면 생기는 문제.** 클라이언트 필터가 없으면 gRPC 쪽 "call is not complete" 예외, 있으면 MagicOnion 쪽 "UnaryResult request is not yet completed" 예외가 납니다. 필터가 있을 때는 응답이 빠르면 예외 없이 지나가기도 해서, 로컬에서는 되고 원격 서버에서만 가끔 실패합니다.

**이렇게 쓴다.** `var call = client.SumAsync(1, 2); var v = await call;` 처럼 핸들을 변수에 담고 `await` 한 뒤에 `call.GetStatus()` 를 부릅니다. 헤더만 먼저 필요하면 `await call.ResponseHeadersAsync` 를 한 번 받아 둡니다.

## 정리

MagicOnion 클라이언트는 **인터페이스 구현 클래스를 JIT 에서는 Emit 으로, AOT 에서는 소스 생성기로 같은 모양으로 만듭니다.**

- 기본 공급자는 `IsDynamicCodeSupported` 로 갈리고, Unity 에서는 IL2CPP 플레이어만 `False` 였습니다.
- 생성기의 초기화기는 기본 공급자를 교체해 동적 폴백을 없애고, 생성된 리졸버는 직접 등록해야 합니다.
- `UnaryResult<T>` 는 값, 서버의 Task, 클라이언트의 호출 핸들을 한 구조체에 담습니다. 상태와 트레일러는 `await` 뒤에 읽습니다.