---
title: "MagicOnion 의 Unary 는 요청마다 ServiceContext 와 서비스 인스턴스를 새로 만든다"
description: "서비스 코드는 this.Context 로 요청 정보를 읽습니다. MagicOnion 은 호출마다 인스턴스를 받아 Context 속성을 채우므로, 서비스를 싱글턴으로 등록하면 동시 요청 200건 중 199건이 남의 컨텍스트를 봤습니다."
author: "유니얼"
pubDate: 2026-10-02
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "네트워크"]
canonical: https://blog.unialgames.com/posts/csharp-magiconion-unary/
---
```csharp
public class MathService : ServiceBase<IMathService>, IMathService
{
    public UnaryResult<int> SumAsync(int x, int y) => UnaryResult.FromResult(x + y);
}
```

서비스 메서드는 인자 `x`, `y` 만 받는데도 `this.Context` 로 헤더, 인증 정보, 요청 원본을 읽을 수 있습니다. 그 컨텍스트가 언제 생기고 누구의 것인지 모르면, 싱글턴 등록 한 줄로 다른 사용자의 요청을 읽게 됩니다.

MagicOnion 의 Unary 는 **필터 사슬과 호출자는 바인딩 때 한 번 만들고, `ServiceContext` 와 서비스 인스턴스는 요청마다 새로 만들어 속성으로 꽂습니다.** 인자 목록은 `Nil`, 값 그대로, 튜플 중 하나로 감싸 gRPC 메시지 하나가 됩니다. 기준 판본은 7.11.0 입니다.

## 조립은 한 번, 컨텍스트는 호출마다

서버가 시작할 때 메서드마다 `BuildUnaryMethod` 가 핸들러 하나를 만듭니다. 이 메서드는 필터를 모아 델리게이트 사슬로 감싼 뒤 로컬 함수 `InvokeAsync` 를 돌려주고, 요청마다 도는 것은 그 로컬 함수뿐입니다.

| 바인딩 때 메서드마다 한 번 | 요청마다 |
| --- | --- |
| 필터 정렬, 필터 인스턴스, 필터 사슬 | `ServiceContext` (`Guid.NewGuid()`, `DateTime.UtcNow`) |
| 직렬화기 | 서비스 인스턴스 (grpc-dotnet 이 넘겨준다) |
| 인터페이스 메서드 호출자(식 트리) | 값 타입 요청과 응답의 `Box<T>` |
| 특성 조회 표 | `InvokeAsync` 의 async 상태 머신 |

`ServiceContext` 는 풀링하지 않습니다. 호출 세 번의 `ContextId` 가 전부 달랐습니다.

## Context 는 인스턴스의 속성이라 싱글턴이면 덮인다

MagicOnion 은 서비스 인스턴스를 만들지 않습니다. grpc-dotnet 이 넘겨준 인스턴스의 `Context` 속성에 방금 만든 컨텍스트를 넣을 뿐입니다. DI 에 등록하지 않은 기본 상태에서는 인스턴스가 호출마다 새로 생기니 문제가 없고, `AddSingleton` 으로 등록하면 인스턴스 하나의 같은 필드를 요청마다 덮어씁니다.

