[React] 무한스크롤 구현하기

이지·2023년 9월 13일

무한스크롤이란?

무한스크롤이란 화면을 아래로 스크롤 하였을 때, 콘텐츠가 끊이지 않고 계속해서 로드되는 것을 의미합니다. 다음 버튼을 눌러 페이지가 로딩되는 것(페이지네이션)을 기다리지 않아도 되어 사용자 경험을 높일 수 있습니다.
인스타그램이나 유튜브에서도 무한스크롤을 사용하고 있는데요. 끊임없이 새로운 콘텐츠가 아래에서 로드되는 것을 알 수 있습니다.

무한스크롤을 구현하는 방식에는 여러 가지가 있지만, 그중에서도 저는 intersection observer API 를 이용하여 구현하였습니다.

Intersection Observer API

뷰포트와 대상 요소 사이의 교차 상태를 감지하는 기능을 제공하여 무한 스크롤과 같은 기능을 구현하는 데 유용합니다.

  • IntersectionObserver(callback, options)
    교차 상태 변화를 관찰하기 위한 Observer 객체입니다. 생성자로 콜백 함수와 옵션을 전달하여 생성됩니다.
  • observe()
    대상 요소를 관찰하도록 설정합니다. 대상 요소가 교차 상태에 들어오거나 나갈 때 콜백 함수가 호출됩니다.
  • disconnect()
    Intersection Observer의 관찰을 중지하고 모든 리소스를 해제합니다.

options

  • root
    교차 상태를 관찰할 루트 요소
    기본 값은 null 이며, 이 경우 뷰포트를 기준으로 교차상태를 감지합니다.
    다른 요소를 지정하면 해당 요소의 경계 내에서 교차 상태를 감지합니다.
  • threshold
    교차 상태가 변경되기 위한 대상 요소와 루트 요소 사이의 비율을 나타냅니다.
    0~1의 숫자나 숫자배열로 설정가능합니다.
    예를 들어, 0.5는 대상 요소가 최소한 반 이상이 뷰포트 안에 들어와야 교차 상태로 판단합니다.
  • rootMargin
    루트와 대상 요소 사이의 여백(마진)을 설정합니다. 여백을 통해 실제 보여지는 영역보다 약간 더 크거나 작게 설정하여 교차 상태 변화 이벤트가 발생하는 시점을 조절할 수 있습니다.

code

일단 저는 무한스크롤은 여러 페이지에서 재사용될 수 있기 때문에 커스텀 훅으로 작성하였습니다.

// useIntersectionObserver 커스텀 훅
const defaultOption = {
  root: null,
  threshold: 0.5,
  rootMargin: "0px",
};

export default function useIntersectionObserver(onIntersect, options, isLoading) {
  const targetRef = useRef(null);
  const checkIntersect = useCallback(([entry]) => {
    if (entry.isIntersecting) {
      onIntersect();
    }
  }, []);

  useEffect(() => {
    let io;
    if (targetRef.current && isLoading) {
      io = new IntersectionObserver(checkIntersect, {
        ...defaultOption,
        ...options,
      }); // 관찰자 생성
      io.observe(targetRef.current); // 관찰할 대상 등록
    }

    return () => {
      io && io.disconnect();
    };
  }, [targetRef, isLoading]);

  return targetRef;
}
// Home.jsx
export default function Home() {
  const [productListData, setProductListData] = useState([]);
  const [page, setPage] = useState(0);
  const [isLoading, setIsLoading] = useState(true);
  const getProductList = async (page) => {
    try {
      const res = await getProductList(page); // 상품리스트 불러오는 API
      setProductListData((prev) => [...prev, ...res.data.results]);
    } catch (err) {
      if (err.response.data.detail === "페이지가 유효하지 않습니다.") // 더이상 불러올 데이터가 없는 경우
        setIsLoading(false);
    }
  };
  const targetRef = useIntersectionObserver(
    () => {
      setPage((prev) => prev + 1); 
    },
    { threshold: 0.5 },
    isLoading
  );

  useEffect(() => {
    if (page === 0) return;
    getProductList(page);
  }, [page]);

  return (
    <MainLayout>
      <ProductList productListData={productListData} />
      <div ref={targetRef} />
    </MainLayout>
  );
}
  • <div ref={targetRef} 가 화면에 감지되면 observer가 setPage((prev) => prev + 1) 을 실행하여 다음 페이지의 데이터를 가져옵니다.
  • isLoading을 통해 모든 데이터가 다 불러와지면 관찰자는 참조하는 대상과의 연결을 끊습니다.
// ProductList.jsx
export default function ProductList({ productListData }) {
  return (
    <ProductUl>
      {productListData.map((product) => (
        <li key={product.product_id}>
           <ProductImg src={product.image} alt="상품이미지" />
           <ProductName>{product.product_name}</ProductName>
           <ProductPrice>{product.price}</ProductPrice>
        </li>
      ))}
    </ProductUl>
  );
}

0개의 댓글