본문 바로가기
next.js

Intersection Observer 리액트 무한스크롤 with 타입스크립트

by DevPearl 2024. 8. 21.

짧은 지식으로 인해 글의 신뢰성이 높지 않습니다. 정확한 내용은 공식문서를 참고해 주세요.

 

Intersection Observer API를 통해 무한스크롤을 구현한 적이 있는데,
얼마 전에 다시 구현할 일이 있었습니다.
그러다 몇 달 전 처음 무한스크롤을 배우면서 처음에 개념이 와닿지 않아 힘들었던 기억이 나서, 작은 지식이나마 공유하고 자 글을 작성합니다.

Intersection Observer API에 대한 설명 중심으로 작성했습니다.

 

목차) 

1. IO API 설명, option과 callback

2. useIntersect.ts (useRef를 활용한 무한스크롤) 

 

 

1. IO API 설명, option과 callback

Intersection Observer API (이하 IO) 이전에는 주로 scroll이벤트로 무한스크롤을 구현했다고 한다.

하지만 scroll이벤트는 치명적인 단점이 있다. 아래와 같다.
원하는 것: 뷰포트에 특정 타깃이 관측될 때 콜백함수 시행

스크롤 이벤트: 스크롤이 움직일때마다 콜백함수 시행

 

IO는 바로 우리가 원하는 작업 (뷰포트에 특정 타깃이 관찰될 때 콜백함수 시행)을 가능하게 해 준다.
이름 그대로 뷰포트에서 교차로(Intersect) 관찰하는 것(Observer)의 역할을 하기 때문이다.

let options = {
  root: null,
  rootMargin: "-100px",
  threshold: 0,
};

let observer = new IntersectionObserver(callback, options);

 

IO API의 인스턴스를 만들어주는데 인자로 콜백함수와 옵션을 전달해 주면 된다.

 

1-1 options

옵션에는 3가지 요소가 있다.

root: 관찰할 대상 target. null일 경우 처음 화면에 보이는 viewport.

rootMargin: 관찰할 target의 마진값. string으로 작성.

threshold: 타깃의 몇 퍼센트 이상 관찰되었을 때 isIntersectiong을 할지 정함. 0 ~ 1.0

 

rootMargin을 설명하기 위해 그림으로 그려보았다.

 

root가 null이고 rootMargin이 "-100px", threshold가 0이면,
스크롤을 내리는 경우,

초기의 뷰포트가 완전히 화면에 보지 않게 되는 지점의 100px 위에서부터 isIntersecting은 false가 된다.

(isIntersecting은 타깃이 관측 중인지에 대한 값으로, 아래 callback에서 소개하겠다)

 

1-2 callback

 

isIntersecting(true/false) 값이 바뀔 때, 더 정확하게는 요소가 뷰포트에 옵션을 충족하여 들어오거나 나갈 때마다 콜백함수가 시행된다.

우리가 원하는 작업은 target이 관찰될 때, 혹은 관찰되지 않을 때 특정 작업을 하는 것일 거고 그 내용을 callback함수에 적어주면 된다.

 

그전에 콜백함수의 구성을 살펴보면 다음과 같다

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와 observer를 매개변수로 받는데 observer는 IO 인스턴스를 가리키고, entries는 콘솔에 찍어보면 다음과 주석과 같은 정보들을 담고 있다.

주로 사용하는 값은 타깃이 관측되고 있는지를 나타내는 entry.isIntersecting이다.

실제 사용할 때는 주로 구조분해 할당으로 entries배열의 첫 번째 요소를 가져온다. ([entry])

 

1. useIntersect.ts (useRef를 활용한 IO 훅)

import { useState, useEffect, useCallback } from "react";

interface ObserverOptions extends IntersectionObserverInit {
  root?: HTMLDivElement | null;
  rootMargin?: string;
  threshold?: number;
}

const defaultOption = {
  root: null,
  threshold: 0.5,
  rootMargin: "0px",
};

