본문 바로가기

study/React

React - Tanstack-Query(React-Query) v5 와 IntersectionObserver를 이용한 무한스크롤(useInfiniteQuery)

 

Next.js App Router를 이용하여 개발한 개인 블로그에 적용한 무한스크롤에 대한 내용에 대해 적어보려고 합니다.

개발한 블로그 링크는 아래에 있습니다.

https://www.siklog.shop/blog

 

 

 

위의 이미지처럼 검색어를 통해 게시글을 조회하는 페이지에 무한스크롤을 적용하였으며, useInfiniteQueryIntersectionObserver를 이용하여 구현하였습니다.

 

무한 스크롤을 적용한 이유는 아래와 같습니다.

  • 게시글이 많아질수록 한 번에 데이터를 조회하면 조회 속도가 늦어져 UX가 좋지 않다. (로딩 UI가 너무 긿어짐)
  • 페이지네이션으로도 구현이 가능하나 모바일 유저의 UX까지 생각한다면 무한 스크롤이 더 적합하다.

 

1. 무한 스크롤이란?

무한 스크롤은 웹 페이지에서 리스트나 테이블과 같은 콘텐츠를 끊임없이 스크롤 할 수 있는 방식을 말합니다. 사용자가 페이지를 스크롤하면 새로운 항목이 자동으로 로드되어 사용자가 스크롤할 때마다 새로운 데이터를 보여줄 수 있습니다.

 

 

2. Tanstack-Query(React-Query) v5 useInfiniteQuery에 대해서

https://tanstack.com/query/v5/docs/framework/react/reference/useInfiniteQuery

 

useInfiniteQuery | TanStack Query React Docs

Does this replace [Redux, MobX, etc]? react

tanstack.com

 

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

const {
  fetchNextPage,
  fetchPreviousPage,
  hasNextPage,
  hasPreviousPage,
  isFetchingNextPage,
  isFetchingPreviousPage,
  ...result
} = useInfiniteQuery({
  queryKey,
  queryFn: ({ pageParam }) => fetchPage(pageParam),
  initialPageParam: 1,
  ...options,
  getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
    lastPage.nextCursor,
  getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) =>
    firstPage.prevCursor,
})

 

 

 

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

 

pageParam

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

 

initialPageParam

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

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

 

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

initialPageParam: 1

 

getNextPageParam, getPreviousPageParam

다음 혹은 이전 페이지에 로드해야 할 데이터가 있는지 여부를 판단하며 어떤 동작을 할 것인지 정하는 옵션입니다.

위 함수에서 전달받는 파라미터는 크게 2가지로 아래와 같습니다.

  • lastPage: useInfiniteQuery를 이용해 호출된 가장 마지막에 있는 페이지 데이터
  • allPage: useInfiniteQuery를 이용해 호출된 모든 페이지 데이터

isLoading

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

 

hasNextPage

pageParam을 더 늘려 불러올 데이터가 있는지 판단하는 boolean 객체입니다.

fetchNextPage

다음 페이지를 요청할 때 사용되는 함수입니다.

 

isFetchingNextPage,  isFetchingPreviousPage

다음 혹은 이전 페이지를 불러오는 중인지 판별하는 boolean 값입니다.

 

 

3. Scroll Event

기존에는 스크롤 이벤트를 이용해서 요소가 화면에 나타나는지 여부를 확인했었습니다.

 

스크롤 이벤트는 성능에 악영향을 줄 수 있는데 스크롤 시 짧은 시간 내에 수 백, 수 천의 이벤트가 동기적으로 실행될 수 있습니다. 그리고 페이지 내에 각 요소가 각기의 목적(광고, 레이지 로딩, 무한 스크롤 등)의 이유로 scroll 이벤트를 리스닝하기 때문에 이에 상응하는 콜백이 무수히 실행될 수 있습니다. 이는 메인 스레드에 큰 부하를 줄 수 있습니다.

 

 

4. Intersection Observer

스크롤 이벤트의 문제를 해결할 방법이 바로 Intersection Observer를 이용하는 방법입니다.

 

Intersection Observer API는 뷰포트와 타겟 요소의 교차점을 관찰합니다. 그리고 타겟 요소가 뷰포트와 교차하는지 아닌지를 구별하는 기능을 제공하고 있습니다. 스크롤 이벤트와 다르게 교차 시 비동기적으로 실행되며 가시성 구분 시 reflow를 발생시키지 않습니다. 여러모로 성능 상 유리합니다.

 

Intersection Observer의 흐름은 아래와 같습니다.

  1. 관찰자(observer)를 생성한다.
  2. 관찰 대상(entry)을 생성한다.
  3. 관찰자(observer)는 관찰 대상(entry)을 관찰한다.
  4. 관찰 대상(entry)이 조건을 만족하는 상태에 놓이게 된다면 콜백 함수를 실행한다.

 

 

5. useInfiniteQuery에 Intersection Observer 적용

적용하기 전에 참고해야 할 사항은 아래와 같습니다.

  • Intersection Observer의 조건: 스크롤이 가장 하단에 도달했을 때 다음 페이지의 데이터를 받아와야 한다.
  • 관찰 대상(entry): 스크롤의 가장 하단부
  • 콜백 함수: 다음 페이지의 데이터 호출 함수(useInfiniteQuery의 fetchNextPage)

 

useInterSection 커스텀 훅 코드 작성

관찰대상은 새로운 데이터를 가져올 때마다 수시로 변경해줘야 하므로 useEffect를 활용해야 합니다. 변경되는 관찰대상은 관찰 대상 리스트에서 제거해줘야 하며, 다시 새롭게 관찰 대상을 지정해줘야 합니다.

