안녕하세요. 단단입니다.
오늘은 @tanstack/query useInfiniteQuery로 무한스크롤을 구현한 과정을 정리해보겠습니다.
저는 이전 '포잉마켓' 프로젝트에서 Intersection Observer API를 활용한 무한스크롤 커스텀 훅을 만들었습니다. 이 글엔 무한스크롤 커스텀 훅 구현과 적용 과정을 정리했습니다.
무한스크롤은 사용자가 페이지를 스크롤할 때 페이지 하단에 도달하면 스크롤 이벤트를 감지해 추가 데이터를 로드하는 기능입니다. 상품 리스트 등 많은 데이터를 불러오면서, 콘텐츠를 끊기지 않게 로드해야 할 때 무한스크롤을 사용합니다.
무한스크롤을 구현할 때 고려해야 할 점은 데이터 요청이 너무 잦으면 서버에 부하가 걸릴 수 있으므로 주의해야 합니다. 실제로 프로젝트를 할 때 무한 요청을 보내 서버에 500에러가 뜨는 경우도 종종 봤어요.
그래서 저는 @tanstack/query useInfiniteQuery를 사용해 스크롤 이벤트 발생 시 추가 데이터를 자동으로 가져오게 구현했습니다.
useInfiniteQuery는 useQuery와 다르게 fetchNextPage, hasNextPage 속성을 지원해 무한 스크롤을 더 쉽게 구현할 수 있습니다.
pageParam의 타입 에러가 계속 떠서 어려움을 겪었지만, 제너릭 타입으로 쿼리 응답 데이터 타입, 에러 타입, 훅의 반환 데이터 타입, 쿼리 키 타입을 명확하게 지정해 타입 안전성을 높이고, 동료가 코드를 봤을 때 데이터 구조를 파악하기 쉽게 구현했습니다.
const {
data: quotesData,
isLoading,
isError,
error,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useInfiniteQuotes()
export const useInfiniteQuotes = () => {
return useInfiniteQuery<
GetQuotesResponse,
Error,
UseInfiniteQuotesResponse,
[string]
>({
queryKey: [QUERY_KEYS.quotes],
queryFn: async ({ pageParam = 0 }) => {
const res = await getQuotes({ pageParam: pageParam as number })
if (res.status === 'error') {
throw new Error(res.error)
}
return res.data
},
initialPageParam: 0,
getNextPageParam: (lastPage) => {
const nextPage = lastPage.skip + lastPage.limit
return nextPage < lastPage.total ? nextPage : undefined
},
getNextPageParam 옵션의 반환 값을 다음 API 호출할 때의 pageParam으로 넣고, undefined를 반환하면 더이상 API를 호출하지 않게 했습니다.
Intersection Observer API는 특정 요소가 viewport와 얼마나 겹치는지 비동기로 감지할 수 있는 도구로, 무한스크롤을 구현할 때 이용했습니다.
이를 사용하지 않으면 스크롤 이벤트를 수동으로 감지하는 방식을 사용할 수 있는데, 스크롤 이벤트로 무한스크롤을 구현하면 리플로우가 발생해 렌더링 성능을 저하될 수 있습니다.
저는 Intersection Observer API를 사용하는 부분을 useIntersect 커스텀 훅으로 분리했습니다.
먼저, DOM 요소(target)를 관찰하기 위해 ref를 생성했습니다. root와 target이 교차 상태인지 확인하는 isIntersecting의 값이 true이면 콜백 함수를 실행합니다.
그리고 useEffect 콜백에서 IntersectionObserver 객체를 생성하고 observe 호출로 target 요소 관찰을 합니다. target이 viewport에 겹칠 때만 onintersect를 실행하게 했습니다.
또, 컴포넌트가 언마운트 될 땐 observer.disconnect를 호출해 모든 요소의 관찰을 중지(cleanup)합니다.
observer의 메서드는 unobserve와 disconnect 등이 있습니다. unobserve 메서드는 특정 요소 관찰을 중지할 때 사용하는데, 이걸 사용해도 IntersectionObserver를 완전히 비활성화하진 않습니다. 반면, disconnect는 IntersectionObserver를 비활성화하고, 메모리에서 해당 observer를 정리합니다. 그래서 disconnect를 사용해 클린업 함수를 구현했습니다.
export default function useIntersect<T extends HTMLElement>(
onIntersect: (
entry: IntersectionObserverEntry,
observer: IntersectionObserver
) => void,
options?: IntersectionObserverInit
): React.MutableRefObject<T | null> {
const ref = useRef<T | null>(null)
useEffect(() => {
if (!ref.current) return
const callback = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
onIntersect(entry, observer)
}
})
}
const observer = new IntersectionObserver(callback, options)
observer.observe(ref.current)
return () => {
observer.disconnect()
}
}, [ref, options, onIntersect])
return ref
}
오늘은 무한스크롤을 구현해본 경험을 정리해봤습니다.
윈도잉 최적화(화면에 보이는 요소만 렌더링해 성능 최적화)도 적용해볼 생각인데, 적용 후에 내용 추가하겠습니다.
이 글을 읽는 분들께 조금이나마 도움이 됐길 바라며, 모든 피드백 환영합니다~
참고
카카오엔터프라이즈 테크블로그 실전 Infinite Scroll with React
Intersection Observer API mdn 문서