IntersectionObserver로 무한 스크롤 구현하기

Odyssey·2025년 7월 10일
0

Next.js_study

목록 보기
55/58
post-thumbnail

2025.7.11 금요일의 공부기록

IntersectionObserver를 사용하여 쉽고 효율적인 무한 스크롤(Infinite Scroll)을 구현하는 방법을 알아보자.


IntersectionObserver란?

IntersectionObserver는 브라우저에서 제공하는 강력한 API로, 특정 엘리먼트가 사용자의 화면(뷰포트)에 나타나거나 사라질 때를 효율적으로 감지할 수 있게 해준다.

기존의 스크롤 이벤트로 처리하는 방식과 달리 성능상 우수하며, 복잡한 계산 없이 간단히 구현할 수 있다는 장점이 있다.

🔗 MDN IntersectionObserver 문서

🔗 IntersectionObserver 개념 설명 영상

IntersectionObserver를 활용한 무한 스크롤 원리

IntersectionObserver를 활용한 무한 스크롤은 다음과 같은 순서로 동작한다.

  1. 페이지 하단에 위치한 특정 엘리먼트를 관찰 대상으로 설정한다.
  2. 사용자가 스크롤하여 이 엘리먼트가 뷰포트에 나타나면(isIntersecting) 추가 데이터를 로딩한다.
  3. 추가 데이터가 로딩되면 다시 새로운 관찰 대상을 설정하여 반복한다.

예시

components/product-list.tsx

"use client";

import { InitialProducts } from "@/app/(tabs)/products/page";
import ListProducts from "./list-product";
import { useEffect, useRef, useState } from "react";
import { getMoreProducts } from "@/app/(tabs)/products/actions";

interface ProductsListProps {
  initialProducts: InitialProducts;
}

export default function ProductsList({ initialProducts }: ProductsListProps) {
  const [products, setProducts] = useState(initialProducts);
  const [isLoading, setIsLoading] = useState(false);
  const [page, setPage] = useState(0);
  const [isLastPage, setIsLastPage] = useState(false);
  const trigger = useRef<HTMLSpanElement>(null);

  useEffect(() => {
    // IntersectionObserver 인스턴스 생성
    const observer = new IntersectionObserver(
      async (
        entries: IntersectionObserverEntry[],
        observer: IntersectionObserver
      ) => {
        const element = entries[0];
        if (element.isIntersecting && trigger.current && !isLoading) {
          observer.unobserve(trigger.current);
          setIsLoading(true);

          // 다음 페이지 데이터를 가져옴
          const newProducts = await getMoreProducts(page + 1);

          if (newProducts.length !== 0) {
            setProducts((prev) => [...prev, ...newProducts]);
            setPage((prev) => prev + 1);
          } else {
            setIsLastPage(true);
          }

          setIsLoading(false);
        }
      },
      {
        threshold: 1.0, // 관찰 대상이 완전히 보일 때 콜백 실행
      }
    );

    // 관찰 시작
    if (trigger.current) {
      observer.observe(trigger.current);
    }

    // 정리(cleanup) 함수
    return () => {
      observer.disconnect();
    };
  }, [page]);

  return (
    <div className="flex flex-col gap-5 p-5">
      {products.map((product) => (
        <ListProducts key={product.id} {...product} />
      ))}

      {!isLastPage && (
        <span
          ref={trigger}
          className="text-sm font-semibold bg-orange-500 w-fit mx-auto px-3 py-2 rounded-md hover:opacity-90 active:scale-95"
        >
          {isLoading ? "로딩중..." : "더보기"}
        </span>
      )}

      {isLastPage && (
        <span className="text-sm font-semibold bg-orange-500 w-fit mx-auto px-3 py-2 rounded-md hover:opacity-90 active:scale-95">
          마지막 페이지입니다.
        </span>
      )}
    </div>
  );
}
  • useRef & IntersectionObserver:

    • trigger라는 ref를 이용하여 관찰할 대상(span)을 설정한다.
    • IntersectionObserver로 trigger.current가 뷰포트에 완전히 나타날 때(threshold: 1.0) 다음 페이지 데이터를 가져오는 로직이 실행된다.
  • 데이터 추가 로딩:

    • IntersectionObserver 콜백이 실행되면 getMoreProducts 함수를 호출해 새로운 데이터를 가져오고, 상태를 업데이트한다.
  • 무한 스크롤의 종료 처리:

    • 새로운 데이터가 없다면 isLastPage를 설정하여 더 이상 무한 스크롤이 일어나지 않게 한다.
  • cleanup 처리:

    • useEffect의 cleanup 함수로 컴포넌트 언마운트 시 IntersectionObserver를 반드시 종료한다.

Prisma를 이용한 Pagination 처리

데이터를 가져올 때 Prisma의 pagination 옵션(skip, take)을 사용하여 구현할 수 있다.

// actions.ts 예시 코드
export async function getMoreProducts(page: number) {
  const products = await db.product.findMany({
    skip: page * 10, // 페이지 번호에 따라 데이터를 건너뜀
    take: 10, // 한 번에 가져오는 데이터 수
    orderBy: {
      created_at: "asc",
    },
  });
  return products;
}

🔗 Prisma Pagination 공식 문서

0개의 댓글