<x-flow>
<script type="application/json">
{
  "label": "요청 1 과 요청 2 가 겹칠 때 Context 필드 (소스를 줄여 옮김)",
  "lines": [
    "// MagicOnionGrpcMethodHandler.BuildUnaryMethod 가 돌려주는 InvokeAsync",
    "async Task<TRawResponse> InvokeAsync(TService instance, TRawRequest requestRaw, ServerCallContext context)   // instance 는 grpc-dotnet 이 넘긴다",
    "    var serviceContext = new ServiceContext(instance, method, context, messageSerializer, metrics, logger, requestServiceProvider);",
    "    serviceContext.SetRawRequest(request);",
    "    InitializeServiceProperties(instance, serviceContext);",
    "        ((IServiceBase)instance).Context = serviceContext;   // ServiceBase 의 private set",
    "    await wrappedBody(serviceContext);                      // 필터 사슬과 서비스 메서드",
    "",
    "// ServiceBase<TServiceInterface>",
    "public ServiceContext Context { get; private set; }         // 서비스 메서드가 await 뒤에 읽는 속성"
  ],
  "cases": [
    {
      "label": "등록 안 함 (기본)",
      "state": "인스턴스 #1, #2 를 호출마다 따로 받는다",
      "path": [
        {
          "line": 1,
          "why": "요청 1 이 도착합니다. grpc-dotnet 이 인스턴스 #1 을 넘깁니다.",
          "frames": [
            "요청 1"
          ]
        },
        {
          "line": 2,
          "why": "요청마다 새 컨텍스트를 만듭니다.",
          "state": "C1 = 요청 1 의 컨텍스트"
        },
        {
          "line": 5,
          "state": "#1.Context = C1"
        },
        {
          "line": 6,
          "why": "서비스 메서드가 Task.Delay 에서 기다립니다.",
          "frames": [
            "요청 1",
            "EchoAfterDelay (대기)"
          ]
        },
        {
          "line": 1,
          "why": "요청 2 가 도착합니다. 인스턴스 #2 는 따로입니다.",
          "frames": [
            "요청 2"
          ]
        },
        {
          "line": 2,
          "state": "#1.Context = C1 · C2 = 요청 2 의 컨텍스트"
        },
        {
          "line": 5,
          "state": "#1.Context = C1 · #2.Context = C2"
        },
        {
          "line": 6,
          "why": "요청 2 도 기다립니다.",
          "frames": [
            "요청 2",
            "EchoAfterDelay (대기)"
          ]
        },
        {
          "line": 9,
          "why": "요청 1 이 깨어나 #1.Context 를 읽습니다.",
          "frames": [
            "요청 1",
            "EchoAfterDelay"
          ],
          "out": "요청 1: 1 == 1 → true"
        },
        {
          "line": 9,
          "why": "요청 2 가 깨어나 #2.Context 를 읽습니다.",
          "frames": [
            "요청 2",
            "EchoAfterDelay"
          ],
          "out": "요청 2: 2 == 2 → true"
        }
      ]
    },
    {
      "label": "AddSingleton 으로 등록",
      "state": "인스턴스 #S 하나를 모든 요청이 나눠 쓴다",
      "path": [
        {
          "line": 1,
          "why": "요청 1 이 도착합니다. grpc-dotnet 이 인스턴스 #S 을 넘깁니다.",
          "frames": [
            "요청 1"
          ]
        },
        {
          "line": 2,
          "why": "요청마다 새 컨텍스트를 만듭니다.",
          "state": "C1 = 요청 1 의 컨텍스트"
        },
        {
          "line": 5,
          "state": "#S.Context = C1"
        },
        {
          "line": 6,
          "why": "서비스 메서드가 Task.Delay 에서 기다립니다.",
          "frames": [
            "요청 1",
            "EchoAfterDelay (대기)"
          ]
        },
        {
          "line": 1,
          "why": "요청 2 가 도착합니다. 같은 인스턴스 #S 가 넘어옵니다.",
          "frames": [
            "요청 2"
          ]
        },
        {
          "line": 2,
          "state": "#S.Context = C1 · C2 = 요청 2 의 컨텍스트"
        },
        {
          "line": 5,
          "why": "같은 필드를 덮어씁니다. 예외도 경고도 없습니다.",
          "state": "#S.Context = C2"
        },
        {
          "line": 6,
          "why": "요청 2 도 기다립니다.",
          "frames": [
            "요청 2",
            "EchoAfterDelay (대기)"
          ]
        },
        {
          "line": 9,
          "why": "요청 1 이 깨어나 Context 를 읽는데, 이제 요청 2 의 것입니다.",
          "frames": [
            "요청 1",
            "EchoAfterDelay"
          ],
          "out": "요청 1: 2 == 1 → false"
        },
        {
          "line": 9,
          "why": "요청 2 가 깨어나 #S.Context 를 읽습니다.",
          "frames": [
            "요청 2",
            "EchoAfterDelay"
          ],
          "out": "요청 2: 2 == 2 → true"
        }
      ]
    }
  ],
  "note": "싱글턴 등록에서 동시 호출 200건을 넣으면 199건이 자기 요청이 아닌 컨텍스트를 읽었습니다."
}
</script>
</x-flow>

