ASP.NET Core의 미들웨어(Middleware)

❓ 미들웨어(Middleware)

https://learn.microsoft.com/en-us/aspnet/core/fundamentals/middleware/?view=aspnetcore-8.0

ASP.NET Core의 미들웨어는 모든 HTTP 요청과 응답 파이프라인을 형성하는 일련의 구성 요소입니다.

각 미들웨어 구성 요소는 다음을 수행할 수 있습니다:

  • 들어오는 요청을 검사합니다.
  • 요청 또는 응답을 수정합니다 (필요한 경우).
  • 파이프라인의 다음 미들웨어를 호출하거나, 프로세스를 단락(short-circuit)시키고 자체적으로 응답을 생성합니다.

이러한 파이프라인을 통해 애플리케이션의 로직을 모듈화하고, 인증, 로깅, 오류 처리, 라우팅 등과 같은 기능을 깔끔하고 유지 관리하기 쉬운 방식으로 추가할 수 있습니다.

⛓️ 미들웨어 체인 (요청 파이프라인)

ASP.NET Core 요청 파이프라인은 차례로 호출되는 일련의 요청 대리자로 구성됩니다.

요청 파이프라인을 일련의 연결된 파이프라고 상상해 보세요.

각 미들웨어 조각은 이 파이프라인의 밸브와 같아서, 정보의 흐름을 제어하고 다양한 단계에서 특정 작업을 적용할 수 있습니다.

미들웨어를 등록하는 순서는 매우 중요하며, 등록된 순서대로 실행됩니다.

https://learn.microsoft.com/en-us/aspnet/core/fundamentals/middleware/?view=aspnetcore-8.0

각 델리게이트는 다음 델리게이트를 호출하기 전과 후에 작업을 수행할 수 있습니다.

예외 처리 델리게이트는 파이프라인의 후반 단계에서 발생하는 예외를 Catch할 수 있도록 파이프라인의 초기에 호출되어야 합니다.

가장 간단한 ASP.NET Core 앱은 모든 요청을 처리하는 단일 요청 델리게이트를 설정합니다.

이 경우에는 실제 요청 파이프라인이 포함되지 않습니다.

대신, 모든 HTTP 요청에 대한 응답으로 단일 익명 함수가 호출됩니다.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.Run(async context =>
{
    await context.Response.WriteAsync("Hello world!");
});

app.Run(); // 이 app.Run은 위의 app.Run이 파이프라인을 종료하므로 실행되지 않습니다.

Use 메서드를 사용하면 여러 요청 델리게이트를 체인으로 연결할 수 있습니다.

이때 next 매개변수는 파이프라인의 다음 델리게이트를 나타냅니다.

next 매개변수를 호출하지 않음으로써 파이프라인을 단락(short-circuit)시킬 수 있습니다.

다음 예시에서 보여주듯이, 일반적으로 next 델리게이트를 호출하기 전과 후에 모두 작업을 수행할 수 있습니다.

var builder = WebApplication.CreateBuilder(args); // 웹 애플리케이션 빌더를 생성합니다.
var app = builder.Build(); // 빌더를 사용하여 웹 애플리케이션 인스턴스를 생성합니다.

// 첫 번째 미들웨어: app.Use()를 사용하여 파이프라인에 추가합니다.
app.Use(async (context, next) =>
{
    // 응답에 쓸 수 있는 작업을 수행합니다. (예: 헤더 추가, 응답의 시작 부분 작성)
    // 이 부분의 코드는 다음 미들웨어가 실행되기 전에 실행됩니다.

    await next.Invoke(); // 다음 미들웨어(이 경우 app.Run)를 호출하여 제어를 넘깁니다.

    // 응답에 쓰지 않는 로깅 또는 다른 작업을 수행합니다.
    // 이 부분의 코드는 다음 미들웨어가 실행된 후(응답이 생성된 후) 실행됩니다.
});

