
내가 사이드 프로젝트에서 구현 했던 페이지네이션 개발 경험을 기록하기 위해 작성한다. Pagination과 Infinite Scroll 기능은 거의 모든 웹에서 사용하기 때문에 매우 중요한 기능이라고 할 수 있다.
따라서, 나와 같은 주니어 개발자들이 꼭 직접 개발 해 봤으면 하는 기능이다. 그리고 프론트 엔드 개발자라고 해서, 또는 백엔드 개발자라고 해서 본인의 분야만을 구현해 보는 것이 아닌 양쪽 모두를 개발하여 어떻게 흘러가는지 파악하는 것을 추천한다.
내가 구현하고 싶은 페이지네이션 컴포넌트는 다음과 같다.
내가 마음에 들어하는 codepen.io의 슬라이더 UI를 구현할 것을 목표로 하였다.

먼저, UI가 어떠한 특징이 있는지 살펴보자.
hover할 경우 opacity가 1이 됨navigation 화살표가 양쪽에 위치이러한 특징들 중, 나의 페이지네이션 컴포넌트에는 특정 페이지를 이동(Jump)할 수 있는 기능과, 반응형 컴포넌트처럼 window 크기에 따라 한 페이지에 들어가는 box의 개수가 동적으로 바뀌는 기능을 추가하여 차별점을 두고 싶었다.
일단 페이지네이션을 구현하는 것은 페이지네이션 뿐만 아니라 무한 스크롤도 사용할 수 있다. 그래서 둘 중 어느 것을 사용해야 하는지 선택을 해야 했다.
일단 codepen의 슬라이더에서는 어떤 것을 사용해도 상관 없을 것 같아 보였다.
페이지네이션을 사용한다면, 이전, 현재, 다음 페이지의 데이터를 요청하여 배치하면 될 것이다.
무한 스크롤을 사용한다면, 수평 스크롤 박스의 scroll 위치를 감지하고 계속해서 데이터를 요청하면 될 것이다.
이 두가지 방법 중에 하나를 선택해야 했다.
무한 스크롤은 바로 옆에있는 페이지를 가져오는데 적합한 기능이기 때문에, 특정 페이지를 이동하는 기능은 무한 스크롤보다 페이지네이션이 더 적합하다고 생각했다.
슬라이더는 총 3부분으로 나뉘어져 있기 때문에 서버에 데이터를 요청하는 방식을 다음과 같은 방법이 있었다.
페이지 당 4개의 Vote Box 컴포넌트가 들어간다고 가정하자. (실제로는 window 크기에 따라 페이지 당 컴포넌트의 개수가 달라짐)
데이터를 12개 요청을 한 후, 클라이언트에서 3등분 하는 방법
=> 한 번의 요청을 필요로 하지만 클라이언트의 작업이 매우 복잡해진다.
(한 페이지의 박스 개수 * 3으로 요청 후, 응답 받은 데이터를 3등분하여 처리해야 하는 복잡한 로직 또한 왼쪽 또는 오른쪽 페이지가 없을 시 3등분 하는 로직이 굉장히 더러워 짐)
이전, 현재, 다음 페이지의 데이터를 각각 요청하는 방법
=> 클라이언트 코드는 단순해지지만 3번을 요청해야 하는 단점이 있다.
나는 첫 번째 방법으로 해 본 결과, 클라이언트 코드 로직이 더럽고 읽기도 어려워 해당 방법을 철회하였다.
결국, 두 번째 방법을 사용하게 되었다.
UI에 대한 전체적인 구조는 깃허브에 있으므로, 여기에서는 큰 특징들만 간략하게 살펴보도록 하겠다.
페이지는 각각 <VoteCardList />로 이전, 현재, 다음 페이지로 총 3개의 컴포넌트를 구성하였다.
export const VoteListSlider = () => {
...
return (
...
<div ref={sliderRef} className={styles.sliderContent}>
{preVote?.length !== 0 && (
<VoteCardList
voteList={preVote}
onClick={leftArrowHandler}
className={`${styles.voteList} ${styles.inActiveList} ${styles.prevList}`}
/>
)}
<VoteCardList voteList={curVote} className={styles.voteList} />
{nxtVote?.length !== 0 && (
<VoteCardList
voteList={nxtVote}
onClick={rightArrowHandler}
className={`${styles.voteList} ${styles.inActiveList} ${styles.nextList}`}
/>
)}
</div>
</div>
);
};
왼쪽과 오른쪽 페이지 이동 버튼이 존재하고, 첫 페이지의 이전 페이지와 마지막 페이지의 다음 페이지는 이동 버튼 컴포넌트가 보이지 않게 설정하였다.
const ARROW_SIZE = 30;
export const PaginationSlider = ({
page = 1,
setPage,
isShowLeftArrow = false,
isShowRightArrow = false,
leftSlideHandler,
rightSlideHandler,
}: Partial<Props>) => {
return (
<nav className={styles.pageNavBox}>
{isShowLeftArrow && (
<div className={`${styles.sliderBtn} ${styles.sliderLeftBtn}`}>
<IoIosArrowBack
color="white"
size={ARROW_SIZE}
onClick={leftSlideHandler}
className={styles.arrowIcon}
/>
<GoToPageButton page={page - 1} setPage={setPage} />
</div>
)}
{isShowRightArrow && (
<div className={`${styles.sliderBtn} ${styles.sliderRightBtn}`}>
<IoIosArrowForward
color="white"
size={ARROW_SIZE}
onClick={rightSlideHandler}
className={styles.arrowIcon}
/>
<GoToPageButton page={page + 1} setPage={setPage} />
</div>
)}
</nav>
);
};
위의 <PaginationSlider />에서 사용되는 <GoToPageButton /> 컴포넌트가 보이는데, 이것은 특정 페이지를 이동하기 위한 UI이다.
export const GoToPageButton = ({ page, setPage }: Props) => {
const [inputPage, setInputPage] = useState<number>(page);
useEffect(() => {
setInputPage(page);
}, [page]);
const onChangeInputPage = (e: ChangeEvent<HTMLInputElement>) => {
setInputPage(parseInt(e.target.value));
};
const onClickSetPage = () => {
setPage && setPage(inputPage);
};
return (
<div className={styles.movePageContainer} >
<input
type="number"
title="페이지 이동"
value={inputPage}
onChange={onChangeInputPage}
className={styles.movePageInput}
/>
<Button className={styles.movePageButton} onClick={onClickSetPage}>
이동
</Button>
</div>
);
};
페이지네이션으로 개발을 완료 했지만 여러가지의 한계점을 발견하였다.
페이지를 넘기면서 중복되는 데이터를 계속 호출한다는 것은 여전히 성능에 영향을 주며, 개발자로선 넘어갈 수 없는 사항이였다.
페이지를 전환할 때, 슬라이드 애니메이션을 구현을 시도하였지만 결국엔 실패하였고 쉽게 해결할 수 있는 문제가 아니였다.
가장 구현에 근접했던 방법
컴포넌트를 리렌더링 할 때, 애초에 미리
transformX(100%)이런 식으로 위치 시킨 후, 1초에 걸쳐 `transformX(0) 즉, 제자리로 돌아오게 하는 방법을 사용하였다.
그러나, 슬라이드 애니메이션처럼 보이는 꼼수에 성공했지만, 렌더링 하는 과정에서 다음과 같은 문제가 있었다.
렌더링 문제
<VoteCardList />X의 위치를 100% -> 0%로 CSS를 작성했지만, 렌더링 과정에서 X 위치가 0% -> 100% -> 0%로 끊겨서 보이는 문제가 발생
css가 끊겨서 보이는 문제 말고도, <VoteCardList />를 페이지 전활할 때 마다 리렌더링을 하여 다시 그리는 것도 크나큰 성능 문제였다.
따라서 다시 무한 스크롤을 잡게 되었다.
무한 스크롤로 구현을 했을 시, 이전과 다음 페이지 전환은 당연히 구현이 가능하다고 생각하였고, 두 가지 문제를 해결해야 했다.
내 요구사항은 한 화면에 이전, 현재, 다음 페이지가 보여야 하기 때문에 이를 달성하기 위한 아이디어는 다음과 같다.
<VoteCardList />의 width: 70%정도로 조정한다.사실 처음부터 무한 스크롤에 접근하지 않았던 이유는 특정 페이지 이동 때문이였다.
다음과 같은 시행착오가 있었지만 결국, 구현을 할 수 있게 되었다.
특정 페이지를 이동하기 위해서는 { pageParam } 값을 특정 값(페이지 이동)으로 변경할 수 있어야 한다.
useInfiniteQuery({
...
queryFn: ({ pageParam }) => fetchUser(pageParam),
...
})
그러나 pageParam의 값을 바꾸는 기능을 찾아볼 수 없었다. (GPT, 구글링) 이 것 말고도 여러가지 삽질을 해봤는데, 지금은 기억이 나지 않는다..
무한 스크롤에 특정 페이지를 도입하기 위해 다음을 생각해야 했다.
pageParam을 바꾸는 방법을 찾지 못했기 때문에, 다른 방법으로라도 page를 바꾸는 방법을 찾아야 한다.
=> { pageParams }를 바꿀 수 없다면 initialPageParam을 이용해야 한다.
특정 페이지를 이동한 후, 이전 페이지의 데이터가 잘 못 표시될 수 있다.
예를 들어, 1p -> 10p 일 때, 10p 상태에서 이전페이지가 9p가 아닌 1p로 보여질 수 있다.
=> 기존의 쿼리를 제거하고 다시 생성해야 한다.
종합적으로, "page를 이동할 때, 새롭게 쿼리를 재생성 한다."가 결론이다.
따라서, 핵심적인 아이디어를 반영한 결과 코드는 다음과 같다. 이 예제는 나의 프로젝트에서 사용한 것도 아닌 이해를 위한 예제의 일부 코드이다.
const useFetchVoteList = (initPage: number) => {
return useInfiniteQuery({
queryKey: ['voteList'],
queryFn: ({ pageParam }) => fetchUser(pageParam),
initialPageParam: initPage,
getNextPageParam: (lastPage) => {
return lastPage.id + 1
}
});
};
export const App = () => {
const [page, setPage] = useState<number>(1);
const {..., refetch } = useFetchVoteList(page);
const goToPage = async () => {
queryClient.removeQueries({ queryKey: ["voteList"] });
await refetch();
};
return (
<div>
...
<input
type="number"
value={page}
onChange={(e) => {
setPage(Number(e.target.value));
}}
/>
<button onClick={goToPage}>Jump</button>
...
</div>
}
코드를 대충 보자면, page라는 상태를 관리하고, 특정 페이지 이동 버튼을 누르면 page의 상태를 변경하고, 쿼리를 refetch하여 initialPageParam을 매개 변수로 만든 useInfiniteQuery를 다시 실행시키는 방법이다.
다시 생각해보면, 되게 별 것 아니고 단순하게 해결될 문제 였는데, 당시에는 굉장히 오랜 고민을 하고 삽질을 많이 했던 것 같다. 나의 방법이 정답이 아닐지라도 구현에 성공하여 도파민을 얻고 간다.