리액트/리액트

[리액트]무한 스크롤(useInfiniteQuery & IntersectionObserver)

삼록이 2025. 11. 6. 12:49

우리가 흔히 앱에서 게시물을 볼 때 목록을 계속해서 아래로 내리는 무한 스크롤을 리액트에서 구현하기 위해서는

2가지가 필요하다.

useInfiniteQuery와 IntersectionObserver.

 

먼저 결론부터 말하자면, useInfiniteQuery가 fetchNextPage라는 함수를 생성한다.

IntersectionObserver라는 것이 사용자가 스크롤하는 것을 감시하다가 감시대상(observerTarget)이 화면에 들어온 것을 감지한다. 그러면 이것이 트리거가 되어 fetchNextPage()를 실행시킨다. 이것이 계속해서 반복되서 다음 페이지 데이터를 계속해서 가져올 수 있다.

 

그렇다면, useInfiniteQuery는 뭐고, fetchNextPage는 뭐고 IntersectionObserver는 무엇인지 저게 무슨 말인지 알아보자.

 

useInfiniteQuery란?

무한 스크롤이나 페이지네이션을 간편하게 처리하는 React Query 훅

일반적인 useQuery는 "한 번의 데이터 요청"만 담당하지만,

useInfiniteQuery는 "페이지 단위로 데이터를 이어서 가져오는 기능"을 제공한다.

자동으로 다음 페이지 요청, 데이터 병합, 캐싱 기능을 처리한다.

처음엔 첫 페이지 데이터를 가져오고, 이후 fetchNectPage()를 호출 할 때 마다 다음 데이터를 가져와 누적 저장한다.

useInfiniteQuery를 사용하면 page와 data상태를 직접 관리해야하나 이를 자동으로 처리하는 장점이 있다.

 

 

기본 문법

useInfiniteQuery는 아래와 같은 리턴값과 옵션을 가진다.

const {
  data,              //모든 페이지의 데이터
  fetchNextPage,     //다음 페이지를 불러오는 함수
  hasNextPage,       //다음 페이지가 있는지 여부
  isFetchingNextPage, // 다음 페이지 불러오는 중인지 여부
  status,             //Loading,error,sucess
} = useInfiniteQuery({
  queryKey: ['posts'],
  queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
  getNextPageParam: (lastPage, allPages) => lastPage.nextPage,
});

 

 

 

옵션 중 중요한 포인트는 아래 세 가지다.

 

위의 코드에서 initalPageParam 옵션을 정의하지않은 이유는 queryFn 함수부분에 pageParam =1 이라고 초기 페이지 번호를 이미 넘기고 fetchPosts함수에서 이 기본값을 처리하기 때문이다. 

// 게시글 목록을 페이지 단위로 가져오는 함수
const fetchPosts = async ({ pageParam = 1 }) => {
  const response = await fetch(`/api/posts?page=${pageParam}`);
  
  if (!response.ok) {
    throw new Error('게시글을 불러오는 데 실패했습니다.');
  }

  const data = await response.json();

  // 백엔드에서 이런 형태로 응답을 준다고 가정
  // {
  //   posts: [...],
  //   nextPage: 1,  // 다음 페이지 번호
  //   isLast: false // 마지막 페이지 여부
  // }

  return {
    posts: data.posts,
    nextPage: data.isLast ? undefined : data.nextPage, // 다음 페이지가 없으면 undefined
  };
};


export const usePosts = () => {
  return useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    getNextPageParam: (lastPage) => lastPage.nextPage,
    staleTime: 1000 * 60 * 5, // 5분 동안 데이터를 신선한 상태로 유지
    gcTime: 1000 * 60 * 10, // 10분 동안 캐시 유지 (cacheTime)
    refetchOnWindowFocus: true, // 창이 포커스될 때 자동으로 리페칭
  });
};

 

 

fetchNextPage란?

useInfiniteQuery에 의해 리턴되는 결과 값 중 fetchNextPage함수는 우리가 queryFn에 정의해놓은 진짜 함수를 호출하되 getNextPageParam으로 얻은 다음 페이지 인자값을 넘겨 호출하는 함수다.


 

IntersectionObserver란?

어떤 DOM 요소(element)가 화면(Viewport)에 얼마나 보이는지를 자동으로 감시하는 브라우저 내장 API다.

쉽게 말해서, 이 div가 화면에 보이기 시작했는지 브라우저에게 알려달라고 할 수 있는 도구다.

이 div가 보이면 우리는 아 첫 페이지에서 가져온 데이터를 다보여줬구나. 그러면 다음 페이지 보여줘야지하고 fetchNextPage함수를 호출하도록 하기 위해서 이 IntersectionObserver가 필요하다.

 

기본문법

 

//1. IntersectionObserver 객체 생성
const observer = new IntersectionObserver((entries, observer) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      console.log('요소가 화면에 보이기 시작!');
    }
  });
}, options);

//2.감시 요소 등록
const target =  document.querySelector('#target');
observer.observe(target);

 


작동원리

아래 실제 코드를 보면서 다시 정리해보자.

  const observerTarget = useRef(null);

// React Query 무한 스크롤 훅 사용
  const { 
    data, 
    isLoading, 
    isError, 
    error,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage
  } = usePosts();
  
  
 // Intersection Observer를 사용한 무한 스크롤
useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
          fetchNextPage();
        }
      },
      { threshold: 0.5 } // 50% 보이면 트리거
    );

    const currentTarget = observerTarget.current;
    if (currentTarget) {
      observer.observe(currentTarget);
    }

    return () => {
      if (currentTarget) {
        observer.unobserve(currentTarget);
      }
    };
  }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
  
  .
  .
  .
  
   <div ref={observerTarget} className="load-more-trigger">
                {isFetchingNextPage && (
                  <div className="loading-more">
                    <p>게시글을 더 불러오는 중...</p>
                  </div>
                )}
              </div>

 

 

useInfiniteQuery를 사용한 usePost()라는 함수를 따로 다른 곳에 정의해놓았다.

 

컴포넌트가 처음 렌더링 되면 observeTaeget에 연결된 <div ref={observerTarget} />가 실제 DOM에 존재한다.

useEffect()가 실행되며 IntersectionObserver객체가 생성되고 observer.observe(currentTarget)으로 감시가 시작된다.

사용자가 스크롤을 내리면 entries[0].inIntersecting이 true가 되어 아래부분에서 fetchNextPage()를 호출한다.
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { fetchNextPage(); }

 

 

 

 

 

 

'리액트 > 리액트' 카테고리의 다른 글

[리액트] 쿼리 훅?  (2) 2025.10.28
[리액트] useParams, useSearchParams, useNavigate란?  (0) 2025.10.22
[리액트] useContext  (0) 2025.10.19
[리액트] memo, useCallBack  (0) 2025.10.17
[리액트] useMemo란?  (0) 2025.10.17