minTech

[React] intersection observer API를 이용해 무한 스크롤 구현하기 본문

React

[React] intersection observer API를 이용해 무한 스크롤 구현하기

pushzzeong 2024. 3. 7. 16:30

프로젝트를 하면서 내가 맡았던 기능 중에 하나가 바로 무한 스크롤이다. 개인적으로 intersection observer API를 이용하기 위해, 그것을 먼저 이해하고, 내가 원하는 기능으로 구현하려는 것이 어려워서 가장 오래걸렸던 기능이다,, 😭

그래도 어려웠던 만큼 성공했을 때 제일 짜릿했고, 이 과정을 글로 작성하면 나중에 꼭꼭 활용될 것이라는 확신에 바로 정리를 시작해본다.

 

🕵️‍♀️ Infinite Scroll(무한 스크롤) ?

무한 스크롤이란 사용자가 스크롤 동작을 할 때마다 새로운 데이터를 동작으로 로드하여 컨텐츠를 보여주는 방식을 얘기한다. 기존의 페이지를 새로고침하거나, 별도의 페이지 버튼을 누르지 않고도 사용자가 끊임없이 컨텐츠를 스크롤 하여 볼 수 있게 해주는 기술이다. 

via GIPHY

 

특히 무한 스크롤을 가장 잘 이해할 수 있는 사이트가 Instagram(인스타그램) 피드이다. 인스타그램 피드를 보면 밑으로 스크롤만 하면다양한 컨텐츠들이 끊임없이 나온다.  실제로 인스타그램을 정말 애용하는데, 이 무한 스크롤 방식의 피드는 내가 얼마만큼의 페이지 이동을하고,  얼마나 많은 양의 시글들을 지나갔는지 인지할 수 없 정말 시간 가는 줄 모른다,,,, 좋지만 싫어,, 그렇지만 못잃어😭

 

❓React로 Infinite Scroll(무한 스크롤)을 어떻게 구현할까?

 

1. scroll event 

스크롤 이벤트는 속성 값으로 이벤트의 대상을 지정하여 해당 타깃이 스크롤 될 때 이벤트가 발생한다. 따라서 무한 스크롤을 구현하가 위해서는 스크롤의 위치를 계속 확인하다가 스크롤이 페이지의 가장 아래에 닿았을 때,  다음 데이터를 위한 GET 요청이 가도록 구현할 수 있다.

 

하지만 이 방식은 스크롤을 움직일 때마다 동기적으로 함수 호출이 일어난다. 서버에 많은 부하가 걸리게 되고 더 나아가 성능을 저하시킬 수 있다. 그래서 내가 이번 프로젝트의 무한 스크롤 구현을 위해 채택한 방식이 바로 intersection Observer API이다. 

 

물론 스크롤 이벤트의 최적화를 위해 적용할 수 있는 방안으로 Debounce(디바인스)와 Throttle(스로틀)이 있는데 아직 이러한 개념조차 익숙하지 않은 상태라 나중에 프로젝트 코드 리팩토링 시에 리플로우 현상을 고려한 스크롤 이벤트로 무한 스크롤을 구현하기를 도전해보겠다!

 

 

2. intersection Observer API

intersection Observer의 경우는 Web API이다. 따라서 자바스크립트 엔진이 아닌 브라우저단에서 관리되어 비동기적으로 실행된다. 즉,  따라서 두 요소 간의 교차점을 지정해주고, 콜백함수만 등록한다면, 엔진은 스크롤을 계속 확인할 필요도 없고, 브라우저가 적합하다고 판단될 때 콜백함수를 실행해주기만 하면 되기 때문에 자바스크립트 엔진이 많은 부하가 걸리지 않는다.

intersection Observer 이란 뷰포트와 다깃 요소 사이의 변화를 비동기으로 관찰할 수 있는 기능을 제공한다.

따라서 이는 무한 스크롤 뿐만 아니라 광고의 가시성을 통한 광고 수익 산정, 사용자가 보이는지의 여부에 따라 애니메이션 프로세스 수행하는 작업 등에  사용된다. 

 

 

 

