.NET/ASP.NET

ASP.NET Core - 1. 요청 파이프라인(Request Pipeline)과 Service, Middleware component등 ASP.NET Core Platform에 관한 전반적인 이해

클리엘 2022. 9. 16. 13:00
728x90

ASP.NET Core는 MVC나 Blazor와 같은 기능을 통해 Web Application을 개발하기 위한 Platform입니다. 이번에는 ASP.NET Core에 관한 특징 및 구조 그리고 HTTP 요청을 처리하는 요청 Pipeline에 대해 알아보고 customize 할 수 있는 방법에 대해서도 살펴볼 것입니다.

 

1. 시작전 준비사항

 

우선 아래 명령을 통해 최소한의 ASP.NET Core Project를 생성하도록 합니다. 예제에서 Project는 MyWebApp으로 지정하였습니다.

dotnet new globaljson --sdk-version 6.0.400 --output MyWebApp
dotnet new web --no-https --output MyWebApp --framework net6.0
dotnet new sln -o MyWebApp
dotnet sln Platform add MyWebApp

Visual Studio(2022)를 통해 Project를 Open하고 실행시켜 다음과 같이 동작하는지를 확인합니다.

2. ASP.NET Core Platform

 

ASP.NET Core Platform은 요청 pipeline이나 middleware, service와 같은 핵심적인 기능에 초점을 맞추어 보는 것이 이해하는데 도움이 될 수 있습니다. 그렇게 상세한 부분까지 들어가지 않더라도 이들 기능들이 어떻게 조화를 이루는지를 알고 나면 전반적인 ASP.NET Core platform에 접근하는 것이 한층 더 쉬어질 것입니다.

 

(1) Middleware와 Request Pipeline

 

근본적으로 ASP.NET Core platform의 동작은 middleware component에 위임하여 요청을 받고 그에 대응하는 응답을 생성하는 것입니다. middleware component는 요청 pipeline이라고도 하며 chain형태의 연결된 구조를 이루고 있습니다.

 

새로운 HTTP요청이 도달하는 경우 ASP.NET Core platform은 요청에 대한 개체와 함께 다시 응답으로 보내지게 될 개체를 생성하게 됩니다. 특히 응답 개체는 chain으로 연결된 여러 middleware component를 거치면서 만들어지게 되는데 각 middleware component는 요청을 분석하고 응답 개체에 자신만의 응답을 붙이는 방법으로 생성됩니다. 즉, 요청은 단계적으로 pipeline을 거치게 되고 그에 따라 최종적으로 응답이 형성되는 것입니다.

대부분의 Middleware Component는 요청에 대한 응답을 생성하는 것이 주된 목적이지만 일부는 data type의 Format지정, cookie Access등의 기능을 제공하기 위한 것도 존재합니다. 심지어 ASP.NET Core는 일반적으로 발생하는 문제들을 해결하기 위한 Middleware Component들도 포함하고 있습니다. 그러나 만약 Middleware Component에 의해 생성된 응답이 존재하지 않는다면 ASP.NET Core는 404 Not Found 상태 코드를 대신 응답하게 됩니다.

 

(2) Service

 

Service는 간단하게 Web Application에서 어떤 기능을 제공하는 개체라고 할 수 있습니다. 모든 class가 Service로 사용될 수 있기에 사실상 service가 제공할 수 있는 기능에 대한 제한은 없다고 할 수 있습니다. 또한 Service는 ASP.NET Core에의해 관리되므로 dependency injection기능을 통해 Application의 어디서든 middleware component를 포함하여 어떠한 Service에도 쉽게 접근이 가능합니다.

 

의존성 주입(dependency injection)에 관해서는 추후에 좀 더 상세히 다루게 될것이므로 지금은 ASP.NET Core platform에 의해 관리되면서 middleware component에 의해 공유될 수 있는 하나의 개체라는 것으로 이해하면 되겠습니다. 그러므로 인해서 개체는 component 간에 조정을 담당하거나 logging이나 설정 data의 loading과 같은 일반적인 기능에 필요한 code를 중복으로 구현하게 되는 상황을 피할 수 있게 합니다.

상기 이미지에서 묘사된것처럼 Middleware Component는 자신이 필요한 Service만을 사용합니다. 또한 ASP.NET Core는 기본적인 몇몇 Service를 제공하는데 이들 Service 역시 Application에서 특정하게 추가된 다른 Service들로 보완될 수 있습니다.

 

3. ASP.NET Core Project Template

 

