728x90

예전 프로젝트에서 tanstack query의 in useInfiniteQuery를 활용하여 무한스크롤을 구현한 적이 있습니다.

그 당시에 굉장히 수월하게 구현할 수 있었는데, 원티드 프리온보딩에서 라이브러리 없이 Intersection Oberserver API을 활용하여 무한스크롤을 구현하라는 사전과제를 받은 김에 정리하고자 글을 작성하게 되었습니다.

 

 

 Intersection Observer API란?

MDN에 따르면 Intersection Observer API의 필요성은 아래와 같습니다.

역사적으로, 요소의 가시성 또는 관련된 두 요소 사이의 상대적 가시성을 감지하는 것은 해결책을 신뢰할 수 없고 브라우저와 사용자가 접근하는 사이트를 느리게 만드는 어려운 작업이었습니다. Web이 성숙해짐에 따라, 이러한 종류의 정보의 요구가 늘어났습니다. 교차 정보는 다음과 같은 많은 이유로 필요합니다.

 

즉, 특정 요소가 얼만큼 노출되었는지(교차 정보)를 확인할 필요성이 점점 늘어났고 이를 위해 생긴 API입니다.

이때 상호작용 요소를 메인스레드가 아닌 콜백함수에서 관리함으로써 , 브라우저는 적합하다고 판단되는 대로 교차 관리를 자유롭게 최적화할 수 있게 됩니다.

 

문법

new IntersectionObserver(callback)
new IntersectionObserver(callback, options)

각각 callback과 options의 타입을 확인하면 아래와 같습니다.

// callback의 타입
interface IntersectionObserverCallback {
    (entries: IntersectionObserverEntry[], observer: IntersectionObserver): void;
}

// options의 타입
interface IntersectionObserverInit {
    root?: Element | Document | null;
    rootMargin?: string;
    threshold?: number | number[];
}

 

  • 콜백함수
    콜백 함수는 대상 요소가 지정한 가시성 임계값을 넘을 때 호출됩니다. 콜백 함수는 두 개의 매개변수를 입력받는데, 
    - entries는 콜백함수가 생길 때 발생한 정보를 담고 있습니다.
    - observer는 관측자에 대한 정보를 담고 있습니다.
  • options
    option은 콜백함수가 발생하는  조건에 대해 커스텀 할 때 쓸 수 있습니다. 선택적인 요소라서 필요할 때 적용하면 됩니다.
    - root  :  상호작용의 경우에 뷰포트 기준으로 할 때가 많습니다. 하지만 특정 모달 내부의 스크롤 등  관측 대상자의 상위 요소를 지정해줘야 할 경우에 사용합니다.
    -rootMargin : 이미지 등 미리 보여야할 경우에는 뷰포트에 도달하기 전에 사용해야할 수 있습니다. 이때 해당 관측대상 기준으로 미리 감지할 수 있는 여백의 크기를 결정합니다.
    -threshold : 관측 대성이 얼만큼의 가시성을 확보해야 상호 작용할 지 설정할 떄 사용합니다.

이를 바탕으로 무한 스크롤에 사용한 옵션의 예시는 아래와 같습니다.

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          fn();
        }
      },
      { threshold: 1.0 }
    );

 

관측 대상이 완벽하게 보일 때  fn이라는 함수를  실행하겠다 입니다.

 

 Intersection Observer를 활용하여 React에서 무한스크롤 구현하기

무한스크롤을 구현하는 방법은 여러가지가 있습니다.  스크롤 높이 기준으로 구현할 수도 있고, 특정 물체가 감지될 때마다 다음페이지를 요청하는 옵저버 방식도 있습니다.

Intersection Observer API를 통해 구현할 옵저버 방식으로 감지대상이 감지되면 다음 페이지에 대한 정보를 요청할 것 입니다.

 

우선 완성된 화면은 아래와 같습니다.  상품 리스트를 담고, 상품의 가격을 전부다 합산한 내용이 우측 상단에 표시했습니다. 그리고 하단으로 내리다 불러온 자료 끝부분에 도달하면, 새로운 요청을 합니다.

 

