React-Query(tanstack query v5)로 만들어보는 무한스크롤

수정·2024년 1월 3일
3

React-Query

목록 보기
2/2
post-thumbnail

이번 캡스톤2 프로젝트를 진행하면서, 머신러닝 학습을 위해 서울시 공공데이터를 많이 끌어와 전처리를 하고나니 데이터의 양이 2000개 정도 육박했다.

프로젝트 컨셉에 따라 rest API 중 GET을 가장 많이 사용하게 되는데,

  • 데이터를 한 번에 불러온다면 엄청나게 느려지고 오히려 과부하가 올 것이라 판단
  • 그렇다고 페이지네이션 형태로 간다면 주로 모바일로 확인하게 될 유저들이 불편할 것이라 판단

2가지 이유로 이번 기회에 무한스크롤을 구현하여 효율적으로 데이터를 불러오고, 유저들이 더 편할 수 있게끔 하는 게 좋다고 생각했다!

참고로 나는 프로젝트 당시 tanstack query 가장 최신 버전인 5.8.3으로 설치했는데, 공식문서에서 v4와 v5의 사용법이 조금씩 다르기 때문에 이는 본인이 설치한 버전에 따라 확인해 볼 필요가 있다!

참고문서: tanstack query InfiniteQuery v5버전


🌵 무한스크롤의 원리

나는 무한스크롤을 구현하기 전, 원리에 대해 이해하고 나서 코드를 작성하기 시작했다.

  • 콘텐츠 영역

우선 콘텐츠 영역에 몇 개의 콘텐츠를 보여줄 것인지 정하는 게 중요하다.
백엔드 파트에서 보내줄 응답 데이터를 가공할 때, 한 페이지 당 몇 개의 콘텐츠까지 보여줄 것인지 코드를 작성해야하기 때문이다.
그리고 첫 페이지 API를 불러와 본인의 콘텐츠 영역에 나타내주면 되고, 개수에 따라 자연스레 스크롤이 생성될 것이다.

  • API 호출 영역

해당 부분은 보여주는 데이터의 개수가 끝났을 때, 다음 페이지에 대한 API를 호출하기 위한 호출 영역이다.
보통은 콘텐츠 영역의 스크롤이 끝날 때 쯔음 해당 영역이 나타나게 된다.

참고로 받아오는 응답 데이터 형식에 따라 다르겠지만, nextPage: true 이런식의 응답값이 있다고 하자.
이는 아직 호출해야 하는 API의 페이지가 남았다는 것을 의미하여 true일 땐 다음 페이지 호출 영역을 보여주고 false라면 보여주지 않는 형식으로 진행한다.

🌵 tanstack query 무한스크롤 구현 시 알아야 할 정보

useInfiniteQuery 작성시 알아야 할 객체와 옵션!

useInfiniteQuery는 더 많은 데이터를 로드하거나 무한 스크롤을 제작할 때, tanstack query에서 제공하는 유용한 useQuery의 종류 중 하나다.

일반적인 useQuery를 사용할 줄 안다고 가정했을 때,
useInfiniteQuery에서 알아야 할 것은 다음과 같다.

✔️ pageParam

페이지 값을 매개변수로 넘겨서 API를 호출할 때, 페이지 매개변수를 포함하는 배열이다.

✔️ initialPageParam

tanstack query 버전 5부터 달라진 점 중 하나다.
버전 4까지는 초기 페이지 값을 지정할 때, 아래와 같이 지정해줬는데 지정하는 방법이 달라졌다.

queryFn: ({ pageParam=1 }) => API 호출

이제는 initialPageParam이라는 것을 직접 등록하여, 초기 페이지 시작을 몇 부터 할 것인지 정할 수 있다.

initialPageParam: 1

✔️ getNextPageParam

로드해야 할 데이터가 있는지의 여부 판단과 어떤 동작을 할 것인지를 작성하는 옵션이다.
이전 페이지와 관련된 getPreviousPageParam 옵션도 지원한다!

✔️ isLoading

API를 호출 후 모두 완료되었는지 판단하는 boolean 객체다.