위에서 생성한 Project를 보면 ASP.NET Core를 runtime에서 동작시키기 위한 기본적인 services와 Middleware Component를 포함해 기초적인 Code및 설정까지 같이 생성되는 것을 볼 수 있습니다.

Visual Studio Code가 모든 Item을 표시하는 것과는 달리 Visual Studio에서는 개발단계에서 일반적으로 사용되지 않는 Item은 숨기고 비슷한 유형의 file끼리는 appsettings.Development.json처럼 중첩하여 Project의 File들을 표시하게 됩니다. 만약 숨겨진 모든 Item을 표시하고 file의 중첩을 방지하려면 Solution Explorer의 'Show All Files'나 'File Nesting...'과 같은 button을 눌러주면 됩니다.

Folder Properties Application의 시작과 관련된 설정에 사용되는 file이 존재합니다.
Folder bin compile된 Application의 File이 존재합니다.
Folder obj compiler로 부터 생성된 중간 출력file이 존재합니다.
File appsettings.Development.json 특정 개발환경을 위한 설정file입니다.
File appsettings.json Application을 위한 기본 설정file입니다.
File global.json 사용되는 .NET Core SDK의 특정 version을 명시하기 위한 File입니다.
File MyWebApp.sln Project를 구성하기위해 사용되는 File입니다.
File Program.cs 이 File은 ASP.NET Core platform에서 주요진입점에 해당하는 File이며 platform을 구성하는데 사용되는 File입니다.
File MyWebApp.csproj project 자체를 구성하는 File이며 package의존성과 build 구조를 포함합니다. 이 File은 기본적으로 Solution Explorer에 표시되지 않지만 Project를 Mouse 오른쪽 Button으로 눌러 'Edit Project File'을 선택하면 해당 File을 수정할 수 있습니다.

상기 표는 현재 Project에 구성된 각 Folder와 File의 목적을 명시한 것입니다.

 

(1) Entry Point

 

Project의 Program.cs는 Application이 시작될때 실행되는 code와 ASP.NET Core Platform과 이를 지원하는 각각의 개별 framework를 구성하기 위한 code들이 작성됩니다. 현재 Project에서 Program.cs는 아래와 같은 code가 기본적으로 구현되어 있습니다.

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

app.MapGet("/", () => "Hello World!");

app.Run();

.NET Core 6부터 Program.cs의 code는 기본적으로 top-level로 작성됩니다. file에서는 우선 가장 먼저 WebApplication. CreateBuilder() Method를 호출하고 그 결과를 builder에 할당하고 있습니다. 이 Method는 data와 logging을 구성하기 위한 service를 생성하고 ASP.NET Core platform의 기본적인 기능을 설정하기 위한 것이며 또한 Kestrel이라고 하는 내장된 HTTP Server를 사용해 HTTP 요청에 응답할 수 있도록 합니다.

 

CreateBuilder() Method를 호출하고 나면 WebApplicationBuilder라는 개체를 전달받게 되는데 현재 예제에서는 더 이상의 service등록을 수행하고 있지는 않지만 이 개체는 다른 추가적인 service를 등록하는 데 사용될 수 있습니다. WebApplicationBuilder class는 다시 Build method를 호출하여 초기 설정을 종료합니다.

 

호출된 Build() Method는 WebApplication개체를 반환하게 되는데 이 개체를 통해서는 middleware component를 설정하게 됩니다. 현재 project에서는 MapGet() 확장 Method를 호출함으로서 최초 하나의 middleware component의 설정을 수행하고 있습니다.

 

MapGet() Method는 WebApplication class에 의해 구현되는 IEndpointRouteBuilder interface를 위한 확장 Method인데 특정한 URL 경로를 통해 HTTP 요청을 처리하게될 익명 함수를 설정하고 있는데 예제의 함수는 기본적으로 '/'로 표현된 URL로의 모든 요청에 대해 "Hello World!"라는 string형식의 응답을 반환하게 됩니다.

 

물론 대부분의 경우 HTTP 요청에 더욱더 풍부한 형식의 응답을 필요로 할것입니다. 때문에 Microsoft는 ASP.NET Core의 일부로서 Web Application에서 요구되는 일반적인 기능을 처리하기 위해 필요한 다른 Middleware들도 함께 제공하고 있습니다. 물론 필요하다면 자신의 Middleware를 만들어 필요한 기능을 구현하는 것도 가능합니다.

 

