FrontEnd

Pagination 알아보기

SambaLim 2023. 4. 2. 20:37

현재 가게에서 사용하는 메뉴(ex. 떡볶이, 돈까스 등)가 페이지네이션이 안되어있어 6000개가 넘는 가게들의 경우 메뉴와 연관있는 서비스들이 느리게 동작한다는 것을 확인하였습니다. Image CDN을 통한 처리, 코드 스플리팅을 통해서도 한계가 있어 서비스내에서 메뉴들을 페이지네이션하려고 합니다.

페이지네이션의 대표적인 두 방법으로 Offset Pagination, Cursor Pagination이 있습니다. 이 둘을 비교해보고 어떤 것을 도입해볼지 생각해봅니다.

Offset Pagination

GET /items?offset=10&limit=20

offset과 limit 라는 예약어를 사용해서 데이터를 일정한 크기로 분할하고, 페이지당 항목 수와 페이지 수를 결정하는데 사용합니다.

offset은 앞에서부터 건너 뛸 값의 갯수를 뜻하고 limit 는 몇 개의 값을 출력할 것인지를 정합니다.

SQL

만약 SQL에서 Offset pagination을 하고자하면 어떻게 조회할지 SQL을 예시로 보도록 하겠습니다.

SELECT *
FROM POST
LIMIT {출력할 값의 갯수}
OFFSET (({현재 페이지} - 1) * {출력할 값의 갯수});

이와 같이 쉬운 방법으로 페이지 단위의 데이터를 조회할 수 있습니다.

Offset Pagination의 단점

Offset Pagination은 위의 SQL과 같이 간단하게 조회할 수 있다는 장점이 있지만, 데이터 규모가 크거나 데이터 수정이 빈번한 경우 단점을 가지고 있습니다.

데이터 변경에 대한 민감성

Offset Pagination은 수정, 생성, 삭제가 반복되는 서비스에서 효율적으로 동작하지 않습니다. offset과 limit를 사용하여 시작위치와 페이지당 항목 수를 지정하기 때문에 시작위치 이전에 해당하는 데이터가 변경되면 이전 페이지와 다른 내용이 화면에 표시될 수 있습니다.

따라서 화면에서는 데이터의 변화가 있을 경우 해당 데이터의 id 값으로 지금까지 불려온 데이터 전체를 조회하여 수정해주거나 서버에 지금까지 조회했던 데이터를 다시 요청해야합니다.

대규모 데이터 세트에서의 성능 문제

Offset Pagination에는 데이터의 위치를 특정할 수 있는 값이 없이 일정한 범위의 데이터만 가져오기 때문에, 데이터 베이스에서 많은 수의 행을 생략하고 조회를 하여 성능상 이슈가 있을 수 있습니다.

예를들어 offset 값이 1억을 넘어가는 경우, 데이터베이스는 offset + limit의 행을 처리하고 앞의 offset인 1억개를 버리도록합니다. 따라서 응답의 속도가 늦어지게 됩니다.

데이터 분할 문제

Offset Pagination은 일정한 크기(limit)로 데이터를 분할하기 때문에 데이터의 전체 크기에 비해 분할한 크기가 너무 작거나 큰 경우 조회에 대한 결과가 일관성 없이 제공됩니다. 따라서 일관성을 유지하기 위해 데이터의 분할 방법을 조정해야할 수 있습니다.

Cursor Pagination

GET /items?after=abc123&limit=20

Offset Pagination이 offset과 limit를 가지고 우리가 원하는 데이터가 몇 번째에 있는지 집중했다면, Cursor Pagination은 우리가 원하는 데이터가 어떤(after)데이터 다음에 있다는 것에 집중합니다.

SQL

SQL에서 Cursor Pagination을 어떻게 구현하는지 알아보도록 합니다.

SELECT *
FROM POST
WHERE some_column > {이전 페이지의 마지막 값}
ORDER BY some_column ASC
LIMIT {출력할 값의 갯수}

이전 페이지의 마지막 값으로 some_column 을 정하고 정렬한 후, 페이지의 크기를 제한하여 제공합니다.

Cursor Pagination을 사용하므로 해결되는 Offset Pagination의 단점

Offset Pagination의 단점인 데이터의 일관성, 대규모 데이터에 대한 성능문제등을 Cursor Pagination으로 해결할 수 있습니다.

데이터 변경에 대한 민감성에 대한 해결

Cursor Pagination은 커서(after)를 사용하여 다음 페이지를 가져오기 때문에 데이터의 변경에 상대적으로 덜 민감합니다. 데이터가 변경되면 커서가 변경된 데이터의 위치를 가리키도록 업데이트되기 때문입니다.

