재우니의 블로그

Entity Framework Core 와 ASP.NET Core 를 활용한 cursor paging 사용해 보기

 

 

간략 설명

 

아래 2가지 방식이 있는데, 적은 량이면 1번을 사용하도 상관이 없으며, 2번은 대용량 처리의 페이징을 할 경우 3배 이상의 속도 개선이 이루어진다는 점을 설명한 내용입니다. database 는 sqlite 또는 mysql 을 대상으로 구축 된 사항입니다.

 

  1. 페이지 번호 및 페이지 크기별 페이징 (자주 사용하는 페이징 방법)
  2. cursor identifier 와 페이지 크기를 사용한 페이징 (대용량 경우 페이징 3배 이상 개선)

 

 

번역 시작

 

중요하지 않은 양의 데이터를 반환하는 API를 구축할 때 기본 데이터베이스를 기반으로 설계 결정을 내려야 합니다. 예를 들어, 개발자는 클라이언트가 창 기반 컬렉션을 반환할 수 있도록 API에서 특정 끝점을 구현할 수 있습니다. API 구현자로서 우리는 페이지 기반 페이징과 cursor 기반 페이징 중에서 선택할 수 있습니다.

이 게시물은 두 가지 접근 방식을 모두 구현하는 방법과 cursor 기반 페이징이 성능 중심 개발자가 옵션 중에서 선택할 때 선호해야 하는 이유를 보여줍니다.

 

페이징이란 무엇입니까?

 

데이터 기반 API를 구축하는 개발자에게 페이징은 이해해야 할 중요한 개념이 됩니다. 컬렉션은 bounded  unbounded 의 두 가지 논리적 범주 중 하나에 존재할 수 있습니다 .

 

제한된 컬렉션에는 논리적 한계가 있습니다. 예를 들어, 가족은 가족 단위 내의 자녀 수에 대한 상한선이 있을 수 있습니다. 대부분의 경우 한 가족은 2-4명의 자녀를 갖게 됩니다. 따라서 가족 리소스를 중심으로 API를 구축할 때 단일 응답으로 모든 자식을 반환할 가능성이 높습니다.

 

무제한 컬렉션에는 알려진 제한이 없습니다. 일반적으로 사용자 입력, 시계열 데이터 또는 기타 데이터 수집 메커니즘과 관련된 컬렉션입니다. 예를 들어, 모든 소셜 미디어 플랫폼은 무제한 리소스에서 작동합니다. 예를 들어, Twitter에서 트윗은 전 세계 수백만 명의 개인이 동시에 전송합니다. 이러한 경우 클라이언트에 완전한 데이터 수집을 반환하는 것은 불가능하지만 대신 API는 요청 기준에 따라 조각을 생성합니다.

API와 관련하여 페이징은 상호 작용이 불가능할 수 있는 보다 중요한 데이터 소스에서 클라이언트에게 부분적 결과 집합을 제공하려는 개발자의 시도입니다.

 

올바른 페이징 접근 방식 선택

 

개발자가 페이징 접근 방식을 고려할 때 일반적으로 두 가지 접근 방식이 있습니다.

  1. 페이지 번호 및 페이지 크기별 페이징
  2. cursor identifier 와 페이지 크기를 사용한 페이징

cursor 라는 용어 는 관계형 데이터베이스의 cursor 개념과 혼동되어서는 안 되므로 오버로드됩니다. 아이디어는 데이터베이스의 구현과 유사하지만 관련이 없습니다.

cursor 우리의 다음 페이징 요청에 다음 요소를 검색하는 식별자입니다. 따라서 사용자는 "이 cursor identifier 뒤에 오는 것은 무엇입니까?"라는 질문을 하는 것으로 생각할 수 있습니다.

요청 형식으로 두 가지 접근 방식을 살펴보겠습니다.

 

### Paging By Page and Size
GET https://localhost:5001/pictures/paging?page=100000&size=10
Accept: application/json

### Paging By Cursor and Size
GET https://localhost:5001/pictures/cursor?after=999990&size=10
Accept: application/json

 

두 HTTP 요청은 사용자의 관점에서 매우 유사해 보이지만 근본적으로 다르게 작동합니다.

페이징 요청의 경우 데이터 저장소에서 창을 계산하고 해당 요소를 검색합니다. 여기에서는 Picture특정 페이지에서 결과를 검색하기 위해 의 컬렉션을 페이징합니다.

 