❓intersection Observer를 이용해 무한 스크롤을 구현하자!

그 전에! 내가 무한 스크롤을 통해 구현하려는 동작은 다음과 같다. 

  1. GET 요청을 통해 카드 리스트 데이터를 받는다. 이때, 받은 리스폰스에 다음 데이터에 대한 내용이 있다면 setNextCard를 이용해 해당 GET 요청을 날릴 url 저장
  2. 받은 카드 리스트를 사용자가 모두 봤으면, 콜백함수가 실행
  3. 콜백함수는 만약 setNexrCard에 값이 있다면, 해당 값으로 GET 요청 후, 리스폰스 받은 데이터를 기존의 카드리스트에서 업데이트

이제 이 동작을 머릿속에 두고, 무한 스크롤 구현을 시작하였다.


 

 

타깃 요소와 루트, 이 두 요소의 교차점을 관찰하는 것이 바로 intersection Observer가 하는 일이다. 

루트의 경우 별도의 지정이 없으면 뷰포트로 지정된다. 

 

1. 타깃을 설정한다.

 

타깃 생성 시에는 intersection observer 생성자를 호출하고, 두 개의 인자를 넣어 생성한다.

- 첫 번째 인자는 정해진 교차 정도에 도달하면 실행할 콜백함수

- 두 번째 인자는 콜백함수가 언제 호출되는 지에 대한 옵션을 설정한다.

 

option객체의 key 값은 다음과 같다.

1) root: 루트 대상을 설정한다. null 값이라면 뷰포트가 루트로 설정된다.

2) rootMargin: 루트 주위 여백을 설정한다. 기본 값은 0이다.

3) threshold: 타깃의 가시성 백분율이다. 해당 값은 0.0과 1.0 사이의 값으로 지정한다. 즉, 0,0은 0%, 0.5는 50%이다.

 

 useEffect(() => {
    const observer = new IntersectionObserver(onIntersectionHandle, {
      threshold: 1,
    });

    if (lastCardRef.current) {
      observer.observe(lastCardRef.current);
    }

    return () => {
      if (lastCardRef.current) {
        observer.unobserve(lastCardRef.current);
      }
    };
  }, [id, loading]);

 

root는 별도로 설정하지 않고, 뷰포트로 두었고, threshold 값은 1로 주어 카드리스트를 담은 div가 뷰포트에 모두 보이면 콜백함수가 실행되게끔 설정하였다. 

 

하지만, mdn에서는 querySelector을 이용해 직접적으로 DOM에 접근하여 타깃을 지정하였다. 하지만 프로젝트는 styled components를 이용하기 때문에 해당 방법으로 타깃을 지정하는데 어려움을 겪었다.

 

 

⭐react에서는 어떻게 타깃을 지정하는가?

이럴 때 사용하느  것이 바로 useRef이다.  useRef()를 이용해 Ref 객체를 생성하고, 내가 필요한 타깃의 DOM에 ref 값으로 지정한다. 

 

 

useRef를 이용해 카드리스트를 담은 div에 ref를 지정해주었더니 잘 돌아갔다. 

 

만약 잘 돌아가는 테스트 하고 싶다면 콜백함수로 consnole.log("화면에 끝입니다") 등의 콘솔을 찍어주어 확인해주면 간단하게 테스트가 가능하다. 

const ref = useRef(null);

 

 

<CardsListsDiv ref={ref}>
              <AddCard onClick={onAddCardClickHandle} />
              {cardlist.length !== 0
                    return (
                      <RollingCard
                        key={card.id}
                        id={card.id}
                        sender={card.sender}
                        relationship={card.relationship}
                        createdAt={card.createdAt}
                        content={card.content}
                        font={card.font}
                        profileImageURL={card.profileImageURL}
                        $isEditMode={isEditMode}
                        onClick={() => onDetailClickHandle(card)}
                        setDeleteId={setDeleteMsgId}
                      />
                    );
               }
            </CardsListsDiv>

 

 

2. 콜백 함수를 구현한다.

 

