.NET/ASP.NET

ASP.NET Core - 6. Data Caching

클리엘 2022. 11. 13. 12:21
728x90

이제까지의 만들어왔던 예제는 모두 각 요청에 대해서만 유효한 응답이 이루어지던 것으로서 간단한 문자열이나 작은 일부 HTML을 처리하는데 유용한 방법이었습니다. 그러나 대부분 Project의 경우 생성하는데 고비용이면서 최대한 효휼적으로 사용되어야 하는 Data를 다루게 됩니다. 이에 따라 ASP.NET Core에서 제공하는 data caching과 전체 응답을 caching 하는 것에 대해 알아보고자 합니다.

 

1. Project 준비하기

 

예제를 위한 Project는 이전에 만들었던 Project를 그대로 사용할 것입니다. 다만 Program.cs의 내용은 아래와 같이 변경합니다.

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();
app.MapGet("/", async context => {
    await context.Response.WriteAsync("Hello World!");
});

app.Run();

Project를 실행하여 아래와 같은 결과가 나오는지 확인합니다.

2. Data Caching

 

어떤 Web Application의 경우에는 생성하는데 비용이 많이 들면서 반복적으로 필요한 몇몇 Data가 있을 수 있습니다. Data의 정확성은 각 Project에 따라 다를 수 있지만 동일한 일련의 계산을 반복적으로 수행하는 것은 Application을 Host하는데 필요한 Resource를 증가시킬 수 있습니다. 같은 상황을 만들어 보기 위해 Project의 Infrastructure folder에 SumEndPoint.cs라는 file을 아래와 같이 생성합니다.

namespace MyWebApp.Infrastructure
{
    public class SumEndpoint
    {
        public async Task Endpoint(HttpContext context)
        {
            int count;
            int.TryParse((string?)context.Request.RouteValues["count"], out count);
            long total = 0;

            for (int i = 1; i <= count; i++)
                total += i;

            string totalString = $"({DateTime.Now.ToString("HH:mm:ss")}) {total}";

            await context.Response.WriteAsync($"({DateTime.Now.ToString("HH:mm:ss")}) Total => {count}" + $" Value:\n{totalString}\n");
        }
    }
}

위 예제는 endpoint에서 사용되는 route를 생성한 것으로 아래와 같이 MapEndpoint 확장 method를 통해 적용합니다.

using MyWebApp.Infrastructure;
using MyWebApp.Services;

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();
app.MapEndpoint<SumEndpoint>("/sum/{count:int=1000000000}");

Project를 실행하여 /sum으로 URL을 요청합니다. 그러면 endpoint는 지정한 정수값의 합계를 구하게 될 것이며 그 결과를 다음과 같이 표현하게 됩니다.

Browser를 새로고침하면 endpoint는 계산을 다시 수행하게 되므로 출력 시간은 바뀌게 됩니다. 이것은 응답이 각 요청에 대해 새롭게 생성된다는 것을 나타내는 것입니다.

위 예제는 test하고자 하는 Machine에 따라 route 매개변수에 주어진 기본값을 변경해야 할 수 있습니다. 시간이 너무 오래 걸린다고 생각되면 대략 2~3초정도안애 결과값을 내도록 값을 바꿔주는 것이 좋습니다.

(1) Data값 Caching

 

ASP.NET Core는 IDistributedCache Interface를 통해 Data값을 Caching하는데 사용되는 Serivce를 제공하고 있습니다. 아래 예제는 이전 SumEndPoint를 수정하여 service에 의존성을 정의하고 계산된 값을 Cache 하는 방법을 보여주고 있습니다.

using Microsoft.Extensions.Caching.Distributed;

