React.js에서 무한스크롤 구현할때의 최적의 방법은 무엇일까

euneun·2021년 11월 23일
23

React

목록 보기
1/5

들어가며

졸업프로젝트의 첫학기가 어느덧 기말을 향해 달려가고 있다.
지금까지는 어떤 프로젝트를 진행할지 기획하고 분석하는것을 위주로 하였는데, 슬슬 큰그림이 마무리되어 어떤 기술스택을 사용해야될지도 큰 범위에서는 잡힌 상태이다.
UI기획을 하던중에, 우리 프로젝트에서도 무한스크롤 구현이 필요할 것 같아서 어떤 방식으로 무한스크롤을 구현해야될지에 대해서 간단하게 실험해보고 더 괜찮은것으로 결정해보려고한다.

테스트로 사용할 api주소는 https://picsum.photos/
https://picsum.photos/v2/list 에 요청을 보내면 무료로 이미지 리스트를 받아 볼 수 있게 되어있다!
?page=2&limit=100 와 같은식으로 query parameter를 붙여서 커스텀해서 사용할 수 있다.


page=1&limit=7로 요청을 보냈을때의 화면이다.

우리는 한페이지당 7개씩 이미지를 가져와서, 화면 끝에 도달했을때 다음 페이지 요청을 날려보려고한다.

1. 브라우저의 기본 onScroll 이벤트를 이용했을때의 문제점

원시적으로 생각할 수 있는 방법은
페이지 끝까지 유저가 스크롤을 했을때,
브라우저의 기본 스크롤 이벤트를 이용해서 그 시점을 감지하여 다음 이미지들을 받아오는 요청을 보내는 방법이다!


type RandomImageType = {
  id: string;
  author: string;
  width: number;
  height: number;
  url: string;
  download_url: string;
};

export default function FavoredProductTypePage() {
  const [randomImageList, setRandomImageList] = useState<RandomImageType[]>([]);
  const [page, setPage] = useState(1);

  const handleScroll = () => {
    const scrollHeight = document.documentElement.scrollHeight;
    const scrollTop = document.documentElement.scrollTop;
    const clientHeight = document.documentElement.clientHeight;

    console.log('스크롤 이벤트 발생');

    if (scrollTop + clientHeight >= scrollHeight) {
      console.log('페이지 끝에 스크롤이 닿았음');
      setPage((prev) => prev + 1);
    }
  };

  const getRandomImageThenSet = async () => {
    console.log('fetching 함수 호출됨');
    try {
      const { data } = await axios.get(
        `https://picsum.photos/v2/list?page=${page}&limit=7`
      );
      setRandomImageList(randomImageList.concat(data));
    } catch {
      console.error('fetching error');
    }
  };

  useEffect(() => {
    console.log('page ? ', page);
    getRandomImageThenSet();
  }, [page]);

  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, []);

  return (
    <>
      {randomImageList?.map((randomImage) => (
        <img key={randomImage.id} src={randomImage.download_url} alt="random" />
      ))}
    </>
  );
}

onScroll 이벤트 핸들러를 위의 Test컴포넌트가 마운트될때 달아준 후, 페이지 끝에 스크롤이 닿았을때 page 값을 1만큼 올려준다.
page값이 달라질때마다 getRandomImageThenSet()을 호출하게 되어있기때문에
스크롤이 페이지 끝에 닿았을때마다 page값을 하나씩 증가시켜 다음 fetch 요청을 보내게된다.

이렇게 로그를 찍어보면 스크롤을 다 감시하고 있어야해서
페이지를 탐색하는동안 해당함수가 매번 호출되게되고,
성능저하는 당연히 있을수 밖에 없는 구조이다.

이를 조금 더 개선해볼 수 있는 방법

스크롤이벤트로 구현하려고 한다면, lodash라이브러리의 throttle을 사용해서 리소스를 줄일 수 있는 방법도 있다.
이 방법으로 스크롤 이벤트가 트리거되는 양을 어느정도 줄여줄 수는 있다.

그치만 throttle 주기를 너무 짧게하면, throttle을 적용하는 효과가 미미할 수 있고
throttle 주기를 크게한다면, 예를들어 2초에 한번씩만 스크롤 이벤트핸들러를 호출한다고 정한다면
유저가 2초안에 페이지 끝에 도달했을 경우에는 조금 더 기다려야지 다음 fetch함수가 실행된다는 문제가 발생한다.

이렇게 throttle의 특정 주기를 적절하게 정해서 한 반복주기 내에서는 이벤트가 또다시 트리거되지 않도록 처리할 수 있지만, 스크롤을 움직일 때마다 이벤트가 발생하게된다는 핵심문제를 해결하지는 못한다.

2. Intersection Observer API 사용하기