지정된 교차값을 만족하면, 콜백함수가 호출된다. 콜백은 entries와 observer를 인수로 받는다.

이 타깃은 여러 개를 지정할 수 있다. 따라서 entries은 여러 개가 배열의 형태로 온다.

 

entries를 forEach를 이용해 각 엔트리를 어떤 방법으로 확인할 것인지 지정해준다. 여러가지가 있는데 각각에 대한 설명은 mdn에 잘 나와있다.

➡️ https://developer.mozilla.org/ko/docs/Web/API/Intersection_Observer_API

 

나는 여러 개의 속성들 중 isIntersection 즉, 관찰 대상의 교차 상태를 boolean 값을 반환하는 방법을 택했다. 

  const onIntersectionHandle = async (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting && !loading) {
        loadMoreData();
      }
    });
  };

 

 

그렇다면 지금까지의 진행 상황은 다음과 같다.

만약, 나의 카드리스트를 담은 div와 뷰포트가 100% 교차한다면, loadMoreData() 함수가 실행된다.

 

😂 실패와 원인 분석

이렇게 다음 데이터가 있으면 새롭게 GET 요청을 하여 기존의 카드리스트를 업데이트 하는 loadMoreData() 함수까지 작성하고, 테스트를 해보았다.

 

하지만 처음 스크롤엔 잘 받아오다가, 다음 스크롤이 바닥으로 갔을 때는 전혀 동작을 실행하지 않았다.

콜백함수에 console.log("스크린의 끝") 을 추가해보아도, 이 함수조차 실행되지 않았다. 

 

via GIPHY

 

 왜 그럴까 생각을 해보았다.

처음 감지 때는 콜백함수가 잘 실행되어 GET 요청을 하는 것을 보니, 콜백함수의 문제는 아니다.

그렇다면 타깃을 관찰하는데 문제가 발생했다는 것을 깨달았다.

 

이를 깨닫고, 열심히 intersection observer API에 대해 천천히 정독해보았다. 

그러다가 나는 타깃 대상과 뷰포트의 교차점이 잘못되었다는 것을 깨달았다.

 

생각을 해보면, 리스트들을 받아오고, 리스트는 스크롤을 내리면서 화면 위로 넘어간다.

그럼 카드리스트를 담은 div는 뷰 포트 밖으로 나가게 되고, 교차점이 100%가 가능할 수가 없게 된다!! 

 

 

 

이것을 깨닫고, 나는 타깃 대상을 바꿔주어야한다고 생각했고, 고민 끝에 새롭게 업데이트된 카드 리스트의 마지막 카드 컴포넌트를 타깃으로 택하기로 했다. 

            <CardsListsDiv>
              <AddCard onClick={onAddCardClickHandle} />
              {cardlist.length !== 0
                ? cardlist.map((card, index) => {
                    const isLastCard = index === cardlist.length - 1;
                    return (
                      <RollingCard
                        key={card.id}
                        id={card.id}
                        sender={card.sender}
                        relationship={card.relationship}
                        createdAt={card.createdAt}
                        content={card.content}
                        font={card.font}
                        profileImageURL={card.profileImageURL}
                        $isEditMode={isEditMode}
                        onClick={() => onDetailClickHandle(card)}
                        setDeleteId={setDeleteMsgId}
                        ref={isLastCard ? lastCardRef : null}
                      />
                    );
                  })
                : null}
            </CardsListsDiv>

 

타깃을 바꾸어주었더니, 성공적으로 무한 스크롤이 동작하였다. 이렇게 무한 스크롤 구현의 대장정이 끝이났다..

그래도 무한 스크롤을 구현해보았다는 것에서 의의를 두고, 

다음에 무한 스크롤 구현의 기회가 온다면, 이번보다 더 빠르게 구현할 수 있을 것 같다는 자신감이 생겼다.

 

via GIPHY

 

 

 

 

 

참고자료

https://developer.mozilla.org/ko/docs/Web/API/Document/scroll_event

https://simian114.gitbook.io/blog/undefined/react/intersectionobserverapi

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