Skip to content

Cursor Based Pagination을 구현해보자

changgunyee edited this page Dec 21, 2019 · 1 revision

Pagination, Offset vs Cursor

  1. 오프셋 - DB의 offset쿼리를 이용하여 페이지 단위로 구분하여 요청/응답하게 구현
  2. 커서 - 클라이언트가 가져간 마지막 row의 순서상 다음 row들을 n개 요청/응답하게 구현

왜 커서 기반이 오프셋 기반보다 좋을까?

각각의 페이지를 요청하는 사이에 데이터의 변화가 있을 경우 중복 데이터 노출

클라이언트가 1 페이지를 보고있는 사이, DB에 item이 추가가 된다면 클라이언트가 2 페이지로 넘겼을 때 1페이지 내용을 또 접할 수 있습니다.

RDBMS에서 오프셋 쿼리의 퍼포먼스 이슈

DB는 offset을 가진 쿼리를 만났을 때 해당 쿼리의 모든 값들을 전부 만들어 지정된 갯수만큼을 순회하여 자르는 방식을 사용합니다. row수가 아주 많으면 offset값이 올라갈수록 쿼리의 퍼포먼스는 더욱 떨어지게 되어있습니다.

커서 기반의 구현

커서 기반으로 페이지네이션을 위해서는 반드시 정렬 기준이 되는 필드 중 하나는 고유값이어야 합니다.(구분을 위해) or절을 이용한 구현과 커스텀 cursor를 이용하여 가격이 14100이상인 상품 목록을 조회하는 쿼리를 만들어 비교해 보겠습니다.

OR절을 사용하여 구현

SELECT id, title, price
    FROM `products`
    WHERE 
        (price > 14100
            OR
    (price = 14100 AND id > 446))
    ORDER BY price ASC, id ASC
    LIMIT 5

위와 같은 방식으로도 구현이 가능합니다.
하지만 클라이언트가 ORDER BY에 걸려있는 모든 필드를 알아야하고, 매 페이지 요청 시마다 이값들을 전부 보내야하기 때문에 불편합니다.

커스텀 cursor를 사용하여 구현

SELECT id, title, price,
        CONCAT(LPAD(price, 10, '0'), LPAD(id, 10, '0')) as `cursor`
    FROM `products`
    HAVING `cursor` < '00000058000000000242'
    ORDER BY price DESC, id DESC
    LIMIT 5;

위 와같이 커스텀으로 커서 attribute를 만든다면 클라이언트에서 필요한 필드를 몰라도 일관성있게 쉽게 처리할 수 있습니다.

Graphql에 cursor based pagination 적용

{
  products(first: 10, after: "beforeLastCursor") {//지난 페이지의 마지막 커서를 기준으로 다음 10개의 데이터를 가져옴
    edges {
      cursor
      node {//실제 하나의 데이터
        id
        title
        price
      }
    }
    pageInfo {
      lastCursor//현재 페이지에서 마지막 커서
      hasNextPage
    }
  }
}

쿼리를 보낼 때 위와 같은 형식의 쿼리를 보내게 됩니다.

  • first : 페이지의 원하는 데이터 개수입니다.
  • after: 해당 cursor이후의 데이터를 가지고 옵니다.
  • edges: 원하는 데이터의 array
  • node: 단일 데이터
  • pageInfo: 현재 페이지의 정보

Apollo-client에서 새로운 페이지 요청

Apollo-client에서는 useQuery와 같은 훅에서 fetchmore함수를 지원합니다
fetchmore함수에서 이전 데이터와 받아온 데이터를 합쳐 local state에 저장하고 query를 업데이트하여 간단하게 매번 새로운 페이지를 받아올 수 있습니다.

const COMMENTS_QUERY = gql`
  query Comments($cursor: String) {
    Comments(first: 10, after: $cursor) {
      edges {
        node {
          author
          text
        }
      }
      pageInfo {
        endCursor
        hasNextPage
      }
    }
  }
`;

function CommentsWithData() {
  const { data: { Comments: comments }, loading, fetchMore } = useQuery(
    COMMENTS_QUERY
  );

  return (
    <Comments
      entries={comments || []}
      onLoadMore={() =>
        fetchMore({
          variables: {
            cursor: comments.pageInfo.endCursor
          },
          updateQuery: (previousResult, { fetchMoreResult }) => {
            const newEdges = fetchMoreResult.comments.edges;
            const pageInfo = fetchMoreResult.comments.pageInfo;

            return newEdges.length
              ? {
                  // Put the new comments at the end of the list and update `pageInfo`
                  // so we have the new `endCursor` and `hasNextPage` values
                  comments: {
                    __typename: previousResult.comments.__typename,
                    edges: [...previousResult.comments.edges, ...newEdges],
                    pageInfo
                  }
                }
              : previousResult;
          }
        })
      }
    />
  );
}
Clone this wiki locally