// 두 번째 미들웨어: app.Run()을 사용하여 파이프라인에 추가합니다.
// app.Run()은 파이프라인을 종료하는 미들웨어입니다.
app.Run(async context =>
{
    // 이 미들웨어는 "Hello from 2nd delegate."를 응답에 작성하고 파이프라인을 종료합니다.
    await context.Response.WriteAsync("Hello from 2nd delegate.");
});

// 이 app.Run()은 위의 app.Run()이 이미 파이프라인을 종료했기 때문에 실행되지 않습니다.
app.Run();

✂️ 요청 파이프라인 단락(Short-circuiting)

ASP.NET Core의 미들웨어 파이프라인에서, 특정 델리게이트(미들웨어)가 요청을 다음 델리게이트로 전달하지 않을 때 이를 요청 파이프라인을 단락(short-circuiting)시킨다고 합니다.

파이프라인 단락은 불필요한 작업을 피할 수 있기 때문에 종종 유용합니다.

예를 들어, 정적 파일 미들웨어(Static File Middleware)는 요청된 파일이 정적 파일(이미지, CSS, JavaScript 등)일 경우 해당 요청을 처리하고

파이프라인의 나머지 부분을 단락시킴으로써 종료 미들웨어(terminal middleware) 역할을 할 수 있습니다.

이렇게 되면 정적 파일을 제공한 후에는 인증이나 라우팅과 같은 다른 미들웨어들이 실행될 필요가 없으므로 효율성이 높아집니다.

✅ 단락과 next.Invoke() 이후 코드 실행

파이프라인의 추가적인 처리를 종료시키는 미들웨어보다 앞서 파이프라인에 추가된 미들웨어는 여전히 next.Invoke() 구문 이후의 코드를 처리합니다.

즉, 비록 파이프라인이 단락되어 최종 응답이 더 이상 아래로 전달되지 않더라도, next.Invoke()를 호출했던 이전 미들웨어들은 응답이 완료된 후 자신의 후처리 로직을 실행할 수 있습니다.

⚠️ 경고: 응답 전송 후 작업 주의

응답이 클라이언트에게 이미 전송되었거나 전송되기 시작한 후에는 next.Invoke()를 호출하거나 응답에 쓰려고 시도하지 마세요.

HttpResponse가 시작된 후에는 응답에 변경을 가하려고 하면 예외가 발생합니다.

예를 들어, 응답이 시작된 후 헤더나 상태 코드를 설정하려고 하면 예외가 발생합니다.

next.Invoke() 호출 후 응답 본문에 쓰는 것은 다음과 같은 문제를 야기할 수 있습니다:

  • 프로토콜 위반: 명시된 Content-Length보다 더 많은 내용을 작성하는 것과 같은 프로토콜 위반을 초래할 수 있습니다.
  • 본문 형식 손상: CSS 파일에 HTML 푸터(footer)를 작성하는 것처럼 본문 형식을 손상시킬 수 있습니다.

HasStarted 속성은 헤더가 전송되었는지 또는 본문이 작성되었는지 여부를 나타내는 유용한 힌트가 될 수 있습니다.

🫠 app.Use vs. app.Run

이 두 메서드는 파이프라인에 미들웨어를 추가하는 데 기본적이지만, 핵심적인 차이점이 있습니다:

app.Use(async (context, next) => { ... })

  • 요청 수정 불가:
    마지막 단계이므로 요청을 다음으로 전달하기 전에 수정할 수 없습니다.
  • 비종료(Non-Terminal) 미들웨어:
    이 유형의 미들웨어는 일반적으로 어떤 작업을 수행한 다음, next 델리게이트를 호출하여 파이프라인의 다음 미들웨어로 제어를 전달합니다.
  • 요청/응답 수정 가능:
    요청을 다음으로 전달하기 전에 요청이나 응답을 변경할 수 있습니다.
  • 예시:
    인증, 로깅, 커스텀 헤더 추가 등.