`await` 뒤에 `Context.GetRawRequest()` 가 자기 요청인지 보는 메서드에 동시 호출 200건을 넣었더니 199건이 남의 것이었고, 예외도 경고도 없었습니다. (Windows 11, .NET SDK 10.0.401, net10.0, 한 프로세스 Kestrel HTTP/2.)

## 예외는 grpc-dotnet 에 넘겨 상태 코드가 된다

서비스 메서드는 필터 사슬 안에서 불리고, 핸들러의 `catch` 가 예외를 gRPC 상태로 바꿉니다.

<x-flow>
<script type="application/json">
{
  "label": "InvokeAsync 한 번이 지나가는 줄 (줄인 코드)",
  "lines": [
    "var serviceContext = new ServiceContext(instance, method, context, ...);",
    "var request = GrpcMethodHelper.FromRaw<TRawRequest, TRequest>(requestRaw);",
    "serviceContext.SetRawRequest(request);",
    "InitializeServiceProperties(instance, serviceContext);   // service.Context = ctx",
    "try",
    "{",
    "    await wrappedBody(serviceContext);                   // 필터를 지나 서비스 메서드",
    "    response = (TResponse)serviceContext.Result;",
    "}",
    "catch (ReturnStatusException ex) { context.Status = ex.ToStatus(); throw new RpcException(ex.ToStatus()); }",
    "catch (Exception ex) { if (TryResolveStatus(ex, out var s)) context.Status = s; else throw; }",
    "return GrpcMethodHelper.ToRaw<TResponse, TRawResponse>(response);"
  ],
  "cases": [
    {
      "label": "정상: SumAsync(1, 2)",
      "state": "requestRaw = Box<DynamicArgumentTuple<int, int>>",
      "path": [
        { "line": 0, "why": "호출마다 새 컨텍스트입니다. Guid 와 현재 시각이 여기서 찍힙니다." },
        { "line": 1, "why": "튜플은 구조체라 상자에 담겨 왔습니다. 상자를 벗깁니다.", "state": "request = (1, 2)" },
        { "line": 2 },
        { "line": 3, "why": "grpc-dotnet 이 넘겨준 서비스 인스턴스의 Context 속성에 방금 만든 컨텍스트를 넣습니다. 필터보다 먼저입니다." },
        { "line": 6, "why": "필터 체인을 지나 컴파일해 둔 호출자가 instance.SumAsync(req.Item1, req.Item2) 를 부릅니다.", "state": "serviceContext.Result = 3 (object 로 박싱)" },
        { "line": 7 },
        { "line": 11, "why": "int 응답은 다시 Box<int> 에 담아 grpc-dotnet 에 넘깁니다.", "out": "Box<int>(3) 을 직렬화해 응답" }
      ],
      "skip": [9, 10]
    },
    {
      "label": "ReturnStatusException",
      "state": "서비스가 throw new ReturnStatusException(StatusCode.NotFound, \"no such user\")",
      "path": [
        { "line": 0 },
        { "line": 1 },
        { "line": 2 },
        { "line": 3 },
        { "line": 6, "why": "서비스 메서드 안에서 예외가 납니다." },
        { "line": 9, "why": "상태를 정하고 RpcException 으로 다시 던집니다. 응답이 null 이면 grpc-dotnet 이 Cancelled 로 덮어쓰기 때문에 두른 우회로라고 주석이 적어 둡니다.", "out": "클라이언트: NotFound(5) \"no such user\"" }
      ],
      "skip": [7, 10, 11]
    },
    {
      "label": "일반 예외 (기본 옵션)",
      "state": "서비스가 throw new InvalidOperationException(\"boom-secret-internal\")",
      "path": [
        { "line": 0 },
        { "line": 1 },
        { "line": 2 },
        { "line": 3 },
        { "line": 6 },
        { "line": 10, "verdict": "false", "why": "스택 트레이스 옵션이 꺼져 있으면 TryResolveStatus 가 false 입니다. MagicOnion 은 throw; 로 grpc-dotnet 에 넘깁니다.", "out": "클라이언트: Unknown(2) \"Exception was thrown by handler.\"" }
      ],
      "skip": [7, 9, 11]
    }
  ],
  "note": "세 경우 모두 ServiceContext 생성과 Context 주입은 필터와 서비스 메서드보다 먼저 일어납니다."
}
</script>
</x-flow>

