Intersection Observer API를 이용한 무한 스크롤 구현 - 리액트 커스텀 훅

jonyChoiGenius·2023년 2월 9일
0

useIntersection 커스텀 훅 만들기

지난번 글에서 정리했던 BetterProgramming에서 예시로 만든 useIntersection 커스텀 훅을 사용하여 무한 스크롤을 구현하고자 한다.

먼저 해당 useIntersection을 재사용 가능하도록 수정해주어야 한다.
기존 이미지 lazyLoading에서 사용할 때에는 한번 관측 후 이벤트를 삭제해주었지만,
무한 스크롤에서는 한 번 정의한 ref를 여러번 재사용해야 한다.
이에 따라 loop라는 파라미터를 true, false로 받고, 기본 값은 false로 주어 하위 호환 하도록 하였다.

수정된 useIntersection은 아래와 같다.

import { useEffect } from "react";

let listenerCallbacks = new WeakMap();

let observer;

function handleIntersections(entries) {
  entries.forEach((entry) => {
    if (listenerCallbacks.has(entry.target)) {
      let cb = listenerCallbacks.get(entry.target);

      if (entry.isIntersecting || entry.intersectionRatio > 0) {
        //(3) target의 loop가 false일 때에만 옵저버와 콜백을 삭제해준다. true인 경우에는 재사용이 가능하다.
        if (entry.target.loop === false) {
          observer.unobserve(entry.target);
          listenerCallbacks.delete(entry.target);
        }
        cb();
      }
    }
  });
}

function getIntersectionObserver() {
  if (observer === undefined) {
    observer = new IntersectionObserver(handleIntersections, {
      rootMargin: "100px",
      threshold: 0,
    });
  }
  return observer;
}

//(1) loop라는 세번째 파라미터를 받는다.
export function useIntersection(elem, callback, loop: boolean = false) {
  useEffect(() => {
    let target = elem.current;
    //(2) target에 loop라는 새로운 프로퍼티를 선언하여 만들어준다.
    target.loop = loop;
    let observer = getIntersectionObserver();
    listenerCallbacks.set(target, callback);
    observer.observe(target);

    return () => {
      listenerCallbacks.delete(target);
      observer.unobserve(target);
    };
  }, []);
}

가짜 글 목록 만들기

테스트를 위해 가짜 글 목록을 만들 것인데,
무한 스크롤은 페이지네이션된 데이터를 다루는만큼 API를 잘 살펴보는 것이 중요하다.

본 테스트에서는 세가지를 전제하였다.
1. 한 페이지당 글 갯수는 5개
2. 첫 페이지는 0부터 시작
3. 최초 마운트시에 0번 페이지를 로딩하고 시작함.

테스트를 위해 pages/InfiniteScroll.tsx를 만들고 아래와 같이 변수를 주었다.

const InfiniteScroll = () => {
  const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
  const [articles, setArticles] = useState([1, 2, 3, 4, 5]);
  const [page, setPage] = useState(1);
}
export default InfiniteScroll;

15개의 글을 의미하는 arr이라는 배열을 만들었고,
앞선 전제조건에 맞게 arr의 첫 다섯개의 글 1, 2, 3, 4, 5를 articles의 초기값으로,
페이지 0이 이미 로딩되었으므로 그 다음 페이지인 1을 page의 초기값으로 주었다.

page의 초기값을 0으로 주느냐, 1로 주느냐에 따라서 이후 발생할 이벤트의 순서가 달라진다.
가령, 앞으로 페이지 1을 로딩해야 하는데,
초기값이 0이라면 page에 1을 더해준 후 fetchData를 실행하고,
초기값이 1이라면 fetchData를 한 후 page에 1을 더해준다.

경험상 초기값을 1(즉, 앞으로 불러올 페이지)로 주는 것이 상태관리가 쉬웠다.

useRef를 선언하기

아티클 목록을 렌더링 한 후 (이때 페이지에서 벗어나 스크롤이 생성될 만큼 충분히 크게 만든다)
아티클 목록 바로 아래에 태그를 하나를 만든다. <p>새로운 글을 불러오는 중</p>과 같이 p태그를 사용할 수 있다.
만일 div태그를 사용하는 경우 width를 100%로 설정하여 줄바꿈이 일어나게 만든다.

  return (
    <div>
      {articles.map((e) => {
        return (
          <div key={e} style={{ width: "100%", padding: "200px" }}>
            {e}
          </div>
        );
      })}
      <div ref={trigger} style={{ width: '100%'}}></div>
    </div>
  );

useEffect로 이벤트의 발생을 탐지하기

이제 커스텀훅인 useIntersection(element, callbackFn, loop)를 trigger Ref가 화면에 들어왔을 때 추가로 글을 불러오면 되는데...
문제가 있다.

  1. useIntersection(element, callbackFn, loop)에서 정의되는 callbackFn은 특정 시점에 렌더링된, 그 시점의 컴포넌트를 클로저로 갖고 있다. 즉 callbackFn은 그 시점의 state들을 참조하고 있다. 리렌더링이 되면 렉시컬 환경이 달라져서 state가 서로 일치하지 않을 수 있다.
  2. 1의 문제와 더불어, setState 함수들이 비동기적으로 처리되기 때문에 callbackFn이 적절한 시점에 적절한 state를 참조할 것이라고 보장하지 않는다.
  3. 1, 2의 문제와 더불어 Intersection이 여러번 Observing되었을 때 역시 예기치 못한 동작(가령 같은 페이지를 무한히 불러오는 등)이 발생할 수 있다.