'use client';

import type { RefObject } from 'react';
import { useEffect } from 'react';

/**
 *  @description IntersectionObserver Target 이벤트를 실행하는 함수
 *  @param {RefObject} target - IntersectionObserver Target을 전달하는 RefObject
 *  @param {() => void} onIntersect - Target이 ViewPort에 보일 경우 실행 할 함수
 *  @param {number} threshold - IntersectionObserver 인식 시점을 전달하는 값
 *  @param {boolean} enabled - IntersectionObserver 사용 여부
 *  @returns None
 *  @example useIntersectionObserver({ target: myRef, onIntersect: () => { alert('Intersect'); }, enabled: isLoading? false : true });
 */

interface UseIntersectionObserverProps {
  root?: null | unknown;
  target: RefObject<HTMLDivElement>;
  onIntersect: () => void;
  threshold?: number;
  enabled?: boolean;
}

export const useIntersectionObserver = ({
  target,
  onIntersect,
  threshold = 0.5,
  enabled = true,
}: UseIntersectionObserverProps) => {
  useEffect(() => {
    // IntersectionObserver 사용 여부 체킹
    if (!enabled) {
      return;
    }

    // IntersectionObserver 생성
    const observer = new IntersectionObserver(
      (entries) => entries.forEach((entry) => entry.isIntersecting && onIntersect()),
      // 인식 시점에 지정한 event handler 적용
      // entry 의 속성인 isIntersecting 를 이용해 조건을 검사하고, 콜백함수 를 실행
      {
        threshold,
      },
    );
    const element = target && target.current; // IntersectionObserver Target 정의
    if (!element) {
      // IntersectionObserver Target이 없을 경우, 종료
      return;
    }
    observer.observe(element); // IntersectionObserver 실행
    // eslint-disable-next-line consistent-return
    return () => {
      observer.unobserve(element);
    };
  }, [enabled, threshold, target, onIntersect]); // IntersectionObserver Target 업데이트
};

 

useInfiniteQuery 작성

import { useSuspenseInfiniteQuery } from '@tanstack/react-query';
import { SearchPostInfo } from '@/types/postType';
import { getSearchPosts } from '@/service/post';

// 게시글 관련 Query들을 usePostQuery에서 관리
export default function usePostQuery(keyword = '') {
  const ...;
  const ...;
  // React Suspense를 사용하기 위해 useSuspenseInfiniteQuery를 사용
  const getSearchPostsQuery = useSuspenseInfiniteQuery<SearchPostInfo>({
    queryKey: ['searchPost', keyword],
    queryFn: ({ pageParam }) => getSearchPosts(keyword, pageParam),
    initialPageParam: 1,
    getNextPageParam: (lastPage) =>
      // 현재 페이지가 마지막 페이지일 경우 undefined
      lastPage.curPage === lastPage.totalPage ? undefined : lastPage.curPage + 1,
  });

  return { getSearchPostsQuery, ... };
}

 

 

컴포넌트에서 useInterSection 커스텀 훅과 useInfiniteQuery 적용

'use client';

export const dynamic = 'force-dynamic';

import React, { useRef } from 'react';
import PostListCard from '../cards/PostListCard';
import usePostQuery from '@/hooks/usePostQuery';
import { useIntersectionObserver } from '@/hooks/useInterSection';
import { BeatLoader } from 'react-spinners';

type Props = {
  searchWord: string;
};

export default function SearchPostArticle({ searchWord }: Props) {
  // target ref obj
  const observeBox = useRef<HTMLDivElement>(null);
  
  // useInfiniteQuery 사용
  const { getSearchPostsQuery } = usePostQuery(searchWord);
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = getSearchPostsQuery;

  // Intersection Observer 커스텀 훅 사용
  useIntersectionObserver({
    // 관찰 대상
    target: observeBox,
    // 관찰 대상이 뷰포트에 들어왓을 때 실행시킬 함수(다음페이지 데이터 get)
    onIntersect: fetchNextPage,
    // 다음페이지에 가져올 데이터가 있고 데이터 패칭중이 아니라면 IntersectionObserver 사용
    enabled: hasNextPage && !isFetchingNextPage,
  });

  return (
    <article>
      {data && (
        <ul>
          {data.pages.map(({ posts }) =>
            posts.map((post) => (
              <li key={post._id}>
                <PostListCard post={post} />
              </li>
            )),
          )}
        </ul>
      )}
      // 로딩중이고 다음페이지에 불러올 데이터가 있다면 스크롤 하단에 로딩UI 보여줌
      {!isLoading && hasNextPage && (
        <div className="flex justify-center">
          <BeatLoader />
        </div>
      )}
      {/* 여기 div가 관찰 대상 */}
      <div ref={observeBox} />
    </article>
  );
}

 

 

정리

Tanstack-Query(React-Query) v5의 useSuspenseInfiniteQuery와 IntersectionObserver를 이용해서 개인 블로그에 무한스크롤 기능을 적용한 내용을 정리해 보았습니다.

useInfiniteQuery를 이용해서 무한스크롤을 구현하려고 했으나, React Suspense컴포넌트와 useInfiniteQuery를 같이 사용하려면 Tanstack-Query에서 제공하는 useSuspenseInfiniteQuery를 사용해야 해서 useSuspenseInfiniteQuery를 통해 구현하였습니다.

'study > React' 카테고리의 다른 글

React - Error Boundary, React-Query와 사용하기  (0) 2024.04.29