| 서버에서 한 일 | 클라이언트가 받은 것 |
| --- | --- |
| 일반 예외 (기본 옵션) | `Unknown(2)` `"Exception was thrown by handler."` |
| 일반 예외 (`IsReturnExceptionStackTraceInErrorDetail = true`) | `Unknown(2)`, 예외 메시지와 서버 소스 파일 절대 경로 |
| `throw new ReturnStatusException(NotFound, "no such user")` | `NotFound(5)` `"no such user"` |
| `Context.CallContext.Status = FailedPrecondition` 후 정상 반환 | `FailedPrecondition(9)`, 본문은 버려짐 |

기본값에서 서버 예외 메시지는 새지 않습니다. 상태 코드는 열거형 밖의 값(`1001`)도 그대로 전달됩니다.

## 인자 목록은 메시지 하나로 감싸진다

gRPC 는 요청 메시지 **하나**, 그것도 참조 타입을 요구합니다. MagicOnion 은 인자 0개를 `Nil`, 1개를 값 그대로, 2~15개를 `DynamicArgumentTuple<...>` 구조체로 묶고, 값 타입이면 `Box<T>` 에 담아 넘깁니다. 선 위의 바이트는 직렬화기에 따라 이렇게 갈립니다.

<x-compare>
<script type="application/json">
{
  "label": "같은 호출의 요청 바이트 (클라이언트 직렬화기 덤프, gRPC 길이 머리 제외)",
  "tabs": [
    {
      "label": "MessagePack (기본)",
      "text": "인자 0개는 nil 한 바이트, 1개는 값 그 자체, 2개 이상은 배열 머리(92 = 원소 2개) 뒤에 값을 하나씩 씁니다. 필드 이름은 나가지 않습니다.",
      "stats": [["Zero()", "C0 (1 B)"], ["One(1)", "01 (1 B)"], ["One(300)", "CD 01 2C (3 B)"], ["Str(\"ab\")", "A2 61 62 (3 B)"], ["Two(1, \"ab\")", "92 01 A2 61 62 (5 B)"], ["Two(1, null)", "92 01 C0 (3 B)"], ["IntLong(1, 2)", "92 01 02 (3 B)"]],
      "tone": "good"
    },
    {
      "label": "MemoryPack",
      "text": "머리 없이 값을 이어 붙입니다. int 는 늘 4 B 입니다. 값 타입만 든 튜플은 MagicOnion 의 포매터가 구조체 메모리를 통째로 복사해서, IntLong(1, 2) 는 long 이 먼저 나오고 패딩까지 16 B 가 됩니다.",
      "stats": [["Zero()", "00 (1 B)"], ["One(1)", "01 00 00 00 (4 B)"], ["One(300)", "2C 01 00 00 (4 B)"], ["Str(\"ab\")", "10 B"], ["Two(1, \"ab\")", "14 B"], ["Two(1, null)", "01 00 00 00 FF FF FF FF (8 B)"], ["IntLong(1, 2)", "02 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00 (16 B)"]]
    }
  ]
}
</script>
</x-compare>

MemoryPack 쪽 마지막 줄은 값 타입만 든 튜플을 구조체 메모리째 쓰기 때문입니다. `DynamicArgumentTuple` 은 `LayoutKind.Auto` 라서 필드 배치를 런타임이 정하고, 그 배치가 런타임마다 달랐습니다.

| 보낸 쪽 | `IntLong(7, 1234567890123)` 의 바이트 | CoreCLR 서버가 읽은 값 |
| --- | --- | --- |
| CoreCLR | `CB 04 FB 71 1F 01 00 00 07 00 00 00 00 00 00 00` | `a=7 b=1234567890123` |
| Unity (에디터 Mono, Mono·IL2CPP 플레이어) | `07 00 00 00 00 00 00 00 CB 04 FB 71 1F 01 00 00` | `a=1912276171 b=7` |