const useIntersect = (
  onIntersect: (
    entry: IntersectionObserverEntry,
    observer: IntersectionObserver
  ) => void,
  option: ObserverOptions
) => {
  const [ref, setRef] = useState<HTMLDivElement | null>(null);

// 콜백함수를 useCallback으로 감싸 onIntersect가 바뀔때만 새로 declare
// isIntersecting이 true일 경우 매개변수로 받은 onIntersect 함수에 entry와 observer전달해 호출
  const obsCallback = useCallback(
    ([entry]: IntersectionObserverEntry[], observer: IntersectionObserver) => {
      if (entry.isIntersecting) {
        onIntersect(entry, observer);
      }
    },
    [onIntersect]
  );

  useEffect(() => {
    let observer: IntersectionObserver | undefined;

	// setRef로 ref가 연결이 되었을 때만 IO 인스턴스 생성, 옵션이 없으면 defaultOptions 사용 
    if (ref) {
      observer = new IntersectionObserver(obsCallback, {
        ...defaultOption,
        ...option,
      });

	// observer가 ref를 관측
      observer.observe(ref);
    }

	// 컴포넌트 언마운트 시 disconnect
    return () => observer && observer.disconnect();
  }, [ref, obsCallback, option]);

// setRef 반환
  return setRef;
};

export default useIntersect;

 

컴포넌트에 인수로 타겟이 관측될때 시행할 onIntersect함수와 options 값을 전달했다.

 

추후 IO 인스턴스생성시 매개변수로 전달할 콜백함수는 entry.inIntersecting이 true일경우 onIntersect를 호출하는 함수로 정의한다. 

콜백함수는 onIntersect 가 달라질때만 다시 선언되도록 useCallback으로 감싸주었다.

 

콜백함수와 options를 매개변수로 IO 인스턴스를 만들어준다.

observer.observe(ref)로 ref를 관측한다

 

ref값은 초기에 null이고 hook을 호출한 곳에서 setRef로 div에 연결할 경우 그 div가 된다

 

"use client";

import useIntersect from "@/utils/useIntersect";

export default function Page() {

// useIntersect훅 함수와 options 매개변수로 전달.
  const intersectRef = useIntersect(
  	// 추후 observer인스턴스의 콜백함수에서 타깃이 관측될때 시행할 함수
    (entry, observer) => {
      if (isLoading || !hasMore) return;

	// 일단 기존 타겟 관측 종료
      observer.unobserve(entry.target);
	
    // 페이지 즐가 -> 페이지 증가시 데이터 추가로 불러오는 함수 작성 필요
      setCurrentPage((prev) => prev + 1);
	
    // 새 데이터를 불러온 후 다시 타겟 관측
      observer.observe(entry.target);
    },
    // IO instance에 option으로 전달할 옵션
    { threshold: 0.45 }
  );

  return (
    <div>
    // data 보여주는 부분
      {hasMore && (
        <div className="h-32 w-96 bg-purple-600" ref={intersectRef} />
      )}
    </div>
  );
}

 

useRef를 사용한 이유는 render사이클에서 값을 유지하면서도 불필요한 re-render를 유발하지 않고,

ref인스턴스가 사라졌을 때 불필요하게 관측을 하지 않기 위해서다.

 

주로 클라이언트사이드 데이터 캐싱이 필요한 경우,

react query(tanstack query)의 useInfiniteQuery로 데이터 캐싱을 하는 게 일반적이고,

그럴 경우 useState로 데이터를 계속 관리하면서 re-render를 유발하지 않아도 되고, isLoading이나 hasMore도 간편하게 가져올 수 있다.

나는 이번에 달력을 집어넣고 달력 조회 버튼을 눌렀을 때 시간별 값을 가져와서 딱히 클라이언트 사이드 캐싱을 하지 않아 react query를 쓰지 않았다. 

 

혹시 무한스크롤 로직 관련 추가로 궁금하신 부분 있으시거나 잘못 작성된 부분이 있을 경우 댓글 부탁드립니다.

 

레페런스:

https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API

 

Intersection Observer API - Web APIs | MDN

The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document's viewport.

developer.mozilla.org