Pagination이 대체 뭘까?

책의 page를 하나하나씩 넘기다 보면 잠이 옴...

클라이언트는 서버에서 데이터를 받을 때 양이 많을 경우, 전체를 한번에 받지 않고 마치 책의 한 페이지(page)를 넘기는 것처럼 page를 나누어서 전달받게 되는데, 이를 처리하는 작업을 페이지네이션(Pagination)이라고 한다.  Pagination은 보통 아래의 두가지 방법을 사용한다.

 

1. 오프셋 기반(Offset-based) 페이지네이션

           - 데이터베이스에서 page를 끊어 읽을 단위인 offset을 조절하여 가져옴

           - 예) SELECT title FROM 'board' ORDER BY id DESC LIMIT 10, 20

 

 

2. 커서 기반(Cursor-based) 페이지네이션

            - 클라이언트에서 읽은 마지막 row의 다음 n개의 row들을 가져옴

 

 

오프셋 기반의 페이지네이션의 경우, 구현이 매우 쉽고 가장 일반적인 방법이 되겠다. 데이터들을 일정한 갯수씩 잘라 1~10를 1page로, 11~20를 2page로 지정해 해당 page를 다 읽으면 다음 페이지를 가져오는 식이다.

 

이때, 미리 잘라둔 페이지를 가져오는 것이 아니라. 다음 페이지를 요청할 때마다 자르기 때문에 이전의 데이터가 중복될 경우가 있다. 예를 들어1page에서 10개까지 다 읽고 2page를 읽으려고 하기 전에 새로운 데이터가 1개 들어온다면 데이터의 순위가 하나씩 밀려 2page의 첫번째 row는 아까 보았던 1page의 마지막 row 데이터를 중복해서 조회하게 된다. 이것이 오프셋 기반(Offset-based) 페이지네이션의 가장 큰 단점이다.

 

또한 오프셋 기반의 페이지네이션은 page를 계산할 때마다 비효율적인 전체 Table Scan을 해야하기 때문에 offset이 작은 수라면 문제가 되지 않으나 데이터의 수가 많을 경우 효율이 많이 떨어진다. 

 

커서 기반 페이지네이션의 경우, 마지막으로 가져온 데이터를 기준으로 다음의 n개의 데이터를 가져오는데 이렇게 되면 중복되는 데이터를 읽을 일이 없다. 예를 들어 1~10의 1page에서 다음 데이터를 요청할 때, 이 다음 10개의 데이터까지 이동(skip)해서 20~11의 2page를 요청하는 것이 아니라, 마지막으로 읽었던 10번 데이터를 기준으로 10개를 요청하는 것이다. 이렇게 하면 새로운 데이터가 들어와도 중복을 피할 수 있다. (물론 새로 들어온 데이터는 refetch 하기 전까지는 확인할 수 없겠지만...)

 

 

Apollo에서 지원하는 Pagination

 

Pagination overview

A guide to using the Apollo GraphQL Client with React

www.apollographql.com

이전의 RestAPI에서는 페이지네이션 처리를 위해서는 일일이 로직을 짜는 고생을 했었지만 다행스럽게도 Apollo 라이브러리에서는 이러한 Pagination을 도와주는 기능을 하고 있다. Docs에서 읽은 사용 방법은 아래와 같다.

 

여기에서는 Cursor-based Pagination만 설명하도록 하겠다. 설명을 위해서 가장 많이 쓰이는 FlatList 컴포넌트를 이용한 무한 스크롤 방식의 pagination을 구현해 보겠다.

 

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

 

댓글(Comments)의 갯수를 10개씩 끊어서 가져오는 Query이다. 파라미터로 받는 first에는 가져오는 갯수, cursor에는 이전의 마지막 커서(endCursor)를 넣으면 계속해서 다음 page를 가져올 수 있다. GraphQL 서버에 요청하는 Query에서 pageInfo 라는 필드 내에서 마지막 데이터의 Cursor 주소인 endCursor과 다음 페이지의 존재 여부를 파악하는 hasNextPage 필드를 확인할 수 있다.

 

다음으로는 화면을 구현해보았다. 

 

export const CommentScreen = () => {
    const { loading, data, error, refetch, fetchMore } 
    = useQuery<{ comments: CommentList }, QueryCommentsArgs>(COMMENTS_QUERY, { variables: { first: 10 } })
    const [idleState, setIdleState] = useState<boolean>(false);
    return (
            <FlatList
            		style={{flex:1}}
                    showsVerticalScrollIndicator={false}
                    onRefresh={refetch}
                    refreshing={loading}
                    data={data?.faqs.edges}
                    renderItem={({ item, index }) => (
                        <RenderListTile item={item} />
                    )}
                    onEndReached={() => {
                        if (!idleState && !loading && data && data.comments.pageInfo.hasNextPage) {
                            setIdleState(true);
                            console.log('refetchMore...');
                            fetchMore({ variables: { after: data.comments.pageInfo.endCursor } }).then((data) => {
                                setIdleState(false);
                            }).catch(error => {
                                console.log('Fatch Error', error);
                                setIdleState(false);
                            })
                        }
                    }}
                    onEndReachedThreshold={0.5}
                    keyExtractor={(item, index) => (`${item.node.text}`)}
                />
    );
}

 

useQuery를 사용해 해당 쿼리를 서버의 요청하여 각각의 3가지 상태 (요청중: loading, 가져온 데이터: data, 오류: error)를 비구조화 할당으로 작성하였다. 이때, refetch는 새로고침이고, fetchMore은 다음의 데이터를 요청하는 것이다. flatList에서 onRefresh를 사용하여 refetch를 연결하면 터치를 하여 밑으로 내리면 Indicator가 돌아가며 새로고침하는 것을 확인할 수 있다.

 

 

 

onEndReached는 flatlist가 하단 바닥의 도달할 경우 동작을 하게 되는데, 이때 onEndReached가 하단에서 어느 정도까지 도달했을 때 동작하는 지 정하는 것이 바로 onEndReachedThreshold이다. 위에서는 0.5로 되어 있으므로 현재의 화면에서 절반정도에 도달 했을 때 실행된다.

 

 

 fetchMore({
 variables:{ after: data.comments.pageInfo.endCursor
 } })
 .then((data) => {setIdleState(false);}));

 

 

 

 

리엑트 훅(React Hooks)을 사용하여 idleState라는 boolean 타입의 변수를 생성하였다. 이 변수를 사용하여 flatList의 아래에 도착했을 때 fetchMore 함수가 두 번 이상 실행되는 것을 막기 위함이다. fetchMore을 통해 다음의 데이터를 가져오기 위해 파라미터로 after필드에 현재까지 읽은 데이터의 마지막 커서인 endCursor를 넣어준다.

 

 

 

이제 가장 중요한 부분인데 바로 Apollo Client에서 지원하는 relayStylePagination 함수를 사용하여 Cache에 있는 데이터의 커서 기반 페이지네이션을 처리하는 부분이다.

 

 

import { relayStylePagination } from "@apollo/client/utilities";

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        comments: relayStylePagination(),
      },
    },
  },
});

 

 

 

InMemoryCache에서 페이지네이션 처리를 하려는 데이터 필드명과 relayStylePagination() 만 작성하면 Cursor-based의 페이지네이션이 자동으로 이루어진다! 실로 간편하지 않을 수 없다. 추가적인 파라미터에 따라 pagination 처리를 각기 하려면 relayStylePagination()의 파라미터로 relayStylePagination(["filter", "sort"]) 처럼 배열을 삽입하면 원하는 pagination 처리를 할 수 있다.

 

 

+ Recent posts