
페이지네이션이란, 많은 데이터를 부분적으로 불러오는 기술을 의미한다.
우리가 일반적으로 데이터를 불러올 때, 만약에 모든 데이터를 불러오게 되면 매우 비효율적일 것이다. 데이터가 1억 개가 있다고 한다면, 사용자의 입장에서는 이 데이터를 모두 보지도 않는데 1억 개의 데이터가 모두 불러와질 때까지 기다려야 한다. 또한, 모든 데이터를 불러오게 되면 데이터를 보내는 데 드는 시간과 비용이 더 올라가게 되며, 이는 둘째치고 클라이언트 단에서 모든 데이터를 저장하기에 메모리가 부족할 것이다.
때문에 UX적 측면이나, 비용 효율화의 측면이나, 기술적인 한계의 측면에서 모든 데이터를 불러오기보다 일부의 데이터만을 불러오는 게 훨씬 효율적이다.
이것이 바로 Pagination의 개념이다.

request로 페이지 번호를 보내면, 총 토탈 페이지와 현재 페이지 데이터를 리턴해주는 방식이다.

Page Based Pagination의 문제점으로는 데이터 신규 삽입 시 중복된 데이터가 발생한다.


위와 같이, 중복된 데이터 (id=3)가 발생하고 있다. 이처럼 페이지 기반 페이지네이션의 경우 신규 데이터가 삽입될 시 중복된 데이터가 발생할 수 있다.

이번에는 위 상태에서 id=3인 아이탬이 삭제된다고 가정하면,

id = 4인 아이탬이 누락된다. 그렇기 때문에 그에 대한 대안으로 나오게 된 것이 커서 기반 페이지네이션이다.
무한 스크롤 방식이 해당 페이지네이션 기법이다.
첫 요청에서는 몇개를 가져올 것인지 개수를 명시 한다. 위 경우는 limit=3으로 쿼리를 한 상태이다.

첫 요청 이후 다음 요청부터는 몇개의 아이탬을 가져올 것인지 limit 개수와 함께 내가 가지고 있는 데이터 중에 마지막 아이탬의 id 값을 서버에 요청한다. 위와 같은 경우에는 id = 3, limit = 3 으로 서버에 요청한 상태이다.
커서 기반 페이지네이션에서 데이터가 새로 삽입된다면?


위와 같이 신규 데이터가 들어온다고 하더라도 데이터의 중복 문제 없이 페이지네이션은 진행되지만, 신규 데이터가 누락 되는것 자체는 피할 수 없다. 하지만 논리적 설계 측면에서도 사용자가 관심있는 데이터는 id > 3인 데이터지 id = 2.5인 데이터는 현재의 관심사가 아닐 것 이다. 이는 새로고침 등을 통해서 신규 데이터를 확보하는게 자연스럽다.
커서 기반 페이지네이션에서 데이터가 삭제된다면?

이때도 마찬가지로 id를 기반으로 하는 커서 기반 페이지네이션에서는 데이터 누락 문제 없이 페이지네이션이 동작한다.
tanstack query의 useInfiniteQuery를 사용하여 무한스크롤을 구현하려고 한다. 전통적인 페이지네이션 기법인 Page Based Pagination은 이곳에서 구현했고, 이번에는 Cursor Based Pagination 을 사용하여 페이지네이션을 구현하려 한다.
스크롤 바닥을 감지해서 다음 페이지 데이터를 가져올 지 판단해야 한다.
이번에도 마찬가지로 react-intersection-observer 라이브러리를 사용할 것이다.
관찰하는 객체(observer) 하나를 ref로 설정한 후 해당하는 객체가 화면에 보이면 특정 코드를 실행시킬 수 있다.
https://github.com/thebuilder/react-intersection-observer#readme
npm install react-intersection-observer
import { useInView } from 'react-intersection-observer';
const LiveAdminPage = () => {
const [ref, inView] = useInView();
useEffect(() => {
if (inView){
console.log("다음 페이지 호출")
}
}, [inView]);
return (
<div className="live_admin_page_container">
<div className="live_admin_item_container empty">
<div ref={ref} className="live_loading_indicator" />
</div>
</div>
);
};
export default LiveAdminPage;
감지할 영역 엘리먼트를 ref로 주고 inView가 true값이 됐을 때 다음 페이지를 호출한다.
페이지네이션을 위해 준비된 API는 아래와 같은 리퀘스트를 요구한다.
[GET] /list
// 한 페이지에 몇개를 가져올 것인지 LIMIT 명시
const LIMIT = 6;
export const useGetLiveList = ({ community_id, user_id }) => {
// 핵심: 첫 fetching시 start_id는 없으므로 pageParam을 undefined로 명시한다.
const getLiveList = async ({ pageParam = undefined }) => {
try {
const response = await client.get(`${BASE_URL}/list`, {
params: {
community_id,
user_id,
start_id: pageParam,
limit: LIMIT,
},
});
return response.data;
} catch (error) {
console.error(error);
}
};
const { data, fetchNextPage, isLoading, hasNextPage } = useInfiniteQuery(
['LIVE_LIST', community_id, user_id],
getLiveList,
{
getNextPageParam: (lastPage) => {
// 마지막으로 가져온 페이지 아이탬 개수가 LIMIT에 도달하지 않으면 다음 페이지를 가져오지 않는다.
if (lastPage.data.result.length < LIMIT) {
return undefined;
}
// 마지막으로 가져온 페이지 아이탬 개수가 LIMIT에 도달했다면 다음 페이지 아이탬이 있으므로 (LIMIT와 같다면 없을 수도 있음)
const len = lastPage.data.result.length;
const lastLiveId = lastPage.data.result[len - 1].live_id;
return lastLiveId;
},
},
);
return { data, fetchNextPage, isLoading, hasNextPage };
};
const LiveAdminPage = () => {
const { userId: user_id, communityId: community_id } = useModalBasicAPIRequirement();
const { data, isLoading, fetchNextPage, hasNextPage } = useGetLiveList({ user_id, community_id });
const queryClient = useQueryClient();
const [ref, inView] = useInView();
const liveListReload = () => {
queryClient.invalidateQueries(['LIVE_LIST', community_id, user_id]);
};
useEffect(() => {
if (inView && data.pages[data.pages.length - 1].data.result.length === 6 && hasNextPage) {
fetchNextPage();
}
}, [inView, data]);
return (
<div className="live_admin_page_container">
<AbsoluteLoadingIndicator isLoading={isLoading} />
<button onClick={liveListReload}>새로고침</button>
<div className="live_admin_page_grid">
{data &&
data?.pages.map((page) => {
const pageData = page.data.result;
return pageData.map((item) => {
return <LiveAdminItem key={item.live_id} liveItemData={item} />;
});
})}
{hasNextPage && (
<div className="live_admin_item_container empty">
<div ref={ref} className="live_loading_indicator" />
</div>
)}
{data && data.pages[0].data.result.length === 0 && (
<Text text="진행중인 라이브가 없어요." size="18px" weight="700" />
)}
</div>
</div>
);
};