namespace MyWebApp.Infrastructure
{
    public class SumEndpoint
    {
        public async Task Endpoint(HttpContext context, IDistributedCache cache)
        {
            int count;
            int.TryParse((string?)context.Request.RouteValues["count"], out count);

            string cacheKey = $"sum_{count}";
            string totalString = await cache.GetStringAsync(cacheKey);

            if (totalString == null)
            {
                long total = 0;

                for (int i = 1; i <= count; i++)
                    total += i;

                totalString = $"({DateTime.Now.ToString("HH:mm:ss")}) {total}";

                await cache.SetStringAsync(cacheKey, totalString, new DistributedCacheEntryOptions {
                    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
                });
            }

            await context.Response.WriteAsync($"({DateTime.Now.ToString("HH:mm:ss")}) Total => {count}" + $" Value:\n{totalString}\n");
        }
    }
}

Cache service는 그저 byte배열만을 저장할 수 있으며 제한적이지만 다양한 IDistributedCache구현을 사용할 수 있습니다. 또한 예제는 대부분의 Data caching에 편리한 방법인 문자열을 사용할 수 있는 확장 Method도 같이 구현하고 있는데 아래 표는 그 외 Data caching과 관련한 몇 가지 Method를 더 소개하고 있습니다.

GetString(key) 이 Method는 지정한 Key와 관련하여 Cache된 문자열값을 반환하며 만약 값이 없는 경우는 Null을 반환합니다.
GetStringAsync(key) 이 Method는 지정한 Key와 관련하여 Cache된 문자열값을 Task<string>형식으로 반환하며 만약 앖이 없는 경우는 Null을 반환합니다.
SetString(key, value, option) 이 Method는 지정한 Key를 통해 Cache에 문자열값을 저장합니다. 이 때 Cache는 DistributedCacheEntryOptions객체를 통해 설정될 수 있습니다.
SetStringAsync(key, value, option) 이 Method는 지정한 Key를 통해 Cache에 비동기적으로 문자열값을 저장합니다. 이 때 Cache는 DistributedCacheEntryOptions객체를 통해 설정될 수 있습니다.
Refresh(key) 이 Method는 Key와 관련된 값의 만료주기를 초기화(재설정) 하 Cache로 부터 flushe되지 않도록 합니다.
RefreshAsync(key) 이 Method는 Key와 관련된 값의 만료주기를 비동기적으로 초기화(재설정)하여 Cache로 부터 flushe되지 않도록 합니다.
Remove(key) 이 Method는 Key와 관련된 Cache된 item을 삭제하도록 합니다.
RemoveAsync(key) 이 Method는 Key와 관련된 Cache된 Item을 비동기적으로 삭제하도도록 합니다.

기본적으로 항목은 Cache에 무기한 유지되지만 SetString과 SetStringAsync Method는 DistributedCacheEntryOptions 매개변수의 option을 사용함으로서 만료 정책을 설정할 수 있고 Cache에게 항목이 제거될 시기를 알려줄 수 있습니다. 아래 표는 DistributedCacheEntryOptions class에서 정의된 속성을 나열한 것입니다.

AbsoluteExpiration 이 속성은 완전한 만료날짜를 설정합니다.
AbsoluteExpirationRelativeToNow 이 속성은 상대적인 만료날짜를 설정합니다.
SlidingExpiration 이 속성은 비활성기간을 설정하여 해당 기간동안 Item을 읽지 않은 경우 Cache로 부터 제거될 수 있도록 합니다.

예제에서 endpoint는 GetStringAsync를 사용하여 이전 요청으로부터 Cache된 결과가 존재하는지를 확인하고 있습니다. 만약 Cache 된 값이 없다면 endpoint는 계산을 수행하고 그 결과를 SetStringAsync Method를 사용해 Cache 할 것입니다. 이때 AbsoluteExpirationRelativeToNow속성을 사용하여 2분 후에 item을 Cache에서 제거할 것임을 같이 나타내고 있습니다.

 

이제 Cache Service를 아래와 같은 방법으로 설정합니다.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDistributedMemoryCache(opts => {
    opts.SizeLimit = 200;
});

var app = builder.Build();

예제에서 사용된 AddDistributedMemoryCache Method는 아래 글에서 session data의 저장을 위해 제공하는데 사용된 Method와 동일한 Method이며

 

[.NET/ASP.NET] - ASP.NET Core - 4. Platform 기능 활용하기 - 1

 

ASP.NET Core - 4. Platform 기능 활용하기 - 1

