본문 바로가기
FE/react

[React] react-query useInfiniteQuery로 무한스크롤 구현하기

by s0ojin 2023. 5. 22.

 

무한스크롤(Infinite Scroll)?


 게시판 글 리스트처럼 많은 데이터를 배열로 받아오는 경우, 그 데이터가 너무 방대해지면 api 요청으로 데이터를 받아오는 시간이 오래걸릴 수 밖에 없게됩니다. 우리는 페이지네이션(pagination)으로 데이터를 일정 개수씩 분리해 받아오는 방식을 통해 이러한 문제를 해결해왔습니다. 페이지네이션 또한 좋은 선택지이지만, 오늘 날 모바일 기기를 이용해 웹에 접속하는 경우가 매우 많다보니 터치횟수를 최소화하고 콘텐츠를 끊김없이 보여주는 무한스크롤이 더 좋은 대안이 될 수 있습니다. 

 

무한스크롤 구현방법


 무한스크롤은 한 페이지의 스크롤의 바닥(세로 끝부분)에 도달할 때 api를 요청하는 방식입니다. 자 그럼 문제는 '어떻게 스크롤 바닥에 도달했는지 알 수 있는가?' 입니다. 방법은 대표적으로 onScroll을 이용하는 방식과  Intersection Observer API를 활용하는 방식이 있습니다. 저는 오늘 Intersection Observer API와 react-query를 이용하여 구현하는 방법과 코드를 소개할 예정이지만, 두 방식이 어떻게 작동하는지도 간단히 준비해봤습니다.

 

1. Scroll Event

콘텐츠의 전체 길이와 현재 스크롤 한 길이를 비교하여 스크롤 바닥을 감지하는 방법

scrollHeight: 수직 스크롤 없이 콘텐츠를 전부 나타냈을 때 그 길이

clientHeight: 현재 스크린 상에 보이는 화면의 높이 (css높이  + css padding - 수평스크롤바 높이)

scrollTop: 현재 스크롤 위치에서 가장 윗부분의 위치, 스크롤을 맨 끝으로 내리면 scrollHeight - UI높이 값이 scrollTop값이 됨.

 

 사진으로 더 설명해보자면 현재 전체 콘텐츠에서 중간정도를 보고 있는 상황입니다. 스크롤을 맨끝까지 내렸을 때 비로소 scrollHeight - scrollTop값이 clientHeight이 되기 때문에, 끝에 닿기 전까지는 scrollHeight - scrollTop값이 항상 clientHeight보다는 큽니다. 이를 이용해서 스크롤이 바닥에 닿는 때를 감지할 수 있게되는 거죠. 쉽게말해 '전체 콘텐츠의 길이'와 '현재 스크롤 한 길이'를 비교해서 구하겠다 이 말입니다! 

 하지만 이 방식은 스크롤이 움직일 때마다 이벤트 핸들러가 호출되므로 성능이슈가 있기때문에 이벤트를 한번만 호출시켜주는 Throttle이나 Debounce를 함께 구현해주어야 합니다. Intersection Observer Api를 활용하면 그런 불편함을 피할 수 있습니다.

 

 

2. Intersection Observer API로 바닥 감지하기

타겟 요소와  상위요소 또는 최상위 document의 viewport 사이의 intersection 내의 변화를 비동기적으로 관찰하는 방법

1. 인스턴스 생성하기

let options = {
  root: document.querySelector('#scrollArea'),
  rootMargin: '0px',
  threshold: 1.0
}

let observer = new IntersectionObserver(callback, options);

root: 대상 객체의 가시성을 확인할 때 사용되는 뷰포트 요소, 기본값은 브라우저 뷰포트이며 root값이 null이거나 지정되지 않을 때 기본값으로 설정됨

rootMargin: root가 가진 여백, 교차성을 계산하기 전에 적용됨

threshold: target의 가시성 퍼센티지, 예를들어 target이 root에서 50%만크 요소가 보여졌을때를 탐지하고 싶다면, 0.5로 설정하면 됨. 기본값은 0으로, 요소가 1px이라도 보이면 콜백이 실행됨. 반면 1.0으로 설정할 시 target의 전체가 화면에 노출되기 전엔 콜백이 실행되지 않음. 

 

2. 콜백함수

let callback = (entries, observer) => {
  entries.forEach((entry) => {
    // Each entry describes an intersection change for one observed
    // target element:
    //   entry.boundingClientRect
    //   entry.intersectionRatio
    //   entry.intersectionRect
    //   entry.isIntersecting
    //   entry.rootBounds
    //   entry.target
    //   entry.time
  });
};

entries: IntersectionObserverEntry의 인스턴스 배열

entry.isIntersecting: 관찰 대상의 교차상태 (boolean)

 

정리를 해보면, target요소가 root요소를 교차하는 시점에 콜백함수를 실행시킬 수 있습니다. 콘텐츠 최하단 부분을 target으로 설정해두면, 스크롤이 맨 아래 닿았을 때 콜백함수가 실행되는 것입니다.

 

