리액트 무한스크롤 적용기 (Intersection Observer, Debouncing)

bible_k_·2023년 9월 6일
0

Intersection Observer란?

Intersection Observer
Intersection Observer는 웹 개발에서 사용되는 JavaScript API 중 하나로, 뷰포트(브라우저 화면)와 대상 요소 사이의 교차 상태를 관찰하는 데 사용된다.

Intersection Observer를 활용하여 하단에 target Ref를 지정하여 스크롤 시 가시성을 판단하고 추가적인 데이터를 불러오도록 무한스크롤을 구현했다.

왜 무한스크롤?

지금 리팩토링을 진행하고 있는 프로젝트에서는 이미 페이지네이션으로 적용했었다. 하지만 모바일 반응형으로 전환을 진행하며, 모바일 사용자 입장에서 페이지네이션의 사용감이 매우 뒤떨어진다고 느꼈다. 작은 화면에서 다음 페이지로 가기 위해 다음페이지 버튼을 확인하고 클릭하고 로딩을 기다리기까지 불편할 수 밖에 없다. 그래서 일부 페이지에서는 무한스크롤로 전환하는 작업을 진행하였다.

Intersection Observer 커스텀 훅 생성

여러 페이지에서 무한스크롤을 적용할 예정이기 때문에 편하게 불러서 사용하기 위해 커스텀훅을 만들었다.

import React, { useCallback, useEffect, useRef } from 'react';
import debounce from './debounce';

type IntersectHandler = () => void;

const useIntersect = (
  onIntersect: IntersectHandler, //타겟의 가시성 상태가 변경될 때 호출될 콜백 함수
  options?: IntersectionObserverInit //Intersection Observer의 설정 옵션
) => {
  let observerRef = useRef<IntersectionObserver | null>(null);
  const ref = useRef<HTMLDivElement>(null); // Intersection Observer를 연결할 DOM 요소

  const callback = useCallback(
    (entries: IntersectionObserverEntry[]) => {
      const entry = entries[0];
      if (entry.isIntersecting) {
        onIntersect(); //타겟이 뷰포트에 노출된 경우
      }
    },
    [onIntersect]
  );

  const debouncedCallback = debounce(callback, 300);

  // Intersection Observer 설정
  useEffect(() => {
    if (!ref.current) return; //마운트 되었는지 확인
    observerRef.current = new IntersectionObserver(debouncedCallback, options);
    observerRef.current.observe(ref.current); //타겟 관찰
    return () => {
      //컴포넌트가 언마운트되면 관찰 중지
      observerRef.current && observerRef.current.disconnect();
    };
  }, [ref, options, callback]);

  return ref;
};

export default useIntersect;

디바운싱

사실 Intersection Observer는 이미 기존 addEventListener() 스크롤 이벤트를 통해 무한스크롤을 구현했다면 디바운싱을 통해 해결해야했을 연속적인 이벤트 호출을 해결한 API이다. 매 스크롤마다 이를 감지하고 이벤트를 호출하는 것과 스크롤 이벤트를 통해 타겟의 가시성을 관찰하고 콜백함수를 실행하는 것은 확연히 다르기 때문이다.

여기에 더불어 리스트에 끝에 다다랐을 때, 즉 target element가 가시성이 연속적으로 관찰되는 것을 디바운싱을 통해 방지하면 정해진 delay ms에 한번의 콜백함수 호출을 보장할 수 있다.

궁극적으로 내가 디바운싱을 함께 적용한 이유는 아래 에러 때문이다.

아이템 선택 후 뒤로가기로 다시 돌아왔을 때나 다른 페이지로 갔다가 돌아왔을 때 기존 데이터에 누적되어 재요청되는 에러가 발생했다.
1페이지 이상의 데이터가 로딩되어있는 상태에서 다른 페이지로 갔다가 돌아왔을 때만 에러가 발생하는 것으로 보아 스크롤 위치가 기억되어, 처음 뷰포트 노출과 더불어 타겟의 노출이 연달아 이루어지기 때문에 발생하는 에러로 예측했다. 역시나 디바운싱을 적용하니 해결되었다.

디바운싱 코드

let timer: NodeJS.Timeout | null = null; //지연 호출을 관리할 타이머 선언

const debounce = (fn: Function, delay: number) => {
  return (...args: any[]) => {
    //클로저(debounce된 함수) 반환
    if (timer) {
      clearTimeout(timer); //새로운 함수 호출이 들어올 때마다 타이머 리셋
      timer = null;
    }
    timer = setTimeout(() => {
      fn(...args); //새로운 타이머 설정 delay밀리초 후에 func함수 호출
    }, delay);
  };
};

//새로운 호출이 delay 밀리초 내에 들어오면, 이전 타이머가 취소되고 새로운 타이머가 설정되며, 함수는 마지막 호출 이후에 실행됩니다.
//만약 delay 밀리초 동안 다른 호출이 없으면, 타이머가 만료되고 해당 함수가 실행됩니다.

export default debounce;

Intersection Observer 커스텀 훅을 이용해 무한스크롤 적용

function CommunityPostFeed() {
  const navigate = useNavigate();
  const [postData, setPostData] = useState<PostType[]>([]);
  //스크롤시 page 누적하여 기록하고 데이터 요청 시 활용
  const [page, setPage] = useState(1);
  const [isLoading, setIsLoading] = useState(false);
  //모든 데이터가 로딩이 완료됐는지 확인하는 state
  const [isFetchingEnded, setIsFetchingEnded] = useState(false);

  //Intersection observer 커스텀 훅을 통해 콜백 함수와 option 전달
  const targetRef = useIntersect(
    () => {
      fetchData();
    },
    { threshold: 0, rootMargin: '10px' }
  );

  //타겟이 노출되었을 때 실행할 함수 
  const fetchData = () => {
    if (isFetchingEnded) return; //이미 모든 데이터 호출이 끝났다면 return 
    setIsLoading(true);

    const url = `${process.env.REACT_APP_API_URL}/communities?page=${page}&itemsPerPage=12`;
    const config = {
      withCredentials: true,
    };
    axios
      .get(url, config)
      .then((res) => {
        if (res.status === 204) {
          setIsFetchingEnded(true); //데이터가 더 이상 남아있지 않다면 상태 변경
        } else {
          setPostData((prev) => [...prev, ...res.data.data]);
          setPage((prev) => prev + 1); //정상적으로 불러왔다면 데이터 저장 및 페이지 수 ++
        }

        setIsLoading(false);
      })
      .catch((e) => console.error(e));
  };

  return (
    <>
		...
      <CommunityPostList postData={postData} isLoading={isLoading} />
    	//리스트 컴포넌트 아래에 타겟을 위치시킨다.
      <Target ref={targetRef} isFetchingEnded={isFetchingEnded}></Target>
    </>
  );
}

export default CommunityPostFeed;

무한스크롤 중지

모든 데이터 호출이 완료되면 콜백함수에서 바로 return 하도록 조건도 걸어놓았지만 그래도 observer의 관찰은 지속되기 때문에 target 자체를 없애 observer의 관찰이 중지되도록 하였다.
isFetchingEnded 상태를 타겟의 props로 전달하여 display 속성을 바꿔준다.

const Target = styled.div<{ isFetchingEnded: boolean }>`
  display: ${({ isFetchingEnded }) => isFetchingEnded && 'none'};
  height: 3rem;
`;

Reference

https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver
https://tech.kakaoenterprise.com/149
https://fe-developers.kakaoent.com/2021/211202-gpu-intersection-observer/

⬇️ 싸커퀵 ⬇️
https://soccerquick.kr/

profile
후론트엔드 개발자

0개의 댓글