---
title: "MagicOnion 의 필터는 메서드를 바인딩할 때 한 번 조립된다"
description: "필터는 요청마다 불리는 것처럼 보이지만, 사슬과 필터 인스턴스는 MapMagicOnionService 가 메서드를 바인딩할 때 한 번 만들어집니다. 그래서 요청당 할당은 0 이고, 대신 필터 필드와 생성자 주입은 모든 요청이 나눠 씁니다."
author: "유니얼"
pubDate: 2026-10-02
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "네트워크"]
canonical: https://blog.unialgames.com/posts/csharp-magiconion-filter/
---
```csharp
public class TimingFilter : IMagicOnionServiceFilter
{
    public async ValueTask Invoke(ServiceContext context, Func<ServiceContext, ValueTask> next)
    {
        var start = Stopwatch.GetTimestamp();
        try { await next(context); }
        finally { Console.WriteLine($"{context.CallContext.Method} {Stopwatch.GetElapsedTime(start)}"); }
    }
}
```

필터는 ASP.NET Core 미들웨어처럼 생겼습니다. 그런데 생성자로 받은 scoped 서비스가 요청마다 같거나, `Order = -100` 을 줬는데 맨 뒤에 서거나, 기동 뒤에 넣은 필터가 한 번도 안 불리는 일이 생깁니다.

모두 한 사실에서 나옵니다. **`next` 로 이어진 사슬과 필터 인스턴스는 `MapMagicOnionService()` 가 메서드를 바인딩할 때 한 번 만들어지고, 요청 경로에서는 만들어 둔 델리게이트를 부르기만 합니다.** 기준 판본은 7.11.0 입니다.

## 사슬은 바인딩 때 뒤에서부터 감싸 만든다

Unary 핸들러를 만드는 `BuildUnaryMethod` 는 필터를 모아 정렬하고 `FilterHelper.WrapMethodBodyWithFilter` 로 사슬을 조립한 뒤, 요청마다 도는 로컬 함수를 돌려줍니다. 조립은 목록을 뒤집어 돌며 바깥을 한 겹씩 씌웁니다.

<x-flow>
<script type="application/json">
{
  "label": "FilterHelper.WrapMethodBodyWithFilter 가 사슬을 조립하는 순서",
  "lines": [
    "Func<ServiceContext, ValueTask> outer = methodBody;",
    "foreach (var filterDescriptor in filters.Reverse())",
    "{",
    "    var newFilter = CreateOrGetInstance(serviceProvider, filterDescriptor);",
    "    var inner = outer;",
    "    outer = [StackTraceHidden] (ctx) => newFilter.Invoke(ctx, inner);",
    "}",
    "return outer;"
  ],
  "cases": [
    {
      "label": "필터 A, B, C",
      "state": "outer = body",
      "path": [
        { "line": 0, "why": "맨 안쪽은 서비스 메서드를 부르는 본문입니다." },
        { "line": 1, "why": "목록을 뒤집어 C 부터 돕니다." },
        { "line": 3, "why": "C 의 인스턴스를 여기서 만듭니다. 팩토리면 CreateInstance 를 부릅니다.", "state": "만든 인스턴스 = C · outer = body" },
        { "line": 4 },
        { "line": 5, "why": "C 가 본문을 감쌉니다. 람다 하나가 inner 와 newFilter 를 붙잡습니다.", "state": "outer = C → body" },
        { "line": 1, "why": "다음은 B" },
        { "line": 3, "state": "만든 인스턴스 = C, B · outer = C → body" },
        { "line": 4 },
        { "line": 5, "state": "outer = B → C → body" },
        { "line": 1, "why": "마지막은 A" },
        { "line": 3, "state": "만든 인스턴스 = C, B, A · outer = B → C → body" },
        { "line": 4 },
        { "line": 5, "state": "outer = A → B → C → body" },
        { "line": 7, "out": "사슬 완성. 요청은 A 부터 들어가고, 이 조립은 다시 돌지 않는다" }
      ]
    },
    {
      "label": "필터 없음",
      "state": "outer = body",
      "path": [
        { "line": 0 },
        { "line": 1, "verdict": "빈 목록", "why": "돌 것이 없습니다." },
        { "line": 7, "out": "본문 델리게이트가 그대로 나간다. 감싸는 람다도 없다" }
      ],
      "skip": [3, 4, 5]
    }
  ],
  "note": "필터 N 개면 람다 N 개와 필터 인스턴스 N 개가 이때 한 번 할당되고, 요청 경로에서는 델리게이트 호출만 남습니다."
}
</script>
</x-flow>

