프로젝트를 진행하면서 무한 스크롤로 페이지를 구성해보고 싶다는 생각이 들었다.
무한 스크롤을 구현하면 사용자는 '다음' 페이지로 이동하지 않고 한 페이지에서 콘텐츠들을 전부 확인할 수 있고, 또 모바일 페이지에 적합하기 때문이었다.
마침 프로젝트에서 React Query를 사용하고 있었기에 React Query의 useInfiniteQuery 훅을 사용해 구현해보기로 했다.
const {
isFetching,
fetchNextPage,
data,
refetch
} = useInfinteQuery(
queryKey,
queryFn: ({ pageParam = 1 }) => fetchPage(pageParam),
{
getNextPageParam: (lastPage, allPages) => lastPage.nextCursor
}
)
lastPage.nextCursor를 통해 다음 페이지를 요청할 때 필요한 커서나 페이지 번호를 가져온다.
API에서 nextCursor를 반환하면 이 값을 getNextPageParam에서 반환하여 다음 페이지를 요청하는 데 사용한다.
const { data, fetchNextPage, isLoading, isError, isFetchingNextPage } =
useInfiniteQuery(
["searchPlace", keyword],
({ pageParam = 0 }) => fetchPlace({ page: pageParam, keyword: keyword }),
{
getNextPageParam: (lastPage) => {
const nextPage = lastPage.page + 1;
return lastPage.hasNextPage ? nextPage : undefined;
},
enabled: keyword !== "", // tag가 빈 문자열일 때 쿼리를 실행하지 않음
}
);
내가 작성한 코드이다.
검색 결과 페이지인데, pageParam과 검색어 keyword를 넘겨서 데이터를 fetch한다.
이 때 pageParam의 초기값은 0이다.
API에서 다음 페이지의 존재 유무를 hasNextPage라는 인자로 전달하면, 이를 확인해 pageParam을 1씩 증가시켜 다시 데이터를 요청하는 구조이다.
const [rows] = await connection.execute(
`SELECT *
FROM TouristSpot
WHERE name LIKE CONCAT('%', ?, '%')
OR area LIKE CONCAT('%', ?, '%')
OR subarea LIKE CONCAT('%', ?, '%')
OR JSON_CONTAINS(tags, ?)
LIMIT ?, ?`,
[keyword, keyword, keyword, JSON.stringify([keyword]), page * take, take]
);
const returnAttraction = rows as Array<attraction>;
// 다음 페이지 존재 여부
const hasNextPage =
returnAttraction.length === 6 &&
returnAttraction.length < totalCount;
return NextResponse.json(
{
attractions: returnAttraction,
hasNextPage: hasNextPage, // 다음 페이지 존재 여부
page: page,
},
{ status: 200 }
);
해당 코드는 API Routes와 MySQL을 활용한 API 코드의 예시이다.
주목할 점은 page * take와 take이다.
take는 한 번에 가져올 데이터의 수이다. 나는 6으로 설정했다.
page * take는 데이터의 시작 지점이다.
즉 위의 useInfiniteQuery의 초기 page는 0이므로 0x6 = 0, 첫번째 데이터부터 시작하며, 이후 스크롤이 이뤄져 1페이지, 2페이지가 되면 각각 1x6 = 6, 2x6 = 12가 되어 6번째 데이터, 12번째 데이터부터 시작한다.
결과적으로 page가 2이고 take가 6이라면 2x6 = 12번째 데이터부터 6개를 가져온다는 의미이다.
이렇게 API로부터 데이터를 가져오는데 성공했다면 Intersection Observer API를 이용해 스크롤이 화면의 끝에 닿았음을 감지해 데이터 페칭을 다시 시도하는 방식으로 코드를 작성하였다.
/// 데이터 요청
const fetchPlace = async ({
page,
keyword,
}: {
page: number;
keyword: string;
}) => {
const response = await axios.get("/api/search", {
params: {
page: page,
keyword: decodeURIComponent(keyword),
},
});
return response.data;
};
function SearchPageClient({keyword}: {keyword: string}) {
const router = useRouter();
const { ref, inView } = useInView();
/// infiniteQuery
const { data, fetchNextPage, isLoading, isError, isFetchingNextPage } =
useInfiniteQuery(
["searchPlace", keyword],
({ pageParam = 0 }) => fetchPlace({ page: pageParam, keyword: keyword }),
{
getNextPageParam: (lastPage) => {
const nextPage = lastPage.page + 1;
return lastPage.hasNextPage ? nextPage : undefined;
},
enabled: keyword !== "", // tag가 빈 문자열일 때 쿼리를 실행하지 않음
}
);
/// 스크롤 감지
useEffect(() => {
if (inView) {
fetchNextPage();
}
}, [inView, fetchNextPage]);
if (isLoading) return <Loading />;
if (isError) return <Error />;
return (
<div>
<div
className="grid grid-cols-2 lg:grid-cols-2 gap-12 mt-24"
key={data?.pages[0].name}
>
{data?.pages.map((page) =>
page.attractions.map((place: attraction) => (
<div key={`${place.id}`} onClick={() => cardClick(place.id)}>
<AttractionCard attraction={place} />
</div>
))
)}
</div>
{/* 무한 스크롤을 위한 감지 요소 */}
{isFetchingNextPage ? <Loading /> : <div ref={ref} className="h-10" />}
</div>
);
}
export default SearchPageClient;