Program.cs에서 마지막에는 WebApplication class에서 정의된 Run() Method를 호출하게 되는데 이 때는 HTTP request요청의 수신을 대기하게 됩니다. 물론 MapGet() method에서 string값을 반환하기는 하지만 ASP.NET Core는 Web Browser가 이해할 수 있는 유효한 응답을 생성하기 위해 추가적인 정보를 붙여야 하기 때문입니다. 예를 들어 project를 실행한 뒤  PowerShell을 통해 아래 명령을 통해 HTTP 요청을 보내게 되면

(invoke-webrequest http://localhost:5232).rawcontent 

HTTP 상태code와 몇몇 Header정보가 포함된 응답을 볼 수 있게 됩니다.

(2) Project File

 

예제에서 Project File은 MyWebApp.csproj 이며 .NET Core가 Project를 build 하고 의존성을 확인하는 데 사용되는 정보를 포함하고 있습니다.

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

</Project>

Project file은 XML로 구성되어 있는데 이 방식은 Microsoft build engine인 MSBuild를 위한 것입니다. MSBuild는 복잡한 build process를 생성하는 데 사용될 수 있는데 좀 더 자세한 내용은 아래 Link를 통해 확인하실 수 있습니다.

 

MSBuild - MSBuild | Microsoft Docs

 

MSBuild - MSBuild

Learn about how the Microsoft Build Engine (MSBuild) platform provides a project file with an XML schema to control builds.

docs.microsoft.com

대부분의 Project에서는 해당 Project file을 직접 수정해야하는 경우가 많지는 않습니다. Project file이 변경되는 가장 일반적인 경우는 다른 .NET Package상에서 의존성이 추가될 때인데 이러한 경우에도 명령행 도구나 Visual Studio에서 제공되는 interface를 통하여 추가됩니다.

 

Package를 추가하는 가장 일반적인 방법은 cmd나 PowerShell을 통해 아래와 같은 명령 형식으로 추가하거나

dotnet add package Swashbuckle.AspNetCore --version 6.4.0

Visual Studio의 Nuget Package Manager를 통해 추가하는 방법이 있습니다.

예제에서는 Swashbuckle.AspNetCore Package를 Project에 추가하는 것이며 Package가 추가되면 Project file은 아래와 같이 바뀌게 됩니다.

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
  </ItemGroup>

</Project>

4. Middleware 생성하기

 

Middleware는 경우에 따라 ASP.NET Core에서 제공되는 것중 나에게 필요한 것이 없다면 직접 만들어서 추가할 수도 있습니다. 물론 현재 Project에서는 기본적으로 제공되는 Middleware를 사용하는 것만으로 충분할 수 있지만 그렇다 하더라도 직접 Middleware를 만들어 보는 것은 ASP.NET Core가 동작하는 방식을 이해하는데 또 다른 유용한 방법이 될 수도 있습니다.

 

Middleware를 생성할때 가장 핵심적으로 사용되는 Method는 Use()입니다. 

var app = builder.Build();

app.Use(async (context, next) =>
{
    if (context.Request.Method == HttpMethods.Get && context.Request.Query["mdw"] == "test")
    {
        context.Response.ContentType = "text/plain";
        await context.Response.WriteAsync("Middleware running.\n");
    }

    await next();
});

app.MapGet("/", () => "Hello World!");

예제에서는 Use() Method를 사용해 Lambda 함수를 통해 구현된 Middleware Component를 등록하고 있습니다. 이 Middleware는 Pipeline을 통해 전달된 각 요청을 전달받는데 HttpContext개체와 ASP.NET Core가 pipeline에서 다음 Middleware Component로 요청을 전달하기 위해 호출하는 함수를 매개변수로 갖고 있습니다.

 

HttpContext개체는 HTTP 요청과 응답을 나타내며 요청에서 사용자와 관련된 상세정보를 포함한 추가적인 context를 제공합니다. 아래 표에서는 Microsoft.AspNetCore.Http namespace에 정의된 HttpContext class에서 사용할 수 있는 유용한 member를 표시한 것입니다.

Connection 이 속성은 ConnectionInfo개체를 반환합니다. 해당 개체는 HTTP요청에 기반하여 local과 remote ip주소및 port를 포함한 Network연결정보를 제공합니다.
Request 이 속성은 HTTP 요청을 나타내는 HttpRequest개체를 반환합니다.
RequestServices 이 속성은 요청에서 가능한 Service로의 접근을 제공합니다.
Response 이 속성은 HTTP 요청으로 응답을 생성하는데 사용되는 HttpResponse 개체를 반환합니다.
Session 이 속성은 요청과 관련된 session data를 반환합니다.
User 이 속성은 요청과 관련된 사용자의 상세를 반환합니다. 
Features 이 속성은 요청제어를 위한 저수준으로의 접근을 제공하는 요청기능으로의 접근을 제공합니다.

ASP.NET Core platform은 HTTP 요청을 처리하여 HttpRequest 객체를 생성하게 되고 필요하면 middleware와 endpoint는 해당 객체를 사용하여 필요한 작업을 수행할 수 있습니다. 아래 표는 HttpRequest 객체의 주요 member에 대해 나열한 것입니다.

body 이 속성은 request body를 읽기위해 사용되는 stream을 반환합니다.
ContentLength 이 속성은 Content-Length header 값을 반환합니다.
ContentType 이 속성은 Content-Type header 값을 반환합니다.
Cookies 이 속성은 request cookie 값을 반환합니다.
Form 이 속성은 request body를 Form형태로 반환합니다.
Headers 이 속성은 request header를 반환합니다.
IsHttps 요청이 HTTPS로 생성된 것이면 이 속성의 값은 true입니다.
Method 요청에 사용된 HTTP method의 유형을 반환합니다.
Path 이 속성은 요청URL에 해당하는 경로를 반환합니다.
Query 이 속성은 요청URL에 실려있는 key-value형태의 query string값을 반환합니다.

HttpRequest와 반대로 HttpResponse 객체는 요청이 pipeline을 통과할 때 client로 다시 보내지게 될 Response을 나타냅니다. ASP.NET Core platform은 Response를 가능한 한 단순하게 처리하고 header를 자동으로 설정하며 content를 client로 쉽게 전송할 수 있도록 합니다. 아래 표는 HttpResponse 객체의 주요 member에 대해 나열한 것입니다.

ContentLength 이 속성은 Content-Length header의 값을 설정합니다.
ContentType 이 속성은 Content-Type header의 값을 설정합니다.
Cookies 이 속성은 Cookie를 요청과 연결할 수 있도록 합니다.
HasStarted 이 속성은 ASP.NET Core가 response header를 client로 전송하기 시작했다면 true를 반환할 것이며 이후에는 상태code나 header를 변경하는 것이 불가능합니다.
Headers 이 속성은 response header의 값을 설정합니다.
StatusCode 이 속성은 응답에 대한 상태code를 설정합니다.
WriteAsync(data) 이 비동기 method는 response body로 data string작성합니다.
Redirect(url) 이 method는 redirection response를 전송합니다.

middleware를 직접 구현하는 경우에는 HttpContext, HttpRequest, HttpResponse 객체등을 직접 사용할 수 있으나 MVC Framework와 Razor Pages와 같은 고수준 ASP.NET Core 기능을 사용하는 경우에는 일반적으로 이러한 세부작업까지는 필요로 하지 않습니다.

 

이전에 위에서 작성한 middleware의 경우에는 HttpRequest객체를 사용하여 HTTP method과 query string을 확인하고 있고 이를 통해 query string안에서 mdw매개변수의 값이 test라는 것과 요청 Method가 GET 요청이라는 것을 식별할 수 있었습니다.

if (context.Request.Method == HttpMethods.Get && context.Request.Query["mdw"] == "test")

HttpMethods class는 각 HTTP Method에 해당하는 static값을 정의하고 있으며 예상되는 query string과 함께 GET요청이 오는 경우 middleware는 함수는 ContentType속성을 통해 Content-Type header를 설정하고 WriteAsync() method를 사용하여 response의 body에 문자열을 추가하고 있습니다.

context.Response.ContentType = "text/plain";
await context.Response.WriteAsync("Middleware 동작됨 \n");

특히 Content-Type header를 설정하는 것은 다음 middleware component가 상태code나 header를 설정하는 것을 방지하게 되므로 매우 중요한 부분입니다. ASP.NET Core는 항상 유효한 HTTP response이 전송되는지를 확인하므로 만약 response header나 응답 code가 이전 component에 의해 response body로 content가 이미 작성된 이후 설정되었다면 이는 예외를 발생시킬 수 있습니다.(response header는 response body보다 먼저 client로 전송됩니다.)

 

middleware의 두번째 인수는 next라는 이름의 함수이며 이 것을 통해 ASP.NET Core는 요청 pipeline 중에서 요청을 다음 component로 전달할 수 있습니다.

await next();

특히 위에서 처럼 다음 middleware component가 호출되는 경우에 ASP.NET Core는 HttpContext객체와 자체적인 next 함수를 함께 제공하고 있으므로 별도로 매개변수 룰 지정하지 않더라도 요청을 처리될 수 있습니다. 또한 next는 비동기이므로 await keyword가 사용되었고 lambda 식 자체도 async keyword를 통해 정의되었습니다.

예제에서 next()는 next.invoke()로도 호출될 수 있습니다. invoke()를 사용하지 않으면 compiler가 관례적으로 알아서 해석해 주므로 좀더 간결한 code를 사용할 수 있습니다.

project를 시작하고 URL을 localhost:[port번호]?mdw=test로 이동하면 아래와 같은 결과가 나올 것입니다.

이러한 결과는 요청을 다음 middleware component로 넘기기전 위에서 추가한 middleware가 response message를 추가한 것입니다. mdw query string을 지우거나 mdw의 값으로 test가 아닌 다른 값을 지정하면 새롭게 추가된 middleware는 동작하지 않을 것입니다.

 

(1) Middleware를 class로 정의하기

 

위 예제처럼 Middleware를 lambda식이나 기타 함수로 정의하는 방법은 간단하기는 하지만 logic이 복잡해지는 경우 Program.cs안에서 길고 복잡한 구문이 생성될 수 있고 다른 project에서 Middleware의 재사용을 어렵게 만들 수도 있습니다.

 

이때 Middleware는 class로도 정의하면 위와 같은 문제를 해결할 수 있습니다. Project에 Middleware라는 folder를 만들고 그 안에 다시 CustomMiddleWare.cs라는 이름의 file을 아래와 같이 추가해 줍니다.

namespace MyWebApp.Middleware
{
    public class CustomMiddleWare
    {
        private RequestDelegate _next;

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

        public async Task Invoke(HttpContext context)
        {
            if (context.Request.Method == HttpMethods.Get && context.Request.Query["mdw"] == "test")
            {
                if (!context.Response.HasStarted)
                    context.Response.ContentType = "text/plain";

                await context.Response.WriteAsync("Class Middleware Running.\n");
            }
            
            await _next(context);
        }
    }
}

Middleware class는 생성자 매개변수를 통해서 RequestDelegate를 받을 수 있는데 이 것을 통해 Pipeline에서 다음 component로 요청을 전달하는 데 사용합니다.  RequestDelegate는 task를 반환하므로 비동기로 동작하며 Invoke method는 ASP.NET Core가 요청을 수신할 때 호출하며 이때 요청과 응답으로의 접근을 제공하는 HttpContext객체를 전달받게 됩니다. 전체적으로 이와 비슷한 구현을 이미 lambda Method를 통해 만들어 보았습니다.

 

class를 통해 구현된 Middleware에서 이전과 달리 중요한 차이점은

await _next(context);

위와 같이 RequestDelegate가 요청을 전달하기위해 호출될 때 반드시 HttpContext객체를 사용해야 한다는 것입니다.

 

이렇게 정의된 Class Middleware Component는 UseMiddleware method를 통해 type로서 Middleware를 인수로 전달할 수 있습니다.

app.UseMiddleware<MyWebApp.Middleware.CustomMiddleWare>();

app.MapGet("/", () => "Hello World!");

app.Run();

ASP.NET Core가 시작되면 CustomMiddleWare class는 instance화 되고 Invoke method가 전달받은 요청을 처리하기 위해 호출될 것입니다.

단일 Middleware객체는 Invoke method내부의 code가 thread-safe여야 하므로 들어오는 모든 요청을 제어하는 데 사용됩니다.

Project를 시작하고 mdw=test query string을 통해 URL을 요청하면 추가된 모든 Middleware가 response message를 추가하게 되므로 아래와 같은 결과를 볼 수 있습니다.

 

(2) Middleware와 pipeline

 

Middleware component는 아래와 같이 next() Method가 호출되고 난 이 후 HTTPResponse 객체를 수정할 수 있습니다.

var app = builder.Build();

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

    await context.Response.WriteAsync($"\nStatus Code: {context.Response.StatusCode}");
});

