'집사의 고민' 프로젝트에서 커서 기반 페이지네이션으로 무한스크롤 기능을 구현해보았습니다.(반려동물 사료 목록 조회)
이전에 로드한 데이터 중 마지막 데이터 Id(cursor, 고유한 식별자)를 서버에 전달하여 그 이후부터 일정 개수의 데이터를 가져오는 방식입니다.
export const useFoodListInfiniteQuery = (payload: Parameter<typeof getFoodList>) => {
const { data, ...restQuery } = useInfiniteQuery({
queryKey: [QUERY_KEY.petFoods],
queryFn: ({ pageParam = { ...payload, size: String(SIZE_PER_PAGE) } }) =>
getFoodList(pageParam),
getNextPageParam: (lastFoodListRes, allFoodListRes) => {
const lastFood = lastFoodListRes.petFoods.at(-1);
const isLastPage =
allFoodListRes.flatMap(foodListRes => foodListRes.petFoods).length >=
lastFoodListRes.totalCount || lastFoodListRes.petFoods.length < SIZE_PER_PAGE;
if (!lastFood || isLastPage) return undefined;
return { ...payload, lastPetFoodId: String(lastFood.id), size: String(SIZE_PER_PAGE) };
},
});
return {
foodList: data?.pages.flatMap(page => page.petFoods),
...restQuery,
};
};
getNextPageParam
: 서버에서 보내준 totalCount
값과 현재까지의 foodList
의 길이를 비교해서 마지막 페이지라면 undefined
을 반환하고 마지막 페이지가 아니라면 다음 요청의 파라미터 값인 마지막 식품의 id(foodList.at(-1).id
), 페이징 사이즈 그리고 필터링 옵션을 담아서 반환하도록 구현했습니다.hasNextPage
: 현재 페이지가 마지막페이지인지 여부를 확인합니다. getNextPageParam
이 undefined
를 반환하면 false
, 그 외에는 true
를 반환합니다.isFetchingNextPage
: 사용자가 스크롤을 다시 위로 올렸다가 내렸을 때 데이터 요청 도중 중복으로 데이터를 요청하는 문제를 해결하기 위해 데이터 요청 상태를 확인합니다.무한스크롤 구현 시, 다음 페이지 데이터 호출 시점을 판단하기 위해 사용하였습니다.
hasNextPage
가 true
& isFetchingNextPage
가 false
인 경우 다음 페이지의 데이터를 호출합니다.Scroll event를 감지하는 방법으로도 무한스크롤을 구현할 수 있지만 이 방식은 스크롤 위치가 바뀔 때마다 불필요한 이벤트가 발생하기 때문에 스크롤 이벤트 리스너가 빈번하게 호출되어 성능이 저하될 수 있는 문제가 있습니다. 또한 원하는 시점에 정확하게 이벤트를 제어하고 처리하는 것이 어려울 수 있습니다. 이런 문제들을 고려하여 IntersectionObserver를 사용하여 구현하였습니다.
무한 스크롤 기능 적용 후, 사용자가 사료 목록 페이지에서 필터를 적용할 때마다 사료 목록을 refetch
하는 기능을 구현하면서 만났던 문제들을 정리했습니다.
const queries = useValidQueryString<KeywordEn>([
'nutritionStandards',
'mainIngredients',
'brands',
'functionalities',
]);
// 문제의 코드
useEffect(() => {
refetch();
}, [Object.values(queries)]);
queries
는 사료를 필터링하는 쿼리 파라미터를 담은 객체입니다.
// queries
{
brands: "퓨리나,오리젠",
mainIngredients: "닭고기,연어",
...
}
useEffect
의 의존성 배열에 Object.values(queries)
를 넣어주었고 사료 목록 조회 api를 계~속 요청하면서 무한루프에 빠지는 문제가 있었습니다.
첫 화면 렌더링 후 queries는 {}
➡️ Object.values(queries)
는 []
➡️useEffect
의 refetch()
실행 ➡️ refetch
의 결과로 사료 목록을 새로 가져오면서 이 사료 목록 데이터를 사용하는 컴포넌트의 렌더링을 트리거 ➡️ 무한 반복...
이러한 상황은 Object.values(queries)
가 매 렌더링마다 새로운 배열을 생성하기 때문에 발생합니다. 이 새로운 배열은 이전에 생성된 배열과 메모리 주소가 다르기 때문에 항상 다른 객체로 취급되어, useEffect
가 계속해서 실행되는 원인이 됩니다.
👆위 문제는 의존성 배열안에 매번 새로운 배열을 넣어서 생긴 문제였기 때문에 아래와 같이 수정하여 해결했습니다.
useEffect(() => {
refetch();
}, Object.values(queries));
하지만 위 코드는 또 다른 문제가 있었는데요.
주어진 코드에서 useEffect
의 두 번째 파라미터로 Object.values(queries)
를 넣어주면서 발생하는 문제 상황은 다음과 같습니다.
처음 필터 적용 후에만 queries
의 변경을 useEffect
가 인식하지 못해서 식품목록을 refetch
해오지 못하는 문제가 있었습니다.
예를 들어 queries
가 {}
에서 -> { brands: "아카나" }
로 바뀌는 경우 useEffect
에서 queries
의 변경을 인식하지 못하고 { brands: "아카나" }
에서 -> { brands: "오리젠" }
으로 바뀌는 경우에는 변경을 인식하여 refetch
가 잘 실행되었습니다.
이러한 상황은 useEffect
에 Obejct.values(queries)
를 넣어주는데 첫 렌더링 화면에서는 필터 적용이 안되어 있는 전체 사료 목록을 보여주기 때문에 queries
는 빈 객체{}
가 되고 Object.values({})
는 빈 배열[]
이 나오기 때문이었습니다.
즉, useEffect
의 의존성 배열이 아래와 같이 변화하기 때문에
[]
["아카나"]
["오리젠"]
1->2의 변경사항은 인식하지 못하고 2->3의 경우에만 인식하면서 두번째 필터링 이후부터만 refetch
를 해오는 것이었습니다.
이러한 문제를 해결하기 위해 Object.values(queries).join()
을 한 결과를 의존성 배열에 추가했습니다.
[""]
["아카나"]
["오리젠"]
또한 팀원인 에디에게 위와 같은 코드리뷰를 받고 remove
메소드도 추가해주었습니다.
👇 수정 후
const queriesString = Object.values(queries).join();
useEffect(() => {
remove();
refetch();
}, [queriesString, refetch, remove]);
👇 최종 코드
export const useInfiniteFoodListScroll = () => {
const queries = useValidQueryString<KeywordEn>([
'nutritionStandards',
'mainIngredients',
'brands',
'functionalities',
]);
const queriesString = Object.values(queries).join();
const {
foodList,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
remove,
refetch,
...restQuery
} = useFoodListInfiniteQuery(queries);
const executeFoodListInfiniteQuery = useCallback(
(entries: IntersectionObserverEntry[]) => {
entries.forEach(entry => {
const canLoadMore = entry.isIntersecting && hasNextPage && !isFetchingNextPage;
if (canLoadMore) fetchNextPage();
});
},
[hasNextPage, isFetchingNextPage, fetchNextPage],
);
const targetRef = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(executeFoodListInfiniteQuery, { threshold: 0.1 });
if (targetRef.current) observer.observe(targetRef.current);
return () => observer.disconnect();
}, [executeFoodListInfiniteQuery]);
useEffect(() => {
remove();
refetch();
}, [queriesString, refetch, remove]);
return { foodList, hasNextPage, refetch, targetRef, ...restQuery };
};