[기능] TanStack Query로 서버 상태 관리 : useInfiniteQuery + IntersectionObserver로 무한 스크롤 구현

짜장킴·2025년 10월 18일

프로젝트

목록 보기
36/38

useInfiniteQuery - 무한 스크롤/페이지네이션을 위한 데이터 패칭 훅

  • 다음 페이지를 이어 붙이며 데이터를 가져오기 위한 훅
  • 커서 기반 API(cursorId)와 함께 사용할 때 가장 깔끔함

주요 특징

  • pageParam: 다음 요청에 전달될 커서/페이지
  • getNextPageParam(lastPage): 응답에서 다음 커서를 추출
  • fetchNextPage(): 다음 페이지 불러오기 트리거(반환값)
  • hasNextPage / isFetchingNextPage: 다음 페이지 존재/로딩 상태(반환값)
  • data.pages: 페이지 배열(각 항목이 한 페이지 응답- 반환값)

훅 만들기 예시

import { getMyActivitiesList } from "@/lib/api/myActivities";
import { useInfiniteQuery } from "@tanstack/react-query";

export const QK_MY_ACTIVITIES = ["MyActivities"] as const;

type UseMyActivitiesListProps = {
  size?: number;
};

export function useMyActivitiesListInfinite({ size = 8 }: UseMyActivitiesListProps) {
  return useInfiniteQuery({
    queryKey: QK_MY_ACTIVITIES,
    queryFn: ({ pageParam }: { pageParam: number | null }) =>
      getMyActivitiesList({ size, cursorId: pageParam }),
    initialPageParam: null, // 첫 페이지 커서
    getNextPageParam: (lastPage) => lastPage.cursorId ?? null,
    refetchOnWindowFocus: false,
  });
}
  • initialPageParam: 첫 호출용 커서(null 등)
  • getNextPageParam(lastPage): 응답에서 다음 커서를 반환 → 없으면 null
  • invalidateQueries와 호환: 삭제/수정 후 invalidateQueries(QK) 하면 모든 페이지가 일괄 리패치되어 최신화

IntersectionObserver란?

  • 브라우저가 특정 요소(Element)가 뷰포트 혹은 특정 스크롤 컨테이너 안에서 보이는지(교차 상태) 를 비동기적으로 알려주는 Web API
  • = 어떤 DOM 요소(예: div, img, button 등)가 화면에 실제로 보이는 순간을 감지한다는 뜻
  • = 브라우저가 특정 요소가 화면(또는 스크롤 영역)에 나타나거나 사라질 때, 자동으로 감지해주는 “감시자(observer)”
“#target이라는 요소가 화면(뷰포트)에 10% 이상 보이면 ‘보이기 시작했다!’ 로그를 찍어줘.”
const el = document.querySelector("#target");

const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        console.log("화면에 보이기 시작!");
      } else {
        console.log("화면 밖으로 사라짐!");
      }
    });
  },
  {
    root: null, // 브라우저 뷰포트 기준
    threshold: 0.1, // 요소가 10% 이상 보이면 콜백 실행
  }
);

observer.observe(el);

언제 쓰나?

  • 무한 스크롤(바닥 근처에서 다음 페이지 프리패치)
  • 이미지 지연 로딩(Lazy loading)
  • 노출/비노출 기준으로 애니메이션 트리거

핵심 개념

root : 관찰 기준이 되는 스크롤 컨테이너. null이면 브라우저 뷰포트가 기준
rootMargin : 관찰 영역의 바깥쪽 여백(프리패치 버퍼). "400px 0px"이면 아래쪽 400px 앞당겨 감지
threshold : 요소가 얼마나 보였을 때 콜백을 호출할지(0~1 또는 배열). 0은 단 1픽셀만 보여도 트리거

화면에 붙이기: IntersectionObserver로 자동 로드

"use client";

import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useRef, useEffect, useCallback } from "react";
import { toast } from "react-toastify";

// Icons
import EmptyList from "@/assets/svgs/empty_list.svg";

// UI
import { SideNavigationMenu } from "@/components/layout/SideNavigationMenu/SideNavigationMenu";
import { ActivityItem } from "@/components/my-activities/ActivityItem";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { ErrorView } from "@/components/ui/ErrorView/ErrorView";

// Query
import { useMyActivitiesListInfinite } from "@/lib/hooks/MyActivities/useMyActivitiesListInfinite";

