MagicOnion 클라이언트는 인터페이스 구현을 Emit 이나 소스 생성기로 만든다
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 이고, 그 초기값을 한 줄이 가릅니다.
// 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 빌드에서 처음 깨지는 자리가 여기입니다.
두 경로는 같은 모양의 클래스를 찍는다
동적 빌더와 소스 생성기는 같은 설계도를 씁니다. 생성된 파일을 꺼내 보면 이렇습니다.
// 생성된 클라이언트 (줄임)
[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 가 아니라서, 뒤에 붙는 것이 아니라 교체됩니다.
“생성기를 켜도 JIT 에서는 동적으로 폴백되겠지” 는 틀린 기대입니다. 생성 대상에서 빠진 인터페이스, 예를 들어 클래스 안에 중첩한 인터페이스는 JIT 에서도 곧바로 실패합니다. 생성기는 네임스페이스만 따라 훑기 때문입니다. 생성기가 함께 만드는 MessagePack 리졸버는 정의만 되고 자동 등록되지 않습니다. JIT 에서는 빠뜨려도 동작해서(enum 이 든 호출이 등록 없이 왕복) AOT 빌드에서야 터집니다.
UnaryResult 는 Task 가 아니라 호출 핸들이다
UnaryResult<T> 는 T rawValue 와 object? valueTask 두 필드로 셋을 담습니다. 서버의 이미 있는 값, 서버의 Task<T>, 그리고 클라이언트의 진행 중인 gRPC 호출(Task<IResponseContext<T>>)입니다. 반환 타입이 곧 스키마라 Task<T> 로는 Unary 인지 말할 수 없고, 클라이언트에서는 값 말고도 헤더, 상태, 트레일러를 가진 핸들이어야 했습니다.
[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뒤에 읽습니다.