Next.js 블로그 만들기 (4) - 페이지네이션

shorecrab·2022년 6월 23일
1

이전 포스트에서 OAuth를 통한 인증을 구현하고, back-end와 front-end 레포지토리를 분리했다. 이번에는 페이지네이션을 구현한 것과 관련해 글을 쓰려고 한다.

페이지네이션

위 이미지와 같이 페이지를 선택할 수 있고, 각 페이지마다 다른 컨텐츠를 보여줄 수 있도록 하는 것이 페이지네이션이다.

API

여기서는 SQLite의 LIMIT과 OFFSET을 통해서 쉽게 구현할 수 있었다.

// blog_server
// server/routes/posts/index.ts

const { limit, offset } = req.query;

// Open DB
const db = await open({
   filename: 'db/blog.db',
   driver: sqlite3.Database,
});

// get paginated posts or all posts
const rows =
   limit && offset
      ? await db.all(`SELECT * FROM posts 
                      WHERE published = true
                      LIMIT ${limit}
                      OFFSET ${offset}`)
      : await db.all('SELECT * FROM posts');

await db.close();

기존의 SELECT문에 단순히 LIMIT와 OFFSET을 더함으로써 API 구현은 쉽게 끝낼 수 있었다. LIMIT은 SELECT문을 실행한 결과에서 몇 개의 결과를 반환할 것인지를 정하는 구문이고, OFFSET은 SELECT문을 실행한 결과에서 몇 번째 Row부터 가져올 것인지 정하는 구문이다. OFFSET이 좋은 점은 중간중간 삭제로 인해 id가 연속되지 않는 경우에도 OFFSET으로 정한 Row부터 결과를 뽑아올 수 있다는 것이다.

Main Page

우선 페이지네이션이 필요한 메인 페이지에서는 다음과 같은 변경이 있었다.

// blog
// src/pages/posts/index.tsx
const Home = ({ initialPosts }: HomeProps) => {
  const page = useRecoilValue(pageState);
  const { isLoading, data, error } = usePosts(page, initialPosts);
  return (
    <>
      <Head>
        <title>Shorecrab&apos;s blog</title>
        <meta name="description" content="shorecrab's dev blog" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <Layout>
        <h2 className="mb-3">최근 포스팅</h2>
        <>
          {!isLoading && !error && data?.posts
            ? data.posts.map(post => <PostBox {...post} key={+post.id} />)
            : []}
        </>
        <Pagination paginationItemCount={5} />
      </Layout>
    </>
  );
};

export default Home;

export async function getServerSideProps(context: GetServerSidePropsContext) {
  try {
    const posts = await caxios.get<Post[]>('/posts?limit=5&offset=0');

    return {
      props: { initialPosts: posts.data },
    };
  } catch (err: any) {
    console.error(err.data);
    return {
      props: { posts: [] },
    };
  }
}

아래와 같은 몇 가지 변경점이 있다.

  1. getServerSideProps에서 데이터를 가져올 때 query string으로 limit과 offset을 넘겨준다.
  2. Recoil을 사용해서 현재 페이지를 저장한다.
  3. React-query를 사용해서 (Recoil로 저장한) page가 변할 때 post를 가져온다.

2, 3번에서 React-query와 Recoil은 처음 사용해보는데, 확실히 비동기 처리에 있어서 redux만 사용할 때보다 훨씬 깔끔하게 처리가 가능한 것 같다.

Recoil

우선 page 상태를 관리하는 Recoil 코드를 보자.

// blog
// src/atoms/page/index.ts
import { atom } from 'recoil';

const pageState = atom<number>({ key: 'pageState', default: 1 });

export { pageState };

겨우 이게 끝이다...

redux였으면 reducer, action을 하나하나 작성해야 했겠지만, recoil에서는 그럴 필요 없이 상태만 간단하게 나타내면 된다. 사용할 때도 useRecoilValueuseRecoilState로 마치 useState처럼 간편하게 사용할 수 있다.

React-query

페이지가 변경될 때 포스트를 가져오는 usePosts 훅은 내부적으로 React-query를 사용하고 있다.

import { useQuery } from 'react-query';
import caxios from 'src/lib/axios';
import { Post } from 'types/post';

const usePosts = (page: number, initialPosts: Post[]) => {
  const { isLoading, error, data } = useQuery<
    unknown,
    unknown,
    { posts: Post[] }
  >(
    ['posts', page],
    async () => {
      const result = await caxios.get<Post[]>(
        `/posts?limit=5&offset=${(page - 1) * 5}`
      );
      if (result.status >= 400) throw Error('error');
      return result.data;
    },
    { initialData: initialPosts, staleTime: 0 }
  );

  return { isLoading, error, data };
};

export { usePosts };