ASP.NET Core는 일반적으로 Web개발에 필요한 여러 기능들을 제공하는 일련의 Service와 Middleware component를 포함하고 있습니다. 이번에는 기본적인 Service와 Middleware에 초점을 맞추어 3가지 정도의 가장

lab.cliel.com

IDistributedCache service의 구현체를 선택하는 데 사용되는 3개의 Method 중 하나에 해당합니다.

AddDistributedMemoryCache 이 Method는 In-Memory에 Cache를 설정합니다.
AddDistributedSqlServerCache 이 Method는 SQL Server서 Cache data저장을 설정하게 되며 Microsoft.Extensions.Caching.SqlServer package를 설치해야 합니다.
AddStackExchangeRedisCache 이 Method는 Redis Cache를 설정하며 Microsoft. Extensions.Caching.Redis Package를 설치해야 합니다.

아래표는 IDistributedCache Service의 구현체로서 In-Memory Cache를 생성하는데 AddDistributedMemoryCache Method를 사용하는 경우 사용되는 속성을 나열한 것입니다. 이 Cache는 MemoryCacheOptions class를 사용해 설정됩니다.

ExpirationScanFrequency 이 속성은 TimeSpan을 설정하는데 사용되며 얼마나 자주 만료된 Item을 Scan할지여부를 결정합니다.
SizeLimit 이 속성은 Cache에 들어갈 최대 Item의 수를 설정합니다. 해당 size에 도달하는 Cache는 Item을 배출합니다.
CompactionPercentage 이 속성은 Cache가 SiteLimit에 도달했을때 줄어들 Cache size의 백분율을 지정합니다.

예제에서는 SizeLimit속성을 통해 Cache를 최대 200으로 제한하였습니다. in-Memory cache를 사용하는 경우에는 할당된 Memory사이에서 적당한 수준에 맞는 Cache의 크기를 찾을 수 있도록 반드시 주의를 기울여야 합니다. 과도한 Cache는 Server Resource의 관리에 어려움을 줄 수 있습니다.

 

Cache의 효과를 확인하기 위해 Project를 실행하여 /sum으로 URL을 요청합니다. Browser를 새로고침하고 나면 하나의 항목에 대해서만 시간이 바뀌어 있는 것을 볼 수 있습니다. 이것은 Cache가 calculation응답에 제공되었기 때문이며 endpoint가 계산을 반복하지 않고 결과를 생성했기 때문입니다.

위와 같이 실행 후 2분여를 대기한뒤 다시 Browser를 새로고침 하게 되면 2개의 항목 모두 시간이 바뀌게 됩니다. 지정한 Cache만료시간이 도달했기 때문이며 endpoint는 응답을 생성하기 위해 계산을 다시 수행할 것입니다.

 

(2) Cache Data의 지속과 공유

 

AddDistributedMemoryCache Method에 의해 생성된 Cache는 이름과는 달리 분산되지 않습니다. Item은 ASP.NET Core Process의 일부로서 Memory에 저장되므로 다중 Server혹은 Container상에서 동작하는 Application에서 Cache Data는 공유될 수 없고 또한 ASP.NET Core가 동작을 멈추는 순간 모든 Cache Data는 손실하게 됩니다.

 

AddDistributedSqlServerCache Method는 Cache Data를 SQL Server에 저장하는 방식인데 이러한 특징 때문에 다중 ASP.NET Core Server사이에 공유가 가능하며 저장된 Data는 지속이 가능해질 수 있습니다. Application의 다른 Data를 저장하는 기존의 Database를 사용하면 하나의 Database에서 Application Data와 함께 Cache Data를 함께 저장할 수 있지만 가급적이면 Cache를 위한 Database를 분리하는 것을 추천하며 이번 예제에서도 CacheDb라는 이름으로 별도의 Database를 생성하고 다루게 될 것입니다. Database생성을 위해서는 Azure Data Studio 혹은 SQL Server Management Studio를 사용할 수 있으며 둘 다 microsoft로부터 무료로 사용할 수 있습니다. 물론 UI Tool의 사용이 싫다면 Terminal과 같은 명령행에서 sqlcmd를 사용해 생성할 수도 있습니다.

