MagicOnion 의 필터는 메서드를 바인딩할 때 한 번 조립된다
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 로 사슬을 조립한 뒤, 요청마다 도는 로컬 함수를 돌려줍니다. 조립은 목록을 뒤집어 돌며 바깥을 한 겹씩 씌웁니다.
그래서 사슬 자체는 요청당 할당이 없습니다. 필터 셋을 => next(ctx) 로 걸어도, async 로 걸어도 본문이 동기로 끝나면 호출당 할당이 필터 없는 기준선과 15 B 안쪽으로 같았습니다. 본문이 실제로 기다리면 async 필터 하나마다 약 125160 B 가 붙습니다. 클라이언트 필터는 목록이 인스턴스마다 달라 미리 조립하지 못하므로 110개용 메서드를 붙잡는 것 없는 람다로 펼쳐 두었고, 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 인지만 봅니다.
실측 순서입니다. 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 서비스는 한 번 해결되어 필드에 갇힙니다.
Development 에서는 스코프 검증 때문에 MapMagicOnionService() 가 “Cannot resolve scoped service … from root provider.” 로 실패하지만, Production 에서는 검증이 꺼져 조용히 위 그림처럼 됩니다. 이름과 달리 [FromServiceFilter] 도 DI 에 등록한 인스턴스를 꺼내지 않고 ActivatorUtilities.CreateInstance 로 새로 만듭니다.
StreamingHub 에는 필터가 두 겹으로 걸린다
gRPC 입장에서 허브는 Connect 라는 Duplex 메서드 하나이고 허브 메서드 호출은 그 스트림 위의 메시지입니다.
| 필터 | 걸리는 자리 | 불리는 횟수 |
|---|---|---|
IMagicOnionServiceFilter (전역이나 허브 클래스) |
Connect |
연결당 한 번. 연결이 끝나야 next 가 돌아온다 |
IStreamingHubFilter |
허브 메서드 하나하나 | 호출마다 |
허브 필터가 받는 StreamingHubContext 는 연결마다 하나를 만들어 호출마다 다시 채웁니다. 한 연결의 호출은 순서대로 하나씩 처리되기 때문에 가능한 재사용입니다.
주의할 점
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와 메서드 호출에 필터가 따로 걸립니다.