react query iniginiteQuery

Posted by : on

Category : nextJs


무한 스크롤이란?

무한 스크롤 (infinite scroll): 사용자 경험을 개선하기 위해 페이지 로딩 없이 스크롤을 통해 추가 데이터를 로드하는 기법

페이지의 하단에 도달할 때 새로운 데이터를 가져와서 보여 줌 사용자가 스크롤을 위아래로 움직일 때 이벤트를 감지하고 추가 데이터를 가져오는 로직을 수행 React Query의 Infinite Queries를 사용해서 무한 스크롤을 구현할 수 있음

공식 문서: tanstack.com/query/v4/docs/react/guides/infinite-queries

React Query의 infiniteQuery란?

  • 무한 스크롤: 웹 애플리케이션에서 여러 페이지의 데이터를 동적으로 로드하는 기술
  • React Query의 Infinite Query: 무한 스크롤을 지원하고, 화면 스크롤을 통해 추가 데이터를 자동으로 로드할 수 있는 강력한 기능
  • Pagination 작업을 간소화하고, 데이터를 무한으로 스크롤링할 때 필요한 다양한 도구와 기능을 제공
  • 사용자 경험을 향상시키며, 데이터를 효율적으로 로딩할 수 있음
const {
  data,
  error,
  fetchNextPage,
  hasNextPage,
  isFetching,
  isFetchingNextPage,
  status,
} = useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
});

React Query의 infiniteQuery 기능

  • React Query가 설치된 프로젝트에서, 아래와 같이 React Query를 사용하여 Infinite Query를 생성
  • fetchPosts: 페이지별로 데이터를 가져오는 역할
  • getNextPageParam: 콜백 함수를 사용해서 다음 페이지를 정의
import { useInfiniteQuery } from 'react-query';

const fetchPosts = async ({ pageParam = 1 }) => {
  const response = await fetch(`/api/posts?page=${pageParam}`);
  return response.json();
};

const { data, fetchNextPage, hasNextPage } = useInfiniteQuery(
  'posts', // 고유한 쿼리 키
  fetchPosts, // 데이터 가져오는 비동기 함수
  {
    getNextPageParam: (lastPage, pages) => lastPage.nextPage, // 다음 페이지 파라미터 추출
  }
);

React Query의 infiniteQuery 사용법

  • 아래 예시: 데이터를 사용해서 UI를 그리고, 무한 스크롤을 수동으로 제어할 수 있는 버튼 생성

  • data.pages를 통해 페이지별로 데이터를 렌더링
  • fetchNextPage 함수를 호출하여 다음 페이지의 데이터를 가져옴
  • hasNextPage와 isFetching을 사용하여 무한 스크롤 버튼을 제어
return (
  <div>
    {data.pages.map((page, pageIndex) => (
      <div key={pageIndex}>
        {page.posts.map((post) => (
          <div key={post.id}>{post.title}</div>
        ))}
      </div>
    ))}

    {hasNextPage ? (
      <button onClick={() => fetchNextPage()} disabled={isFetching}>
        {isFetching ? '로딩 중...' : '더 불러오기'}
      </button>
    ) : null}
  </div>
);

React Query의 infiniteQuery 사용법 2

  • Intersection Observer를 활용해서, 특정 영역에 도달했을 때 다음 페이지를 가져오는 무한 스크롤 구현
  • useIntersectionObserver 훅을 생성해서 리스트 하단에 도달했는지 (isIntersecting) 확인
  • 만약 페이지 하단에 도달하고, 다음 페이지가 있다면 리액트 쿼리의 fetchNextPage() 함수 호출
  • 마지막 페이지에 다다를 때까지 위 단계를 반복
import useIntersectionObserver from '@hooks/useIntersectionObserver';
import { useInfiniteQuery } from 'react-query';

const listRef = useRef<HTMLDivElement | null>(null);
const listEnd = useIntersectionObserver(listRef, {});
const isEndPage = !listEnd?.isIntersecting;

useEffect(() => {
  if (isEndPage && hasNextPage) {
    fetchNextPage();
  }
}, [fetchNextPage, hasNextPage, isEndPage]);