sqlcmd는 Visual Studio workload혹은 SQL Server Express의 일부로서 설치될 수 있으나 설치되지 않은 경우라면 아래 주소를 통해서도 sqlcmd를 내려받을 수 있습니다.

sqlcmd Utility - SQL Server | Microsoft Learn

 

sqlcmd Utility - SQL Server

The sqlcmd utility lets you enter Transact-SQL statements, system procedures, and script files using different modes, and uses ODBC to run Transact-SQL batches.

learn.microsoft.com

그럼 sqlcmd를 통해 필요한 명령을 하나씩 알아보도록 하겠습니다. 우선 database에 연결합니다.

sqlcmd -S "(localdb)\MSSQLLocalDB"

이 명령은 system에 MSSQL Local DB가 작동중인 상황에서 가능한 명령으로 원격지의 또 다른 Server에 접속하고자 하는 경우라면

sqlcmd -S 192.168.0.1,1433

과 같이 Server의 Domain이나 IP를 지정하여 접속을 시도할 수 있습니다. 접속이 완료되었다면 '1>'형태의 Prompt가 떨어질 것이며 이 상태에서 아래 명령으로 Cache에 필요한 Database를 생성합니다.

Create Database CacheDb
go

위 명령까지 별다른 오류가 없다면 exit명령으로 연결을 중단합니다. 현재 Terminal을 닫지 말고 그다음 Project folder로 이동하여 Database를 준비하는 global .NET Core Tools를 사용한 새로운 Database에 Table을 생성하는 아래 명령을 실행합니다.

dotnet sql-cache create "Server=[DB SERVER];TrustServerCertificate=True;User ID=[USER ID];Password=[USER PASSWORD];Database=CacheDb" dbo DataCache

이 명령의 매개변수는 Database와 Schema 그리고 Cache Data를 저장할 Table이름을 특정하는 연결문자열입니다. 만약 위 명령을 찾을 수 없다는 오류가 발생한다면 아래 명령으로 관련 도구를 설치해야 합니다.

dotnet tool install --global dotnet-sql-cache

명령의 실행결과로 'Table and index were created successfully.'가 나온다면 성공한 것입니다.

 

● Persistent Cache Service 생성하기

 

이것으로 Database의 준비는 끝났습니다. 이제 Service를 생성하고 Cache Data를 저장할 것입니다. 우선 SQL Server의 Caching지원을 위해 필요한 NuGet Package를 설치해야 합니다. Visual Studio에서 Project의 Manage NuGet Packages를 통해 Microsoft.Extensions.Caching.SqlServer Package를 설치하거나 Project folder에서 아래 명령을 사용합니다.

dotnet add package Microsoft.Extensions.Caching.SqlServer --version 6.0.0

Package를 설치하고 나면 Database Server로의 연결문자열을 생성해야 합니다. 이를 위해 appsettings.json file을 아래와 같이 수정합니다.

{
    "Logging": {
        "LogLevel": {
            "Default": "Information",
            "Microsoft.AspNetCore": "Warning"
        }
    },
    "AllowedHosts": "*",
    "Member": {
        "MemberName": "Kim"
    },
    "ConnectionStrings": {
        "CacheConnection": "Server=192.168.0.1;Database=CacheDb"
    }
}

AddDistributedSqlServerCache Method에 의해 생성된 Cache는 분산이 가능하므로 여러 Application에서 같은 Database사용을 통해 Cache data를 공유할 수 있습니다. 만약 동일한 Application을 여러 Server혹은 Container에 배포한 경우라도  역시 모든 instance는 Cache data를 공유할 수 있습니다. 만약 다른 Application사이에 Cache를 공유하고자 한다면 Application이 예상한 Data유형을 수신받는 데 사용하는 Key에 주의를 기울어야 합니다.

 

ConnectionStrings를 통해 연결문자열을 생성하고 나면 Program.cs를 수정하여 Cache Service가 위에서 정의한 연결 문자열을 통해 SQL Server를 사용하도록 구현합니다.

