본문 바로가기

Language & Framework/React.js

intersection Observer로 React 무한 스크롤 구현하기

작아지는 스크롤 냠냠

 

예전에 바닐라 자바스크립트에서 intersection Observer API를 사용하여 navbar의 각 카테고리에 active를 주는 방법에 대한 글을 작성했었는데 (https://7357.tistory.com/39?category=1027537)

이번에는 무한 스크롤을 위해 리액트에서 intersection Observer API를 활용하게 되었다.

리액트에는 useEffect라는 아주아주 편리한 Hook이 존재하기 때문에 바닐라 자바스크립트를 사용할 때보다 손쉽게 구현할 수 있었다.

 

구현에 필요한 것들

1. (당연하게도) intersectionObserver API에 대한 기본 지식

(https://developer.mozilla.org/ko/docs/Web/API/Intersection_Observer_API)

2. useRef, useEffect, useState

3. 어딘가에서 받아올 데이터

4. 새로운 요소를 로딩할 시점을 정해줄 요소(ref로 지정)

 

 

일단 기본적인 틀을 먼저 만들자.

  const listEnd = useRef(null); // observer가 감시할 요소 (나의 경우에는 '더보기' 버튼)
  const API_KEY = "님이 요청하는 API에 key가 필요하다면 있어야겠죠";
  const [movieData, setMovieData] = useState([]); // 님이 불러올 데이터 배열
  const [loading, setLoading] = useState(true); // 옵저버 갱신하려면 필요함
  const [page, setPage] = useState(1); // 새로운 데이터를 어떻게 받아올 것인지에 따라 알아서

  const getData = async (page) => {
    const data = await axios.get(
      `https://api.themoviedb.org/3/movie/now_playing?api_key=${API_KEY}&language=kr-ko&page=${page}&region=kr`
    );
    const movies = data.data.results;
    setMovieData([...movieData, ...movies]);
    setLoading(false);
  };

  useEffect(() => {
    getData(page);
  }, [page]); // 요소가 뷰포트에 들어오면 page를 +1하면서 데이터도 다시 받아옴

 

 

 

여기까지 아주 평범하게 API를 한 번 받아오는 코드랑 크게 다를 게 없다.

get 요청을 한 번만 날리는 게 아니라 intersectionObserver가 요소를 탐지하고 page를 변경할 때마다 get 요청을 지속적으로 갱신할 뿐.

 

이제 intersection Observer API를 사용해보자.

 

  useEffect(() => {
    if (!loading) {
      console.log(listEnd.current);
      const observerCallback = (entries) => {
        const entry = entries[0]; // observerCallback은 감시 대상들인 entries를 인자로 받음
                                  // 우리는 한 개만 지속적으로 감시할 예정이니 [0]
        if (entry.isIntersecting && entry.intersectionRatio > 0.1) {
        // isIntersecting : 요소가 위로 올라가는 중인가 ?( 스크롤이 아래로 내려가는 중인가? )
        // intersectionRatio : 요소가 얼마나 화면에 노출되고 있는가? ( 비율 )
          setPage((prev) => prev + 1); // 감지될 때마다 page +1
        }
      };
      const observerOption = {
        root: null, (탐지의 기준점을 어디로 잡을 것인지)
        rootMargin: "200px", (감시하는 요소에 가상의 margin이 생겨서 이만큼 미리 감지하는 것)
        threshold: 0, (뷰포트에 얼마나 들어왔을 때 콜백을 실행할 것인가? default는 0 (들어오는 즉시 실행)
      };
      const observer = new IntersectionObserver(
        observerCallback,
        observerOption
      );
      observer.observe(listEnd.current); // 감시 시작
    }
  }, [loading]);

intersectionObserver는 observerCallback과 obsereverOption 두가지를 인자로 받는다.

참고로 observerOption은 기본 값을 그대로 유지하고 싶으면 별도로 지정해줄 필요가 없다. 기본 값은 {root:null, rootMargin:'0px', threshold:0}

observerCallback은 말 그대로 감지 대상이 observerOption을 충족하는 상태일 때 무엇을 실행할지를 설정해주는 것이다.

주석으로 설명을 모두 달아놔서 딱히 어려울 건 없을 것이다.

 

나는 '더보기' 버튼을 observer가 감시하도록 지정해놓고, 어떤 이유로든 버그가 발생해서 페이지가 로드되지 않을 시 수동으로 페이지를 더 불러올 수 있게하기 위해 버튼에 onClick={()=>{setPage(prev=>prev+1)}를 지정해줬다.

 

 

 

 

+++

 

 

별로 중요한 건 아니지만 내가 저질렀던 실수가 있다. 덕분에 상당히 헤맸었는데.. 나 같은 바보가 있다면 내 글이 도움이 되었으면 좋겠다.

 

  const getData = async (page) => {
    const data = await axios.get(
      `https://api.themoviedb.org/3/movie/now_playing?api_key=${API_KEY}&language=kr-ko&page=${page}&region=kr`
    );
    const movies = data.data.results;
    setMovieData([...movieData, ...movies]);
    setLoading(false);
  };
  
  // 위처럼 작성했어야 했는데


  useEffect(() => {
    getData(page);
    setLoading(false);
  }, [page]);

처음에 여기서 setLoading을 getData 함수 안에 넣어주지 않고 useEffect에서 따로 처리되도록 작성한 것이다.

이렇게되면 모든 순서가 완전히 엉망으로 꼬이면서 그야말로 개판이 되어버린다.

 

intersectionObserver는 loading이 변경될 때마다 실행되는데

처음 페이지가 렌더링 될 때 getData(page)와 setLoading(false)가 각자 실행된다면?

 

아직 getData(page)가 완전히 실행되지 않아서 요소들을 렌더링하지 못했고 버튼만 있는 상태에서 setLoading이 실행됨과 함께 intersectionOvserver가 버튼을 탐지하고 바로 페이지를 2번으로 넘겨버린다.

 

가장 큰 문제는 이게 매번 이러는 것이 아니라 완전히 랜덤이라는 것이다.

API 요청이 빠르게 끝난다면? 처음 우리가 원하던 방식대로 작동한다.

API 요청이 조금 늦는다면? 페이지가 2인 상태로 시작한다.

API 요청이 매우 늦어진다면? 처음부터 모든 페이지가 로드된 상태로 시작한다.

 

새로고침 때마다 결과가 바뀌니 정말 정신이 나가버릴 것 같았다.. 

나 같은 바보가 있다면 intersection Observer 문서 읽으면서 시간 낭비하지 말고 로직부터 점검하기를.. ^^~~~~