그래서 사슬 자체는 요청당 할당이 없습니다. 필터 셋을 `=> next(ctx)` 로 걸어도, `async` 로 걸어도 본문이 동기로 끝나면 호출당 할당이 필터 없는 기준선과 15 B 안쪽으로 같았습니다. 본문이 실제로 기다리면 `async` 필터 하나마다 약 125~160 B 가 붙습니다. 클라이언트 필터는 목록이 인스턴스마다 달라 미리 조립하지 못하므로 1~10개용 메서드를 붙잡는 것 없는 람다로 펼쳐 두었고, 11개부터는 겹마다 클로저가 생겨 호출당 약 1 KB 가 늘었습니다. 바인딩 뒤에 `GlobalFilters` 에 넣은 필터는 기동 전이든 후든 한 번도 불리지 않았습니다. (이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release, 워크스테이션 GC.)

## 순서는 Order 하나와 안정 정렬로 정해진다

`GetFilters` 는 전역 목록 뒤에 클래스 속성, 그 뒤에 메서드 속성을 이어 붙이고 `OrderBy(x => x.Order)` 로 정렬합니다. `OrderBy` 는 안정 정렬이라, `Order` 가 모두 기본값 `int.MaxValue` 면 전역, 클래스, 메서드 순서가 그대로 남습니다. 그런데 `Order` 를 읽는 줄은 **속성 자신이** `IMagicOnionOrderedFilter` 인지만 봅니다.

<x-flow>
<script type="application/json">
{
  "label": "GetFilters 가 속성 하나의 순서값을 정하는 줄",
  "lines": [
    "var order = (x is IMagicOnionOrderedFilter ordered) ? ordered.Order : int.MaxValue;",
    "var descriptor = new MagicOnionServiceFilterDescriptor(x, order);",
    "globalFilters.Concat(attributedFilters).OrderBy(x => x.Order)"
  ],
  "cases": [
    {
      "label": "[Named(\"M0\", Order = 0)]",
      "state": "x = MagicOnionFilterAttribute 를 상속한 속성",
      "path": [
        { "line": 0, "verdict": "true", "why": "기반 클래스가 IMagicOnionOrderedFilter 를 구현합니다.", "state": "order = 0" },
        { "line": 1 },
        { "line": 2, "out": "M0 이 전역 필터보다도 앞에 선다" }
      ]
    },
    {
      "label": "[FromTypeFilter(typeof(T), Order = -100)]",
      "state": "x = FromTypeFilterAttribute",
      "path": [
        { "line": 0, "verdict": "false", "why": "Order 라는 공개 속성은 있지만 IMagicOnionOrderedFilter 를 구현하지 않습니다.", "state": "order = int.MaxValue" },
        { "line": 1 },
        { "line": 2, "out": "-100 은 버려지고 메서드 필터 무리의 끝에 선다" }
      ]
    },
    {
      "label": "[Factory] (만든 인스턴스가 Order = -50)",
      "state": "x = IMagicOnionFilterFactory 를 구현한 속성",
      "path": [
        { "line": 0, "verdict": "false", "why": "속성 자신은 순서를 모릅니다. -50 을 가진 인스턴스는 아직 만들어지지도 않았습니다.", "state": "order = int.MaxValue" },
        { "line": 1 },
        { "line": 2, "why": "정렬이 끝난 뒤 WrapMethodBodyWithFilter 가 팩토리를 불러 인스턴스를 만듭니다.", "out": "-50 은 한 번도 읽히지 않는다" }
      ]
    }
  ],
  "note": "FromServiceFilterAttribute 도 FromTypeFilterAttribute 와 같은 이유로 Order 가 무시됩니다."
}
</script>
</x-flow>

실측 순서입니다. `Order` 를 적은 셋(0, 1, 5)이 레벨을 무시하고 맨 앞에 섰고, `-100`, `-90`, `-50` 을 적은 `FromTypeFilter`, `FromServiceFilter`, 팩토리 필터(`T`, `S`, `F`)는 맨 뒤에 섰습니다.