useInfiniteQuery란?


 바닥을 감지하는 방법을 알았으니, 이제 api 호출을 통해 데이터를 한페이지 씩 받아오면됩니다. react-query에서는 무한스크롤을 편하게 구현할 수 있도록 돕는 useInfiniteQuery라는 훅을 제공하고 있습니다. 각 옵션에 대한 자세한 내용은 공식문서에서 확인할 수 있습니다. 오늘은 제가 구현할 때 사용한 옵션들만 소개해드리겠습니다.

const {
  data,
  fetchNextPage,
  hasNextPage,
} = useInfiniteQuery(
  queryKey,
  queryFn: ({ pageParam = 1 }) => fetchPage(pageParam),
  {
    select: data => ({
      pages: data.pages,
      pageParams: data.pageParams,
    }),
    getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
  }
 )

returns

data: 서버에 요청해서 받아온 데이터

fetchNextPage: 다음페이지를 불러옵니다.

hasNextPage: 가져올 다음페이지가 있는지 여부를 나타냅니다(boolean). getNextPageParam옵션을 통해 알 수 있습니다.

 

option

queryKey: 쿼리를 구별하여 캐시를 관리하기위한 이름, key입니다.

queryFn: 쿼리가 데이터를 요청하는 데 사용할 함수, API입니다.

select: 받아온 데이터를 원하는 형태로 가공할 수 있습니다. useInfiniteQuery의 경우 페이지가 쌓이는 형태이기 때문에 구조가 복잡해서 select로 가공해서 사용하는 편이 코드가 깔끔합니다.

getNextPageParam: 새 데이터를 받아올 때 마지막페이지와 전체페이지 배열을 함께 받아옵니다. 더 불러올 데이터가 있는지 여부를 결정하는데 사용합니다. 반환값이 다음 API호출할때의 pageParam으로 들어갑니다.

 

구현하기


1. intersection observer api Hook 분리하기

import React, { useEffect, useState } from 'react';
import { InfiniteQueryObserverResult } from '@tanstack/react-query';

//hook props interface
interface IuseIntersectionObserverProps {
  threshold?: number;
  hasNextPage: boolean | undefined;
  fetchNextPage: () => Promise<InfiniteQueryObserverResult>;
}

export const useIntersectionObserver = ({
  threshold = 0.1,
  hasNextPage,
  fetchNextPage,
}: IuseIntersectionObserverProps) => {

  //관찰할 요소입니다. 스크롤 최하단 div요소에 setTarget을 ref로 넣어 사용할 것입니다.
  const [target, setTarget] = useState<HTMLDivElement | null | undefined>(null);

  const observerCallback: IntersectionObserverCallback = (entries) => {
    entries.forEach((entry) => {
    	
      //target이 화면에 관찰되고, 다음페이지가 있다면 다음페이지를 호출
      if (entry.isIntersecting && hasNextPage) {
        fetchNextPage();
      }
    });
  };

  useEffect(() => {
    if (!target) return;
    
    //ointersection observer 인스턴스 생성
    const observer = new IntersectionObserver(observerCallback, {
      threshold,
    });
    
    // 타겟 관찰 시작
    observer.observe(target);
    
    // 관찰 멈춤
    return () => observer.unobserve(target);
  }, [observerCallback, threshold, target]);

  return { setTarget };
};

 

2. 적용하기 

export default function PostList({ filterProp }: { filterProp: IFilter }) {
  const { data, isLoading, fetchNextPage, hasNextPage } = useInfiniteQuery<
    AxiosResponse,
    AxiosError,
    IArticleData
  >(
    ['articles', filterProp],
    ({ pageParam = 1 }) => getArticles(filterProp, { pageParam }),
    {
      getNextPageParam: (lastPage, allPages) => {
        const nextPage = allPages.length + 1;
        return lastPage.data.length === 0 ? undefined : nextPage;
      },
      select: (data) => ({
        pages: data?.pages.flatMap((page) => page.data),
        pageParams: data.pageParams,
      }),
    },
  );

  // 커스텀 훅에 hasNextPage와 fetchNextPage를 넣어 setTarget을 받아옵니다.
  const { setTarget } = useIntersectionObserver({
    hasNextPage,
    fetchNextPage,
  });

  return (
    <>
      <ul>
        {/* 페이지 ui 생략*/}
      </ul>
      
      {/* 페이지 최하단에 작은 div요소 만들어 ref에 setTarget적용 */}
      <div ref={setTarget} className="h-[1rem]" />
    </>

 

작동 모습


스크롤이 바닥에 닿으면 다음 페이지를 제대로 불러오고 있습니다.

 

 

References


https://tech.kakaoenterprise.com/149

https://velog.io/@elrion018/%EC%8B%A4%EB%AC%B4%EC%97%90%EC%84%9C-%EB%8A%90%EB%82%80-%EC%A0%90%EC%9D%84-%EA%B3%81%EB%93%A4%EC%9D%B8-Intersection-Observer-API-%EC%A0%95%EB%A6%AC

 

 

 

댓글