Infinite Queries 주요 개념

  • data: Infinite Query 결과와 데이터
  • data.pages: 가져온 페이지들의 배열
  • data.pageParams: 페이지를 가져오기 위한 페이지 매개 변수, 배열의 형태
  • fetchNextPage, fetchPreviousPage: 다음 페이지 및 이전 페이지의 데이터를 가져오는 함수
  • getNextPageParam, getPreviousPageParam: 다음 및 이전 페이지에 대한 매개 변수를 생성하는 함수
  • hasNextPage, hasPreviousPage: 다음 페이지 및 이전 페이지가 있는지 여부를 나타내는 불리언 값
  • isFetchingNextPage, isFetchingPreviousPage: 다음 페이지 또는 이전 페이지의 데이터를 가져오는 동안 로딩 상태를 나타내는 불리언 값

Intersection Observer란?

  • Intersection Observer: 브라우저의 viewport와 원하는 요소의 교차점을 관찰하며, 요소가 뷰포트에 포함되거나 아닌지 구별하는 기능
  • 비동기적으로 실행되기 때문에, 메인 스레드에 영향을 주지 않으면서 요소들의 변동상황 관찰
  • Scroll 및 getBoundingClientRect의 성능 문제를 해결
  • 또한, IntersectionObserverEntry 등의 속성을 활용해서 요소들의 위치를 알 수 있음
  • 여러 상황에서 Intersection Observer를 사용할 수 있음:

  • 페이지 스크롤 되는 도중에 발생하는 이미지 지연 로딩
  • 자동으로 페이지 하단에 스크롤 했을 때 무한스크롤 구현
  • 광고 수익 계산을 위한 광고 및 가시성 보고
// IntersectionObserver 등록
const io = new IntersectionObserver(entries => {
  entries.forEach(entry => {
    // 관심 대상이 viewport 안에 들어온 경우 'active' 클래스 추가
    if (entry.intersectionRatio > 0) {
      entry.target.classList.add('active');
    } else {
      // 그 외의 경우 'active' 클래스 제거
      entry.target.classList.remove('active');
    }
  });
});

// 관찰할 대상을 선택하고, 해당 속성을 관찰
const boxList = document.querySelectorAll('.box');
boxList.forEach(el => {
  io.observe(el);
});

Intersection Observer Options 알아보기

  • Intersection Observer는 Options를 통해 관찰이 시작되는 상황에 대한 옵션을 설정할 수 있음
  • root: 대상 객체(target)의 가시성을 확인할 때 사용되는 뷰포트 요소
  • rootMargin: root가 가진 바깥 여백(Margin). margin 값을 이용해 root 범위를 확장/축소할 수 있음 예시: “10px 20px 30px 40px” (top, right, bottom, left). 기본값은 0
  • threshold: observer의 콜백이 실행될 대상 요소(target)의 가시성이 얼마나 필요하거나 나타내는 값
// Options를 설정하고 적용하는 예제
let options = {
  root: document.querySelector('#scrollArea'),
  rootMargin: '0px',
  threshold: 1.0,
};

let observer = new IntersectionObserver(callback, options);

Intersection Observer 기본 문법

  • Intersection Observer API는 다음과 같은 상황에 콜백 함수를 호출:

  • 대상(target) 요소가 기기 뷰포트나 특정 요소(이 API에서 이를 root 요소 혹은 root로 칭함)와 교차할 때
  • 관찰자(observer)가 최초로 타겟을 관측하도록 요청할 때
  • IntersectionObserver() 생성자는 두 개의 인수 (callback, options)를 받는다.

  • callback: 관찰할 대상(target)이 등록되거나, 가시성에 변화가 생기면 실행된다. 두 개의 인수 (entries, observer)를 받는다.
  • Options: 관찰이 시작되는 상황에 대한 옵션을 설정할 수 있음 (root, rootMargin, threshold)
// observer 초기화
let io = new IntersectionObserver(callback, options);
io.observe(element) // 관찰 대상 등록