크기가 같아 역직렬화는 성공했고, 서버는 뒤섞인 값으로 메서드를 실행했습니다. 끝에 인자를 더하는 변경은 MessagePack 서버만 흡수합니다. 서버가 `MethodInfo` 의 기본값으로 빈 칸을 채우는 폴백 포매터를 끼우기 때문이고, MemoryPack 공급자에는 이 폴백이 없어 구버전 클라이언트 호출이 `Unknown` 으로 실패했습니다.

## 주의할 점

### 1. Unary 서비스를 DI 싱글턴으로 등록하지 않는다

**안 지키면 생기는 문제.** 핸들러는 호출마다 `service.Context = serviceContext` 를 합니다. 인스턴스가 하나면 `await` 뒤의 `Context` 가 다른 요청의 것이 됩니다. 인증 정보를 `Context` 에서 읽는 서비스라면 다른 사용자의 권한으로 동작합니다.

**이렇게 쓴다.** 서비스 클래스는 DI 에 등록하지 않고 호출마다 새로 만들어지게 둡니다. 공유 상태는 별도 싱글턴 객체에 두고 생성자로 받습니다. 한 요청 안에서만 쓸 값은 `Context.Items` 에 둡니다.

### 2. 운영 서버에서 스택 트레이스 옵션을 켜지 않는다

**안 지키면 생기는 문제.** `IsReturnExceptionStackTraceInErrorDetail = true` 면 처리하지 않은 예외의 메시지, 메서드 이름, 서버 소스 파일 절대 경로가 상태 문자열로 클라이언트에 갑니다. 게임 클라이언트는 사용자 기기에서 돕니다.

**이렇게 쓴다.** 개발 환경에서만 켭니다. 클라이언트에 알릴 실패는 `ReturnStatusException` 이나 `CallContext.Status` 로 상태 코드와 문구를 직접 정합니다.

### 3. 배포한 메서드의 인자를 하나에서 둘로 바꾸지 않는다

**안 지키면 생기는 문제.** 인자 하나는 값 그대로, 둘부터는 배열로 나갑니다. 1개에서 2개로 바꾸면 와이어 모양이 바뀌어 옛 클라이언트의 요청을 읽지 못합니다.

**이렇게 쓴다.** 인자는 끝에 기본값 있는 선택적 매개변수로 더하고 직렬화기는 MessagePack 을 씁니다. 인자 하나짜리를 늘려야 하면 새 이름의 메서드를 만들어 한동안 같이 둡니다.

### 4. MemoryPack 직렬화기는 양쪽 런타임이 같을 때만 쓴다

**안 지키면 생기는 문제.** 값 타입 인자 여럿은 메모리 배치째 나가서 Unity 와 CoreCLR 사이에서 예외 없이 값이 뒤섞였습니다. 서버와 클라이언트의 직렬화기가 다르면 MessagePack 서버가 MemoryPack 클라이언트의 `07 00 00 00` 을 7 로 읽고 메서드를 실행한 뒤 클라이언트는 실패로 보는 경우도 있었습니다.

**이렇게 쓴다.** Unity 클라이언트와 .NET 서버라면 기본 MessagePack 을 씁니다. 직렬화기를 바꾸는 배포는 서버와 클라이언트를 한 번에 내보냅니다.

## 정리

MagicOnion 의 Unary 는 **사슬은 한 번 조립하고, 컨텍스트와 인스턴스는 요청마다 새로 꽂습니다.**

- `ServiceContext` 는 풀링 없이 호출마다 생기고, 서비스 인스턴스의 `Context` 속성으로 들어갑니다. 싱글턴 등록은 이 필드를 덮어씁니다.
- 처리하지 않은 예외는 `Unknown` 이 되어 메시지가 새지 않고, 상태는 `ReturnStatusException` 이나 `CallContext.Status` 로 정합니다.
- 인자 목록은 `Nil`, 값, `DynamicArgumentTuple` 로 감싸지고, 값 타입은 `Box<T>` 에 담깁니다. MemoryPack 은 이 튜플을 메모리 배치째 씁니다.