app.Run(async (context) => { ... })

  • 종료(Terminal) 미들웨어:
    이 미들웨어는 next를 호출하지 않습니다.
    파이프라인을 종료하고 자체적으로 응답을 생성합니다.
  • 최종 응답에 주로 사용:
    더 이상 처리가 필요 없는 요청(예: 간단한 메시지 반환)을 처리하는 데 일반적으로 사용됩니다.
// 여러 app.Run 호출의 결과

app.Run(async (HttpContext context) => {
    await context.Response.WriteAsync("Hello");
});

app.Run(async (HttpContext context) => {
    await context.Response.WriteAsync("Hello again");
});

app.Run(); // 이 app.Run은 위의 app.Run들이 이미 파이프라인을 종료했기 때문에 절대 실행되지 않습니다.

이 코드에서는 오직 첫 번째 app.Run 미들웨어만 실행됩니다.

“Hello”를 응답에 작성하여 파이프라인을 종료하고, 그 뒤의 app.Run (이것은 “Hello again”을 작성할 것임)은 실행될 기회를 얻지 못합니다.

// app.Use와 app.Run으로 미들웨어 체인 연결하기

// 미들웨어 1
app.Use(async (context, next) => {
    await context.Response.WriteAsync("Hello "); // 1. "Hello " 작성
    await next(context); // 2. 다음 미들웨어 호출
});

// 미들웨어 2
app.Use(async (context, next) => {
    await context.Response.WriteAsync("Hello again "); // 3. "Hello again " 작성
    await next(context); // 4. 다음 미들웨어 호출 (이 경우 app.Run)
});

// 미들웨어 3 (종료 미들웨어)
app.Run(async (HttpContext context) => {
    await context.Response.WriteAsync("Hello again"); // 5. "Hello again" 작성 후 파이프라인 종료
});

이 코드는 미들웨어를 올바르게 연결하는 방법을 보여줍니다.

  1. 첫 번째 app.Use는 응답에 “Hello “를 작성하고 next를 호출하여 다음 미들웨어로 제어를 전달합니다.
  2. 두 번째 app.Use는 “Hello again “을 작성하고 역시 next를 호출합니다.
  3. 마지막 app.Run (종료 미들웨어)는 “Hello again”을 작성하고 파이프라인을 종료합니다.

❓ ASP.NET Core의 커스텀 미들웨어

ASP.NET Core는 다양한 내장 미들웨어 구성 요소를 제공하지만, 때로는 애플리케이션 고유의 특정 요구 사항을 해결하기 위해 자신만의 커스텀 미들웨어를 만들어야 할 수도 있습니다.

커스텀 미들웨어를 통해 다음을 수행할 수 있습니다:

  • 로직 캡슐화:
    관련 작업(예: 로깅, 보안 검사, 사용자 정의 헤더)을 재사용 가능한 구성 요소로 묶습니다.
  • 동작 사용자 정의:
    애플리케이션의 요구 사항에 정확히 맞게 요청/응답 파이프라인을 조정합니다.
  • 코드 구성 개선:
    미들웨어 코드를 깔끔하고 유지 관리하기 쉽게 만듭니다.

🔧 커스텀 미들웨어 클래스의 구조

IMiddleware 구현: 이 인터페이스는 단 하나의 메서드 InvokeAsync(HttpContext context, RequestDelegate next)를 요구합니다.

이 메서드는 미들웨어 로직의 핵심입니다

InvokeAsync 또는 Invoke 메서드

  • context:
    HttpContext는 요청 및 응답 객체에 대한 접근을 제공합니다.
  • next:
    RequestDelegate는 파이프라인의 다음 미들웨어를 호출할 수 있도록 합니다.
// MyCustomMiddleware.cs
namespace MiddlewareExample.CustomMiddleware
{
    public class MyCustomMiddleware : IMiddleware // IMiddleware 인터페이스 구현
    {
        public async Task InvokeAsync(HttpContext context, RequestDelegate next)
        {
            await context.Response.WriteAsync("My Custom Middleware - Starts\n"); // 1. 응답 시작 부분에 출력
            await next(context); // 2. 다음 미들웨어 호출
            await context.Response.WriteAsync("My Custom Middleware - Ends\n"); // 3. 다음 미들웨어 완료 후 응답 끝 부분에 출력
        }
    }