const MyActivities = () => {
  const router = useRouter();
  const {
    data,
    isLoading,
    isError,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    refetch,
    isFetching,
  } = useMyActivitiesListInfinite({ size: 8 });

   // 1) 페이지 배열을 평탄화하여 단일 리스트로 사용
  const list = useMemo(
    () => data?.pages.flatMap((p) => p.activities) ?? [],
    [data]
  );
  
  // 2) 리스트 끝에 둘 "관찰 지점" (센티널)
  const sentinelRef = useRef<HTMLDivElement | null>(null);

  // 3) 다음 페이지 로더 (에러 처리 포함)
  const onLoadMore = useCallback(async () => {
    try {
      await fetchNextPage();
    } catch {
      toast.error("다음 페이지를 불러오지 못했어요. 다시 시도해 주세요.");
    }
  }, [fetchNextPage]);

   // 4) IntersectionObserver 설정
  useEffect(() => {
    if (!sentinelRef.current) return;
    const el = sentinelRef.current;

    const io = new IntersectionObserver(
      (entries) => {
        const entry = entries[0];
         // - sentinel이 보이고
        // - 다음 페이지가 있으며
        // - 이미 다음 페이지를 불러오는 중이 아니라면
        if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) {
          onLoadMore();
        }
      },
      {
       root: null,               // null이면 뷰포트 기준
        rootMargin: "400px 0px",  // 아래쪽 400px 앞당겨 프리패치
        threshold: 0,             // 1픽셀만 보여도 콜백
      }
    );

    io.observe(el);
    return () => io.disconnect(); // 메모리 누수 방지
  }, [hasNextPage, isFetchingNextPage, onLoadMore]);

  return (
    <main className="pb-[200px] pt-[70px] px-[16px] md:px-[32px]">
      <div className="mx-auto flex max-w-[1200px] w-full gap-x-[24px]">
        <aside className="shrink-0 hidden md:block">
          <SideNavigationMenu />
        </aside>

        <section className="flex flex-col gap-y-[24px] flex-1" aria-labelledby="my-activities-title">
          <header className="flex justify-between">
            <h3 id="my-activities-title" className="text-3xl text-black font-bold">
              내 체험 관리
            </h3>
            <button
              className="bg-nomadBlack rounded-[4px] py-[11px] px-[16px] text-white text-md md:text-lg font-bold"
              onClick={() => router.push("/my-activities/registration")}
            >
              체험 등록하기
            </button>
          </header>

          {isLoading ? (
            <div className="flex justify-center items-center h-[400px]">
              <Spinner size="56px" />
            </div>
          ) : isError && !data ? (
            <ErrorView
              message="내 체험 관리를 불러오는 중 오류가 발생했어요."
              refetch={refetch}
              isFetching={isFetching}
            />
          ) : list.length > 0 ? (
            <>
              <ul id="reservation-list" className="flex flex-col gap-y-[24px]">
                {list.map((item) => (
                  <li key={item.id}>
                    <Link href={`/activities/${item.id}`}>
                      <ActivityItem {...item} />
                    </Link>
                  </li>
                ))}
              </ul>
              
				{/* 5) 추가 로딩 상태 표시 */}
              <div className="flex justify-center mt-[24px] min-h-[40px]">
                {isFetchingNextPage && <Spinner size="35px" />}
              </div>

              {/* 6) 관찰 지점(Sentinel) — 이게 보이면 onLoadMore 호출 */}
              <div ref={sentinelRef} className="h-1" />
            </>
          ) : (
            <div className="flex flex-col items-center gap-y-[12px] lg:gap-y-[20px] pt-[40px]">
              <Image
                src={EmptyList}
                alt="내 체험 없음"
                className="w-[200px] h-[200px] lg:w-[240px] lg:h-[240px]"
              />
              <p className="text-2xl text-gray-800">아직 등록한 체험이 없어요</p>
            </div>
          )}
        </section>
      </div>
    </main>
  );
};

export default MyActivities;

코드 설명

  • 센티널 패턴: 리스트 하단에 div를 두고 관찰 → 보이면 fetchNextPage()
  • 프리패치 버퍼: rootMargin: "400px 0px"로 바닥 400px 전에 미리 로드
  • 중복호출 방지: hasNextPage && !isFetchingNextPage
  • 클린업 필수: return () => io.disconnect()로 관찰 해제
  • 평탄화: data.pages.flatMap((p) => p.activities)로 페이지 배열 합치기

스크롤 컨테이너가 따로 있는 경우(예: 내부 스크롤 박스):
root를 그 컨테이너 요소로 설정하고, io.observe(sentinel)와 함께 컨테이너에 overflow: auto가 있어야 함

const container = containerRef.current; // 스크롤 박스
const io = new IntersectionObserver(cb, { root: container, rootMargin: "300px 0px", threshold: 0 });

useInfiniteQuery vs useQuery — data 구조 비교

useInfiniteQuery 반환값

const list = data?.pages.flatMap(p => p.activities) ?? []

{
  pages: [
    {
      activities: [
        { id: 1, title: "제주 감귤 따기" },
        { id: 2, title: "강릉 커피 로스팅" }
      ],
      cursorId: 2
    },
    {
      activities: [
        { id: 3, title: "서울 야경 사진 클래스" },
        { id: 4, title: "남해 요트 투어" }
      ],
      cursorId: 4
    }
  ],
  pageParams: [null, 2]
}

useQuery 반환값

  • 그대로 접근: data.activities
{
  "activities": [
    { "id": 1, "title": "제주 감귤 따기" },
    { "id": 2, "title": "강릉 커피 로스팅" }
  ],
  "totalCount": 24
}
profile
프론트엔드 취준생입니다.

0개의 댓글