이전 포스트에서 OAuth를 통한 인증을 구현하고, back-end와 front-end 레포지토리를 분리했다. 이번에는 페이지네이션을 구현한 것과 관련해 글을 쓰려고 한다.
위 이미지와 같이 페이지를 선택할 수 있고, 각 페이지마다 다른 컨텐츠를 보여줄 수 있도록 하는 것이 페이지네이션이다.
여기서는 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부터 결과를 뽑아올 수 있다는 것이다.
우선 페이지네이션이 필요한 메인 페이지에서는 다음과 같은 변경이 있었다.
// 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'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: [] },
};
}
}
아래와 같은 몇 가지 변경점이 있다.
2, 3번에서 React-query와 Recoil은 처음 사용해보는데, 확실히 비동기 처리에 있어서 redux만 사용할 때보다 훨씬 깔끔하게 처리가 가능한 것 같다.
우선 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에서는 그럴 필요 없이 상태만 간단하게 나타내면 된다. 사용할 때도 useRecoilValue
와 useRecoilState
로 마치 useState
처럼 간편하게 사용할 수 있다.
페이지가 변경될 때 포스트를 가져오는 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는 다시 공부해보는 것이 좋을 것 같다.)
페이지네이션의 페이지 버튼을 구현하는 컴포넌트이다.
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}>«</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}>»</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로 전역 상태 관리와 비동기처리를 모두 진행하는 것보다 훨씬 간단하게 비동기 및 전역 상태 관리를 할 수 있어서 좋았다. 현업 개발자분들이 많이들 도입하고 있는 스택이라는데 그 이유를 알 것 같았다. 앞으로도 사용하면서 계속 공부해야 겠다는 생각이 들었다.
다음에는 마크다운 에디터를 도입한 이야기를 하려고 한다. 원래 후순위로 미루려고 했던 작업이었지만 몇 가지 이유가 있어서 빠르게 구현하게 되었다.