React|서버 상태와 비동기 데이터

useInfiniteQuery와 Intersection Observer: 무한 스크롤의 정석

0
useInfiniteQuery와 Intersection Observer: 무한 스크롤의 정석

크리스가 쇼핑몰 상품 목록을 만들고 있다. 처음에는 하단에 [1] [2] [3]과 같은 전통적인 페이지네이션 버튼을 달았다.

그런데 모바일에서 테스트해보니 버튼이 너무 작아 누르기 불편하고, 흐름이 툭툭 끊기는 느낌이 든다.

"요즘 앱들은 그냥 내리면 계속 나오던데, 우리도 그렇게 바꿀 수 없나?"

인스타그램이나 트위터처럼 끊김 없는 사용자 경험을 제공하는 무한 스크롤(Infinite Scroll). 이를 구현하기 위해서는 데이터를 "누적해서" 관리하는 기술과, 사용자가 "바닥에 닿았는지" 감지하는 기술이 필요하다.

오늘은 리액트 쿼리의 useInfiniteQuery와 브라우저의 Intersection Observer API를 조합하여 완벽한 무한 스크롤을 구현하는 방법을 알아본다.

1. 데이터 관리: useInfiniteQuery

일반적인 useQuery는 페이지 번호가 바뀌면 기존 데이터를 덮어쓴다. 하지만 무한 스크롤은 기존 데이터 뒤에 새로운 데이터를 "이어 붙여야" 한다. 이때 사용하는 것이 useInfiniteQuery다.

핵심 개념: pageParam과 getNextPageParam

  • pageParam: 현재 몇 페이지를 가져올지 결정하는 변수다. (초기값은 보통 0 또는 1)
  • getNextPageParam: 서버 응답을 보고 "다음 페이지 번호가 몇 번인지" 계산하는 함수다. 더 이상 데이터가 없으면 undefined를 반환하여 종료시킨다.
  • // useProducts.ts
    import { useInfiniteQuery } from '@tanstack/react-query';
    
    export function useProducts() {
      return useInfiniteQuery({
        queryKey: ['products'],
        queryFn: async ({ pageParam = 1 }) => {
          // pageParam을 이용해 API 요청
          const res = await fetch(`/api/products?page=${pageParam}`);
          return res.json();
        },
        initialPageParam: 1,
        // 다음 페이지 계산 로직
        getNextPageParam: (lastPage, allPages) => {
          // 만약 마지막 페이지의 데이터가 10개 미만이면 끝난 것으로 간주
          const nextPage = allPages.length + 1;
          return lastPage.items.length < 10 ? undefined : nextPage;
        },
      });
    }

    이렇게 하면 데이터가 data.pages라는 배열 안에 차곡차곡 쌓이게 된다. ([[1페이지 데이터], [2페이지 데이터], ...])

    2. 바닥 감지: Intersection Observer

    이제 사용자가 스크롤을 바닥까지 내렸는지 알아내야 한다.

    과거에는 scroll 이벤트 리스너를 붙여서 계산했지만, 이는 성능에 악영향을 준다(Reflow 유발).

    요즘은 Intersection Observer API를 사용한다. 화면 하단에 투명한 막대기(Sentinel)를 하나 심어두고, 이 막대기가 화면에 등장(Intersect)하는지를 감시하는 것이다.

    커스텀 훅으로 만들기

    매번 Observer를 설정하는 것은 귀찮으므로, 재사용 가능한 훅으로 만들어보자.

    // useIntersectionObserver.ts
    import { useEffect, useRef } from 'react';
    
    export function useIntersectionObserver(
      onIntersect: () => void, // 감지되면 실행할 함수
      options?: IntersectionObserverInit
    ) {
      const targetRef = useRef<HTMLDivElement>(null);
    
      useEffect(() => {
        if (!targetRef.current) return;
    
        const observer = new IntersectionObserver((entries) => {
          // 타겟이 화면에 들어왔으면(isIntersecting) 콜백 실행
          if (entries[0].isIntersecting) {
            onIntersect();
          }
        }, options);
    
        observer.observe(targetRef.current);
    
        return () => observer.disconnect();
      }, [onIntersect, options]);
    
      return targetRef;
    }

    3. 합체: 무한 스크롤 컴포넌트

    이제 두 기술을 합쳐보자. 리스트의 맨 마지막에 div 하나를 놓고, 그 녀석이 보이면 fetchNextPage()를 실행하게 하면 된다.

    // ProductList.tsx
    import { useProducts } from './useProducts';
    import { useIntersectionObserver } from './useIntersectionObserver';
    
    function ProductList() {
      const { 
        data, 
        fetchNextPage, 
        hasNextPage, 
        isFetchingNextPage 
      } = useProducts();
    
      // 바닥에 닿으면 fetchNextPage 실행
      const loadMoreRef = useIntersectionObserver(() => {
        if (hasNextPage) fetchNextPage();
      });
    
      return (
        <div>
          {/* 1. 누적된 데이터를 평탄화(Flat)하여 렌더링 */}
          {data?.pages.map((page) =>
            page.items.map((product) => (
              <ProductCard key={product.id} data={product} />
            ))
          )}
    
          {/* 2. 로딩 표시 및 감지용 Sentinel */}
          <div ref={loadMoreRef} style={{ height: '20px' }}>
            {isFetchingNextPage && <Spinner />}
          </div>
        </div>
      );
    }

    4. UX 디테일: 양방향 스크롤과 가상화

    데이터가 100개, 1000개로 늘어나면 어떻게 될까? DOM 노드가 너무 많아져서 브라우저가 버벅거린다.

    이때 필요한 기술이 가상화(Virtualization)다. 화면에 보이는 부분만 그리고, 지나간 부분은 지워버리는 기술이다. 이는 다음 파트(최적화)에서 자세히 다룰 예정이다.

    또한 채팅방 같은 경우 위로 올릴 때 과거 데이터를 불러오는 양방향 무한 스크롤이 필요할 수도 있다. useInfiniteQuerygetPreviousPageParam도 지원하므로 같은 원리로 구현할 수 있다.

    핵심 정리

  • useInfiniteQuery: 페이지네이션 된 데이터를 누적해서 관리해주는 리액트 쿼리 훅이다. getNextPageParam 설정이 핵심이다.
  • data.pages: 받아온 데이터는 2차원 배열([[page1], [page2]...])로 저장되므로, map을 두 번 돌리거나 flatMap을 사용해야 한다.
  • Intersection Observer: 스크롤 이벤트 대신 요소를 감시하여 성능 저하 없이 바닥 도달 여부를 감지한다.
  • Sentinel Element: 리스트 맨 끝에 눈에 보이지 않는 요소(div)를 두고, 이것을 관찰하여 다음 페이지를 불러온다.
  • 이것으로 Part 3. 서버 상태와 비동기 데이터 파트를 마친다.

    이제 데이터를 잘 가져오는 법을 배웠으니, 앱을 더 빠르고 가볍게 만드는 기술을 익힐 차례다. 사용자가 검색창에 타자를 칠 때마다 API를 호출한다면 서버는 살아남지 못할 것이다.

  • Part 4. 성능 최적화와 보안(Optimization & Security)의 첫 번째 주제, "useDebounce vs useThrottle: 검색어 자동완성과 스크롤 이벤트 최적화" 편에서 계속된다.

  • 🔗 참고 링크

  • TanStack Query - Infinite Queries
  • MDN - Intersection Observer API