var query = db
    .Pictures
    .OrderBy(x => x.Id)
    .Skip((page - 1) * size)
    .Take(size);

 

이 접근 방식이 작동하는 동안 개발자는 논리적 주의 사항을 알고 있어야 하며, 가장 큰 것은 새 데이터가 도착할 때 창을 변경하는 것입니다. 이 접근 방식의 다른 문제는 기본 데이터베이스에 따라 다릅니다. 논리적 창으로 페이징할 때 데이터베이스는 전체 결과 집합을 스캔하여 페이지를 클라이언트에 반환해야 합니다. 따라서 컬렉션을 더 자세히 살펴보면 성능이 저하되기 시작합니다.

 

cursor 페이징은 어떻게 작동합니까? 결과 집합을 검색하기 위해 논리 창을 만드는 것과 달리 이전 결과를 사용하여 다음 결과를 검색합니다. cursor 는 다음 창을 보장하는 모든 값이 될 수 있습니다. 자동 증분 식별자 또는 타임스탬프와 같은 필드는 cursor 기반 페이징에 이상적입니다. cursor 기반 페이징은 또한 우리의 순서가 결정적이라면 들어오는 데이터가 페이징 결과를 버리는 문제를 겪지 않습니다.

cursor 페이징을 LINQ 쿼리로 구현하는 방법을 살펴보겠습니다.

 

var query = db
    .Pictures
    .OrderBy(x => x.Id)
    // will use the index
    .Where(x => x.Id > after)
    .Take(size);

 

페이징은 우리가 어떻게 접근하든 비용이 많이 드는 작업입니다. Google이 최대 25페이지 이후에 결과 페이지 표시를 중단하는 이유가 궁금하신가요? 글쎄, 가치와 성능의 반환을 감소의 지점이 있습니다.

 

 

성능 비교

cursor 페이징과 비교할 때 페이지 및 크기 페이징의 가장 눈에 띄는 단점 중 하나는 성능에 미치는 영향입니다. ASP.NET Core 웹 응용 프로그램의 두 가지 구현을 살펴보겠습니다.

참고: 이 샘플에는 ASP.NET Core Minimal API를 사용하고 있습니다.

 

using System.Linq;
using CursorPaging;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<Database>();

var app = builder.Build();
await using (var scope = app.Services.CreateAsyncScope())
{
    var db = scope.ServiceProvider.GetService<Database>();
    var logger = scope.ServiceProvider.GetService<ILogger<WebApplication>>();
    var result = await Database.SeedPictures(db);
    logger.LogInformation($"Seed operation returned with code {result}");
}

if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}

app
    .UseDefaultFiles()
    .UseStaticFiles();

app.MapGet("/pictures/paging", async http =>
{
    var page = http.Request.Query.TryGetValue("page", out var pages)
        ? int.Parse(pages.FirstOrDefault() ?? string.Empty)
        : 1;

    var size = http.Request.Query.TryGetValue("size", out var sizes)
        ? int.Parse(sizes.FirstOrDefault() ?? string.Empty)
        : 10;

    await using var db = http.RequestServices.GetRequiredService<Database>();
    var total = await db.Pictures.CountAsync();
    var query = db
        .Pictures
        .OrderBy(x => x.Id)
        .Skip((page - 1) * size)
        .Take(size);

    var logger = http.RequestServices.GetRequiredService<ILogger<Database>>();
    logger.LogInformation($"Using Paging:\n{query.ToQueryString()}");

    var results = await query.ToListAsync();
    await http.Response.WriteAsJsonAsync(new PagingResult
    {
        Page = page,
        Size = size,
        Pictures = results.ToList(),
        TotalCount = total,
        Sql = query.ToQueryString()
    });
});

app.MapGet("/pictures/cursor", async http =>
{
    var after = http.Request.Query.TryGetValue("after", out var afters)
        ? int.Parse(afters.FirstOrDefault() ?? string.Empty)
        : 0;

    var size = http.Request.Query.TryGetValue("size", out var sizes)
        ? int.Parse(sizes.FirstOrDefault() ?? string.Empty)
        : 10;

    await using var db = http.RequestServices.GetRequiredService<Database>();
    var logger = http.RequestServices.GetRequiredService<ILogger<Database>>();

    var total = await db.Pictures.CountAsync();
    var query = db
        .Pictures
        .OrderBy(x => x.Id)
        // will use the index
        .Where(x => x.Id > after)
        .Take(size);

    logger.LogInformation($"Using Cursor:\n{query.ToQueryString()}");

    var results = await query.ToListAsync();

    await http.Response.WriteAsJsonAsync(new CursorResult
    {
        TotalCount = total,
        Pictures = results,
        Cursor = new CursorResult.CursorItems
        {
            After = results.Select(x => (int?) x.Id).LastOrDefault(),
            Before = results.Select(x => (int?) x.Id).FirstOrDefault()
        },
        Sql = query.ToQueryString()
    });
});

