무한스크롤 구현하기 2 (스크롤 처리 & 디바운스)

foresec·2024년 6월 8일

Project

목록 보기
9/11

1. 스크롤처리

이제 스크롤 처리를 하기 위해서 IntersectionObserver를 사용해야한다.

IntersectionObserver

IntersectionObserver 인터페이스는 대상 요소와 상위 요소, 또는 대상 요소와 최상위 문서의 뷰포트가 서로 교차하는 영역이 달라지는 경우 이를 비동기적으로 감지할 수 있는 수단을 제공

브라우저의 뷰포트와 대상 요소 간의 교차 여부를 감지하며, 특히 요소가 화면에 나타나거나 사라지는 것을 감지할 때 유용함

관련 공식문서
https://developer.mozilla.org/ko/docs/Web/API/Intersection_Observer_API
https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver/IntersectionObserver

예시코드

var intersectionObserver = new IntersectionObserver(function (entries) {
  // intersectionRatio가 0이라는 것은 대상을 볼 수 없다는 것이므로
  // 아무것도 하지 않음
  if (entries[0].intersectionRatio <= 0) return;

  loadItems(10);
  console.log("새 항목 불러옴");
});
// 주시 시작
intersectionObserver.observe(document.querySelector(".scrollerFooter"));

위 예제코드와 같이 해당 요소가 화면에 나타났을 때 원하는 로직(loadItems)을 수행할 수 있다.

적용해보자

하단에 도달하면

  // INFINITE SCROLL OBSERVER
  const observerRef = useRef(null);

  const observerCallback: IntersectionObserverCallback = useCallback(
    (entries) => {
      const lastPage = searchResults?.pages[searchResults.pages.length - 1];
      entries.forEach((entry) => { 
        if (
           // entry is 'IntersectionObserverEntry'
          entry.isIntersecting &&
          lastPage &&
          lastPage.tracks.items.length > 0
        ) {
          fetchNextPage();
        }
      });
    },
    [fetchNextPage, searchResults?.pages]
  );

  useEffect(() => {
    const currentRef = observerRef.current;
    if (!currentRef) return;

    const observer: IntersectionObserver = new IntersectionObserver(
      observerCallback,
      {
        threshold: 0,
      }
    );
    
    // 대상 요소 관찰
    observer.observe(currentRef);
    
	// 컴포넌트 해제시 관찰 중지
    return () => observer && observer.unobserve(currentRef);
  }, [observerCallback, observerRef]);
  1. 먼저 useRef를 활용해 참조변수를 생성하고
  2. 감지된 변화에 대한 처리작업을 수행할 수 있는 콜백함수를 정의
    • 나와 같은 경우, 저번 포스트에서 제한을 걸었던 조건을 추가했다
  3. useEffect로 IntersectionObserver 인스턴스 생성 및 관찰

isIntersecting

관찰 대상이 현재 루트 안에 포함되어 있는지의 여부

threshold

threshold 옵션은 보이는 부분의 비율을 나타내며 기본값은 0이다.

예를 들어, 0.5일 경우 대상 요소의 50% 이상이 화면에 보일 때 콜백이 호출됨

또한, [0, 0.25, 0.5, 0.75, 1]와 같이 배열형태로도 설정이 가능해 대상 요소가 threshold%만큼 보일 때마다 각각 호출이 일어날 수도 있다

+이와 같은 option에 root(기본값은 null, 브라우저의 뷰포트)와 rootMargin도 있으나 당장 필요한 기능은 아니라 생략
https://heropy.blog/2019/10/27/intersection-observer/

customHook으로 observer를 분리

코드 길이도 길어졌고, 분명 다시 쓰일일이 있을 듯하여 따로 분리했다


const useIntersectionObserver = ({
  target,
  threshold = 0,
  onObserverCallback,
}: IntersectionObserverType) => {
  useEffect(() => {
    const currentRef = target?.current;
    if (!currentRef) return;

    const observer: IntersectionObserver = new IntersectionObserver(
      onObserverCallback,
      {
        threshold: threshold,
      }
    );
    observer.observe(currentRef);

    return () => observer && observer.unobserve(currentRef);
  }, [onObserverCallback, target, threshold]);
};