✔️ hasNextPage

pageParam을 더 늘려 불러올 데이터가 있는지 판단하는 boolean 객체다.
hasNextPage 객체를 활용하여 위 사진에서의 API 호출 영역을 보여주는 것이다!

✔️ fetchNextPage

이는 다음 페이지용 API 호출 영역에서 사용하게 될텐데,
호출 영역을 만나게 되면 getNextPageParam로 정의한 코드를 실행시킬 수 있도록 fetchNextParam을 불러와 실행시키는 용도다.

🌵 예제 프로젝트에서의 useInfiniteQuery 활용법

말로 읽는 것보다 직접 활용한 코드를 보는 게 이해에 더욱 도움이 될 것 같아서 가져왔다.

useInfiniteQuery 작성 시

카테고리별 API를 불러올 때, 넘겨줘야 하는 인수가 여러가지 였는데, 무한스크롤에서 신경써야 할 부분은 pageParam, initialPageParam, getNextPageParam이다.

우선 코드를 읽기 전, 우리의 콘텐츠 영역에는 12개의 콘텐츠를 보여주고 다음 페이지 호출 시 12개를 더 보여주는 형식으로 진행했다.
getNextPageParam의 lastPage, allPages 객체의 형식은 콘솔로 확인해보고 안에 들어갈 코드를 작성하는 게 좋다.

내가 진행했던 프로젝트에선, 받아오는 응답 데이터 값에 총 페이지 개수나 남은 페이지 개수 같은 값이 따로 없어서 직접 계산한 코드라고 봐주면 좋겠다.
즉, 나의 최선이었던 코드..(?)😂

// - 카테고리별 API hook
export const useGetPlacesOfCategory = (
  id: number,
  queryParams?: Record<string, string>,
  headerArgs?: Record<string, string>,
) => {
  const { data, isLoading, ...rest } = useInfiniteQuery({
    queryKey: ['getPlacesOfCategory'],
    queryFn: ({ pageParam }) =>
      api.places.getPlacesOfCategory(id, pageParam, queryParams, headerArgs),
    initialPageParam: 1, // v5 달라진 점 -> 본인이 불러와야 하는 첫 페이지를 지정!

    getNextPageParam: (lastPage, allPages) => {
      if (lastPage.data.total_places) {
        const totalPages = Math.ceil(lastPage.data.total_places / 12); // 총 페이지 개수값 구하기
        return allPages.length < totalPages ? allPages.length + 1 : undefined;
      }
      // return값이 pageParam으로 전달
    },

    retry: 0,
  });

  return { data: data, isLoading, ...rest };
};

필요한 페이지에 불러올 시

필요한 무한스크롤 hook을 불러와서 구조분해 기법으로 필요한 객체들을 불러온다.

나는 호출한 API가 응답을 잘 가져다주었는지 판단하기 위한 isLoading
호출할 페이지가 남아있는지 페이지 여부를 판단해주는 hasNextPage
호출 영역을 만났을 때 다음 함수를 실행시켜주는 fetchNextPage

3가지를 불러 콘텐츠 리스트 컴포넌트로 넘겨주었다.

const CoursePage = () => {
  const { data, isLoading, fetchNextPage, hasNextPage } = useGetPlacesOfCourse();
  
  return (
    <DetailPageWrap>
      <SearchBar backIcon={true} />
      {isLoading ? (
        <Loading />
      ) : (
        <ThumbnailList
          places={data}
          isLoading={isLoading}
          hasNextPage={hasNextPage}
          fetchNextPage={fetchNextPage}
          recentView={true}
        />
      )}
    </DetailPageWrap>
  );
};

export default CoursePage;

API 호출 객체 만들기

API 호출 객체는 hasNextPage true/false 여부로 판단한다.
나는 로딩이 모두 완료되었을 때의 조건도 추가하고 싶어서 !isLoading 조건도 추가했다.