대규모 데이터 세트에서의 성능 문제 해결

Offset Pagination에서 offset만큼의 데이터를 생략하는 것을 Cursor Pagination에서는 이전 페이지의 마지막 항목에 대한 정보를 사용하여 다음 페이지를 가져오기 때문에 대규모 데이터 세트에서의 성능 문제를 해결할 수 있습니다.

프론트엔드에서 대응하기

데이터 집합의 크기, 예상 페이지 수등을 고려하여 적절한 Pagination을 선택해야 합니다. 만약 페이지 수가 적고 값의 수 또한 적다면 Offset Pagination을 선택할 수 있을겁니다. 하지만 데이터가 늘어남에 따라 각 페이지에 대해 페이지에 대한 offset값을 계산하는 로직이 추가되므로 성능에 영향을 미칠 수 있습니다. 그리고 응답에 대한 속도가 늦어져 이에 대한 처리도 필요합니다.

Cursor Pagination을 선택한다면 이전 페이지 마지막 값의 id 를 전달하기 때문에 이를 기록하는 것이 필요합니다. 하지만 페이지에 대한 계산이 줄어들기 때문에 복잡성 감소, 성능 향상을 기대할 수 있습니다. 사용자가 스크롤을 통해 자연스럽게 데이터가 로드되는 것을 구현할 경우 Cursor Pagination이 더 적합합니다.

useSWRInfinite

Cursor Pagination을 구현해야하는 서비스에서는 SWR을 사용하고 있기 때문에 SWR을 사용하여 이를 구현할 수 있게하는 useSWRInfinite를 이해하려합니다.

import useSWRInfinite from 'swr/infinite'
 
// ...
const { data, error, isLoading, isValidating, mutate, size, setSize } = useSWRInfinite(
  getKey, fetcher?, options?
)

useSWR과 유사하게, 이 새로운 Hook은 요청 키, fetcher 함수, 옵션을 반환하는 함수를 받습니다. useSWR 이 반환하는 모든 값을 반환하며, 추가로 두 개의 값을 포함합니다. (size , setSize)

만약 이를 Component내에서 사용한다면 아래의 예시코드와 같이 사용할 수 있습니다. (Cursor Pagination을 사용한 코드입니다.)

import { useState } from "react";
import useSWRInfinite from "swr/infinite";

function ExampleComponent() {
  const fetcher = (url) => fetch(url).then((res) => res.json());
  
  const getKey = (pageIndex, previousPageData) => {
    // 처음 요청할 때
    if (pageIndex === 0) {
      return `/api/data?cursor=null`;
    }

    // 더 이상 가져올 데이터가 없을 때
    if (!previousPageData) {
      return null;
    }

    // 다음 페이지 데이터 가져오기
    return `/api/data?cursor=${previousPageData.nextCursor}`;
  };

  const { data, error, size, setSize } = useSWRInfinite(getKey, fetcher);

  if (error) return <div>에러 발생!</div>;
  if (!data) return <div>데이터를 불러오는 중...</div>;

  const dataList = data.flatMap((pageData) => pageData.items);
  
  const handleLoadMore = () => {
    if (data[size - 1].nextCursor !== null) {
      setSize(size + 1);
    }
  };

  return (
    <div>
      {dataList.map((item) => (
        <div key={item.id}>{item.name}</div>
      ))}
      {data[size - 1].nextCursor !== null && (
        <button onClick={handleLoadMore}>더 불러오기</button>
      )}
    </div>
  );
}

마무리

메뉴의 갯수가 엄청나게 많은 가게들이 등장하면서 서비스의 성능 향상을 위해 페이지네이션을 도입하기로 했습니다. 따라서 페이지네이션을 도입하기 앞서 대표적인 두 방법 Offset, Cursor Pagination을 알아보고 프론트엔드 개발자 입장에서는 어떤 점을 고려해야하는지 알아보았습니다. 또한 현재 사용하고 있는 SWR에서 Cursor Pagination을 어떻게 구현할 수 있을지 코드를 통해 알아보았습니다.

이를 통해 기획자, BE 개발자와 각 Pagination의 특징을 공유하고 이를 바탕으로 각 서비스에 맞는 Pagination을 선택할 수 있도록 해야겠습니다.

'FrontEnd' 카테고리의 다른 글

의식의 흐름에 따라 보는 <img> preload  (0) 2023.09.14
Next.js 도입 전 Rendering Patterns 알아보기  (1) 2022.11.27
Deno Fresh 알아보기  (0) 2022.11.20
자바스크립트 번들러 비교  (0) 2020.07.18
Webpack Core Concepts  (0) 2020.02.18