app.Run();

 

100만 개 이상의 행이 있는 데이터베이스를 SQLite 데이터베이스에 시드하고 가능한 한 컬렉션으로 페이지를 이동하려고 시도합니다.

 

### Paging By Page and Size
GET https://localhost:5001/pictures/paging?page=100000&size=10
Accept: application/json

### Paging By Cursor and Size
GET https://localhost:5001/pictures/cursor?after=999990&size=10
Accept: application/json

 

이 실험의 결과는 무엇입니까?

페이지/크기 요청의 경우 총 응답 시간은 다음과 같습니다. 225ms

 

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Wed, 23 Jun 2021 12:46:27 GMT
Server: Kestrel
Transfer-Encoding: chunked

> 2021-06-23T084627.200.json

Response code: 200 (OK); Time: 225ms; Content length: 1328 bytes

 

비슷한 상황에서 cursor 가 어떻게 fairs 를 요청하는지 볼까요?

 

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Wed, 23 Jun 2021 12:46:27 GMT
Server: Kestrel
Transfer-Encoding: chunked

> 2021-06-23T084627-1.200.json

Response code: 200 (OK); Time: 52ms; Content length: 1370 bytes

 

우와! 52ms 에 비해 225ms! 이는 동일한 결과를 생성하면서도 상당한 차이입니다. 이 성능 향상은 어디에서 얻을 수 있습니까? 자, EF Core에서 생성된 SQL을 비교하고 비교해보자.

 

-- Using Page Size
.param set @__p_1 10
.param set @__p_0 999990

SELECT "p"."Id", "p"."Created", "p"."Url"
FROM "Pictures" AS "p"
ORDER BY "p"."Id"
LIMIT @__p_1 OFFSET @__p_0

-- Using Cursor 
.param set @__after_0 999990
.param set @__p_1 10

SELECT "p"."Id", "p"."Created", "p"."Url"
FROM "Pictures" AS "p"
WHERE "p"."Id" > @__after_0
ORDER BY "p"."Id"
LIMIT @__p_1

 

첫 번째 쿼리(페이지/크기)가 OFFSET 키워드를 사용하는 것을 볼 수 있습니다 . 이 키워드는 데이터베이스 공급자인 SQLite 에게 LIMIT 키워드 를 호출하기 전에 오프셋을 스캔하도록 지시합니다 . 우리가 상상할 수 있듯이 수백만 행의 테이블을 스캔하는 것은 상대적으로 비용이 많이 들 수 있습니다.

 

cursor 쿼리 Id에 대한 WHERE절의 열을 사용하고 있음을 알 수 있습니다. 인덱스를 사용하면 SQLite가 테이블을 효율적으로 탐색하여 쿼리를 보다 효과적으로 실행할 수 있습니다.

 

 

결론

위의 코드와 예제에서 보았듯이 cursor 기반 페이징은 데이터 세트의 중요성이 커질수록 성능이 향상됩니다. 즉, 페이지와 크기를 사용하는 논리적 페이징은 응용 프로그램에서 그 자리를 차지합니다. 접근 방식은 구현하기가 훨씬 더 간단하고 드물게 변경되는 더 작은 데이터 세트를 사용하면 더 쉬운 선택이 될 수 있습니다. 

 

개발자로서 우리는 시나리오에 가장 적합한 접근 방식을 선택해야 합니다. 그래도 쿼리 성능이 거의 4배 향상 되었으므로 cursor 기반 페이징에 반대하기는 어렵습니다.

 

 

번역 사이트 : https://khalidabuhakmeh.com/cursor-paging-with-entity-framework-core-and-aspnet-core

 

Cursor Paging With Entity Framework Core and ASP.NET Core

Use a cursor-based approach to page EF Core results from a SQL database

khalidabuhakmeh.com