| 순서 | 필터 | 붙인 곳 | 적은 `Order` |
| --- | --- | --- | --- |
| 1, 2 | `CA`, `CB` | 클라이언트 | |
| 3 | `M0` | 메서드 | 0 |
| 4 | `C2` | 클래스 | 1 |
| 5 | `G2` | 전역 | 5 |
| 6, 7 | `G1`, `GT` | 전역 | 없음 |
| 8 | `C1` | 클래스 | 없음 |
| 9 | `M1` | 메서드 | 없음 |
| 10 | `T` (`FromTypeFilter`) | 메서드 | -100 (무시됨) |
| 11 | `S` (`FromServiceFilter`) | 메서드 | -90 (무시됨) |
| 12 | `F` (팩토리) | 메서드 | -50 (무시됨) |
| 13 | 서비스 메서드 본문 | | |

(Windows 11, .NET SDK 10.0.401, net10.0, 한 프로세스 Kestrel HTTP/2. `CA`, `CB` 는 클라이언트 필터.)

## 필터 인스턴스는 메서드별 싱글턴이다

조립하면서 인스턴스를 만들고 조립은 메서드마다 한 번이니, 필터 인스턴스는 **메서드마다 하나**로 모든 요청이 같이 씁니다. 전역에 `Add<T>()` 로 건 필터는 메서드 셋짜리 서비스에서 호출 0회 때 이미 3개였고 호출 6번 뒤에도 3개였습니다. 조립에 쓰는 `IServiceProvider` 는 요청 스코프가 아니라 루트 컨테이너라서, 생성자로 받은 scoped 서비스는 한 번 해결되어 필드에 갇힙니다.

<x-wiring>
<script type="application/json">
{
  "label": "생성자로 받은 scoped 서비스와 요청 스코프에서 꺼낸 서비스",
  "width": 620,
  "height": 330,
  "nodes": [
    { "id": "root", "title": "루트 컨테이너", "sub": "바인딩 때", "x": 20, "y": 20, "w": 160 },
    { "id": "scoped", "title": "ScopedThing", "sub": "id 9079", "x": 20, "y": 250, "w": 160 },
    { "id": "filter", "title": "TypeFilter", "sub": "메서드당 하나", "x": 235, "y": 135, "w": 150 },
    { "id": "req", "title": "요청 스코프", "sub": "요청마다 새로", "x": 440, "y": 20, "w": 160 },
    { "id": "ctx", "title": "context.ServiceProvider", "sub": "요청마다 다른 id", "x": 440, "y": 250, "w": 160 }
  ],
  "edges": [
    { "from": "root", "to": "scoped", "label": "해결", "bend": 30 },
    { "from": "scoped", "to": "filter", "label": "생성자 주입", "bend": 30 },
    { "from": "req", "to": "filter", "label": "요청", "bend": 30 },
    { "from": "filter", "to": "scoped", "label": "필드 읽기", "bend": 30 },
    { "from": "req", "to": "ctx", "label": "꺼내기", "bend": 30 }
  ],
  "steps": [
    { "edge": 0, "why": "바인딩 때 루트 컨테이너가 ScopedThing 을 하나 만듭니다. 요청이 아직 없습니다." },
    { "edge": 1, "why": "필터 생성자로 들어가 필드에 남습니다." },
    { "edge": 2, "why": "요청 1 이 필터를 지납니다." },
    { "edge": 3, "why": "필드의 ScopedThing 은 9079 입니다." },
    { "edge": 4, "why": "요청 스코프에서 꺼낸 것은 c95d 입니다." },
    { "edge": 2, "why": "요청 2 가 같은 필터 인스턴스를 지납니다." },
    { "edge": 3, "why": "필드는 여전히 9079 입니다. 요청이 바뀌어도 같습니다." },
    { "edge": 4, "why": "요청 스코프에서 꺼낸 것은 ba7d 로 새것입니다." }
  ],
  "note": "실측 로그: T(inst#1, ctor-injected scoped=9079, request-scope=c95d), 다음 호출은 request-scope=ba7d, b3b0 으로 바뀌고 9079 는 그대로였습니다."
}
</script>
</x-wiring>

`Development` 에서는 스코프 검증 때문에 `MapMagicOnionService()` 가 "Cannot resolve scoped service ... from root provider." 로 실패하지만, `Production` 에서는 검증이 꺼져 조용히 위 그림처럼 됩니다. 이름과 달리 `[FromServiceFilter]` 도 DI 에 등록한 인스턴스를 꺼내지 않고 `ActivatorUtilities.CreateInstance` 로 새로 만듭니다.

## StreamingHub 에는 필터가 두 겹으로 걸린다