스크롤 이벤트는 짧은시간에 수백번, 수천번 호출될 수 있고 동기적으로 실행되기 때문에 메인 스레드(Main Thread) 영향을 준다. 사용자가 스크롤할 때마다 이를 감지하는 이벤트가 끊임없이 호출된다는것을 위에서 알 수 있었다.

이러한 핵심문제점을 해결하기 위해 스크롤이벤트를 핸들링하지 않을 수 있는 방법중 하나인 Intersection Observer API를 사용해보려고 한다.

이전에도 한 프로젝트에서 무한스크롤을 구현해보았던 경험이 있는데, 그때도 Intersection observer api를 이용해서 구현하였었다.


type RandomImageType = {
  id: string;
  author: string;
  width: number;
  height: number;
  url: string;
  download_url: string;
};
export default function FavoredProductTypePage() {
  const [randomImageList, setRandomImageList] = useState<RandomImageType[]>([]);
  const [page, setPage] = useState(1);

  const [lastIntersectingImage, setLastIntersectingImage] =
    useState<HTMLDivElement | null>(null);

  const getRandomImageThenSet = async () => {
    console.log('fetching 함수 호출됨');
    try {
      const { data } = await axios.get(
        `https://picsum.photos/v2/list?page=${page}&limit=7`
      );
      setRandomImageList(randomImageList.concat(data));
    } catch {
      console.error('fetching error');
    }
  };

  //observer 콜백함수
  const onIntersect: IntersectionObserverCallback = (entries, observer) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        //뷰포트에 마지막 이미지가 들어오고, page값에 1을 더하여 새 fetch 요청을 보내게됨 (useEffect의 dependency배열에 page가 있음)
        setPage((prev) => prev + 1);
        // 현재 타겟을 unobserve한다.
        observer.unobserve(entry.target);
      }
    });
  };

  useEffect(() => {
    console.log('page ? ', page);
    getRandomImageThenSet();
  }, [page]);

  useEffect(() => {
    //observer 인스턴스를 생성한 후 구독
    let observer: IntersectionObserver;
    if (lastIntersectingImage) {
      observer = new IntersectionObserver(onIntersect, { threshold: 0.5 });
       //observer 생성 시 observe할 target 요소는 불러온 이미지의 마지막아이템(randomImageList 배열의 마지막 아이템)으로 지정
      observer.observe(lastIntersectingImage);
    }
    return () => observer && observer.disconnect();
  }, [lastIntersectingImage]);

  return (
    <>
      {randomImageList?.map((randomImage, index) => {
        if (index === randomImageList.length - 1) {
          return (
            <img
              key={randomImage.id}
              src={randomImage.download_url}
              alt="random"
              ref={setLastIntersectingImage}
              style={{
                borderRadius: 100,
              }}
            />
          );
        } else {
          return (
            <img
              key={randomImage.id}
              src={randomImage.download_url}
              alt="random"
            />
          );
        }
      })}
    </>
  );
}

1번 코드에서 onScroll과 관련된것들을 지우고
observer 인스턴스를 생성한후에, 감시할 타겟 요소와 뷰포트에 타겟요소가 들어왔을때 실행할 함수를 등록하면 된다.

해당 콜백함수는 entriesobserver를 인자로 받는데,
entriesIntersectionObserverEntry 인스턴스의 배열로
IntersectionObserverEntry는 읽기 전용(Read only)으로 다음의 속성들을 가지고 있다.

boundingClientRect: 관찰 대상의 사각형 정보(DOMRectReadOnly)
intersectionRect: 관찰 대상의 교차한 영역 정보(DOMRectReadOnly)
intersectionRatio: 관찰 대상의 교차한 영역 백분율(intersectionRect 영역에서 boundingClientRect 영역까지 비율, Number)
isIntersecting: 관찰 대상의 교차 상태(Boolean)
rootBounds: 지정한 루트 요소의 사각형 정보(DOMRectReadOnly)
target: 관찰 대상 요소(Element)
time: 변경이 발생한 시간 정보(DOMHighResTimeStamp)

observer인스턴스를 등록할때 threshhold를 0에서 1사이의 옵션값으로 줄 수 있는데 예를 들면 1일때는, 감시할 타겟 요소가 완전히 뷰포트에 들어왔을때 옵저버가 실행된다.

위의 코드에서는 감시할 타겟 요소를 api 요청을 통해 받아온 마지막 이미지로 설정해놓고, 스타일로 border radius를 주어 구분이 되도록 하였다.

감시할 타겟 요소가 뷰포트의 50%만큼 들어왔을때 (threshhold:0.5),
local state인 randomImageList에 7개씩 이미지 fetch 요청을하여 이어붙이게된다.


다음과 같이 현재까지 요청한 이미지 리스트중 마지막 요소가 뷰포트에 들어왔을때 border radius가 적용되어있고, page값을 하나 더 올려 다음 fetch 요청을 보내는 로그가 잘 찍히고 있음을 알수있다!