    // 쉽게 등록하기 위한 확장 메서드
    public static class CustomMiddlewareExtension
    {
        public static IApplicationBuilder UseMyCustomMiddleware(this IApplicationBuilder app)
        {
            return app.UseMiddleware<MyCustomMiddleware>();
        }
    }
}
// Program.cs (또는 Startup.cs)
using MiddlewareExample.CustomMiddleware;

// ... (다른 설정 코드) ...

builder.Services.AddTransient<MyCustomMiddleware>(); // 트랜지언트 서비스로 등록

app.Use(async (HttpContext context, RequestDelegate next) => {
    await context.Response.WriteAsync("From Middleware 1\n"); // 첫 번째 미들웨어: 시작 부분
    await next(context);
});

app.UseMyCustomMiddleware(); // 확장 메서드를 사용하여 커스텀 미들웨어 추가

app.Run(async (HttpContext context) => {
    await context.Response.WriteAsync("From Middleware 3\n"); // 세 번째 미들웨어: 파이프라인 종료
});
  • 등록:
    ASP.NET Core가 필요할 때 MyCustomMiddleware 인스턴스를 생성할 수 있도록 이를 트랜지언트 서비스로 등록합니다.
  • 파이프라인 통합:
    app.UseMyCustomMiddleware() 확장 메서드는 커스텀 미들웨어를 파이프라인에 추가합니다.
  • 실행 순서:
    미들웨어 구성 요소는 파이프라인에 추가된 순서대로 실행됩니다. 이 경우 순서는 미들웨어 1, MyCustomMiddleware, 그리고 미들웨어 3이 됩니다.

🔥 커스텀 컨벤셔널 미들웨어 (Custom Conventional Middleware)

ASP.NET Core 미들웨어에는 두 가지 유형이 있습니다: 컨벤셔널(Conventional)팩토리 기반(Factory-based)

예시에서 보여진 컨벤셔널 미들웨어는 HTTP 요청 및 응답 처리를 위한 커스텀 로직을 캡슐화하는 간단하면서도 강력한 방법입니다.

주요 특징

  • 클래스 기반:
    컨벤셔널 미들웨어는 클래스로 구현됩니다.
  • 생성자 주입:
    의존성(있는 경우)을 생성자를 통해 받습니다.
  • Invoke 메서드:
    이 메서드는 각 요청을 처리하는 로직을 포함하는 미들웨어의 핵심입니다.
  • RequestDelegate:
    Invoke 메서드는 RequestDelegate 매개변수(_next로 명명)를 받습니다. 이 델리게이트는 파이프라인의 다음 미들웨어를 나타냅니다.
  • 유연성:
    Invoke 메서드 내에서 요청 및 응답 객체를 완벽하게 제어할 수 있습니다.
// NameConcatenationMiddleware.cs
public class NameConcatenationMiddleware
{
    private readonly RequestDelegate _next;

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

    public async Task InvokeAsync(HttpContext context)
    {
        if (context.Request.Query.ContainsKey("firstname") &&
            context.Request.Query.ContainsKey("lastname"))
        {
            string fullName = $"{context.Request.Query["firstname"]} {context.Request.Query["lastname"]}";
            await context.Response.WriteAsync(fullName);
            return; // 명시적으로 반환하여 다음 미들웨어 호출 방지
        }
        
        await _next(context);
    }
}

// MiddlewareExtensions.cs
public static class MiddlewareExtensions
{
    public static IApplicationBuilder UseNameConcatenation(this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<NameConcatenationMiddleware>();
    }
}

🔀 미들웨어 파이프라인의 이상적인 순서

미들웨어의 순서는 애플리케이션의 동작과 효율성, 보안에 큰 영향을 미칩니다.

다음은 일반적으로 권장되는 이상적인 순서입니다

https://learn.microsoft.com/en-us/aspnet/core/fundamentals/middleware/?view=aspnetcore-8.0

