이번 캡스톤2 프로젝트를 진행하면서, 머신러닝 학습을 위해 서울시 공공데이터를 많이 끌어와 전처리를 하고나니 데이터의 양이 2000개 정도 육박했다.
프로젝트 컨셉에 따라 rest API 중 GET을 가장 많이 사용하게 되는데,
2가지 이유로 이번 기회에 무한스크롤을 구현하여 효율적으로 데이터를 불러오고, 유저들이 더 편할 수 있게끔 하는 게 좋다고 생각했다!
참고로 나는 프로젝트 당시 tanstack query 가장 최신 버전인 5.8.3으로 설치했는데, 공식문서에서 v4와 v5의 사용법이 조금씩 다르기 때문에 이는 본인이 설치한 버전에 따라 확인해 볼 필요가 있다!
참고문서: tanstack query InfiniteQuery v5버전
나는 무한스크롤을 구현하기 전, 원리에 대해 이해하고 나서 코드를 작성하기 시작했다.
우선 콘텐츠 영역에 몇 개의 콘텐츠를 보여줄 것인지 정하는 게 중요하다.
백엔드 파트에서 보내줄 응답 데이터를 가공할 때, 한 페이지 당 몇 개의 콘텐츠까지 보여줄 것인지 코드를 작성해야하기 때문이다.
그리고 첫 페이지 API를 불러와 본인의 콘텐츠 영역에 나타내주면 되고, 개수에 따라 자연스레 스크롤이 생성될 것이다.
해당 부분은 보여주는 데이터의 개수가 끝났을 때, 다음 페이지에 대한 API를 호출하기 위한 호출 영역이다.
보통은 콘텐츠 영역의 스크롤이 끝날 때 쯔음 해당 영역이 나타나게 된다.
참고로 받아오는 응답 데이터 형식에 따라 다르겠지만, nextPage: true
이런식의 응답값이 있다고 하자.
이는 아직 호출해야 하는 API의 페이지가 남았다는 것을 의미하여 true일 땐 다음 페이지 호출 영역을 보여주고 false라면 보여주지 않는 형식으로 진행한다.
useInfiniteQuery는 더 많은 데이터를 로드하거나 무한 스크롤을 제작할 때, tanstack query에서 제공하는 유용한 useQuery의 종류 중 하나다.
일반적인 useQuery를 사용할 줄 안다고 가정했을 때,
useInfiniteQuery에서 알아야 할 것은 다음과 같다.
✔️ pageParam
페이지 값을 매개변수로 넘겨서 API를 호출할 때, 페이지 매개변수를 포함하는 배열이다.
✔️ initialPageParam
tanstack query 버전 5부터 달라진 점 중 하나다.
버전 4까지는 초기 페이지 값을 지정할 때, 아래와 같이 지정해줬는데 지정하는 방법이 달라졌다.
queryFn: ({ pageParam=1 }) => API 호출
이제는 initialPageParam이라는 것을 직접 등록하여, 초기 페이지 시작을 몇 부터 할 것인지 정할 수 있다.
initialPageParam: 1
✔️ getNextPageParam
로드해야 할 데이터가 있는지의 여부 판단과 어떤 동작을 할 것인지를 작성하는 옵션이다.
이전 페이지와 관련된 getPreviousPageParam 옵션도 지원한다!
✔️ isLoading
API를 호출 후 모두 완료되었는지 판단하는 boolean 객체다.
✔️ hasNextPage
pageParam을 더 늘려 불러올 데이터가 있는지 판단하는 boolean 객체다.
hasNextPage 객체를 활용하여 위 사진에서의 API 호출 영역을 보여주는 것이다!
✔️ fetchNextPage
이는 다음 페이지용 API 호출 영역에서 사용하게 될텐데,
호출 영역을 만나게 되면 getNextPageParam로 정의한 코드를 실행시킬 수 있도록 fetchNextParam을 불러와 실행시키는 용도다.
말로 읽는 것보다 직접 활용한 코드를 보는 게 이해에 더욱 도움이 될 것 같아서 가져왔다.
카테고리별 API를 불러올 때, 넘겨줘야 하는 인수가 여러가지 였는데, 무한스크롤에서 신경써야 할 부분은 pageParam, initialPageParam, getNextPageParam이다.
우선 코드를 읽기 전, 우리의 콘텐츠 영역에는 12개의 콘텐츠를 보여주고 다음 페이지 호출 시 12개를 더 보여주는 형식으로 진행했다.
getNextPageParam의 lastPage, allPages 객체의 형식은 콘솔로 확인해보고 안에 들어갈 코드를 작성하는 게 좋다.
내가 진행했던 프로젝트에선, 받아오는 응답 데이터 값에 총 페이지 개수나 남은 페이지 개수 같은 값이 따로 없어서 직접 계산한 코드라고 봐주면 좋겠다.
즉, 나의 최선이었던 코드..(?)😂
// - 카테고리별 API hook
export const useGetPlacesOfCategory = (
id: number,
queryParams?: Record<string, string>,
headerArgs?: Record<string, string>,
) => {
const { data, isLoading, ...rest } = useInfiniteQuery({
queryKey: ['getPlacesOfCategory'],
queryFn: ({ pageParam }) =>
api.places.getPlacesOfCategory(id, pageParam, queryParams, headerArgs),
initialPageParam: 1, // v5 달라진 점 -> 본인이 불러와야 하는 첫 페이지를 지정!
getNextPageParam: (lastPage, allPages) => {
if (lastPage.data.total_places) {
const totalPages = Math.ceil(lastPage.data.total_places / 12); // 총 페이지 개수값 구하기
return allPages.length < totalPages ? allPages.length + 1 : undefined;
}
// return값이 pageParam으로 전달
},
retry: 0,
});
return { data: data, isLoading, ...rest };
};
필요한 무한스크롤 hook을 불러와서 구조분해 기법으로 필요한 객체들을 불러온다.
나는 호출한 API가 응답을 잘 가져다주었는지 판단하기 위한 isLoading
호출할 페이지가 남아있는지 페이지 여부를 판단해주는 hasNextPage
호출 영역을 만났을 때 다음 함수를 실행시켜주는 fetchNextPage
3가지를 불러 콘텐츠 리스트 컴포넌트로 넘겨주었다.
const CoursePage = () => {
const { data, isLoading, fetchNextPage, hasNextPage } = useGetPlacesOfCourse();
return (
<DetailPageWrap>
<SearchBar backIcon={true} />
{isLoading ? (
<Loading />
) : (
<ThumbnailList
places={data}
isLoading={isLoading}
hasNextPage={hasNextPage}
fetchNextPage={fetchNextPage}
recentView={true}
/>
)}
</DetailPageWrap>
);
};
export default CoursePage;
API 호출 객체는 hasNextPage true/false 여부로 판단한다.
나는 로딩이 모두 완료되었을 때의 조건도 추가하고 싶어서 !isLoading 조건도 추가했다.
const ThumbnailList = ({
places,
isLoading,
hasNextPage,
fetchNextPage,
}: ThumbnailListProps) => {
return places && places.length !== 0 ? (
<ThumbnailListWrap>
<ThumbnailContentArea>
{places.map(data => (
<ThumbnailBox
userId={userId}
key={data.id}
data={data}
like={data.heart}
onClick={() => handleClickThumb(data)}
/>
))}
</ThumbnailContentArea>
{!isLoading && hasNextPage && ( // isLoading이 false이면서 hasNextPage가 true일 시에만 보이도록
<NextFetchTarget>• • •</NextFetchTarget> // API 호출 영역
)}
</ThumbnailListWrap>
) : null;
};
export default ThumbnailList;
API 호출 영역을 만났을 때 fetchNextPage
가 실행되면서, useInfiniteQuery의 getNextPageParam이 실행되도록 해야 한다.
useEffect, useRef, Intersection Observer를 이용해 인식 객체를 만들 수 있다.
const ThumbnailList = ({
places,
isLoading,
hasNextPage,
fetchNextPage,
}: ThumbnailListProps) => {
const nextFetchTargetRef = useRef<HTMLDivElement | null>(null); // ref 객체 생성
// 데이터 무한스크롤
useEffect(() => {
const options = {
root: null, // 뷰포트, Null일 땐 뷰포트는 브라우저창이 기준이 된다.
rootMargin: '0px',
threshold: 0.5, // 대상 요소가 얼마나 보일 때 콜백할 것인지 정하는데, 0.5 나는 50%가 보일 때 콜백함수가 실행되도록 했다.
};
// entries: IntersectionObserverEntry 객체의 배열
// observer: IntersectionObserver 인스턴스
const fetchCallback: IntersectionObserverCallback = (entries, observer) => {
// 각 항목을 반복하며, 뷰포트와 교차하며 hasNextPage가 true인 경우, fetchNextPage 함수를 호출하고 현재 대상 요소 관찰을 중지!
entries.forEach(entry => {
if (entry.isIntersecting && hasNextPage) {
fetchNextPage?.();
observer.unobserve(entry.target);
}
});
};
// 지정된 fetchCallback과 options 객체를 이용해서 관찰 객체 인스턴스를 새로 생성한다.
const observer = new IntersectionObserver(fetchCallback, options);
// - ref 객체가 마운트 될 때
if (nextFetchTargetRef.current) {
observer.observe(nextFetchTargetRef.current);
}
// - ref 객체가 언마운트 될 때
return () => {
if (nextFetchTargetRef.current) {
observer.unobserve(nextFetchTargetRef.current);
}
};
}, [places]);
return places && places.length !== 0 ? (
<ThumbnailListWrap>
<ThumbnailContentArea>
{places.map(data => (
<ThumbnailBox
userId={userId}
key={data.id}
data={data}
like={data.heart}
onClick={() => handleClickThumb(data)}
/>
))}
</ThumbnailContentArea>
{!isLoading && hasNextPage && ( // isLoading이 false이면서 hasNextPage가 true일 시에만 보이도록
<NextFetchTarget ref={nextFetchTargetRef}>• • •</NextFetchTarget> // API 호출 영역
)}
</ThumbnailListWrap>
) : null;
};
export default ThumbnailList;
이는 위 코드로 구현한 나의 무한스크롤 관련 사진이다.
빨간 동그라미를 보면, API 호출 영역이 있는 것을 볼 수 있다.
이는 아직 다음 페이지가 남았다는 것을 의미한다.
무한스크롤 구현 영상이다!
이번에 확실하게 구현해봤기 때문에, 다음 기회에 또 만들게 된다면 좀 더 쉽게 만들 수 있지 않을까 싶다😊ㅎㅎ
감사합니다! 많은 도움이 되었습니다 ^ㅇ^