Intersection Observer API 기본적으로 브라우저 뷰포트(Viewport)와 설정한 요소(Element)의 교차점을 관찰하며, 요소가 뷰포트에 포함되는지 포함되지 않는지, 더 쉽게는 사용자 화면에 지금 보이는 요소인지 아닌지를 구별하는 기능을 제공한다.

이 기능은 비동기적으로 실행되기 때문에 메인스레드에 영향을 주지 않고, scroll 같은 이벤트 기반의 요소 관찰에서 발생하는 렌더링 성능이나 이벤트 연속 호출 같은 문제 없이 사용할 수 있다.

3. React-Virtualized 사용하기

하지만 intersection observer api를 사용하더라도, 사용자가 스크롤을 아주 많이 내려서 많은 이미지들이 DOM에 렌더링되어있다면
이로 인한 성능저하가 발생할 수 있다는 점에서 한계점이 있다.

React-virtualized 를 사용하면,실제 보이는 컴포넌트만 DOM에 렌더링하여 이러한 문제를 해결 할 수 있다.

import axios from 'axios';
import React, { useEffect, useState, useRef } from 'react';
import {
  CellMeasurerCache,
  CellMeasurer,
  InfiniteLoader,
  List,
  AutoSizer,
} from 'react-virtualized';

type RandomImageType = {
  id: string;
  author: string;
  width: number;
  height: number;
  url: string;
  download_url: string;
};

const Test = () => {
  const [randomImageList, setRandomImageList] = useState<RandomImageType[]>([]);
  const [page, setPage] = useState(1);

  const infiniteLoaderRef = useRef<InfiniteLoader>(null);
  const listRef = useRef<List>(null);

  const cellMeasurerCache = new CellMeasurerCache({
    fixedWidth: true,
    defaultHeight: 100,
  });

  const isRowLoaded = ({ index }: { index: number }) => {
    return !!randomImageList[index];
  };

  const getRandomImageThenSet = async () => {
    console.log('fetching 함수 호출됨');
    try {
      const { data } = await axios.get(
        `https://picsum.photos/v2/list?page=${page}&limit=7`
      );
      setRandomImageList(randomImageList.concat(data));
    } catch {
      console.error('fetching error');
    }
  };

  useEffect(() => {
    console.log('page ? ', page);
    getRandomImageThenSet();
  }, [page]);

  function rowRenderer({
    key,
    index,
    parent,
    isScrolling,
    isVisible,
    style,
  }: {
    key: any;
    index: number;
    parent: any;
    isScrolling: boolean;
    isVisible: boolean;
    style: any;
  }) {
    return (
      <CellMeasurer
        key={key}
        cache={cellMeasurerCache}
        parent={parent}
        columnIndex={0}
        rowIndex={index}
      >
        <img
          key={randomImageList[index].id}
          src={randomImageList[index].download_url}
          alt="random"
        />
      </CellMeasurer>
    );
  }

  return (
    <InfiniteLoader
      isRowLoaded={isRowLoaded}
      loadMoreRows={getRandomImageThenSet}
      rowCount={10000000}
      ref={infiniteLoaderRef}
    >
      {({ onRowsRendered, registerChild }) => (
        <AutoSizer>
          {({ width, height }) => (
            <List
              rowCount={randomImageList.length}
              width={width}
              height={height}
              rowHeight={cellMeasurerCache.rowHeight}
              rowRenderer={rowRenderer}
              deferredMeasurementCache={cellMeasurerCache}
              overscanRowCount={2}
              onRowsRendered={onRowsRendered}
              ref={(el) => {
                listRef.current?.setState(el);
                registerChild(el);
              }}
            />
          )}
        </AutoSizer>
      )}
      <List
        width={window.innerWidth}
        height={window.innerHeight}
        rowCount={randomImageList.length}
        rowHeight={200}
        rowRenderer={rowRenderer}
      />
    </InfiniteLoader>
  );
};

export default Test;

마치며

졸업프로젝트의 웹부분을 맡게되어서 React.js를 쓰려고하는데..
기본 CRA를 쓸지 Next.js를 쓸지,
상태관리 라이브러리로는 swr를 쓸지 redux를 쓸지 recoil을 쓸지 react-query를 쓸지에 대해서도 고민이많은데 조만간 정리해보고 결정을 해야겠다..ㅠㅠ

참고

https://heropy.blog/2019/10/27/intersection-observer/
https://aerocode.net/336

profile
제대로 짚고 넘어가자!🧐

3개의 댓글

comment-user-thumbnail
2021년 12월 19일

잘 읽었어요~ 김현수교수

답글 달기
comment-user-thumbnail
2022년 6월 10일

안녕하세요, '3. React-Virtualized 사용하기'은 오류가 있는것같습니다.

1개의 답글