Skip to content

무한 스크롤 로직 개선 (feat. intersection observer)

iHoHyeon edited this page Mar 23, 2022 · 3 revisions

무한 스크롤 로직 개선하기 (feat. intersection observer)

노가리하우스에서는 조회하는 모든 것들을 무한 스크롤을 지원하고 있습니다. 사용자 입장에서 페이지를 정하지 않아도 되며 필요한 데이터만 불러올 수 있다는 점에서 무한 스크롤 방식을 채택했습니다.

기존의 구현에서는 단지 "스크롤을 어느정도 내리면 데이터 요청을 보내야지" 라는 생각에 충실하여 화면의 scroll 이벤트가 발생할 때마다 현재 스크롤 위치는 어느정도인지, 지금 데이터를 불러오는 중인지를 매번 검사해주었습니다.

위 방식은 scrollTop 등 계산된 스타일 정보를 브라우저에게 요청하게 되며 reflow로 인한 렌더링 성능에 영향을 줄 수 있으며 모든 스크롤 관련 코드가 메인스레드에서 동작하기 때문에 비동기적으로 상태를 관찰할 수 있는 intersection observer 를 사용하는 방식으로 변경하게 되었습니다.

const scrollBarChecker = useCallback((e: UIEvent<HTMLDivElement>) => {
    if (!nowFetchingRef.current) {
      const diff = e.currentTarget.scrollHeight - e.currentTarget.scrollTop;
      if (diff < 700) {
        setNowFetching(true);
        nowFetchingRef.current = true;
        setTimeout(() => {
          nowFetchingRef.current = false;
        }, 200);
      }
    }
  }, []);

위는 기존의 scroll 이벤트에 등록되는 콜백함수입니다.

매번 스크롤이 발생할 때마다 호출되며 reflow 요청을 하고, nowFetchingRef, serNowfetching 등 여러개의 변수를 의존하게 만듭니다. 나름대로 Throttle 을 적용하기 위해서 setTimeout까지 들어가서 이해하기 어렵고 여러 곳에서 부작용이 발생하기 쉬운 코드가 될 수 있습니다.

우리가 원하는 것은 특정한 이벤트, 즉 스크롤 바가 어느정도 내려오면 setNowFetching(true) 를 호출해서 nowFetching 을 의존하고 있는 데이터 fetch useEffect를 실행시키기만 하면 되는 것입니다.

따라서 스크롤을 제어하는 변수를 전부 지우고 리스트 최하단의 Element가 어느정도 이상 사용자의 view에 노출이 되면 setNowFetching(true) 를 호출해버리는 intersection observer를 등록해보도록 하겠습니다.

import { RefObject, useEffect, useRef } from 'react';
import { useRecoilState } from 'recoil';

import { nowFetchingState } from '@atoms/main-section-scroll';

const useItemFecthObserver = (loading: boolean): [RefObject<HTMLDivElement>] => {
  const [nowFetching, setNowFetching] = useRecoilState(nowFetchingState);
  const targetRef = useRef<HTMLDivElement>(null);

  const onIntersect = async (entries: IntersectionObserverEntry[]) => {
    if (entries[0].isIntersecting && !nowFetching) {
      setNowFetching(true);
    }
  };

  useEffect(() => {
    let observer: IntersectionObserver;
    if (targetRef.current) {
      observer = new IntersectionObserver(onIntersect, {
        threshold: 0.4,
      });
      observer.observe(targetRef.current);
    }
    return () => observer?.disconnect();
  }, [targetRef.current, loading]);

  return [targetRef];
};

export default useItemFecthObserver;

userItemFetchObserver 훅에서 targetRef.current 엘리먼트가 40%이상 노출되면 nowFetching을 true 로 세팅하는 함수를 호출합니다.

이 훅은 targetRef를 반환하며 이는 리스트를 보여주는 view 컴포넌트 들에서 어느 element를 target으로 할 지 정하게 됩니다.

  useEffect(() => {
    let observer: IntersectionObserver;
    if (targetRef.current) {
      observer = new IntersectionObserver(onIntersect, {
        threshold: 0.4,
      });
      observer.observe(targetRef.current);
    }
    return () => observer?.disconnect();
  }, [targetRef.current, loading]);

컴포넌트 로딩이 완료되면 targetRef.current의 엘리먼트를 observer에 observe 등록을 합니다.

IntersectionObserver 생성자는 설정한 비율만큼 사용자에게 보여지면 호출하게되는 콜백함수와 옵션을 선택할 수 있습니다.

위 코드에서는 threshold 0.4 옵션과 onIntersect 콜백을 파라미터로 넘겨주어서 observer를 생성했습니다.

observe 중인 타겟이 40% 만큼 보여지는 경우 onIntersect 콜백을 수행합니다.

  const onIntersect = async (entries: IntersectionObserverEntry[]) => {
    if (entries[0].isIntersecting && !nowFetching) {
      setNowFetching(true);
    }
  };

onIntersect 콜백에서는 현재 데이터를 불러오는 중이 아니며, 타겟 Div 엘리먼트가 교차한 경우라면 데이터를 불러오는 트리거를 동작하도록 하였습니다.

여러개의 entry가 존재할 수 있지만 우리의 코드에서는 리스트 최하단의 하나의 타겟만 존재하므로 entries[0] 으로 접근하였습니다.

// room-view.tsx

...
return (
    <>
      <RoomCardList roomCardClickHandler={roomCardClickHandler} roomList={nowItemList} />
      <ObserverBlock ref={targetRef}>
        {nowFetching && <LoadingSpinner />}
      </ObserverBlock>
    </>
  );

리스트 최하단에 ObserverBlock이라는 div엘리먼트를 만들어서 targetRef를 등록해주었습니다!

ob.gif

참고자료

Intersection Observer API - Web API | MDN

React 무한 스크롤 구현하기 with Intersection Observer