위에서 추가된 Middleware는 즉각적으로 next method를 호출해 요청을 pipeline으로 넘긴 다음 WriteAsync() Method를 사용하여 response body에 문자열을 추가하고 있습니다. 다소 이상해 보일 수도 있지만 이것은 Middleware가 요청 pipeline을 전달하기 전과 이후 즉, next() method를 호출하기 전과 이후에 관련 구문을 정의함으로써 response를 변경할 수 있다는 것을 의미하기도 합니다.

 

Middleware는 요청이 전달되기 전이나 다른 component로 요청이 전달된 후에도 작용할 수 있는데 그 결과 middleware component가 전체적으로 생성된 response에 관여하게 됨으로서 각각은 response의 일부 측면을 제공하거나 pipeline의 막바지에 사용되는 일부 기능 또는 data를 제공할 수 있게 됩니다.

 

Project를 실행하면 새롭게 추가된 Middleware에 의해 추가된 내용을 볼 수 있습니다.

참고로 Middleware component는 ASP.NET Core가 client로 응답을 전송하기 시작하면 HTTP 상태 Code나 Header를 바꾸지 말아야 합니다. 또한 이런 경우에 예외를 피하기 위해서는 HasStarted속성을 확인할 수 있습니다.

 

(3) 요청 Pipeline의 종단 처리

 

