2025.7.11 금요일의 공부기록
IntersectionObserver를 사용하여 쉽고 효율적인 무한 스크롤(Infinite Scroll)을 구현하는 방법을 알아보자.
IntersectionObserver
는 브라우저에서 제공하는 강력한 API로, 특정 엘리먼트가 사용자의 화면(뷰포트)에 나타나거나 사라질 때를 효율적으로 감지할 수 있게 해준다.
기존의 스크롤 이벤트로 처리하는 방식과 달리 성능상 우수하며, 복잡한 계산 없이 간단히 구현할 수 있다는 장점이 있다.
🔗 IntersectionObserver 개념 설명 영상
IntersectionObserver를 활용한 무한 스크롤은 다음과 같은 순서로 동작한다.
isIntersecting
) 추가 데이터를 로딩한다.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)을 설정한다.trigger.current
가 뷰포트에 완전히 나타날 때(threshold: 1.0
) 다음 페이지 데이터를 가져오는 로직이 실행된다.데이터 추가 로딩:
getMoreProducts
함수를 호출해 새로운 데이터를 가져오고, 상태를 업데이트한다.무한 스크롤의 종료 처리:
isLastPage
를 설정하여 더 이상 무한 스크롤이 일어나지 않게 한다.cleanup 처리:
데이터를 가져올 때 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;
}