코드 구조는 다음과 같습니다.
App에서 무한스크롤 영역과 가격 총합을 보여주는 Header부분이 있습니다. 이때 가격을 계산하는 부분은 커스텀훅으로 따로 구현하였습니다.

import './App.css';
import useTotalPrice from './hooks/useTotalPrice';
import Header from './components/Header';
import InfinityScroll from './components/InfinityScroll';

function App() {
  const { totalPrice, updateTotalPrice } = useTotalPrice();

  return (
    <main className="wrapper">
      <Header totalPrice={totalPrice} />
      <InfinityScroll updateTotalPrice= {updateTotalPrice}/>
    </main>
  );
}

export default App;

 

이제 저희의 관심사는 InfinityScroll에 대해서 알아보겠습니다.

import { MockData } from '../const/mock';
import useFetchData from '../hooks/useFetchData';
import useInfiniteScroll from '../hooks/useInfiniteScroll';
import CardList from './CardList';
import Spinner from './Spinner';

const InfinityScroll: React.FC<{
  updateTotalPrice: (datas: MockData[]) => void;
}> = ({ updateTotalPrice }) => {
  const { list, isLastPage, isLoading, fetchData } =
    useFetchData(updateTotalPrice);
  const observerRef = useInfiniteScroll(fetchData, isLoading, isLastPage);

  return (
    <>
      <CardList list={list} />
      {isLoading && <Spinner />}
      {!isLastPage ? (
        <div ref={observerRef} className="observer" />
      ) : (
        <div className="end">마지막 페이지 입니다</div>
      )}
    </>
  );
};

export default InfinityScroll;

 

카드는 배열을 매개변수로 넘기면 되고, 로딩중일때는 로딩처리, 마지막 페이지일 때는 마지막 페이지라 알려주고 아닌 경우에는 oberserRef를 할당항 옵저버 div가 렌더링되도록 하였습니다.

 

useFetchData 훅을 통해서 마지막 페이지인지, 반환된 배열은 어떤건지, 로딩처리, 데이터 요청 시 사용하는 함수가 반환됩니다. 

그리고 useInifinieScroll훅에 요청함수와 로딩중인지, 마지막 페이지인지를 전달해주고 ref를 반환받고 있습니다.

 

결국 useInfiniteScroll이 어떻게 구현되어있는지만 확인하면 됩니다.

import { useEffect, useRef } from 'react';

function useInfiniteScroll(
  callback: () => void,
  isLoading: boolean,
  isLastPage: boolean
) {
  const observerRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    if (isLoading || isLastPage) return;

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          callback();
        }
      },
      { threshold: 1.0 }
    );

    const currentRef = observerRef.current;

    if (currentRef) {
      observer.observe(currentRef);
    }

    return () => {
      if (currentRef) {
        observer.unobserve(currentRef);
      }
    };
  }, [isLoading, isLastPage, callback]);

  return observerRef;
}

export default useInfiniteScroll;

해당 훅에서는 로딩이나 마지막 페이지일때는 useEffect가 작동하지 않게 하였습니다. 

observerRef를 선언한 후에, useEffect 내부에서는 Intersectionobserver를 통해서 대상 옵저버 div가 완전히 보일 때 콜백함수 (매개변수로 넘겨준 콜백함수(fetchData함수))를 실행하도록 하였습니다. 

이후 관측대상이 존재한다면 (observerRef.current가 참이라면) 관측을 시작하고, 컴퍼넌트가 업데이터 되거나 언마운트될 떄 관측을 취소하도록 하였습니다.

 

그리고  반환된 observerRef는 InfinityScroll 컴퍼넌트의 관측자 div의 ref에 할당되어있습니다.

 

 

참고문헌 및 링크

https://developer.mozilla.org/ko/docs/Web/API/Intersection_Observer_API

 

Intersection Observer API - Web API | MDN

Intersection Observer API는 상위 요소 또는 최상위 문서의 viewport와 대상 요소 사이의 변화를 비동기적으로 관찰할 수 있는 수단을 제공합니다.

developer.mozilla.org

 

https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver/IntersectionObserver

 

IntersectionObserver() - Web API | MDN

IntersectionObserver() 생성자는 새로운 IntersectionObserver 객체를 생성하고 반환합니다.

developer.mozilla.org

 

 

 

+ Recent posts