특정 Middleware Component가 완전한 response를 생성하게 되었다면 해당 component는 next() method를 호출할지의 여부를 선택할 수 있습니다. 당연하지만 next() method를 호출하지 않으면 다음 Middleware로 요청을 전달하지 않게 됩니다.

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

    await context.Response.WriteAsync($"\nStatus Code: {context.Response.StatusCode}");
});

app.Use(async (context, next) =>
{
    if (context.Request.Path == "/short")
        await context.Response.WriteAsync($"Request Short Circuited");
    else
        await next();
});

Program.cs에서 두번째 Middleware는 HttpRequest객체의 Path속성을 통해 요청된 URL이 /short 인지를 확인하고 그런 경우 Response에 문자열을 추가할 뿐 next()를 호출하지 않습니다.

 

Project를 실행하여 /short?mdw=text로 URL을 요청해 보면 아래와 같은 결과를 볼 수 있는데

mdw=text에서 반응해야할, 즉 새롭게 추가된 Middleware다음에 존재하는 Middleware들이 동작하지 않았음을 알 수 있습니다.(단, 이전에 위치하는 Middleware까지는 차단하지 않으므로 가장 처음에 있는 Middleware는 동작하게 됩니다.)

 

(4) Pipeline Branch 생성

 

Map Method는 특정한 URL에서 특정한 동작을 수행하기 위해 pipeline의 영역을 분리하여 생성하는 데 사용됩니다. 다시 말해 여러 middleware component로부터 실행 영역이 분리할 수 있는 것입니다.

