ASP.NET Core 4.1 · 4부. 인증·인가와 미들웨어

미들웨어 직접 작성하기

파이프라인에 내 코드를 끼웁니다.

예상 학습 시간 18분 난이도 중급
개념 설명

파이프라인에 내 코드를 끼웁니다

1.3에서 app.Use(…) 로 요청이 지나는 길에 코드를 놓았습니다. 그 자리에 놓을 것이 길어지면 클래스로 옮깁니다. 모든 요청이 지나는 자리라 기록·인증·오류 처리 같은 것을 여기에 둡니다.

방법언제
app.Use(…)짧을 때입니다. Program.cs 에 그대로 적습니다.
클래스 + UseMiddleware길어질 때입니다. 가장 흔한 방법입니다.
IMiddleware 구현요청마다 새 인스턴스가 필요할 때입니다.

가운데 방법에는 물려받을 것도 구현할 인터페이스도 없습니다. 정해진 모양만 갖추면 됩니다.

최소 예제

처리 시간을 재는 미들웨어

TimingMiddleware.cs · Program.cs
public class TimingMiddleware {
    private readonly RequestDelegate _next;

    public TimingMiddleware(RequestDelegate next) {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context) {
        var 시작 = Stopwatch.GetTimestamp();

        await _next(context);

        var 걸린 = Stopwatch.GetElapsedTime(시작);
        Console.WriteLine($"{context.Request.Path} {context.Response.StatusCode} {걸린.TotalMilliseconds:F1}ms");
    }
}

// Program.cs — Build() 뒤, 가장 바깥에 두려면 가장 먼저 적습니다
app.UseMiddleware<TimingMiddleware>();
서버 콘솔 (요청 세 번)
/ 200 5.0ms / 200 0.1ms /count 200 2.0ms

이 코드는 서버가 있어야 하므로 브라우저에서 실행할 수 없습니다. 파이프라인의 순서만 보려면 ASP.NET Core 1.3 의 예제를 실행해 보십시오.

갖출 모양은 둘입니다.

  • 생성자가 RequestDelegate첫 매개 변수로 받습니다. 그것이 다음 처리기입니다.
  • InvokeAsync 또는 Invoke 라는 이름의 메서드가 있고 Task 를 돌려줍니다.

이름이 어긋나도 컴파일은 됩니다. 반사로 찾기 때문입니다. 대신 기동할 때 예외가 발생합니다.

상세 사용법

인스턴스는 하나뿐입니다

만들어지는 횟수와 처리하는 횟수를 함께 찍어 보았습니다.

서버 콘솔 (요청 여섯 번)
[미들웨어] 만들어짐 1번째
[미들웨어] / 200 5.0ms  (처리 1번째)
[미들웨어] / 200 0.1ms  (처리 2번째)
[미들웨어] / 200 0.0ms  (처리 3번째)
[미들웨어] /admin/x 200 0.5ms  (처리 4번째)
[미들웨어] /count 200 2.0ms  (처리 5번째)
[미들웨어] /count 200 0.4ms  (처리 6번째)
읽는 법
한 번 만들어져 여섯 요청을 처리했습니다. 기동할 때 하나 만들고 그것을 계속 사용합니다.

여기서 함정이 나옵니다. 인스턴스가 하나이므로 필드에 요청별 값을 담으면 안 됩니다. 여러 요청이 동시에 지나며 서로의 값을 덮습니다.

더 자주 겪는 것은 생성자로 서비스를 받는 것입니다. 3.2에서 본 DbContext 는 Scoped 인데, 미들웨어는 기동할 때 한 번 만들어지므로 그 자리에 요청 범위가 없습니다.

생성자로 받으면
public TimingMiddleware(
    RequestDelegate next,
    TodoContext db) { … }

// 기동할 때
// Application startup exception
// InvalidOperationException:
// Cannot resolve scoped service
// 'TodoContext' from root provider.
InvokeAsync 로 받으면
public async Task InvokeAsync(
    HttpContext context,
    TodoContext db) { … }

// 요청마다 그 범위의 것을
// 넣어 줍니다.

더 조심할 것이 있습니다. 왼쪽의 예외는 Development 에서만 납니다. 범위 검사가 그때만 켜지기 때문입니다. 운영에서는 오류 없이 뜨고, 미들웨어가 맨 처음 만들어진 하나를 끝까지 붙들고 모든 요청에 사용합니다.

그러면 3.2에서 본 일이 그대로 생깁니다. 지켜보는 기억이 쌓이고, 여러 요청이 동시에 건드립니다. 개발에서 잡히지 않으면 운영에서 이따금 터지는 모양이 되므로, 미들웨어에서 서비스가 필요하면 InvokeAsync 의 매개 변수로 받으십시오.

InvokeAsync 로 받았을 때
[미들웨어] 만들어짐 1번째
[미들웨어] / 200 4.2ms  범위 ff8a491d  (처리 1번째)
[미들웨어] / 200 0.1ms  범위 bbdae442  (처리 2번째)
[미들웨어] / 200 0.0ms  범위 d08c9f9d  (처리 3번째)
[미들웨어] /count 200 0.2ms  범위 92738ecd  (처리 4번째)
읽는 법
미들웨어는 하나인데 범위 서비스는 요청마다 다릅니다.
상세 사용법

일부 요청에만 붙이기

모든 요청에 붙일 것이 아니라면 조건을 걸어 갈라 붙입니다.