useQuery는 query key가 변경될 때 비동기 요청을 보내게 된다. 비동기 요청을 보내기 전에 query key가 동일하다면 캐시에 저장된 값을 사용하게 된다. 그래서 query key에 page 값을 넣어서 페이지가 변경될 때 비동기 요청을 통해 포스트를 가져올 수 있도록 했다.

여기서는 initialData를 사용했는데, Next.js에서 getServerSideProps로 넣어준 값을 사용하기 위해서이다. 그리고 staleTime을 0으로 두어서 값을 캐싱하지 않고 매 요청마다 새롭게 받아올 수 있도록 했다.
(이 부분에서 의문점이 있다. initialData를 설정해두면 query key가 변경되어도 새롭게 데이터를 가져오지 않는 문제가 있었다. 그래서 staleTime을 0으로 변경하니까 그제서야 데이터를 가져오게 되었다. React-query는 다시 공부해보는 것이 좋을 것 같다.)

Pagination Component

페이지네이션의 페이지 버튼을 구현하는 컴포넌트이다.

const Pagination = ({ paginationItemCount }: PaginationProps) => {
  const [selected, setSelected] = useRecoilState(pageState);
  const [basePage, setBasePage] = useState<number>(1);
  const { isLoading, data, error } = useEndPage();

  const endPage = !isLoading && !error && data?.endPage ? data.endPage : 10;
  const pageItemArray = useMemo(() => {
    return Array.from({ length: paginationItemCount }, (_, i) => i + 1);
  }, [paginationItemCount]);

  const changePage = (page: number) => {
    setSelected(page);
    if (page < 3) setBasePage(1);
    else if (page > endPage - 2) setBasePage(endPage - 4 > 0 ? endPage - 4 : 1);
    else setBasePage(page - 2);
  };
  const changePageToStart = () => {
    setSelected(1);
    setBasePage(1);
  };
  const changePageToEnd = () => {
    setSelected(endPage);
    setBasePage(endPage - 4 > 0 ? endPage - 4 : 1);
  };

  return (
    <nav>
      <ul className="pagination">
        <PaginationItem onClick={changePageToStart}>&laquo;</PaginationItem>
        {pageItemArray.map((val, idx, arr) => {
          return (
            val <= endPage && (
              <PaginationItem
                key={idx}
                selected={selected}
                page={basePage + idx}
                onClick={changePage}
              >
                {basePage + idx + ''}
              </PaginationItem>
            )
          );
        })}
        <PaginationItem onClick={changePageToEnd}>&raquo;</PaginationItem>
      </ul>
    </nav>
  );
};

페이지네이션 컴포넌트는 5개의 페이지 이동 버튼과 동시에 맨앞, 맨뒤로 갈 수 있는 버튼을 보여준다. 각각의 버튼은 PaginationItem 컴포넌트로 구현이 되어 있다. 각 버튼에서 클릭 시 실행할 콜백을 받아서 실행한다. 각 버튼이 표시할 페이지는 현재 선택된 페이지에 따라서 다르게 보여지도록 했다. 예를 들어, 페이지 번호가 3보다 작거나 같으면 1부터 보여주고, 4 이상이면 (현재 선택된 페이지 - 2)부터 보여줄 것이다.

페이지의 개수는 prop에 따라 변할 수 있도록 했다. 그리고 배열에 원소를 채워넣는 작업을 매 렌더링마다 반복하지 않기 위해서 useMemo()를 사용했다. 여기서도 주목할만한 것이 Recoil과 React-query를 사용했다는 점이다. Recoil은 메인 페이지에서 사용한 그 pageState를 변경하기 위해서 사용하였고, React-query는 전체 페이지의 개수를 가져오기 위해서 사용했다.

이렇게 해서 페이지네이션을 구현할 수 있었다. 실제 결과는 아래와 같다.

마무리

페이지네이션 또는 무한 스크롤은 어떤 서비스를 만들던지와 상관없이 항상 한번은 구현하는 것 같다. 따라서 back-end / front-end에서 어떤 것이 필요한지 모두 알고 있는 것이 좋을 것 같다.

또 이번에 React-query와 Recoil 조합을 처음 사용해보는데 redux로 전역 상태 관리와 비동기처리를 모두 진행하는 것보다 훨씬 간단하게 비동기 및 전역 상태 관리를 할 수 있어서 좋았다. 현업 개발자분들이 많이들 도입하고 있는 스택이라는데 그 이유를 알 것 같았다. 앞으로도 사용하면서 계속 공부해야 겠다는 생각이 들었다.

다음에는 마크다운 에디터를 도입한 이야기를 하려고 한다. 원래 후순위로 미루려고 했던 작업이었지만 몇 가지 이유가 있어서 빠르게 구현하게 되었다.

profile
주니어 프론트엔드 개발자!

0개의 댓글