이에 대해 const [triggered, setTriggered] = useState(false);라는 새로운 state를 만들었다. triggered는 두 가지 역할을 하는데
1. 이벤트가 발생되었음을 알린다.
2. 이벤트가 이미 발생되어 실행중이라는 의미로, 추가적인 이벤트를 방지하는 쓰로틀의 기준점이 된다.

useIntersection의 콜백 함수는 아래와 같이 단순하게 triggered라는 state를 true로 바꿔주기만 하면 된다.
useIntersection(trigger, () => setTriggered(true), true)

그리고 모든 페이지가 로딩되었음을 의미하는 const [isLastPage, setIsLastPage] = useState(false) 역시 선언해주었다.

나머지 로직은 useEffect로 triggered를 추적하면 된다. 의존자 배열에 triggered를 두면, () => setTriggered(true)가 여러번 실행되어도, false=>true로 바뀌었을 때, 혹은 true=>false로 바뀌었을 때만 추적할 것이다.

  useEffect(() => {
    //빠른 반환
    if (!triggered) return; //triggered가 false이면 아무 것도 하지 않는다.
    if (isLastPage) return setTriggered(false); //만일 마지막 페이지까지 다 불러왔다면 triggered를 false로 바꾸고 끝낸다.
	
    //새로운 글을 가져온다.
    const newSlice = arr.slice(page * 5, page * 5 + 5);
    //새로운 글이 1개 이상이면
    if (newSlice.length) {
      //글 목록에 새로운 글들을 추가하고
      setArticles([...articles, ...newSlice]);
      //페이지를 1 증가시킨다.
      setPage(page + 1);
    } else { //만일 새로 가져온 글이 0개면
      //마지막 페이지까지 모두 렌더링 된 것으로 판단하고 lastPage를 true로 전환한다.
      setIsLastPage(true);
    }
	
    //작업이 끝나면 triggered를 false로 바꾼다.
    setTriggered(false);
  }, [triggered]);

triggered를 의존하는 useEffect안에 setTriggered가 있음에도 '빠른 반환'패턴이 있으므로 무한 루프는 일어나지 않는다.

한편 적절한 시기에(본 예제에서는 IsLastPage가 true가 되는 시기에) Intersection Observer를 unobserve해주는 것도 중요한데,
페이지 하나에 쓰이는 intersection Observer가 하나 정도에 불과하고 '빠른 반환'을 하고 있어 성능에 큰 영향을 미치지 않는다.
또한 페이지는 리렌더링하지 않고 유지한 체, 하위 컴포넌트인 articles 목록만 교체하는(가령 카테고리 분류만 갈아 끼우는 등) 로직 등에서는 현재와 같이 한 번 선언된 ref를 그대로 재사용할 수 있다는 이점이 있다.
페이지를 나가서 컴포넌트가 언마운트 되면 useInersection 커스텀 훅의 클린업 함수가 해당 옵저버를 unobserve해준다.

최종 코드

import React, { useEffect, useRef, useState } from "react";
import { useIntersection } from "../utils/useIntersection";

const InfiniteScroll = () => {
  const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
  const [articles, setArticles] = useState([1, 2, 3, 4, 5]);
  const [page, setPage] = useState(1);
  const [triggered, setTriggered] = useState(false);
  const [isLastPage, setIsLastPage] = useState(false);

  const trigger = useRef();
  useEffect(() => {
    if (!triggered) return;
    if (isLastPage) return setTriggered(false);

    const newSlice = arr.slice(page * 5, page * 5 + 5);
    if (newSlice.length) {
      setArticles([...articles, ...newSlice]);
      setPage(page + 1);
    } else {
      setIsLastPage(true);
    }

    setTriggered(false);
  }, [triggered]);

  useIntersection(
    trigger,
    () => {
      setTriggered(true);
    },
    true,
  );

  return (
    <div>
      {articles.map((e) => {
        return (
          <div key={e} style={{ width: "100%", padding: "200px" }}>
            {e}
          </div>
        );
      })}
      <div ref={trigger} style={{ width: "100%" }}></div>
    </div>
  );
};

export default InfiniteScroll;

보다시피 크게 글 목록 상태와 useIntersection에 쓰일 상태, 두가지로 나뉘는데, 둘이 서로 의존하고 있는 것을 확인할 수 있다.
만일 컴포넌트를 분리해야 한다면,
useRef로 옵저빙을 하는 무한 스크롤과 관련된 로직을 부모에 넣고,
글 목록을 관리하는 로직을 자녀 컴포넌트로 분리하는 것이
무한 스크롤로 글을 불러옴 -> 자녀 컴포넌트에 props로 글 목록을 주입함 과 같은 로직이 되어 관리하기가 쉬웠다.

profile
천재가 되어버린 박제를 아시오?

0개의 댓글