Intersection Observer + React Query 로 성능과 UX 모두 잡는 무한 스크롤 구현기

kind J·2025년 8월 19일

현대의 사용자들은 더 이상 '다음(NEXT)' 버튼을 누르고 싶어하지 않는다.
SNS 피드를 내리듯, 상품 목록을 보듯, 콘텐츠가 끊임없이 이어지는 부드러운 경험에 익숙해졌다.

이것이 바로 '무한 스크롤(Infinite Scroll)'이 현대 웹 애플리케이션의 필수 UX로 자리잡은 이유이다.

하지만 무한 스크롤은 자칫 잘못 구현하면 성능 저하의 주범이 되기도 한다. 이벤트를 남용하면 메인 스레드를 블로킹하고, 어설픈 데이터 관리는 불필요한 API 호출을 남발한다.

브라우저의 똑똑한 감시자인 Intersection Observer와 서버 상태 관리의 절대강자 React Query(TanStack Query)를 조합하여, 어떻게 우아하고 성능까지 뛰어난 무한 스크롤을 구현했는지 소개하고자 한다.

  1. 똑똑한 감시자: Intersection Observer

과거에는 스크롤 위치를 계산하기 위해 scroll 이벤트 리스너를 사용했다. 하지만 이 방법은 스크롤이 발생할 때마다 수십, 수백 번씩 이벤트가 발생하여 브라우저에 큰 부담을 준다.
Intersection Observer는 이런 문제를 해결하기 위해 등장한 브라우저 API 이다.
요소(Element)화면(Viewport)에 들어오거나 나가는 순간을 비동기적으로 감지해준다. 덕분에 불필요한 계산 없이 '리스트의 맨 끝'이 보이는 순간을 정확히 포착할 수 있다.

이 로직을 재사용하기 위해 다음과 같이 커스텀 훅으로 만들었다.

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

interface UseIntersectionObserverProps {
  // 관찰 대상이 화면에 보였을 때 실행할 콜백 함수
  onIntersect: () => void;
  // 관찰 활성화 여부 (ex: 더 불러올 데이터가 있을 때만 true)
  enabled?: boolean;
  // 트리거될 임계값
  threshold?: number;
  // 루트의 마진
  rootMargin?: string;
}

export const useIntersectionObserver = ({
  onIntersect,
  enabled = true,
  threshold = 0.1,
  rootMargin = "0px",
}: UseIntersectionObserverProps) => {
  // 관찰할 요소를 참조하기 위한 ref
  const targetRef = useRef<HTMLDivElement>(null);

  // isIntersecting 상태가 변경될 때마다 콜백을 실행하는 함수
  const callback = useCallback(
    (entries: IntersectionObserverEntry[]) => {
      const [entry] = entries;
      // enabled가 true이고, 요소가 교차 상태일 때만 onIntersect 실행
      if (entry.isIntersecting && enabled) {
        onIntersect();
      }
    },
    [onIntersect, enabled]
  );

  useEffect(() => {
    const target = targetRef.current;
    if (!target) return;

    const observer = new IntersectionObserver(callback, {
      threshold,
      rootMargin,
    });

    observer.observe(target);

    // 컴포넌트 언마운트 시 관찰 중지
    return () => {
      observer.unobserve(target);
    };
  }, [callback, threshold, rootMargin]);

  return targetRef;
};

이 hook의 핵심은 onIntersect 콜백을 외부에서 주입받고, enabled 옵션으로 관찰 여부를 제어하여 어떤 데이터 로직에도 종속되지 않는다는 점이다.

  1. 데이터 관리의 절대 강자 : React QueryuseInfiniteQuery

이제 스크롤 끝을 감지 했으니, 다음 페이지의 데이터를 서버에서 가져와야 한다. useState, useEffect 로 직접 로딩, 에러, 데이터 상태를 관리할 수도 있지만, React QueryuseInfiniteQuery 를 사용하면 훨씬 코드가 간결해지고 강력한 캐싱 기능을 활용할 수 있다.

데이터를 불러오는 로직도 커스텀 훅으로 분리했다.

// src/hooks/useInfiniteItems.ts
import { useInfiniteQuery } from "@tanstack/react-query";
import { fetchItemsAPI } from "@/api/items"; // 가상의 API 함수

// 가상의 API 파라미터 및 응답 타입
interface ApiParams {
  page: number;
  pageSize: number;
  [key: string]: any; // 다른 필터 조건들
}

interface ApiResponse {
  items: any[]; // 아이템 리스트
  totalCount: number; // 전체 아이템 개수
}

