페이지네이션
이란, 많은 데이터를 부분적으로 불러오는 기술을 의미한다.
우리가 일반적으로 데이터를 불러올 때, 만약에 모든 데이터를 불러오게 되면 매우 비효율적일 것이다. 데이터가 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>
);
};