React에서 Infinite scroll 구현하기 (IntersectionObserver)

수박·2021년 6월 19일
3

react

목록 보기
14/15

React에서 Infinite scroll 구현하기 (IntersectionObserver)


무한스크롤을 구현하는 방법은 다양하다!

  • scroll
  • IntersectionObserver
  • getBoundingClientRect()
  • useRef


scroll

스크롤이벤트를 attachment하여 현재 스크롤위치를 계산해서

맨 끝에 스크롤되었을 때 다음 데이터를 fetch하는 방법을 사용한다.

스크롤마다 한번의 이벤트가 발생하므로(거의 픽셀단위) 성능이슈를 예방하기 위해 쓰로틀링 적용을 고려해야한다.

해당 포스팅에선 IntersectionObserver로 구현하는 방법을 다룰 것이다.
자세한 사항은 해당 링크에서 확인할 수 있다.
스크롤로 구현하기



getBoundingClientRect()

특정요소가 viewport 에 존재하는지 판단해 데이터 fetch를 결정하는 방법이 있다.

그러기 위해 특정요소가 어디에 있는지에 대한 위치를 계산하기 위해서 element.getBoundingClientRect() 함수를 사용하는데

이 과정에서 reflow가 발생하여 마찬가지로 성능이슈가 생길 수 있다.

요소의 위치, 크기를 계산하고 위치시키는 reflow를 수행하지 않는 IntersectionObserver 를 사용함으로써 이러한 문제를 해결할 수 있다.

IntersectionObserver

설명은 해당블로그 참고

viewport와 특정 요소가 교차하는지를 관찰하여

특정 %이상겹쳐졌을 때 겹쳐졌음을 감지하는 기능을 제공한다.

스크린샷 2021-06-19 오후 11 10 46

위 사진과 같이 뷰포트와 교차 대상(interSectRef)이 50%일 때 초록색 박스를 띄우는 행위를 할 수 있다.

무한스크롤에서는 다음 데이터를 가져오는 작업이 그 행위이므로 해당 기능을 콜백에 작성하도록 한다.




초기화

IntersectionObserver을 초기화할 때 options 인자로 넘긴다.

options은 특정요소와 viewport가 얼마만큼 겹쳤을 때 겹쳤다고 할지와 관찰대상의 부모요소를 지정하는 객체이다.

observe 메소드를 사용해 관찰할 target dom을 지정하는데 dom지정은 리액트에서 ref 를 사용해 할 수 있다.

이를 코드로 구현하면 다음과 같다.

const options = {
    root: null, //기본 null, 관찰대상의 부모요소를 지정
    rootMargin: "20px", // 관찰하는 뷰포트의 마진 지정
    threshold: 1.0, // 관찰요소와 얼만큼 겹쳤을 때 콜백을 수행하도록 지정하는 요소
};
useEffect(() => {
    const observer = new IntersectionObserver(handleObserver, options);
    if (interSectRef.current) observer.observe(interSectRef.current);
    return () => observer.disconnect();
  }, [handleObserver]);

interSectRef 요소를 관찰할 것이며 viewport와 1.0(100%) 겹쳐졌을 때 겹쳐짐이 true로 set될 것이다.

options로 초기화, target을 관찰하기 시작했다면 다음은 교차되었을 때 특정 행위를 수행할 콜백을 작성하는 것이다.

위의 코드에서 콜백은 handlerObserver 이며 이는entries라는 배열인자를 받는다.

IntersectionObserver은 비동기적으로 수행되므로 useEffect에서 clean up에 disconnect를 사용해 관찰을 중지시킬 수 있도록 한다.

entires

관찰대상의 정보들을 갖고있는 배열이다. 관찰대상을 여러개 지정했으면 각 요소마다 그 정보들이 담겨있다.

observe의 대상이 intersectRef 하나이기에 0번째 요소가 관찰 대상이된다.

콜백

  const handleObserver = useCallback(async (entries) => {
    const target = entries[0];
    if (target.isIntersecting) {
      console.log("is InterSecting");
      setPage((prev) => prev + 1);
    }
  }, []);

target과 뷰포트가 1.0, 즉 100% 겹쳐졌을 때 isIntersecting이 true로 되고 if조건에 만족해 다음 페이지를 불러오도록 state값을 더했다.

  useEffect(async () => {
    await fetchData(page);
  }, [page]);

교차될 때마다 page값을 증가시켜 useEffect가 호출되어 다음페이지에 대한 요청을 호출한다.







발생했던 이슈

내가 예제로 사용했던 api는 여러정보를 불러오는 것이 아닌 한번에 하나의 데이터만 불러오는 api였다.

그래서 연습삼아 5개의 요청 수행해 5개씩 받아오도록 하는 코드를 작성했었다. (실제로 이러진 않겠지만)

첫번째로 반복문을 통해 5개의 요청을 보내 데이터를 쌓으려고 했었다.

  const getNextData = async () => {
    const dataContainer = [];
    try {
      for (let i = id; i <= id + 5; i++) {
        const getFiveData = async (i) => {
          const nextData = await fetchData(i);
          if (nextData !== undefined) {
            console.log("container push", i, nextData);
            dataContainer.push(nextData);
          }
        };
        getFiveData(i);
      }
      setData([...data, ...dataContainer]);
    } catch {
      console.log("NOT FOUND");
    }
  };

실제로 쌓이는 값은 결과의 마지막, 5번째의 요청만 쌓이고 있었다.

setState는 비동기로 호출되어 마지막값만 반영된다는 것을 확인하고 다른 방법을 택하기로 했다.

promise all

  const getNextData = async (id) => {
    const promiseContainer = [];
    try {
      for (let i = id; i < id + 5; i++) {
        promiseContainer.push(fetchData(i));
      }
      Promise.all(promiseContainer).then((value) => {
        value = value.filter((v) => v !== undefined);
          setData([...data, ...value]);
      });
    } catch {
      console.log("NOT FOUND");
    }
  };

각 promise를 배열에 담아 promise.all 를 사용해 모든 요청에 대한 결과를 쌓도록 변경해 원하는 결과를 얻을 수 있었다.

하지만 요청이 실패하는 경우 (데이터가 없을 때)엔 다음 page에 대한 요청을 해야하는데 그건 for문으로 처리할 수 없었다.

결국 한개의 요청을 보내기로 하고 다음과 같이 코드를 변경했다.

single Request

useEffect(async () => {
    await fetchData(page);
  }, [page]);  

const fetchData = useCallback(
    async (nextPage) => {
      try {
        const res = await axios.get(
          `${API_ENDPOINT}/${nextPage}?api_key=${API_KEY}`
        );
        setData([...data].concat({ ...res.data }));
        return res.data;
      } catch {
        console.log("ERROR");
        setPage((prev) => prev + 1);
      }
    },
    [data]
  );

요청이 실패할 경우 setPage 로 다음 페이지를 set하여 요청을 다시보내도록 변경했고 없는 데이터가 있을 때 다음 page에 대한 데이터를 받아올 수 있도록 처리하였다.

재밌다 !

결과

화면 기록 2021-06-19 오후 11 24 49

참고

0개의 댓글