상세 컨텐츠

본문 제목

[React.js] 쌈뽕하게 무한스크롤 구현하기 | useInfiniteQuery, IntersectionObserver

Development Study/프론트엔드

by yooputer 2025. 6. 25. 16:58

본문

최근에 듣고있는 강의에서 무한 스크롤에 대해 배웠는데, 

코드가 너무 쌈뽕해서 별도의 포스팅으로 기록하고 싶었다. 

 

 

Next.js 15로 완성하는 실전 YouTube 클론 개발 강의 | antonio - 인프런

antonio | 이 24시간 튜토리얼에서는 자신만의 유튜브 클론을 만드는 방법을 배우게 됩니다. Next 15와 React 19(tRPC 포함), 서버 컴포넌트에서의 프리페칭, 클라이언트 컴포넌트에서의 서스펜스 활용,

www.inflearn.com

 

강의에서는 tRPC 기반 API를 사용하여 trpc의 useSuspenseInfiniteQuery를 사용하였는데

대부분의 프로젝트는 rest api를 사용하기 때문에 

이번 포스팅에서는 rest api와 useInfiniteQuery을 사용하여

무한스크롤을 구현하는 방법을 정리해보려 한다. 


API 구현

이번 포스팅을 위해 간단한 SpringBoot RestAPI 서버를 구현하였다. 

자세한 내용은 아래 포스팅에서 확인할 수 있다. 

 

 

[IntelliJ + Claude] 초간단하게 SpringBoot API 서버 구현하기

요즘 React.js와 Next.js를 공부하고 있다. 이번에 무한 스크롤에 대해 배우게 되었는데, 간단한 demo 프로젝트를 만들어서 복습해보려 했다. 이때 목록을 조회하는 api가 필요했는데, 내가 실무에서

yooputer-devlog.tistory.com

 

 


리액트 프로젝트 생성

아래와 같이 react 프로젝트를 생성하였다. 

npx create-react-app infinite-scroll-react-demo --template typescript

 

스타일링을 편하게 하기 위해 별도로 Tailwind를 적용하였다. 


fetch 함수 구현

restaurant 목록 조회 api를 호출하여 반환하는 fetchRestaurants 함수를 구현한다. 

restaurant는 최대 10개까지 조회되고, 이후 restaurant 목록을 조회하려면

마지막으로 조회한 restaurant의 아이디를 파라미터로 넘기면 된다. 

/* API 반환 형식 */
interface fetchRestaurantsResponse {
    restaurants: Restaurant[];
    hasNext: boolean;
    lastId: number;
    totalElements: number;
}

/* Restaurant 목록 조회 API 호출 함수 */
const fetchRestaurants = async ({ lastId }: {lastId? : number}) : Promise<fetchRestaurantsResponse> => {
    const res = await fetch(`http://localhost:8080/api/restaurants${lastId ? '?lastId=' + lastId : ''}`);

    if (!res.ok) throw new Error('네트워크 오류');

    return res.json();
}

RestaurantSection, RestaurantItem 컴포넌트 구현

우선 fetchRestaurants와 useInfiniteQuery를 사용하여 restaurant 목록과 페이징 정보를 조회한 후

조회한 restaurant를 RestaurantItem 컴포넌트로 매핑하는 코드를 작성하였다. 

 

RestaurantSection 컴포넌트 구현

export const RestaurantSection = () => {
    const { data, fetchNextPage, hasNextPage, isLoading, isError } = useInfiniteQuery(
        ['page'],
        ({ pageParam }) => fetchRestaurants({ lastId: pageParam }),
        {
            getNextPageParam: (lastPage) => lastPage.lastId,
        },
    );

    if (isLoading) return <h3>로딩중</h3>;
    if (isError) return <h3>Error</h3>;

    return (
        <div className="max-w-sm mx-auto p-6">
            {data?.pages.flatMap(page =>
                page.restaurants.map(restaurant => (
                    <RestaurantItem restaurant={restaurant} key={restaurant.id} />
                ))
            )}
        </div>
    )
}

RestaurantItem 컴포넌트 구현