gRPC 입장에서 허브는 `Connect` 라는 Duplex 메서드 하나이고 허브 메서드 호출은 그 스트림 위의 메시지입니다.

| 필터 | 걸리는 자리 | 불리는 횟수 |
| --- | --- | --- |
| `IMagicOnionServiceFilter` (전역이나 허브 클래스) | `Connect` | 연결당 한 번. 연결이 끝나야 `next` 가 돌아온다 |
| `IStreamingHubFilter` | 허브 메서드 하나하나 | 호출마다 |

허브 필터가 받는 `StreamingHubContext` 는 연결마다 하나를 만들어 호출마다 다시 채웁니다. 한 연결의 호출은 순서대로 하나씩 처리되기 때문에 가능한 재사용입니다.

<x-flow>
<script type="application/json">
{
  "label": "연결 하나의 소비 루프가 StreamingHubContext 를 다시 쓰는 순서 (소스를 줄여 옮김)",
  "lines": [
    "// StreamingHubBase.ConsumeRequestQueueAsync",
    "var hubContext = new StreamingHubContext();   // 연결마다 하나",
    "await foreach (var request in requests.Reader.ReadAllAsync(default))",
    "{",
    "    hubContext.Initialize(handler, Context, this, request.Body, messageId: request.MessageId, timestamp: ...);",
    "    try { await handler.MethodBody.Invoke(hubContext); }   // 필터 사슬과 허브 메서드",
    "    finally { CleanupRequest(hubContext, ...); }            // 끝에서 hubContext.Uninitialize()",
    "}",
    "",
    "// StreamingHubContext.Uninitialize",
    "handler = default!; streamingServiceContext = default!; HubInstance = default!;",
    "Request = default!; Timestamp = default!; MessageId = default!; ResponseSize = -1;",
    "items?.Clear();"
  ],
  "cases": [
    {
      "label": "같은 연결에서 A 다음 B",
      "state": "ctx: 아직 없음",
      "path": [
        {
          "line": 1,
          "why": "연결이 열리면 컨텍스트를 하나만 만듭니다.",
          "state": "ctx: 비어 있음",
          "frames": [
            "연결의 소비 루프"
          ]
        },
        {
          "line": 2,
          "why": "메서드 A 요청이 큐에서 나옵니다."
        },
        {
          "line": 4,
          "state": "ctx: Path = A"
        },
        {
          "line": 5,
          "why": "필터 사슬로 들어갑니다. 필터가 Items[\"k\"] 에 경로를 넣고, 해시를 기록하고, 이 ctx 를 정적 필드에 붙잡아 50 ms 뒤에 읽도록 걸어 둡니다.",
          "state": "ctx: Path = A, Items[\"k\"] = A · 붙잡은 것 = ctx",
          "frames": [
            "연결의 소비 루프",
            "필터(A)"
          ],
          "out": "A 에 들어올 때 컨텍스트 해시 = h"
        },
        {
          "line": 6,
          "frames": [
            "연결의 소비 루프",
            "CleanupRequest"
          ]
        },
        {
          "line": 10,
          "why": "호출이 끝나면 핸들러, 요청 바이트, 허브 인스턴스를 비웁니다."
        },
        {
          "line": 12,
          "why": "Items 도 비웁니다. 붙잡은 참조가 가리키는 것도 이 객체입니다.",
          "state": "ctx: 비워짐 · 붙잡은 것 = ctx"
        },
        {
          "line": 2,
          "why": "같은 연결로 B 가 나옵니다.",
          "frames": [
            "연결의 소비 루프"
          ]
        },
        {
          "line": 4,
          "why": "새 객체가 아니라 같은 hubContext 를 다시 채웁니다.",
          "state": "ctx: Path = B · 붙잡은 것 = ctx"
        },
        {
          "line": 5,
          "frames": [
            "연결의 소비 루프",
            "필터(B)"
          ],
          "out": "B 에 들어올 때 컨텍스트 해시 = h (A 와 같음)"
        },
        {
          "line": 12,
          "why": "B 도 끝나 다시 비워집니다.",
          "state": "ctx: 비워짐 · 붙잡은 것 = ctx",
          "frames": [
            "연결의 소비 루프",
            "CleanupRequest"
          ]
        },
        {
          "line": 10,
          "why": "50 ms 가 지나 A 의 값을 읽으려 하지만, 붙잡은 것은 이 줄들이 비운 객체입니다.",
          "frames": [
            "50 ms 뒤 작업"
          ],
          "out": "Path: NullReferenceException, Items[\"k\"]: 없음, MessageId: 0"
        }
      ]
    }
  ],
  "note": "늦게 읽는 시점이 다음 호출 도중이면 그 호출의 값을, 호출 사이면 빈 값을 봅니다. 어느 쪽이든 붙잡은 호출의 값은 아닙니다."
}
</script>
</x-flow>

