프로젝트를 진행하던 중 무한 스크롤을 적용시키고 싶은 부분이 생겼습니다.

아직 서버쪽 코드가 완성되지 않아 바로 프로젝트에 적용시킬 수 없어서 코드 샌드박스를 활용하여 간단하게 예제를 제작해보았습니다.

사용된 핵심기술은 Intersection Observer입니다.

잘못됐거나 개선할 부분이 있다면 언제든지 댓글 남겨주세요!

문제 상황 🤷‍♂️

현재 진행중인 프로젝트에는 강의 후기 기능이 있습니다.

초기에는 크게 상관없겠지만 서비스 운영 시간이 길어질수록 후기가 많이 쌓이게 될 것입니다. 그렇게 되면 한 번의 API 요청으로 모든 데이터를 받아오는 것이 성능, UX 적인 측면에서 좋지 않을 것 같다는 생각이 들었습니다.

따라서 무한 스크롤 방식을 활용하여 성능과 UX를 향상시켜보고자 합니다.

Intersection Observer를 사용하는 이유 🙄

처음에 무한 스크롤을 구현하기 위해 Scroll 이벤트에 대해 알아봤습니다. 하지만 좀 더 조사하다보니 Intersection Observer라는 웹표준 API가 존재하는 것을 알게되었습니다.

기존 Scroll 이벤트를 활용하여 무한 스크롤을 구현하게 되면 두 가지 큰 문제점이 있습니다.

첫번째는 이벤트가 짧은 시간에 굉장히 많이 발생한다는 것입니다. 스크롤 이벤트를 적용시키고 console.log만 찍어봐도 금방 알 수 있습니다. 따라서 최적화를 시키려면 debounce나 throttle같은 기법을 통해 스크롤 이벤트가 끝난 후 한 번만 함수가 실행되게 하거나 특정 시간 내에 한 번만 함수가 실행되게 해줘야 합니다.

두번째는 스크롤 이벤트를 통해 특정 지점이 viewport에 등장하는지 확인하기 위해서는 getBoundingClientRect 함수를 사용해야 하는데 이 함수가 Reflow를 발생시킨다는 것입니다. 이러한 특징 역시 웹을 최적화 할 때 방해가 되는 요소입니다.

Intersection Observer는 위의 두 문제를 한 번에 해결해줍니다. 심지어 사용법도 간단합니다.

Intersection Observer 사용법 🤗

선언은 다음과 같이 할 수 있습니다.

new IntersectionObserver(callback[, options]);

보시는 것과 같이 callback 함수와 options를 인자로 받는데 callback 함수는 관찰중인 대상이 viewport에 등장했을 때 실행할 함수를 전달해주면 됩니다.

Callback

callback 함수는 entires와 observer를 매개변수로 갖습니다. entires는 관찰중인 대상 목록(배열)이며 observer는 callback 함수를 호출한 Intersection Observer입니다.

entries의 값들은 다음과 같은 읽기 전용 속성들을 갖고 있습니다.

  • boundingClientRect : getBoundingClientRect를 통해 계산된 관찰중인 대상의 사각형 영역 정보
  • intersectionRatio : 관찰중인 대상이 Viewport와 교차한 영역의 비율
  • intersectionRect : 관찰중인 대상이 보여지는 영역에 대한 정보
  • isIntersecting : 관찰중인 대상이 Viewport와 교차를 했는지에 대한 boolean 값
  • rootBounds : Intersection Observer의 root의 영역 대한 정보
  • target : 관찰중인 대상
  • time : 교차가 일어난 시점

필요에 따라 위의 속성들을 사용하여 원하는 기능을 구현하면 될 것 같습니다. 무한 스크롤은 intersectionRatio나 isIntersecting을 통해 특정 지점이 등장했을 때 새로운 값을 불러오는 형태로 구현하면 될 것 같다는 생각이 들었습니다.

Options

options에는 root, rootMargin, threshold 이렇게 3가지 요소가 들어갈 수 있습니다.

root는 교차 영역의 기준이 되는 엘리먼트입니다. 관찰 대상은 반드시 root의 하위 엘리먼트여야 합니다. root의 기본값은 Viewport입니다.

rootMargin은 말 그대로 root의 margin 값입니다. CSS의 margin 속성 구문과 동일합니다. rootMargin을 통해 root 영역을 늘리거나 줄일 수 있습니다. 기본값은 ‘0px 0px 0px 0px’입니다.

threshold는 루트에 대한 관찰 대상의 교차영역 비율입니다. 0.0 에서 1.0 사이의 값이 들어가야 하며 값의 비율만큼 교차했을 때 callback 함수가 실행됩니다. 0.0은 단일 픽셀이라도 보여지면 callback 함수가 실행되며 1.0은 관찰 대상의 전체가 Viewport(root) 내에 들어와야 callback 함수가 실행됩니다.

Methods