Program.cs
app.UseWhen(
    c => c.Request.Path.StartsWithSegments("/admin"),
    b => b.Use(async (c, next) => {
        Console.WriteLine("  [admin 전용] 지납니다");
        await next();
    }));
서버 콘솔
// GET / [미들웨어] / 200 0.1ms // GET /admin/x [admin 전용] 지납니다 [미들웨어] /admin/x 200 0.5ms
메서드하는 일
UseWhen조건에 맞으면 곁길을 지난 뒤 본래 길로 돌아옵니다.
MapWhen조건에 맞으면 곁길로 가서 돌아오지 않습니다.
Map주소가 맞으면 곁길로 갑니다. 돌아오지 않습니다.

UseXxx 로 감싸기

app.UseMiddleware<TimingMiddleware>() 는 형식 이름이 드러나 읽기 나쁩니다. 확장 메서드로 감싸는 것이 관례입니다. UseStaticFiles·UseAuthentication 이 모두 이렇게 되어 있습니다.

public static class TimingMiddlewareExtensions {
    public static IApplicationBuilder UseTiming(this IApplicationBuilder app) =>
        app.UseMiddleware<TimingMiddleware>();
}

// Program.cs
app.UseTiming();
버전 배지

요청마다 새로 만들려면

ASP.NET Core 2.0 부터

IMiddleware 를 구현하면 요청마다 새 인스턴스를 받습니다. 다만 직접 등록해야 합니다builder.Services.AddScoped<MyMiddleware>() 를 빠뜨리면 기동할 때 예외가 발생합니다.

대개는 필요하지 않습니다. 관례 기반으로 두고 필요한 서비스를 InvokeAsync 의 매개 변수로 받으면 같은 일이 되고, 인스턴스를 매번 만들지 않아 가볍습니다.

실습 문제

직접 해보기

1. 추적 번호 붙이기 난이도 하

2.5에서 본 traceId 처럼, 모든 응답에 X-Request-Id 머리글을 붙이는 미들웨어를 적어보세요.

1.3에서 본 대로 응답이 나가기 시작한 뒤에는 머리글을 고칠 수 없습니다. 언제 붙여야 합니까.
public async Task InvokeAsync(HttpContext context) { context.Response.Headers["X-Request-Id"] = context.TraceIdentifier; await _next(context); } // next 앞에 붙입니다. 뒤에 붙이면 본문이 이미 나가 // "Headers are read-only" 가 발생합니다(1.3).
2. 느린 요청만 기록하기 난이도 중

모든 요청을 찍으면 기록이 너무 많습니다. 500ms 를 넘는 것만 찍도록 고치고, 그 기준을 설정에서 읽어 오도록 해보세요.

설정은 요청마다 바뀌지 않습니다. 생성자로 받아도 되는 것과 안 되는 것을 구분해보십시오.
public TimingMiddleware(RequestDelegate next, IConfiguration config) { _next = next; _기준 = config.GetValue<int>("Timing:SlowMs", 500); } public async Task InvokeAsync(HttpContext context) { var 시작 = Stopwatch.GetTimestamp(); await _next(context); var 걸린 = Stopwatch.GetElapsedTime(시작); if (걸린.TotalMilliseconds >= _기준) Console.WriteLine(…); } // IConfiguration 은 싱글턴이라 생성자로 받아도 됩니다. // Scoped 인 것만 InvokeAsync 로 받습니다.
3. 개발에서만 잡히는 결함 난이도 상

동료가 미들웨어 생성자로 DbContext 를 받도록 적었고, 개발 기계에서는 앱이 뜨지 않는다고 합니다. 그런데 운영에는 이미 올라가 잘 돌고 있습니다. 무슨 일이 벌어지고 있는지 적어보세요.

범위 검사가 언제 켜지는지 생각해보십시오. 검사가 꺼져 있으면 무엇이 만들어집니까.
// 개발에서는 범위 검사가 켜져 있어 기동을 막습니다. // Cannot resolve scoped service 'TodoContext' from root provider. // 운영에서는 검사가 꺼져 있어 그냥 뜹니다. 대신 루트에서 만든 // DbContext 하나를 미들웨어가 끝까지 붙들고, 모든 요청이 그것을 // 함께 사용합니다. // 그래서 이렇게 됩니다. // · 지켜보는 기억이 쌓여 메모리가 늘어납니다(3.1). // · 다른 곳에서 바뀐 값을 다시 읽지 않아 옛 값을 돌려줍니다. // · 요청이 몰릴 때 동시에 건드려 이따금 예외가 발생합니다. // "개발에서 안 뜨니 검사를 끄자"가 아니라, 개발이 옳고 운영이 // 이미 위험한 상태입니다. InvokeAsync 매개 변수로 옮깁니다.
요약
  • 생성자가 RequestDelegate 를 받고 InvokeAsync 가 있으면 미들웨어입니다. 물려받을 것이 없습니다.
  • 인스턴스는 하나입니다. 필드에 요청별 값을 담지 마십시오.
  • Scoped 서비스는 InvokeAsync 의 매개 변수로 받습니다. 생성자로 받으면 개발에서는 기동이 막히고 운영에서는 조용히 잘못 동작합니다.
  • 일부 요청에만 붙이려면 UseWhen·MapWhen·Map 을 사용합니다.
  • UseXxx 확장 메서드로 감싸는 것이 관례입니다.