## 주의할 점

### 1. 필터에 요청 상태를 필드로 두거나 생성자로 scoped 서비스를 받지 않는다

**안 지키면 생기는 문제.** 동시 요청이 모두 같은 필터 인스턴스를 지나므로 필드에 적은 사용자 id 나 시작 시각은 서로 덮어씁니다. 생성자로 받은 scoped 서비스는 운영 환경에서 모든 요청이 같은 인스턴스를 씁니다. 요청마다 새로 나와야 할 데이터베이스 컨텍스트가 전 요청에 공유됩니다.

**이렇게 쓴다.** 요청 하나 동안의 값은 지역 변수나 `context.Items` 에 둡니다. 요청 스코프 서비스는 `context.ServiceProvider.GetRequiredService<T>()` 로 꺼냅니다. 생성자로는 싱글턴이거나 상태 없는 것만 받습니다.

### 2. 요청을 막을 때 next 를 생략하지 않는다

**안 지키면 생기는 문제.** `next` 를 부르지 않고 돌아오면 `context.Result` 가 비어 `default` 가 정상 응답으로 나갑니다. `int` 메서드는 0 을, `string` 메서드는 `null` 을 돌려줬고 클라이언트는 정상 응답과 구별할 수 없었습니다.

**이렇게 쓴다.** 막을 때는 `throw new ReturnStatusException(StatusCode.PermissionDenied, "...")` 를 던집니다. 본문에서 던진 것과 같은 자리에서 상태 코드로 바뀝니다.

### 3. FromTypeFilter, FromServiceFilter, 팩토리 인스턴스의 Order 에 기대지 않는다

**안 지키면 생기는 문제.** 셋 모두 컴파일되고 경고도 없지만 순서는 `int.MaxValue` 로 정렬됩니다. 인증 필터가 맨 앞에 설 줄 알았는데 로깅 필터 뒤에 서서 인증 전에 인자를 로그로 남깁니다. 공식 문서의 확장 예제처럼 팩토리가 만드는 인스턴스에 `Order` 를 줘도 반영되지 않습니다.

**이렇게 쓴다.** 순서가 중요한 필터는 `MagicOnionFilterAttribute` 를 상속한 속성으로 만들어 `Order` 를 주거나, 전역이면 `new MagicOnionServiceFilterDescriptor(typeof(T), order)` 로 등록합니다. 팩토리 방식이면 팩토리 속성 쪽이 `IMagicOnionOrderedFilter` 를 구현하게 합니다.

### 4. 허브 필터가 받은 StreamingHubContext 를 호출 밖으로 가져가지 않는다

**안 지키면 생기는 문제.** 컨텍스트를 필드나 백그라운드 작업에 넘기면 메서드가 끝난 뒤에는 비워진 값이나 다음 호출의 값을 봅니다. 50 ms 뒤에 읽은 컨텍스트는 `Path` 에서 `NullReferenceException`, `MessageId` 0 이었습니다.

**이렇게 쓴다.** 호출 밖에서 필요한 값(메서드 이름, MessageId)은 필터 안에서 지역 변수로 복사해 넘기고, 컨텍스트 객체는 `next` 를 부르는 동안만 씁니다. 허브 메서드 단위로 할 일은 Unary 필터가 아니라 `IStreamingHubFilter` 로 겁니다.

## 정리

MagicOnion 의 필터는 **메서드를 바인딩할 때 한 번 조립되는 델리게이트 사슬**입니다.

- 목록을 뒤에서부터 감싸 만들고 요청 경로에는 델리게이트 호출만 남아, 사슬 자체의 요청당 할당은 0 입니다.
- 순서는 `Order` 오름차순에 같으면 전역, 클래스, 메서드 순이고, `Order` 는 속성 자신이 `IMagicOnionOrderedFilter` 일 때만 읽힙니다.
- 필터 인스턴스는 메서드별 싱글턴이라 요청 상태와 scoped 서비스를 둘 수 없고, 허브에서는 `Connect` 와 메서드 호출에 필터가 따로 걸립니다.