export const useInfiniteItems = (params: Omit<ApiParams, 'page'>) => {
  return useInfiniteQuery<ApiResponse>({
    // 쿼리 키: params가 바뀌면 쿼리가 새로 실행됨
    queryKey: ["infiniteItems", params],

    // 데이터 페칭 함수
    queryFn: ({ pageParam = 1 }) =>
      fetchItemsAPI({ ...params, page: pageParam as number }),

    // 다음 페이지 파라미터를 계산하는 함수 (핵심!)
    getNextPageParam: (lastPage, allPages) => {
      // 현재까지 로드된 데이터 개수 계산
      const fetchedItemsCount = allPages.reduce(
        (acc, page) => acc + page.items.length,
        0
      );

      // 마지막 페이지에서 받은 전체 아이템 개수
      const totalItemsCount = lastPage.totalCount;

      // 현재 불러온 아이템 개수가 전체 개수보다 적다면, 다음 페이지가 존재함
      if (fetchedItemsCount < totalItemsCount) {
        return allPages.length + 1; // 다음 페이지 번호
      }

      // 더 이상 불러올 페이지가 없으면 undefined 반환
      return undefined;
    },

    // 초기 페이지 파라미터
    initialPageParam: 1,
  });
};

여기서 가장 중요한 부분은 getNextPageParam 이다. API 응답으로 받은 totalCount와 현재까지 불러온 아이템의 총 개수fetchedItemsCount 를 비교해서 더 불러올 페이지가 있는지를 판단한다. 더 이상 페이지가 없다면 undefined 를 반환하고, React Query는 hasNextPage 상태를 false 로 만들어준다.

  1. 조립 : 두 훅을 조합하여 컴포넌트 완성하기

이제 만든 두 개의 훅을 실제 컴포넌트에서 조립해보자.

// src/components/ItemList.tsx

import { useInfiniteItems } from "@/hooks/useInfiniteItems";
import { useIntersectionObserver } from "@/hooks/useIntersectionObserver";
import { useMemo } from "react";
import { ItemTable } from "./ItemTable"; // 아이템 리스트를 렌더링할 테이블
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { NoData } from "@/components/common/NoData";

export const ItemList = () => {
  // 검색 조건 (예시)
  const searchFilters = useMemo(() => ({ pageSize: 20, sortBy: "date" }), []);

  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = useInfiniteItems(searchFilters);

  // Intersection Observer 훅에 데이터 페칭 함수와 상태를 연결
  const observerRef = useIntersectionObserver({
    onIntersect: fetchNextPage,
    enabled: hasNextPage, // 다음 페이지가 있을 때만 observer 활성화
  });

  if (isLoading) return <LoadingSpinner />;

  // 모든 페이지의 데이터를 하나의 배열로 펼침
  const allItems = data?.pages.flatMap((page) => page.items) ?? [];

  if (allItems.length === 0) {
    return <NoData />;
  }

  return (
    <div className="item-list-container">
      <ItemTable data={allItems} />

      {/* 이 div가 화면에 보이면 다음 페이지를 불러옴 */}
      <div ref={observerRef} style={{ height: "1px" }} />

      {isFetchingNextPage && <LoadingSpinner />}
    </div>
  );
};

위의 코드처럼, 컴포넌트의 역할은 명확하고 간결하다.
1. useInfiniteItems 훅을 호출해 데이터의 상태(hasNextPage, fetchNextPage 등) 를 받아온다.
2. useIntersectionObserver 훅에 fetchNextPage 함수와 hasNextPage 상태를 넘겨줍니다.
3. 받아온 데이터를 ItemTable에 그려주고, 리스트의 맨 마지막에 observerRef를 달아주면 끝이다.

이렇게 하면 컴포넌트는 데이터 페칭이나 스크롤 감지의 복잡한 로직을 알 필요 없이 오직 UI를 랜더링하면 된다.
이것이 관심사의 분리의 원칙이다.

결론 : 기술을 통해 비즈니스 가치를 만든다.
이처럼 Intersection Observer 와 React Query를 활용해 다음과 같은 이점을 얻을 수 있다.

  • 성능 : 불필요한 scroll 이벤트 리스너 없이, 꼭 필요한 시점에 다음페이지를 요청한다.
  • 사용자 경험 : 사용자의 끊김 없이 부드럽게 콘텐츠를 탐색할 수 있다.
  • 코드 품질 : 데이터 패칭과 UI 로직을 커스텀 훅으로 분리해서 코드의 재사용성, 가독성, 유지보수성을 향상시켰다.
profile
프론트앤드 개발자로 일하고 있는 kind J 입니다.

0개의 댓글