var app = builder.Build();

((IApplicationBuilder)app).Map("/branch", branch =>
{
    branch.UseMiddleware<MyWebApp.Middleware.CustomMiddleWare>();

    branch.Use(async (HttpContext context, Func<Task> next) =>
    {
        await context.Response.WriteAsync($"Branch Middleware");
    });
});

global import는 경우에 따라 모호함이 문제가 될 수 있습니다. 위 예제에서도 WebApplication class에서 구현된 다른 interface의 Map Method가 동일한 이름으로 문제 될 수 있으므로 예제의 경우 IApplicationBuilder interface의 확장 method임을 명시하기 위해 app에 대한 형 변환을 시도하고 있습니다.

 

Map method의 첫번째 인수는 URL을 나타내는 문자열이며 두 번째 인수가 UseMiddleware와 Use Method를 통해 middleware components를 추가시키는 pipeline의 brach에 해당합니다. 따라서 예제는 /branch라고 하는 특정한 URL에서 사용되며 CustomMiddleWare class를 통해 요청을 전달하고 Response에 Message를 추가하는 Middleware lambda표현식을 갖는 branch를 생성합니다. 즉, 기존의 pipeline과는 전혀 다른 영역이 만들어지는 것입니다.

 

Map에서 정의된 /branch URL과 요청 /branch URL이 일치하게 되면 예제의 branch가 사용되는데 해당 branch는 next delegate를 호출하지 않음으로써 해당 branch밖의 Middleware component로는 요청을 전달하지 않게 됩니다.

 

여기서 CustomMiddleWare class는 해당 class를 사용하는 다른 Middleware component와 예제의 branch모두에서 사용될 수 있는데 project를 실행하여 mdw=test로만 URL을 요청하게 되면 pipeline의 주요 영역에 존재하는 Middleware component가 요청을 제어하게 되므로 아래와 같은 결과가 나오지만

/branch?mdw=test를 요청하게 되면 branch가 실행되어 아래와 같이 다른 결과를 보여주게 될 것입니다.