const RestaurantItem = ({ restaurant }: { restaurant: Restaurant }) => {
    return (
        <div className="bg-white rounded-xl shadow p-4 mb-6 flex flex-col items-start border border-gray-200">
            <img
                src={restaurant.imageUrl}
                alt={restaurant.name}
                className="w-full h-44 object-cover rounded-lg mb-2"
            />
            <div className="w-full px-1">
                <h2 className="flex items-center gap-2 text-lg font-bold mb-1">
                    {restaurant.name}
                    <span className="inline-block bg-gray-100 text-gray-700 rounded-full px-2 py-0.5 text-xs font-semibold ml-1">
            {restaurant.category}
          </span>
                </h2>
                <div className="flex items-center gap-2 mb-2">
                    {restaurant.isOpen ? (
                        <span className="text-green-600 text-sm font-bold">OPEN</span>
                    ) : (
                        <span className="text-red-500 text-sm font-bold">CLOSE</span>
                    )}
                    <span className="text-gray-600 text-sm">{restaurant.openingHours}</span>
                </div>
                <p className="mb-0 text-gray-800 text-sm">{restaurant.description}</p>
            </div>
        </div>
    );
};

 

data.pages는 fetchRestaurantsResponse[]가 저장되어 있다. 

fetchNextPage를 실행하면 data.pages에 새로 조회한 반환값이 append 된다. 

 

그래서 우리는 적절한 타이밍에 fetchNextPage를 호출하기만 하면 된다. 


useIntersectionObserver 훅, InfiniteScroll 컴포넌트 구현

 

아래과 같이 마지막 RestaurantItem 컴포넌트 뒤에 InfiniteScroll 컴포넌트를 배치하고,

해당 컴포넌트가 뷰포인트에 감지되면 fetchNextPage를 호출하도록 구현할 것이다. 

 

이때 특정 컴포넌트가 뷰포인트에 감지되었는지 확인하기 위해 useIntersectionObserver 훅을 사용할 것이다. 


useIntersectionObserver 훅 구현

import { useEffect, useRef, useState } from "react";

export const useIntersectionObserver = (options?: IntersectionObserverInit) => {
    const [isIntersecting, setIsIntersecting] = useState(false);
    const targetRef = useRef<HTMLDivElement>(null);

    useEffect(() => {
        const observer = new IntersectionObserver(([entry]) => {
            setIsIntersecting(entry.isIntersecting);
        }, options);

        if (targetRef.current) {
            observer.observe(targetRef.current);
        }

        return () => observer.disconnect();
    }, [options]);

    return { targetRef, isIntersecting };
};

 

IntersectionObserver를 사용하여 intersecting 여부를 반환한다. 


InfiniteScroll 컴포넌트 구현

import {useEffect} from "react";
import {useIntersectionObserver} from "../hooks/use-intersection-observer";

interface InfiniteScrollProps {
    hasNextPage?: boolean;
    isFetchingNextPage: boolean;
    fetchNextPage: () => void;
    endText?: string;
};

export const InfiniteScroll = ({hasNextPage, isFetchingNextPage, fetchNextPage, endText, }: InfiniteScrollProps) => {
    const { targetRef, isIntersecting } = useIntersectionObserver({
        threshold: 0.5,
        rootMargin: "100px",
    });

    useEffect(() => {
        if (isIntersecting && hasNextPage && !isFetchingNextPage) {
            fetchNextPage();
        }
    }, [isIntersecting, hasNextPage, isFetchingNextPage, fetchNextPage]);

    return (
        <div className="flex flex-col items-center p-4">
            <div ref={targetRef} className="h-1" />
            {!hasNextPage && endText ? (<p>{endText}</p>) : undefined }
        </div>
    );
};

 

isIntersecting와 hasNextPage가 true이고 isFetchingNextPage가 false이면 fetchNextPage함수를 호출한다. 


InfiniteScroll 컴포넌트 추가하기

RestaurantSection 컴포넌트의 마지막 부분에 InfiniteScroll 컴포넌트를 추가한다. 

export const RestaurantSection = () => {
	...
    
    return (
        <div className="max-w-sm mx-auto p-6">
            {data?.pages.flatMap(page =>
                page.restaurants.map(restaurant => (
                    <RestaurantItem restaurant={restaurant} key={restaurant.id} />
                ))
            )}

            {/* ⭐ InfiniteScroll 추가 */}
            <InfiniteScroll
                hasNextPage={hasNextPage}
                isFetchingNextPage={isLoading}
                fetchNextPage={fetchNextPage}
                endText="더이상 조회할 수 있는 맛집이 없습니다."
            />
        </div>
    )
}

결과 확인

gif로 만드니까 화질이 너무 떨어졌지만 아무튼 잘된다~~!

 


useInfiniteQuery와 useIntersectionObserver를 사용하여 무한 스크롤을 구현해 보았는데, 

이게 모던 프론트엔드인가 싶다. 

코드가 굉장히 깔끔하고 사용자 경험이 너무 좋다! 

 

나중에 무한스크롤을 구현하게 된다면 이 방법으로 구현해보려 한다. 

관련글 더보기