//builder.Services.AddDistributedMemoryCache(opts => {
//    opts.SizeLimit = 200;
//});

builder.Services.AddDistributedSqlServerCache(opts => {
    opts.ConnectionString = builder.Configuration["ConnectionStrings:CacheConnection"];
    opts.SchemaName = "dbo";
    opts.TableName = "DataCache";
});

IConfiguration Service는 Application의 설정 Data로 부터 연결 문자열 값에 접근하기 위해 사용됩니다. 또한 Cache Service는 AddDistributedSqlServerCache Method를 통해 생성되고 SqlServerCacheOptions class의 Instance를 통해 설정되고 있습니다. 아래 표는 이때 사용 가능한 속성을 나열한 것입니다.

ConnectionString 이 속성은 Server연결을 위한 연결문자열을 특정하는데 대부분의 경우 해당 연결값은 JSON file에 지정하고 IConfguration Service를 통해 접근합니다.
SchemaName 이 속성은 Cache Table을 위한 schema 명을 특정합니다.
TableName 이 속성은 Cache Table의 이름을 특정합니다.
ExpiredItemsDeletionInterval 이 속성은 Item의 만료를 위해 얼마나 주기적으로 Table을 Scan할지를 지정합니다. 기본값은 30분입니다.
DefaultSlidingExpiration 이 속성은 Cache가 만료되기전 얼마나 오랫동안 Cache를 읽지않은 상태로 유지할지를 설정합니다. 기본값은 20분입니다.

예제에서는 Database Table을 사용하기 위해 Cache Middleware를 구성할때 ConnectionString, SchemaName, TableName 속성들을 사용하였습니다. Project를 실행하여 /sum URL을 요청합니다. 생성된 응답에는 이전과 달리 변함이 없지만 Cache 된 응답이 지속되고 심지어 ASP.NET Core가 재시작되어도 유지됨을 알 수 있을 것입니다.

IDistributedCache Serivce를 사용하는 경우에 data값은 모든 요청사이에서 공유됩니다. 만약 각 사용자마다 다른 값을 Cache 하고자 한다면 session middleware를 사용할 수 있습니다. session middleware는 data의 저장을 위해 IDistributedCache Service에 의존하게 되는데 이것은 AddDistributedSqlServerCache Method가 사용될 때 session data가 지속성을 가지고(영구적으로) 저장되며 분산 Application에서 사용될 수 있음을 의미합니다.

 

3. Caching Response

 

개별 Data item을 Caching하는 것에 대한 대안은 전체 응답을 Cache 하는 것이며 응답이 구성되는데 많은 비용이 들어가고 반복적인 과정이 필요한 경우 유용한 접근법이 될 수 있습니다. 응답을 Caching 하기 위해서는 아래와 같이 추가적인 Service와 Middleware가 필요합니다.

using MyWebApp.Infrastructure;
using MyWebApp.Services;
using HTMLResponse = MyWebApp.Services.HTMLResponse;

var builder = WebApplication.CreateBuilder(args);

//builder.Services.AddDistributedMemoryCache(opts => {
//    opts.SizeLimit = 200;
//});

builder.Services.AddDistributedSqlServerCache(opts => {
    opts.ConnectionString = builder.Configuration["ConnectionStrings:CacheConnection"];
    opts.SchemaName = "dbo";
    opts.TableName = "DataCache";
});

builder.Services.AddResponseCaching();
builder.Services.AddSingleton<IMyResponse, HTMLResponse>();

var app = builder.Build();

AddResponseCaching Method는 Cache에서 사용되는 Service를 설정하는데 사용됩니다. Middleware component는 UseResponseCaching을 통해 추가되며 응답의 Cache가 필요한 Middleware나 Endpoint전에 호출되어야 합니다.

 

예제에서는 어떻게 의존성 주입이 작동하는지를 알리기 위해 사용한 IMyResponse Service를 정의하였습니다.

response caching기능은 IDistributedCache Service를 사용하지 않습니다. 응답은 Memory에 Cache 되며 분산될 수 없습니다.