ASP.NET Core에서는 Map이외에 MapWhen Method를 사용할 수 있습니다. 이 Method로는 특정 URL만을 지정하는 대신 조건에 따라 요청되는 URL의 일치 여부를 결정할 수 있습니다.

 

MapWhen method의 인수로는 HttpContext를 받고 branch에 들어갈 수 있는 요청의 경우 true를 반환하는 조건자 함수(predicate function)가 올 수 있습니다. 또한 IApplicationBuilder객체를 받아 middleware가 추가된 pipeline branch를 내부에서 구현할 수 있습니다.

app.MapWhen(context => context.Request.Query.Keys.Contains("branch"),
    branch => {
        //...
    }
);

예제는 query string 매개변수가 branch라고 하는 문자열을 포함하는 경우 해당 branch에 true를 반환합니다. 참고로 MapWhen은 IApplicationBuilder interface에 정의된 확장 method가 아닌 한 곳에만 정의된 method이므로 형 변환이 필요하지 않습니다.

(5) Terminal Middleware의 이해

 

Terminal Middleware(종단(단말) 미들웨어)는 다른 component로 요청을 전달하지 않는 요청 Pipeline의 끝을 의미합니다. 따라서 위에서 구현한 branch도 종단 미들웨어로 정의될 수 있습니다.

branch.Use(async (HttpContext context, Func<Task> next) =>
{
    await context.Response.WriteAsync($"Branch Middleware");
});

다만 ASP.NET Core는 Run method를 Terminal Middleware를 생성하는 관례적인 기능으로서 지원하고 있는데 이 method를 통해서 middleware component가 더이상 요청을 전달하지 않으며 의도적으로 next 함수를 호출하지 않음을 분명히 할 수 있습니다.

app.Run();

같은 의미로 위에서 언급한 branch의 경우도 Terminal Middleware로서 취급될 수 있기 때문에 아래와 같이 Use()대신 Run() method를 대신 사용할 수 있습니다.

branch.Run(async (HttpContext context) =>
{
    await context.Response.WriteAsync($"Branch Middleware");
});

당연하게도 Run()은 Terminal Middleware를 의미하므로 next 매개변수는 가질 수 없습니다. Run()은 Use() Method를 통해 구현되는 method일 뿐, 종단을 위한 특별한 기능을 가진 것이 아니고 단지 관례적인 의미로만 사용될 뿐입니다.

 

주의할 점은 terminal middleware가 동작한 이후에 존재하는 middleware는 절대 요청을 전달받을 수 없습니다. ASP.NET Core는 이러한 모순된 구현에 대해서는 따로 경고를 보내주지 않습니다.

 

Class를 통해 구현된 component에서도 일반적인 middleware와 함께 의미적으로 termianl middleware가 구현될 수 있습니다.

namespace MyWebApp.Middleware
{
    public class CustomMiddleWare
    {
        private RequestDelegate? _next;

        public CustomMiddleWare()
        {
        }

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

        public async Task Invoke(HttpContext context)
        {
            if (context.Request.Method == HttpMethods.Get && context.Request.Query["mdw"] == "test")
            {
                if (!context.Response.HasStarted)
                    context.Response.ContentType = "text/plain";

                await context.Response.WriteAsync("Class Middleware Running.\n");
            }

            if (_next != null)
                await _next(context);
        }
    }
}

CustomMiddleware에서 RequestDelegate는 ?를 통해 null 가능한 type이 되었습니다. class에서 새롭게 추가된 매개변수 없는 생성자가 호출되는 경우 _next는 당연히 null이 될 것이며 Invoke()에서는 _next가 null이 아닌 경우에만 호출될 수 있도록 구현되었습니다.

 

따라서 Program.cs의 branch에서는 아래와 같이 매개변수없는 생성자를 호출하게 되면

branch.Run(new MyWebApp.Middleware.CustomMiddleWare().Invoke);

자연스럽게 termianl middleware로서 사용할 수 있게 됩니다. 참고로 예제는 termianl middleware를 위한 UseMiddleware() method가 존재하지 않으므로 Run() method를 middleware class의 instance를 생성함과 동시에 Invoke() method를 호출하는 구문을 통해서 사용될 수 있습니다.

 

5. option pattern을 통한 Middleware 설정

 

