[React-Query] useInfiniteQuery 사용해서 무한스크롤 구현하기

sujin·2024년 8월 18일
1
post-thumbnail

프로젝트 진행을 하다보면 무한스크롤 기능을 구현할 일이 생각보다 많다. 특히나 앱의 경우 모바일 기기 특성상 여러가지 페이지네이션 중에서도 무한스크롤이 UI/UX 상으로 적절하다.

지금 회사에서는 서버 상태관리 툴로 react-query 를 사용하고 있는데 react-query에서 이 무한스크롤을 구현할 수 있는 기능을 제공한다는 것을 알게되었고 useInfiniteQuery를 사용해서 무한스크롤을 구현한 과정을 공유하려고 한다.

본격적인 무한스크롤 구현방법에 대해서 알아보기 전에 react-query에서 제공하는 Infinite Queries 에 대해서 먼저 간략히 소개해보고자 한다.


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>
  );
};
  • Infinite Queries(무한쿼리)는 무한 스크롤이나 더보기 와 같이 특정 조건에서 데이터를 추가적으로 받아오는 기능을 구현할 때 사용한다.
  • react query는 이러한 무한 쿼리를 지원하기 위해 useQuery의 유용한 버전인 useInfiniteQuery을 지원한다.
  • 기본적으로 useQuery와 사용법은 비슷하지만 리턴 값에 차이가 있다.

리턴 데이터

  • data.pages : 모든 페이지 데이터를 포한하는 배열이다.
  • data.pageParams : 모든 페이지 매개변수를 포함하는 배열이다.
  • fetchNextPage : 다음 페이지를 fetch 할 수 있다.
  • fetchPreviousPage : 이전 페이지를 fetch 할 수 있다.
  • isFetchingNextPage : fetchNextPage 메서드가 다음 페이지를 가져오는 동안 true
  • isFetchingPreviousPage : fetchPreviousPage 메서드가 이전 페이지를 가져오는 동안 true
  • hasNextPage : 가져올 수 있는 다음 페이지가 있는 경우 true
  • hasPreviousPage : 가져올 수 있는 이전 페이지가 있을 경우 true