const ThumbnailList = ({
  places,
  isLoading,
  hasNextPage,
  fetchNextPage,
}: ThumbnailListProps) => {
  
  return places && places.length !== 0 ? (
    <ThumbnailListWrap>
      <ThumbnailContentArea>
        {places.map(data => (
          <ThumbnailBox
            userId={userId}
            key={data.id}
            data={data}
            like={data.heart}
            onClick={() => handleClickThumb(data)}
          />
        ))}
      </ThumbnailContentArea>
      {!isLoading && hasNextPage && ( // isLoading이 false이면서 hasNextPage가 true일 시에만 보이도록
        <NextFetchTarget>• • •</NextFetchTarget> // API 호출 영역
      )}
    </ThumbnailListWrap>
  ) : null;
};

export default ThumbnailList;

API 호출 영역 인식 객체 만들기

API 호출 영역을 만났을 때 fetchNextPage가 실행되면서, useInfiniteQuery의 getNextPageParam이 실행되도록 해야 한다.

useEffect, useRef, Intersection Observer를 이용해 인식 객체를 만들 수 있다.

const ThumbnailList = ({
  places,
  isLoading,
  hasNextPage,
  fetchNextPage,
}: ThumbnailListProps) => {
   const nextFetchTargetRef = useRef<HTMLDivElement | null>(null); // ref 객체 생성
  
   // 데이터 무한스크롤
  useEffect(() => {
    const options = {
      root: null, // 뷰포트, Null일 땐 뷰포트는 브라우저창이 기준이 된다.
      rootMargin: '0px',
      threshold: 0.5, // 대상 요소가 얼마나 보일 때 콜백할 것인지 정하는데, 0.5 나는 50%가 보일 때 콜백함수가 실행되도록 했다.
    };

    // entries: IntersectionObserverEntry 객체의 배열
    // observer: IntersectionObserver 인스턴스
    const fetchCallback: IntersectionObserverCallback = (entries, observer) => {
      // 각 항목을 반복하며, 뷰포트와 교차하며 hasNextPage가 true인 경우, fetchNextPage 함수를 호출하고 현재 대상 요소 관찰을 중지!
      entries.forEach(entry => {
        if (entry.isIntersecting && hasNextPage) {
          fetchNextPage?.();
          observer.unobserve(entry.target);
        }
      });
    };

    // 지정된 fetchCallback과 options 객체를 이용해서 관찰 객체 인스턴스를 새로 생성한다.
    const observer = new IntersectionObserver(fetchCallback, options);

    // - ref 객체가 마운트 될 때
    if (nextFetchTargetRef.current) {
      observer.observe(nextFetchTargetRef.current);
    }

    // - ref 객체가 언마운트 될 때
    return () => {
      if (nextFetchTargetRef.current) {
        observer.unobserve(nextFetchTargetRef.current);
      }
    };
  }, [places]);
  
  return places && places.length !== 0 ? (
    <ThumbnailListWrap>
      <ThumbnailContentArea>
        {places.map(data => (
          <ThumbnailBox
            userId={userId}
            key={data.id}
            data={data}
            like={data.heart}
            onClick={() => handleClickThumb(data)}
          />
        ))}
      </ThumbnailContentArea>
      {!isLoading && hasNextPage && ( // isLoading이 false이면서 hasNextPage가 true일 시에만 보이도록
        <NextFetchTarget ref={nextFetchTargetRef}>• • •</NextFetchTarget> // API 호출 영역 
      )}
    </ThumbnailListWrap>
  ) : null;
};

export default ThumbnailList;

구현 사진

이는 위 코드로 구현한 나의 무한스크롤 관련 사진이다.

빨간 동그라미를 보면, API 호출 영역이 있는 것을 볼 수 있다.
이는 아직 다음 페이지가 남았다는 것을 의미한다.

무한스크롤 구현 영상이다!

이번에 확실하게 구현해봤기 때문에, 다음 기회에 또 만들게 된다면 좀 더 쉽게 만들 수 있지 않을까 싶다😊ㅎㅎ

profile
💛

1개의 댓글

comment-user-thumbnail
2024년 8월 11일

감사합니다! 많은 도움이 되었습니다 ^ㅇ^

답글 달기