MagicOnion 의 필터는 메서드를 바인딩할 때 한 번 조립된다
Esc

    MagicOnion 의 필터는 메서드를 바인딩할 때 한 번 조립된다

    –C#라이브러리네트워크
    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 와 메서드 호출에 필터가 따로 걸립니다.