Intersection Observer Callback: Entry 속성

  • IntersectionObserverEntry는 읽기 전용의 여러 가지 속성들을 포함:
  • boundingClientRect: 관찰 대상의 경계 사각형을 DOMRectReadOnly로 반환
  • intersectionRect: 관찰 대상의 교차한 영역 정보를 DOMRectReadOnly로 반환
  • intersectionRatio: 관찰 대상의 교차한 영역의 비율을 0.0과 1.0 사이의 숫자로 반환
  • isIntersecting: 관찰 대상이 교차 상태인지 아닌지 반 (Boolean)
  • rootBounds: 지정한 루트 요소의 사각형 정보를 DOMRectReadOnly로 반환
  • target: 관찰 대상 요소 (Element) 반환
  • time: 변경이 발생한 시간 정보 (DOMHighResTimeStamp) 반환
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;
  });
};

Intersection Observer 메서드

  • observe: 대상 요소(target)의 관찰을 시작할 때 사용
  • unobserve: 대상 요소의 관찰을 중지할 때 사용. 관찰을 중지할 하나의 대상 요소를 인수로 지정해야 함
  • disconnect: IntersectionObserver 인스턴스가 관찰하는 모든 요소의 관찰을 중지할 때 사용
const io = new IntersectionObserver(callback, options);

const div = document.querySelector('div');
const li = document.querySelector('li');

io.observe(div); // div 요소 관찰 시작
io.observe(li);  // li 요소 관찰 시작

io.unobserve(div); // div 요소 관찰 중지
io.unobserve(li);  // li 요소 관찰 중지

io.disconnect(); // io가 관찰하는 모든 요소 (div, li) 관찰 중지

Intersection Observer hook 예시

참고: https://usehooks-ts.com/ elementRef, options 두 개의 인수를 받아, Intersection Observer API를 사용하여 DOM 요소의 가시성을 감시하고 관찰 결과를 반환하는 훅

import { RefObject, useEffect, useState } from 'react';

function useIntersectionObserver(
  elementRef: RefObject<Element>,
  { threshold = 0.1, root = null, rootMargin = '0%' }: IntersectionObserverInit
) {
  const [entry, setEntry] = useState<IntersectionObserverEntry | undefined>();

  const updateEntry = (entry: IntersectionObserverEntry[]) => {
    setEntry(entry);
  };

  useEffect(() => {
    const node = elementRef?.current;
    const hasIOSupport = !!window.IntersectionObserver;

    if (!hasIOSupport || !node) return;

    const observerParams = { threshold, root, rootMargin };
    const observer = new IntersectionObserver(updateEntry, observerParams);

    observer.observe(node);

    return () => observer.disconnect();

    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [elementRef?.current, JSON.stringify(threshold), root, rootMargin]);

  return entry;
}

export default useIntersectionObserver;

js로 구현

Javascript에서 무한 스크롤 구현 방법: addEventListener()에 scroll 이벤트를 이용해서 구현 또한, getBoundingClientRect() 메서드로 원하는 특정 위치에서 다음 페이지를 가져오도록 구현 하지만, 위 코드들은 성능 문제를 발생시킴. scroll 이벤트: 단시간에 수백번 호출되며 동기적으로 실행 getBoundingClientRect 메서드: 계산을 할 때마다 리플로우 현상이 일어나며 성능 저하 해결 방법: Intersection Observer를 사용해 비동기적으로 교차점 관찰

// 빈 리스트 선택
const listElem = document.querySelector('#infinite-list');
let nextItem = 1;

// 20개의 아이템 추가 함수
const loadMore = function () {
  for (let i = 0; i < 20; i++) {
    let item = document.createElement('li');
    item.innerText = 'List Item #' + nextItem++;
    listElem.appendChild(item);
  }
}

// ul 리스트 바닥까지 스크롤 했는지 확인
listElem.addEventListener('scroll', function () {
  if (listElem.scrollTop + listElem.clientHeight >= listElem.scrollHeight) {
    loadMore()
  }
});

// 아이템 20개씩 더 가져오는 loadMore 함수 실행
loadMore()


About 유재석
유재석

개발자 유재석 입니다. Web Developer.

Email : jaeseok9405@gmail.com

Website : https://github.com/yoo94