예외/오류 처리(Exception/Error Handling):

  • 목적: 파이프라인의 어느 곳에서든 발생하는 예외를 Catch하고 처리합니다.
  • 예시: UseExceptionHandler (프로덕션용), UseDeveloperExceptionPage (개발 환경용).
  • 이유: 예외를 초기에 Catch하여 파이프라인 아래로 전파되어 더 큰 문제를 일으키는 것을 방지합니다.

HTTPS 리디렉션(HTTPS Redirection):

  • 목적: 보안을 위해 HTTP 요청을 HTTPS로 리디렉션합니다.
  • 예시: UseHttpsRedirection.
  • 이유: 보안을 최우선으로 하여 모든 통신이 암호화되도록 합니다.

정적 파일(Static Files):

  • 목적: 이미지, CSS, JavaScript 파일과 같은 정적 파일을 클라이언트에게 직접 제공합니다.
  • 예시: UseStaticFiles.
  • 이유: 정적 파일 요청은 빠르게 처리되어야 하며, 불필요하게 파이프라인의 다른 무거운 구성 요소를 거치지 않도록 일찍 처리합니다.

라우팅(Routing):

  • 목적: URL을 기반으로 들어오는 요청을 특정 엔드포인트에 매칭합니다.
  • 예시: UseRouting, UseEndpoints.
  • 이유: 라우팅은 애플리케이션의 핵심 로직이 요청을 어떻게 처리할지 결정하는 기반이 됩니다.

CORS (Cross-Origin Resource Sharing):

  • 목적: 다른 도메인으로부터의 안전한 교차 출처(cross-origin) 요청을 가능하게 합니다.
  • 예시: UseCors.
  • 이유: 인증/인가 전에 위치하여, 사전 요청(preflight request)이 불필요하게 인증/인가 미들웨어를 거치지 않도록 합니다.

인증(Authentication):

  • 목적: 사용자 신원을 확인하고 사용자 주체(principal)를 설정합니다.
  • 예시: UseAuthentication.
  • 이유: 사용자가 누구인지 확인한 후에 리소스에 대한 접근 권한을 부여할 수 있습니다.

인가(Authorization):

  • 목적: 사용자가 특정 리소스에 접근하거나 특정 작업을 수행할 수 있는지 여부를 결정합니다.
  • 예시: UseAuthorization.
  • 이유: 인증된 사용자에게만 권한 부여 여부를 검사합니다.

커스텀 미들웨어(Custom Middleware):

  • 목적: 로깅, 기능 플래그 등 애플리케이션별 미들웨어 구성 요소를 처리합니다.
  • 이유: 애플리케이션별 로직을 적절한 단계에서 파이프라인 내에 배치합니다.

MVC/Razor Pages/Minimal APIs:

  • 목적: 실제 애플리케이션의 최종 엔드포인트 처리 로직을 실행합니다.
  • 예시: MapControllers(), MapRazorPages(), MapGet() 등.
https://learn.microsoft.com/en-us/aspnet/core/fundamentals/middleware/?view=aspnetcore-8.0

Program.cs 파일에 미들웨어 구성 요소가 추가되는 순서는 요청 시 미들웨어 구성 요소가 호출되는 순서를 정의하며, 응답 시에는 역순으로 호출됩니다.

이러한 순서는 보안, 성능 및 기능에 매우 중요합니다.

Program.cs의 다음 강조 표시된 코드는 보안 관련 미들웨어 구성 요소를 일반적으로 권장되는 순서로 추가하는 예시입니다:

using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using WebMiddleware.Data;

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
    ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.AddRazorPages();
builder.Services.AddControllersWithViews();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseMigrationsEndPoint();
}
else
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();
// app.UseCookiePolicy();

app.UseRouting();
// app.UseRateLimiter();
// app.UseRequestLocalization();
// app.UseCors();

app.UseAuthentication();
app.UseAuthorization();
// app.UseSession();
// app.UseResponseCompression();
// app.UseResponseCaching();