선언된 Intersection Observer는 다음과 같은 메소드를 갖습니다.

  • observe(targetElement) : 대상 엘리먼트의 관찰을 시작합니다.
  • unobserve(target) : 대상 엘리먼트의 관찰을 중지합니다.
  • disconnect() : 현재 관찰중인 모든 엘리먼트에 대해 관찰을 중지합니다.
  • takeRecords() : An array of IntersectionObserverEntry objects, one for each target element whose intersection with the root has changed since the last time the intersections were checked. => IntersectionObserverEntry를 반환합니다. 위에서 언급한 entries와 동일한 값입니다. MDN의 설명에 따르면 굳이 호출할 필요가 없다고 합니다.

코드 작성해보기 👨‍💻

사용법을 대충 알았으니 코드를 작성해보겠습니다. 전체 코드는 다음 링크를 참고해주세요.

코드샌드박스 링크

무한 스크롤에 필요한 데이터는 json 파일에 간단하게 mock data를 만들어놓고 import 해왔습니다.

// src/App.js

...

export default function App() {
  // 1
  const [data, setData] = useState([]);
  const [startIdx, setStartIdx] = useState(0);

  // 2
  const target = useRef();

  // 3
  const io = new IntersectionObserver((entries, observer) => {
    const targetBox = entries[0];
    if (targetBox.isIntersecting) {
      if (startIdx < mock.length) {
        setStartIdx(startIdx + 5);
        observer.unobserve(targetBox.target);
      }
    }
  });
  const getItems = () => {
    return data.map((elem, idx) => {
      return <Item key={`${elem.id}${idx}`} data={elem} />;
    });
  };

  // 4
  useEffect(() => {
    if (target.current) {
      io.observe(target.current);
    }
    setData((data) => [...data, ...mock.slice(startIdx, startIdx + 5)]);
  }, [startIdx]);

...

핵심이 되는 부분에 주석으로 번호를 적어놓았습니다. 위의 코드에 대해 설명하면 다음과 같습니다.

  1. 상태들을 선언해줍니다. data는 불러온 data들을 저장하기 위한 변수이며 startIdx는 스크롤이 내려갔을 때 불러올 다음 데이터의 id를 저장하기 위한 변수입니다.

  2. 위의 코드에는 포함되어 있지 않지만 전체 코드를 보면 가장 아래쪽에 target div를 하나 선언했습니다. useRef를 사용하여 해당 엘리먼트를 참조합니다.

  3. 가장 아래쪽에 선언한 target div가 Viewport에 등장하게 되면 다음 데이터들을 불러오기 위해 startIdx의 값을 바꿔줍니다. 데이터를 다 불러온 후에는 Viewport에 target div가 보이더라도 다음 동작을 할 필요가 없기 때문에 분기 처리를 해줬습니다. 또한 기존의 target에 대하여 unobserver를 해줘야 하는데 그렇지 않으면 컴포넌트가 리렌더링 될 때마다 새로운 Intersection Observer가 선언되고 구독하는 대상이 추가 되기 때문입니다.

  4. 컴포넌트가 리렌더링 될 때마다 새로 선언된 옵저버가 맨 아래쪽에 존재하는 target div를 관찰하게 해줍니다. startIdx 값이 업데이트 되면 업데이트 된 값을 통해 data 역시 업데이트 시켜줍니다.

아래와 같이 잘 동작하는 것을 확인할 수 있습니다.

infinite scroll example

target div에 대해 빨간색 배경을 줬는데 빨간색 배경이 화면에 보이면 데이터를 불러오는 것을 확인할 수 있습니다.

결론 😊

Intersection Observer를 통해 간단하지만 Scroll 이벤트보다 더 효율적인 무한 스크롤 예제를 만들어보았습니다.

요즘 프로젝트를 진행하며 성능 최적화 문제로 씨름을 오랫동안 하다보니 성능 최적화에 대해 관심이 많아졌습니다.

Intersection Observer로 무한 스크롤이나 Lazy Loading을 구현하여 최적화에 도전해볼 예정입니다.

또한 무한 스크롤을 통해 새롭게 불러온 정보에 대해서만 리렌더링을 하고 기존에 렌더링 된 것들에 대해서는 리렌더링이 안되게 하고 싶었는데 아직은 적용시키지 못했습니다.
코드 샌드박스에서 제공하는 리액트 개발자 도구의 Highlight 기능을 통해 확인해보려고 했는데 제가 사용법을 잘 몰라서 그런지 Highlight 기능이 작동을 하지 않습니다…ㅎ
하지만 각 item을 감싸고 있는 부모 컴포넌트가 리렌더링이 되는 구조이기 때문에 자식 컴포넌트들인 item들 역시 리렌더링이 될 것이라고 예상합니다.

실제 프로젝트에서는 위에서 언급한 내용들까지 신경써가며 적용시켜 볼 예정입니다.

참고자료

함께보면 더 좋은 자료