export default useIntersectionObserver;

2. Debounce

Debounce란?

짧은 시간내 연속적으로 발생한 이벤트를 하나로 처리하는 방식, 과도한 이벤트의 호출을 방지함

'가나'를 검색할때, input Value를 console에 찍어보면 'ㄱ', '가', '간', '가나'와 같이 입력하는 족족 API요청이 들어간다.
이렇게 필요 이상의 요청을 만들고 싶지 않을때 사용한다.

구현코드

const [debouncedVal, setDebouncedVal] = useState("");

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedVal(searchVal.trim());
    }, 500);

    return () => {
      clearTimeout(timer);
    };
  }, [searchVal]);

간단하게 debounceVal를 따로 state를 분리하고, setTimeout함수로 0.5초 이후 검색하도록 하면 된다.
이전에 설정된 타이머가 남아있지 않도록 useEffect훅이 재실행되기 전에 이전에 설정된 타이머를 취소해줘야한다.

customHook으로 분리

import { useEffect, useState } from "react";

const useDebounce = (val: string, delay: number) => {
  const [debouncedVal, setDebouncedVal] = useState("");

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedVal(val.trim());
    }, delay);

    return () => {
      clearTimeout(timer);
    };
  }, [val, delay]);

  return debouncedVal;
};

export default useDebounce


// page.tsx
const debouncedSearchVal = useDebounce(searchVal, 500);

커스텀훅으로 분리하면 보다 깔끔하게 사용가능하다

결과

export default function SearchPage() {
  const [searchVal, setSearchVal] = useState("");
  const ITEMS_PER_PAGE = 10;
  const observerRef = useRef(null);

  const handleSearchVal = (val: string) => {
    setSearchVal(val);
  };

  // Debounce
  const debouncedSearchVal = useDebounce(searchVal, 500);

  // Data(Infinite)
  const handleSearchForInfinite = async (
    q: string,
    limit: number,
    offset: number
  ) => {
    const response = await getSearchResultAPI({
      q: q,
      type: ["track"],
      market: "KR",
      limit: limit,
      offset: offset,
    });
    return response;
  };

  const {
    data: searchResults,
    isLoading,
    isError,
    error,
    isFetching,
    fetchNextPage,
  } = useInfiniteQuery({
    queryKey: ["searchInfinite", { searchVal: debouncedSearchVal }],
    initialPageParam: 0,
    queryFn: ({ pageParam }) =>
      handleSearchForInfinite(debouncedSearchVal, ITEMS_PER_PAGE, pageParam),
    getNextPageParam: (lastPage, allPages, lastPageParam) => {
      let nextOffset = lastPageParam + ITEMS_PER_PAGE;
      return nextOffset;
    },
    enabled: !!debouncedSearchVal.trim(),
  });

  // Infinite Scroll
  const observerCallback: IntersectionObserverCallback = useCallback(
    (entries) => {
      const lastPage = searchResults?.pages[searchResults.pages.length - 1];
      entries.forEach((entry) => {
        if (
          entry.isIntersecting &&
          lastPage &&
          lastPage.tracks.items.length > 0
        ) {
          fetchNextPage();
        }
      });
    },
    [fetchNextPage, searchResults?.pages]
  );

  useIntersectionObserver({
    target: observerRef,
    onObserverCallback: observerCallback,
  });

다른 것 보다 표면적으로 useEffect가 작성되지 않은 컴포넌트라니 새롭다.

isFetching으로 무한스크롤 로딩관리하기

{/* 무한 스크롤 LOADING */}
<div ref={observerRef}>{isFetching && <Spinner />}</div>

useInfiniteQuery에서 제공하는 isFetching으로 무한스크롤에서 발생하는 로딩스피너의 노출을 관리할 수 있다.

  • isFetching은 쿼리가 데이터를 가져오는 중인지 여부를 나타냄
profile
왼쪽 태그보다 시리즈 위주로 구분

0개의 댓글