app.MapRazorPages();
app.MapDefaultControllerRoute();

app.Run();

✅ UseWhen()

UseWhen()은 ASP.NET Core의 IApplicationBuilder 인터페이스에 있는 강력한 확장 메서드입니다.

이는 조건(predicate)에 따라 미들웨어를 요청 파이프라인에 조건부로 추가할 수 있도록 합니다.

즉, 특정 조건이 충족될 때만 특정 미들웨어 구성 요소가 실행되는 동적인 파이프라인을 만들 수 있습니다.

app.UseWhen(
    context => /* 여기에 조건 */, // HttpContext를 받아 true/false 반환
    app => /* 이 분기에서 실행될 미들웨어 구성 */ // 조건이 true일 때 실행될 미들웨어 파이프라인
);
  • context:
    현재 요청을 나타내는 HttpContext 객체입니다.
  • Predicate (조건):
    HttpContext를 받아들이고 미들웨어 분기가 실행되어야 할 경우 true를, 그렇지 않을 경우 false를 반환하는 함수입니다.
  • Middleware Configuration (미들웨어 구성):
    조건이 true일 때 실행되어야 할 미들웨어 구성 요소를 구성하는 액션입니다.
    여기서 app.Use(), app.Run(), 또는 다른 미들웨어 등록 메서드를 사용합니다.

UseWhen() 작동 방식

  • 조건 평가:
    요청이 들어오면 UseWhen() 메서드는 먼저 HttpContext에 대해 조건 함수를 평가합니다.
  • 분기(조건이 true일 경우):
    조건이 true를 반환하면, 구성 액션에 지정된 미들웨어 분기가 실행됩니다.
    요청은 이 분기를 통해 흐르며, 수정되거나 응답을 생성할 수 있습니다.
  • 메인 파이프라인 재진입:
    분기가 실행된 후(또는 조건이 false여서 건너뛰어진 경우), 요청 흐름은 메인 파이프라인으로 다시 진입하여 UseWhen() 호출 뒤에 등록된 다음 미들웨어 구성 요소로 계속 진행됩니다.
app.UseWhen(
    context => context.Request.Query.ContainsKey("username"), // 조건: 쿼리 문자열에 "username"이 있는지 확인
    app => {
        app.Use(async (context, next) =>
        {
            await context.Response.WriteAsync("Hello from Middleware branch\n"); // 분기 미들웨어: "Hello from Middleware branch" 작성
            await next(); // 다음 미들웨어 호출
        });
    });

app.Run(async context =>
{
    await context.Response.WriteAsync("Hello from middleware at main chain"); // 메인 파이프라인 미들웨어
});
  • 조건:
    context.Request.Query.ContainsKey("username") 조건은 쿼리 문자열에 “username”이라는 매개변수가 포함되어 있는지 확인합니다.
  • 분기 미들웨어:
    “username” 매개변수가 존재하면 분기 미들웨어가 실행됩니다.
    이 미들웨어는 응답에 “Hello from Middleware branch”를 작성하고 next를 호출하여 나머지 파이프라인이 계속되도록 합니다.
  • 메인 파이프라인:
    마지막 app.Run 미들웨어는 메인 파이프라인의 일부입니다.
    이는 응답에 “Hello from middleware at main chain”을 작성합니다.

🦋 UseWhen() 사용 시점

  • 조건부 기능:
    요청에 따라 특정 기능을 활성화하거나 비활성화합니다 (예: 특정 사용자에게만 로깅, 쿼리 매개변수에 따른 캐싱 규칙 적용).
  • 동적 파이프라인:
    다양한 요청에 맞춰 조정되는 파이프라인을 만듭니다 (예: 특정 경로에 대해 다른 인증 미들웨어).
  • A/B 테스트:
    실험을 위해 사용자 하위 집합을 대체 미들웨어 분기를 통해 라우팅합니다.
  • 디버깅 및 진단:
    개발 환경에서만 진단 미들웨어를 적용합니다.

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