Middleware를 설정하기 위한 통상적인 방법으로 options pattern이라는 것이 존재합니다. 이 방법은 ASP.NET Core에 내장된 몇몇 component에서도 사용되는데 Middleware component가 동작하는데 필요한 설정을 다루는 것이라 이해할 수 있습니다. 즉, Middleware component에서 필요한 설정을 임의의 class를 구현하여 해당 속성을 통해 적용하는 방법으로 이러한 방식을 option pattern이라고 합니다

 

option pattern을 시작해 보기 위해 우선 아래와 같은 class를 Middleware folder에 먼저 생성합니다.

namespace MyWebApp.Middleware
{
    public class Member
    {
        public string MemberName { get; set; } = "hong";
        public string MemberGroup { get; set; } = "user";
    }
}

예제의 class는 MemberName과 MemberGroup이라는 2개의 속성을 정의하고 있습니다. 그리고 다음과 같이 option pattern을 구현하기 위해 Member class에 의존하는 Middleware component를 생성하였습니다.(code의 간결함을 위해 몇몇 Midleware는 삭제하였습니다.)

using Microsoft.Extensions.Options;
using MyWebApp.Middleware;

var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<Member>(options =>
{
    options.MemberName = "homg";
});

var app = builder.Build();

app.MapGet("/member", async (HttpContext context, IOptions<Member> memOpts) =>
{
    Member opts = memOpts.Value;
    await context.Response.WriteAsync($"{opts.MemberName}, {opts.MemberGroup}");
});

app.MapGet("/", () => "Hello World!");

app.Run();

예제에서 option은 WebApplicationBuilder class에 의해 정의된 Services.Configure method를 사용해 generic type 매개변수로 설정하고 있습니다. 이것으로 Member class를 사용해 option을 만들고 MemberName의 속성 값을 바꾸고 있습니다. 따라서 project를 시작하면 ASP.NET Core는 Member class의 새로운 instance를 생성하고 이를 Configure method의 인수로 제공된 함수에 전달하여 속성의 값이 바꿀 수 있도록 처리하게 됩니다.(option은 service로 취급될 수 있으므로 예제와 같은 구문은 반드시 Build() Method가 호출되기 이전에 만들어 저야 합니다.)

 

그리고 Middleware component는 요청을 처리하는 함수에 매개변수를 정의함으로서 구성 option에 접근할 수 있게 되는데 Middleware component를 등록하는 일부 확장 method의 경우 요청을 처리하기 위한 모든 함수에 적용될 수 있습니다.

 

예제에서는 요청이 처리될때 ASP.NET Core Platform이 해당 함수를 분석하여 매개변수로 middleware component가 생성되는 응답에서 구성 옵션을 사용하기 위한 필요한 서비스를 찾게 됩니다.

 

project를 시작하고 /member URL을 요청하면 Middleware component가 option pattern을 사용한 결과를 다음과 같이 볼 수 있습니다.

(1) class 기반 Middleware에서 option pattern 사용하기

 

option pattern은 또한 class로 구현된 Middleware에서도 사용될 수 있으며 비교적 쉽게 적용할 수 있습니다. Project의 CustomMiddleWare.cs file에서 아래와 같은 class를 추가하여 설정을 위해 Member class를 사용하는 class기반의 Middleware component를 정의하도록 합니다.

public class MemberMiddleware
{
    private RequestDelegate _next;
    private Member _options;

    public MemberMiddleware(RequestDelegate nextDelegate, IOptions<Member> opts)
    {
        _next = nextDelegate;
        _options = opts.Value;
    }

    public async Task Invoke(HttpContext context)
    {
        if (context.Request.Path == "/member")
        {
            await context.Response.WriteAsync($"{_options.MemberName}, {_options.MemberGroup}");
        }
        else
        {
            await _next(context);
        }
    }
}

MemberMiddleware에서는 IOptions<Member>라는 생성자 매개변수를 정의하고 있는데 이는 Invoke method안에서 option 설정에 접근하도록 하는 역할을 수행합니다.

 

위와 같이 class를 생성하고 나면 Program.cs에서는 기존의 lambda 함수로 Middleware component가 구현되었던 것을 class로 변경하여 요청 Pipeline을 재설정할 수 있습니다.

var app = builder.Build();

app.UseMiddleware<MemberMiddleware>();

app.MapGet("/", () => "Hello World!");

UseMiddleware()가 실행되면 MemberMiddleware의 생성자가 확인되고 IOption<Member>매개변수는 Services.Configure()에 의해 생성된 객체를 사용하여 resolve를 수행합니다.

 

project를 실행하여 /member로 URL요청을 실행하고 결과를 확인합니다.

728x90