프로젝트 진행을 하다보면 무한스크롤 기능을 구현할 일이 생각보다 많다. 특히나 앱의 경우 모바일 기기 특성상 여러가지 페이지네이션 중에서도 무한스크롤이 UI/UX 상으로 적절하다.
지금 회사에서는 서버 상태관리 툴로 react-query 를 사용하고 있는데 react-query에서 이 무한스크롤을 구현할 수 있는 기능을 제공한다는 것을 알게되었고 useInfiniteQuery를 사용해서 무한스크롤을 구현한 과정을 공유하려고 한다.
본격적인 무한스크롤 구현방법에 대해서 알아보기 전에 react-query에서 제공하는 Infinite Queries 에 대해서 먼저 간략히 소개해보고자 한다.
import { useInfiniteQuery } from "@tanstack/react-query";
const fetchColors = async ({ pageParam }) => {
return await axios.get(
`http://localhost:3000/colors?page=${pageParam}`
);
};
// useInfiniteQuery의 queryFn의 매개변수는 `pageParam`이라는 프로퍼티를 가질 수 있다.
const InfiniteQueries = () => {
const { data, hasNextPage, isFetching, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ["colors"],
queryFn: ({pageParam: 0}) => fetchColors({page: pageParam}),
getNextPageParam: (lastPage, allPages) => {
return allPages.length < 4 && allPages.length + 1;
},
// ...
});
return (
<div>
{data?.pages.map((group, idx) => ({
/* ... */
}))}
<div>
<button disabled={!hasNextPage} onClick={() => fetchNextPage()}>
LoadMore
</button>
</div>
<div>{isFetching && !isFetchingNextPage ? "Fetching..." : null}</div>
</div>
);
};
useInfiniteQuery
을 지원한다.data.pages
: 모든 페이지 데이터를 포한하는 배열이다.data.pageParams
: 모든 페이지 매개변수를 포함하는 배열이다.fetchNextPage
: 다음 페이지를 fetch 할 수 있다.fetchPreviousPage
: 이전 페이지를 fetch 할 수 있다.isFetchingNextPage
: fetchNextPage 메서드가 다음 페이지를 가져오는 동안 trueisFetchingPreviousPage
: fetchPreviousPage 메서드가 이전 페이지를 가져오는 동안 truehasNextPage
: 가져올 수 있는 다음 페이지가 있는 경우 truehasPreviousPage
: 가져올 수 있는 이전 페이지가 있을 경우 trueinitialPageParam
( V5부터 새로 생긴 필수 옵션 )첫 페이지를 가져올 때 사용할 기본 페이지 매개변수이다.
const { data, hasNextPage, isFetching, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ["colors"],
queryFn: fetchColors,
initialPageParam: 1,
getNextPageParam: (lastPage, allPages, firstPageParam, allPageParam) => {
return allPages.length < 4 && allPages.length + 1;
},
// ...
});
getNextPageParam
lastPage
: fetch 해온 가장 최근에 가져온 페이지 목록allPages
: 현재까지 가져온 모든 페이지 데이터firstPageParam
: 첫 번째 페이지의 매개변수 (v5부터 새로 생김)allPageParam
: 모든 페이지의 매개변수 (v5부터 새로 생김)const { data, hasNextPage, isFetching, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ["colors"],
queryFn: fetchColors,
initialPageParam: 1,
getNextPageParam: (lastPage, allPages, firstPageParam, allPageParam) => {
return (
allPages.length 4 > && {
page: allpages.length + 1,
name: asj
}
)
};
});
const fetchColors = async ({pageParam}) => {
const {page, name} = pageParam;
return await axios.get(
`http://localhost:3000/colors?page=${page}&name=${name}`
);
}
Intersection Observer를 사용한 무한 스크롤 구현 방법은 여기를 참고해주세요!
API 호출을 통해서 문제 데이터를 불러오고 있다. 데이터의 response 구조는 총 데이터의 total 값과 리스트로 되어있다.
{
total: 200
exam_list: [
{
id: 1234
title: "[23년 1학기 중간]2학년 국어",
school: "동탄고등학교"
},
{
id: 1234
title: "[23년 1학기 중간]2학년 수학",
school: "동탄고등학교"
},
{
id: 1234
title: "[23년 1학기 중간]2학년 과학",
school: "동탄고등학교"
},
...
]
}
이때 exam_list는 백엔드에서 페이지네이션 기본값으로 10개씩 보내주고 있다. 이런 상황에서 스크롤을 할 때마다 리스트를 10개씩 계속 불러와야하고 200개를 다 불러왔을 때는 더 이상의 API호출이 일어나면 안된다.
(+추가적으로 데이터를 불러오는 중에서 UX를 고려하여 스켈레톤 처리도 해주어야한다.)
필요한 부분만 설명하기 위해 생략된 코드도 있으니 이부분 참고해서 봐주세요
const Exam = () => {
...
// 1번
const getExamFilter = useMemo(() => {
subject : selectedSubject.id
period: selectedPeriod
},[selectedSubject.id, selectedPeriod])
// 2번
const getExamInfinityReponse = useInfiniteQuery(
["get","exam","list", getExamFilter],
({pageParam = 0}) => getExam({page: pageParam, ...getExamFilter}),
{
staleTime: 1000 * 60 * 60,
getNextPageParam : (lastPage, pages) => {
if(pages.length * 10 < lastPage.total) {
return pages.length
} else {
return undefinded
}
}
}
)
...
return(
<Container>
...
{/* 기본 구조 */}
<Section>
{getExamInfinityReponse.isSuccess && (
getExamInfinityReponse.data.pages.map((group, i) => (
<ul key={i}>
{group.exam_list.map((item) => (
<li key={item.id}>
<span>{item.school}</span>
<span>{item.title}</span>
</li>
))}
</ul>
))
)}
</Section>
</Container>
)
}
getExamFilter
subject
과 학기 period
를 query-string으로 받고 있었기 때문에 이 부분을 한번에 관리하기 위해서 getExamFilter
로 만들어 주었고, useInfiniteQuery의 쿼리키 부분에 매개변수로 두고 값이 바뀌면 API가 조건에 맞게 재호출될 수 있도록 했다.getExamInfinityReponse
pageParams
exam_list
10개를 불러오고 스크롤을 해서 그 다음 10개 리스트를 불러오기 위해서는 다음 페이지 번호를 알아야할 것이다. 이때 이 정보가 pageParams에 들어간다.getNextPageParam
lastPage 는 가장 최근에 가져온 데이터를, pages 는 현재까지 가져온 모든 페이지의 데이터 들을 배열에 누적시킨다.
- 첫번째 데이터 호출
- 두번째 데이터 호출했을 때
- pages 의 길이에 10을 곱한 값이 lastPage의 total 값을 넘지 않는다면 다음 데이터가 존재한다는 뜻이니 pages * 10 한 값(다음 페이지 값)을 리턴해줬고, 그 반대라면 다음 데이터가 없다는 뜻이니 undefinded를 리턴해줬다.
const Exam = () => {
...
const {ref} = useInView({
onChange: (inView) => {
inView && getExamInfinityReponse.hasNextPage && getExamInfinityReponse.fetchNextPage()
}
})
...
return(
<Container>
...
<Section>
{getExamInfinityReponse.isSuccess && (
getExamInfinityReponse.data.pages.map((group, i) => (
<ul key={i}>
{group.exam_list.map((item) => (
<li key={item.id}>
<span>{item.school}</span>
<span>{item.title}</span>
</li>
))}
</ul>
))
)}
{getExamInfinityReponse.hasNextPage && <div ref={ref} />}
</Section>
</Container>
)
}
react-intersection-observer
의 useInView
를 사용했는데 이는 특정 요소가 뷰포트 안에 들어왔는지 여부를 감지할 수 있는 기능을 제공한다.fetchNextPage()
를 통해 다음 데이터를 자동으로 로드할 수 있도록 처리했다.const Exam = () => {
...
return(
<Container>
...
<Section>
{getExamInfinityReponse.isInitialLoading && (
<>
<Skeleton aspectRatio={109/24} />
<Skeleton aspectRatio={109/24} />
<Skeleton aspectRatio={109/24} />
<Skeleton aspectRatio={109/24} />
<Skeleton aspectRatio={109/24} />
</>
)}
{getExamInfinityReponse.isRefetching && (
<>
<Skeleton aspectRatio={109/24} />
<Skeleton aspectRatio={109/24} />
<Skeleton aspectRatio={109/24} />
</>
)}
{!getExamInfinityReponse.isInitialLoading &&
!getExamInfinityReponse.isRefetching &&
getExamInfinityReponse.isSuccess && (
getExamInfinityReponse.data.pages.map((group, i) => (
<ul key={i}>
{group.exam_list.map((item) => (
<li key={item.id}>
<span>{item.school}</span>
<span>{item.title}</span>
</li>
))}
</ul>
))
)}
{getExamTypeInfinityResponse.isFetching && (
<>
<Skeleton aspectRatio={109/24} />
<Skeleton aspectRatio={109/24} />
</>
)}
{getExamInfinityReponse.hasNextPage && <div ref={ref} />}
</Section>
</Container>
)
}
isInitialLoading
isRefetching
isFetching
isInitialLoading
는 페이지 첫 진입시, isRefetching
는 필터 값 변경시, isFetching
는 스크롤 페이지네이션으로 다음 데이터 불러올 때 이렇게 각각 다르게 사용되었다.useInfiniteQuery로 무한스크롤을 구현하면서 느꼈던 장점은
1. 간단한 코드 구현
2. 캐싱 및 중복 요청 방지
처음에 접했을 때는 개념을 이해하기 어렵고 적응하기가 힘들었는데 한번 구현해보니 그 다음부터는 쉽게쉽게 할 수 있었던 것 같다. 사용하는 곳이 많아지다 보니깐 무한스크롤 부분을 커스텀 훅으로 분리해서 공통적으로 사용할 수 있지 않을까 싶다. 이부분에 대해서도 좀 더 고민해봐야겠다.
https://github.com/ssi02014/react-query-tutorial?tab=readme-ov-file#infinite-queries
꾸준히 공부하고 계시는군요. 멋지십니다.