옵션

  • initialPageParam ( 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
    • 다음 페이지의 매개변수 (pageParam)를 가져오는 함수를 정의한다. 이 함수는 다음 페이지 요청에 필요한 매개 변수(pageParam) 값을 변환한다.
    • 이 함수는 총 4개의 인자를 가진다.
      • lastPage : fetch 해온 가장 최근에 가져온 페이지 목록
      • allPages : 현재까지 가져온 모든 페이지 데이터
      • firstPageParam : 첫 번째 페이지의 매개변수 (v5부터 새로 생김)
      • allPageParam : 모든 페이지의 매개변수 (v5부터 새로 생김)
    • 사용 가능한 다음 페이지가 없음을 표시하려면 undefined 또는 null 을 반환하다.

pageParam

  • pageParam 은 단순히 다음 page 값만 관리할 수 있는 것은 아니다.
  • pageParam 값은 getNextPageParam 에서 원하는 형태로 변경시켜줄 수 있다.
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}`
  );
}
  • 즉, getNextPageParam의 반환 값이 pageParams로 들어가기 때문에 pageParams를 원하는 형태로 변경하고 싶다면 getNextPageParam의 반환 값을 설정하면 된다!
  • pageParam과 getNextPageParam부분이 작업을 하면서 가장 이해하기 어려웠던 부분이었는데 내가 구현한 코드들을 보면서 좀 더 자세히 알아보자!

useInfiniteQuery로 무한 스크롤 구현하기


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를 고려하여 스켈레톤 처리도 해주어야한다.)

구현 과정

필요한 부분만 설명하기 위해 생략된 코드도 있으니 이부분 참고해서 봐주세요

1. useInfinitiQuery 필터 및 API 호출

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>
	
	)
}
  • 1번 getExamFilter
    • 해당 API에는 request 값으로 과목 subject 과 학기 period 를 query-string으로 받고 있었기 때문에 이 부분을 한번에 관리하기 위해서 getExamFilter 로 만들어 주었고, useInfiniteQuery의 쿼리키 부분에 매개변수로 두고 값이 바뀌면 API가 조건에 맞게 재호출될 수 있도록 했다.
  • 2번 getExamInfinityReponse
    • 본격적으로 API를 호출하는 부분인데 기본적인 구조는 useQuery 와 동일하지만 pageParam 와 getNextPageParam 부분을 좀 더 자세히 보도록하자
    • pageParams
      • pageParams는 다음 페이지의 데이터를 가져오기 위해 필요한 정보를 담고 있는 매개변수이다.
      • 예를 들어, 처음에 exam_list 10개를 불러오고 스크롤을 해서 그 다음 10개 리스트를 불러오기 위해서는 다음 페이지 번호를 알아야할 것이다. 이때 이 정보가 pageParams에 들어간다.
      • 쉽게 말하자면, pageParams 는 다음에 어떤 데이터를 부를지 정하는 역할이며 나는 여기서 pageParams에 페이지 번호 값을 넣었다. (초기 값은 0)
    • getNextPageParam
      • getNextPageParam 는 다음 페이지의 pageParams를 가져오는 함수를 정의한다.
      • 나는 인자로 lastPage, pages를 사용했다. 나는 애초에 백엔드에서 총 데이터의 total 값을 보내줬기 때문에 이부분을 활용했다.

        lastPage 는 가장 최근에 가져온 데이터를, pages 는 현재까지 가져온 모든 페이지의 데이터 들을 배열에 누적시킨다.

        • 첫번째 데이터 호출
          첫번째 데이터 호출했을 때
        • 두번째 데이터 호출했을 때
          두번째 데이터 호춯했을 때
        • pages 의 길이에 10을 곱한 값이 lastPage의 total 값을 넘지 않는다면 다음 데이터가 존재한다는 뜻이니 pages * 10 한 값(다음 페이지 값)을 리턴해줬고, 그 반대라면 다음 데이터가 없다는 뜻이니 undefinded를 리턴해줬다.
      • 그리고 getNextPageParam 는 fetchNextPages 함수 호출시 사용되는데 이것에 대해서는 아래에서 살펴보도록 하자!

2. useInfinitiQuery refetch 트리거 설정

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>
	
	)
}
  • fetchNextPage() 를 실행시키기 위해서 react-intersection-observeruseInView 를 사용했는데 이는 특정 요소가 뷰포트 안에 들어왔는지 여부를 감지할 수 있는 기능을 제공한다.
    • 스크롤을 했을 때 페이지의 가장 마지막 요소가 화면에 나타났을 때 fetchNextPage() 를 통해 다음 데이터를 자동으로 로드할 수 있도록 처리했다.

3. 스켈레톤 추가

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
      • 쿼리가 처음 실행될 때 즉, 데이터를 처음 가져올 때의 로딩 상태를 나타내며 처음에만 true로 설정이 된다. 데이터가 리패칭되어도 상태값이 바뀌지 않는다.
    • isRefetching
      • 이미 캐싱된 데이터가 있는 상태에서 사용자가 수동으로 데이터를 다시 가져올 때 발생하는 로딩 상태다.
    • isFetching
      • 데이터를 가져오는 모든 상황에서의 로딩 상태를 나타낸다. 초기 로딩, 리패칭, 다음 페이지 데이터 로딩 등 다양하게 쓰인다.
  • 정리하자면 isInitialLoading는 페이지 첫 진입시, isRefetching는 필터 값 변경시, isFetching는 스크롤 페이지네이션으로 다음 데이터 불러올 때 이렇게 각각 다르게 사용되었다.

짧은 회고

useInfiniteQuery로 무한스크롤을 구현하면서 느꼈던 장점은

1. 간단한 코드 구현

  • 이전에 Intersection Observer를 사용했을 때는 useState로 데이터들을 관리해서 새로운 데이터를 가져오면 이전 데이터와 직접 병합하고 없애고 하는 로직을 일일이 관리해야해서 코드가 길어졌고 이로 인한 사이드이펙트가 발생했었는데 useInfiniteQuery는 그 전보다 코드가 훨씬 짧아졌고 또 안정적으로 데이터 관리를 할 수 있어서 좋았다.

2. 캐싱 및 중복 요청 방지

  • 리액트 쿼리는 자동으로 데이터를 캐싱하고 동일한 데이터 중복 요청을 방지해주기 때문에 네트워크 요청을 줄여서 앱성능을 향상 시킬 수 있어서 좋았다.

처음에 접했을 때는 개념을 이해하기 어렵고 적응하기가 힘들었는데 한번 구현해보니 그 다음부터는 쉽게쉽게 할 수 있었던 것 같다. 사용하는 곳이 많아지다 보니깐 무한스크롤 부분을 커스텀 훅으로 분리해서 공통적으로 사용할 수 있지 않을까 싶다. 이부분에 대해서도 좀 더 고민해봐야겠다.


📚 참고 자료


https://github.com/ssi02014/react-query-tutorial?tab=readme-ov-file#infinite-queries

profile
개발댕발

2개의 댓글

comment-user-thumbnail
2024년 8월 20일

꾸준히 공부하고 계시는군요. 멋지십니다.

1개의 답글