아래 예제에서는 SumEndpoint class를 변경함으로서 data 값을 Caching 하는 대신 응답을 Caching 하도록 요청합니다.

public class SumEndpoint
{
    public async Task Endpoint(HttpContext context, IDistributedCache cache, IMyResponse res, LinkGenerator gen)
    {
        int count;
        int.TryParse((string?)context.Request.RouteValues["count"], out count);

        long total = 0;
        for (int i = 0; i <= count; i++)
        {
            total += i;
        }

        string totalString = $"({DateTime.Now.ToString("HH:mm:ss")} {total})";

        context.Response.Headers["Cache-Control"] = "public, max-age=120";

        string? url = gen.GetPathByRouteValues(context, null, new { count = count });

        await res.Format(context, $"<div>{DateTime.Now.ToString("HH:mm:ss")} Total : {count}, values : {totalString} <br /> <a href={url}>Reload</a>");
    }
}

Endpoint에 대한 이러한 변경사항중 일부는 응답을 Caching 하도록 하지만 다른 부분은 이것이 작동함을 표현하기 위한 것입니다. 응답을 Caching 하기 위한 중요한 구문은 Header에 response를 다음과 같이 추가한 것입니다.

context.Response.Headers["Cache-Control"] = "public, max-age=120";

Cache-Control는 응답 Caching을 제어하는데 사용됩니다. 따라서 Middleware는 public지시자를 포함한 Cache-Control header를 가진 응답만을 Caching할 것입니다. max-age지시자는 응답이 Cache될 수 있는 간격을 특정하는 데 사용되며 second단위로 나타냅니다. 예제에서는 120 값을 사용하고 있으므로 Cache-Control header는 2분동안 Caching이 유지될 것입니다.

 

Browser를 새로고침하거나 URL탐색을 시작하는 순간 Browser는 max-age를 0로 설정한 Cache-Control header를 요청에 포함하게 될 것이며 응답 cache를 우회하여 endpoint에 의해 새로운 응답이 생성되도록 할 것입니다. Cache-Control header 없이 URL을 요청하는 방법은 HTML의 a element를 사용하여 탐색하는 것이며 endpoint가 HTML응답을 생성하기 위한 IMyResponse Service와 anchor element의 href 속성에 사용될 수 있는 URL 생성을 위해 LinkGenerator Service를 사용한 이유입니다.

 

응답 Cache를 확인하기 위해 Project를 실행하여 /sum으로 URL을 요청합니다. 일단 응답이 생성되고 나면 Reload를 Click 하여 같은 URL로 요청을 시도합니다. 그러면 전체 응답이 Cache 됨으로 인해 응답 시간이 어떤 것도 변경되지 않음을 알 수 있을 것입니다.

Cache-Control Header는 Vary Header와 결합하여 Cache된 요청을 세부적으로 제어할 수 있습니다. 아래 2곳의 Link를 통해 각각의 header에 대한 자세한 내용을 살펴볼 수 있습니다.

 

Cache-Control - HTTP | MDN (mozilla.org)

 

Cache-Control - HTTP | MDN

The Cache-Control HTTP header field holds directives (instructions) — in both requests and responses — that control caching in browsers and shared caches (e.g. Proxies, CDNs).

developer.mozilla.org

Vary - HTTP | MDN (mozilla.org)

 

Vary - HTTP | MDN

The Vary HTTP response header describes the parts of the request message aside from the method and URL that influenced the content of the response it occurs in. Most often, this is used to create a cache key when content negotiation is in use.

developer.mozilla.org

ASP.NET Core는 압축된 Data를 처리할 수 있음을 표현한 Browser를 위해 응답을 압축하는 Middleware를 포함하고 있습니다. Middleware는 UseResponseCompression Method에 의해 Pipeline에 추가됩니다. 압축은 압축에 필요한 Server의 Resource와 client에 전송할 content에 필요한 대역폭 사이에 균형이며 성능적 영향을 충분히 알 수 있는 TEST 없이는 적용하는 걸 권장하지 않습니다.

 

728x90