useInfiniteQuery 무한스크롤 구현 및 스크롤유지시키기 (custom-hook 작업)

Wonhyo LEE·2025년 3월 30일
0

useInfiniteQuery 를 스크롤을 유지시키고 사용하면서 필요한 로직들이 방대해져 custom-hook 작업 진행

useInfiniteScroll.ts


'use client';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import axios from '@/libs/client/axios';

import { useSearchParams } from 'next/navigation';

interface IInfiniteScroll {
    createParams: any;
    watchValues: any;
    SESSIONSTORAGE_KEY: string; // 스크롤 위치와 같은 데이터를 sessionStorage에 저장할 때 사용하는 key
    apiUrl: string; // API 요청을 보낼 엔드포인트
    apiKey: string; // react-query에 사용될 queryKey 식별자
    ITEMS_PER_PAGE: number; // 페이지 하나당 보여줄 아이템 수
    queryKey: any;
}

// ---------------------------------------------------------------------
// useInfiniteScroll 훅
// - createParams, watchValues 등 필요한 요소들을 받아서 무한 스크롤 로직을 구현
// - sessionStorage에 스크롤 위치를 저장 & 복원
// - 인터섹션 옵저버(IntersectionObserver)를 통해 무한 스크롤 구현
// ---------------------------------------------------------------------
export const useInfiniteScroll = ({
    createParams,
    watchValues,
    SESSIONSTORAGE_KEY,
    apiUrl,
    apiKey,
    ITEMS_PER_PAGE,
    queryKey,
}: IInfiniteScroll) => {
    const searchParams = useSearchParams(); // Next.js 13+ 서버 라우팅에서 제공되는 훅
    const loader = useRef<HTMLDivElement | null>(null); // 무한 스크롤 트리거 위치를 관찰할 DOM ref

    // 스크롤 복원 여부를 판단하는 상태값
    const [shouldRestoreScroll, setShouldRestoreScroll] = useState(false);

    // ---------------------------------------------------------------------
    // useInfiniteQuery: react-query의 무한 스크롤을 담당하는 훅
    // - queryKey: 캐시 및 refetch를 식별하기 위한 key
    // - queryFn: 실제 데이터를 요청하는 함수
    // - getNextPageParam: 다음 페이지 호출을 위한 pageParam 결정 로직
    // - enabled: false로 설정하여, 로직에서 수동으로 fetchNextPage를 호출
    // ---------------------------------------------------------------------
    const { data, isLoading, isFetchingNextPage, isError, hasNextPage, fetchNextPage, refetch } = useInfiniteQuery({
       queryKey: [apiKey, queryKey],
       queryFn: async ({ pageParam = 1 }) => {
          try {
             const params = createParams(pageParam); // API 호출에 필요한 파라미터 생성
             const response = await axios.get(apiUrl, { params });
             return response.data;
          } catch (err) {
             console.error('error:', err);
             throw err; // 에러가 발생하면 react-query로 던져서 isError 상태로 관리
          }
       },
       initialPageParam: 1,
       getNextPageParam: (lastPage, allPages) => {
          const totalSize = lastPage?.totalSize || 0; // 전체 데이터 개수
          const loadedItems = allPages.flatMap((page) => page.data).length; // 현재까지 불러온 아이템 개수

          // 불러온 아이템이 전체 아이템보다 적을 때에만 다음 페이지를 요청
          return loadedItems < totalSize ? allPages.length + 1 : undefined;
       },
       enabled: false, // 초기에는 자동으로 fetch되지 않도록 설정
    });

    // data?.pages에 있는 모든 데이터를 flatMap으로 편히 합쳐서 반환
    const allData = data?.pages.flatMap((page) => page.data) || [];

    // const allData = useMemo(() => {
    //     // data가 없으면 빈 배열
    //     // data가 있으면, pages를 합쳐서 새 배열 반환
    //     return data?.pages.flatMap((page) => page.data) || [];
    // }, [data]);
    // ---------------------------------------------------------------------
    // saveScrollPosition
    // - 특정 아이템을 클릭(또는 이벤트 발생) 시에 스크롤 위치와 클릭한 아이템의 index 정보를 세션에 저장
    // - 다른 페이지에서 돌아왔을 때, 이전 스크롤 위치를 복원하기 위해 사용
    // ---------------------------------------------------------------------
    const saveScrollPosition = (index: number) => {
       const scrollData = {
          scrollPosition: window.pageYOffset, // 현재 스크롤 위치
          clickedItemIndex: index, // 클릭된(또는 특정 이벤트가 일어난) 아이템의 인덱스
          searchParams: Object.fromEntries(searchParams.entries()), // 현재 URL 파라미터
          watchValues: watchValues, // 의존성으로 사용되는 값
       };
       sessionStorage.setItem(SESSIONSTORAGE_KEY, JSON.stringify(scrollData));
    };

    // ---------------------------------------------------------------------
    // 무한 스크롤 구현을 위한 IntersectionObserver 설정
    // - loader Ref가 화면에 일정 비율 이상 보이면 fetchNextPage 호출
    // ---------------------------------------------------------------------
    useEffect(() => {
       const currentLoaderRef = loader.current;

       // loader가 없거나, 다음 페이지가 없거나, 이미 로딩 중이거나, 에러가 있다면 옵저버 생성 X
       if (!currentLoaderRef || !hasNextPage || isFetchingNextPage || isError) return;

       const observer = new IntersectionObserver(
          (entries) => {
             // 감시 요소가 threshold 기준치 이상으로 뷰포트에 들어왔을 때
             if (entries[0].isIntersecting && !isFetchingNextPage && !isError && hasNextPage) {
                fetchNextPage();
             }
          },
          { threshold: 0.3 }, // 30% 정도 나타났을 때 트리거
       );

       // 실제 관찰 시작
       observer.observe(currentLoaderRef);

       // 언마운트나 의존성 변경 시 관찰 중지
       return () => {
          if (currentLoaderRef) observer.unobserve(currentLoaderRef);
       };
    }, [loader, hasNextPage, isFetchingNextPage, isError, fetchNextPage]);

    // ---------------------------------------------------------------------
    // 세션 스토리지에 저장된 스크롤 정보가 있으면, 해당 정보를 통해 필요한 페이지 로딩
    // - 스크롤 복원 로직은 페이지들이 미리 로드된 다음에 동작해야 함
    // ---------------------------------------------------------------------
    useEffect(() => {
       const savedData = sessionStorage.getItem(SESSIONSTORAGE_KEY);
       if (!savedData) return;

       try {
          const { searchParams: savedSearchParams, clickedItemIndex } = JSON.parse(savedData);
          const currentParams = Object.fromEntries(searchParams.entries());

          // 현재 페이지의 searchParams와 저장된 searchParams가 동일하면 로직 실행
          const paramsMatch = JSON.stringify(currentParams) === JSON.stringify(savedSearchParams);

          if (paramsMatch) {
             // 스크롤 복원을 위해 필요한 페이지 수 계산
             const pagesNeeded = Math.ceil((clickedItemIndex + 1) / ITEMS_PER_PAGE);

             // 필요한 만큼 페이지 fetch
             const loadPages = async () => {
                for (let i = 0; i < pagesNeeded; i++) {
                   await fetchNextPage();
                }
                setShouldRestoreScroll(true);
             };

             loadPages();
          } else {
             // URL이 달라진 경우 세션 데이터 삭제
             sessionStorage.removeItem(SESSIONSTORAGE_KEY);
          }
       } catch (error) {
          console.error('Error parsing saved scroll data:', error);
          sessionStorage.removeItem(SESSIONSTORAGE_KEY);
       }
       // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);

    // ---------------------------------------------------------------------
    // shouldRestoreScroll 상태가 true가 되고, 데이터가 모두 로딩되면 스크롤 위치 복원
    // - requestAnimationFrame으로 DOM이 준비된 후에 스크롤 이동
    // ---------------------------------------------------------------------
    useEffect(() => {
       if (!shouldRestoreScroll || !data || isLoading || isFetchingNextPage) return;

       const savedData = sessionStorage.getItem(SESSIONSTORAGE_KEY);
       if (!savedData) return;

       try {
          const { scrollPosition } = JSON.parse(savedData);

          requestAnimationFrame(() => {
             // 실제 스크롤을 이전 위치로 이동
             window.scrollTo({
                top: scrollPosition,
                behavior: 'instant', // 부드러운 스크롤이 아닌 즉시 이동
             });
             console.log('scrollPosition', scrollPosition);

             // 복원이 끝났으면 세션 데이터 삭제 & 상태값 false로
             sessionStorage.removeItem(SESSIONSTORAGE_KEY);
             setShouldRestoreScroll(false);
          });
       } catch (error) {
          console.error('Error restoring scroll position:', error);
          sessionStorage.removeItem(SESSIONSTORAGE_KEY);
       }
       // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [shouldRestoreScroll, data, isLoading, isFetchingNextPage]);

    // ---------------------------------------------------------------------
    // 훅에서 반환하는 값들
    // - data: react-query가 관리하는 무한 스크롤 원본 데이터
    // - allData: 페이지별로 나뉜 데이터를 flatMap으로 합쳐놓은 배열
    // - isLoading: 첫 페이지 로딩 중 상태
    // - isFetchingNextPage: 다음 페이지 로딩 중 상태
    // - isError: 요청 에러 여부
    // - hasNextPage: 다음 페이지 존재 여부
    // - fetchNextPage: 다음 페이지를 수동으로 fetch
    // - loader: IntersectionObserver가 감시할 DOM ref
    // - saveScrollPosition: 스크롤 위치를 세션에 저장하는 함수
    // - refetch: react-query의 재요청 함수
    // ---------------------------------------------------------------------
    return {
       data,
       isLoading,
       isFetchingNextPage,
       isError,
       hasNextPage,
       fetchNextPage,
       allData,
       loader,
       saveScrollPosition,
       refetch,
    };
};

1. 훅에서 받는 주요 Props

  • createParams: API 호출 시 필요한 파라미터를 동적으로 생성해주는 함수
  • watchValues: 의존성으로 사용되는 값들. 스크롤 복원 시 동일한 상황인지 확인할 때 쓰임
  • SESSIONSTORAGE_KEY: 스크롤 위치 및 클릭된 아이템 인덱스를 sessionStorage에 저장할 때 사용하는 key
  • apiUrl: 실제로 요청을 보낼 API 엔드포인트
  • apiKey: React Query에서 query를 식별하기 위한 key
  • ITEMS_PER_PAGE: 한 페이지에서 보여줄 아이템 수
  • queryKey: React Query의 useInfiniteQuery를 구성할 때 함께 사용하는 key (apiKey와 함께 배열 형태로 사용)

2. useInfiniteQuery로 무한 스크롤 데이터 관리

const {
  data,
  isLoading,
  isFetchingNextPage,
  isError,
  hasNextPage,
  fetchNextPage,
  refetch
} = useInfiniteQuery({
       queryKey: [apiKey, queryKey],
       queryFn: async ({ pageParam = 1 }) => {
          try {
             const params = createParams(pageParam); // API 호출에 필요한 파라미터 생성
             const response = await axios.get(apiUrl, { params });
             return response.data;
          } catch (err) {
             console.error('error:', err);
             throw err; // 에러가 발생하면 react-query로 던져서 isError 상태로 관리
          }
       },
       initialPageParam: 1,
       getNextPageParam: (lastPage, allPages) => {
          const totalSize = lastPage?.totalSize || 0; // 전체 데이터 개수
          const loadedItems = allPages.flatMap((page) => page.data).length; // 현재까지 불러온 아이템 개수

          // 불러온 아이템이 전체 아이템보다 적을 때에만 다음 페이지를 요청
          return loadedItems < totalSize ? allPages.length + 1 : undefined;
       },
       enabled: false, // 초기에는 자동으로 fetch되지 않도록 설정
    });
  • queryKey: [apiKey, queryKey] 형태로, 캐싱과 refetch 시 식별에 사용
  • queryFn: 실제 데이터를 가져오는 비동기 함수
  • pageParam을 통해 현재 페이지를 판단
  • createParams(pageParam)로 파라미터를 만들고 axios.get(apiUrl, { params })로 호출
  • getNextPageParam: 다음 페이지를 불러올지 말지 판단하는 함수
  • lastPage(가장 최근에 불러온 데이터의 페이지)에서 totalSize(전체 아이템 수)를 꺼내고
  • 지금까지 로드된 아이템 개수(loadedItems)와 비교해서 더 불러올 게 있으면 allPages.length + 1 리턴, 없으면 undefined 리턴
  • enabled: false로 설정해, 초기 자동 fetch를 막고 로직에서 fetchNextPage를 수동 호출
  • 결과: data?.pages 안에 페이지별 데이터가 들어오며, useInfiniteQuery가 가져다주는 다양한 상태와 함수를 사용할 수 있음.

3. 모든 페이지 데이터를 한 배열에 모아서 사용하기

const allData = data?.pages.flatMap((page) => page.data) || [];

각 페이지의 data만 쏙쏙 모아 하나의 배열로 만들어주기

뷰단에서 편하게 allData를 map 돌려서 그릴 수 있음

4. 스크롤 위치 및 클릭 아이템 인덱스 저장 (saveScrollPosition)

const saveScrollPosition = (index: number) => {
  const scrollData = {
    scrollPosition: window.pageYOffset,
    clickedItemIndex: index,
    searchParams: Object.fromEntries(searchParams.entries()),
    watchValues,
  };
  sessionStorage.setItem(SESSIONSTORAGE_KEY, JSON.stringify(scrollData));
};

어떤 아이템을 클릭하거나 특정 이벤트가 일어났을 때 현재 스크롤 위치와 클릭된 아이템 인덱스를 저장

searchParams와 watchValues도 함께 저장해서, 돌아왔을 때 동일한 상황인지 판단

추후 페이지 복원 시 이 정보를 가지고 페이지를 얼만큼 불러올지, 어디까지 스크롤할지 결정

5. Intersection Observer로 무한 스크롤 트리거 감시

useEffect(() => {
  const currentLoaderRef = loader.current;

  if (!currentLoaderRef || !hasNextPage || isFetchingNextPage || isError) return;

  const observer = new IntersectionObserver((entries) => {
    if (
      entries[0].isIntersecting &&
      !isFetchingNextPage &&
      !isError &&
      hasNextPage
    ) {
      fetchNextPage();
    }
  }, { threshold: 0.3 });

  observer.observe(currentLoaderRef);

  return () => {
    if (currentLoaderRef) observer.unobserve(currentLoaderRef);
  };
}, [loader, hasNextPage, isFetchingNextPage, isError, fetchNextPage]);
  • loader DOM 요소(일종의 ‘바닥’ 트리거)가 뷰포트에 30% 정도 보이기 시작하면 fetchNextPage() 호출

  • 이 로직 덕분에 사용자는 스크롤을 내릴 때마다 데이터를 계속 불러오게 됨

  • 에러거나 더 이상 데이터가 없거나 로딩 중이면 옵저버를 붙이지 않음

6. 세션 스토리지에 저장된 스크롤 정보 불러오기

useEffect(() => {
  const savedData = sessionStorage.getItem(SESSIONSTORAGE_KEY);
  if (!savedData) return;

  try {
    const { searchParams: savedSearchParams, clickedItemIndex } = JSON.parse(savedData);
    const currentParams = Object.fromEntries(searchParams.entries());
    
    // 현재 URL 파라미터와 저장된 파라미터가 동일하면 복원 로직 진행
    if (JSON.stringify(currentParams) === JSON.stringify(savedSearchParams)) {
      // 필요한 페이지 수 계산
      const pagesNeeded = Math.ceil((clickedItemIndex + 1) / ITEMS_PER_PAGE);

      const loadPages = async () => {
        for (let i = 0; i < pagesNeeded; i++) {
          await fetchNextPage();
        }
        setShouldRestoreScroll(true);
      };

      loadPages();
    } else {
      sessionStorage.removeItem(SESSIONSTORAGE_KEY);
    }
  } catch (error) {
    console.error('Error parsing saved scroll data:', error);
    sessionStorage.removeItem(SESSIONSTORAGE_KEY);
  }
}, []);

세션 스토리지(SESSIONSTORAGE_KEY)에 저장된 정보를 꺼내와, 필요한 페이지 개수를 계산

예: 예전에 23번째 아이템을 클릭했다면, 페이지당 10개씩일 경우 최소 3페이지 정도는 불러와야 그 아이템이 렌더링됨

URL 파라미터나 watchValues가 변했으면, “다른 문맥”이라고 보고 세션 스토리지 정보를 삭제

7. 실제 스크롤 위치 복원

useEffect(() => {
  if (!shouldRestoreScroll || !data || isLoading || isFetchingNextPage) return;

  const savedData = sessionStorage.getItem(SESSIONSTORAGE_KEY);
  if (!savedData) return;

  try {
    const { scrollPosition } = JSON.parse(savedData);

    requestAnimationFrame(() => {
      window.scrollTo({
        top: scrollPosition,
        behavior: 'instant',
      });
      sessionStorage.removeItem(SESSIONSTORAGE_KEY);
      setShouldRestoreScroll(false);
    });
  } catch (error) {
    console.error('Error restoring scroll position:', error);
    sessionStorage.removeItem(SESSIONSTORAGE_KEY);
  }
}, [shouldRestoreScroll, data, isLoading, isFetchingNextPage]);

필요한 페이지를 다 불러온 다음에(즉, DOM 상에서 요소가 렌더링된 후), requestAnimationFrame을 통해 스크롤을 이동

이동이 끝나면 세션 스토리지 비우고, shouldRestoreScroll을 false로 돌림

8. 훅에서 반환하는 값들

data: React Query에서 관리하는 무한 스크롤 원본 데이터

allData: 페이지별 데이터를 flatMap해서 합친 배열

isLoading: 초기에 데이터를 가져오는 중인지

isFetchingNextPage: 다음 페이지를 가져오는 중인지

isError: 에러 발생 여부

hasNextPage: 다음 페이지가 존재하는지

fetchNextPage: 다음 페이지 수동 호출

loader: Intersection Observer가 감시할 ref

saveScrollPosition: 스크롤 위치를 저장하는 함수

refetch: React Query의 재요청 함수

사용 방법

	const ITEMS_PER_PAGE = 30;
	
	const methods = useForm<IProductSearchFormValues>({
		defaultValues,
		mode: 'onSubmit',
	});
	const watchValues = methods.watch();
	
	const createParams = (page: number) => {
		const { ...rest } = watchValues;

		const filteredParams = Object.fromEntries(
			Object.entries(rest).filter(([, value]) => value !== null && value !== undefined && value !== ''),
		);

		return { ...filteredParams, page, size: ITEMS_PER_PAGE };
		// 해당 부분은 비지니스 로직, 커스텀을 통해 진행
	};


    const {
        data,
        isLoading,
        isFetchingNextPage,
        // isError,
        // hasNextPage,
        fetchNextPage,
        allData,
        loader,
        saveScrollPosition,
    } = useInfiniteScroll({
        createParams,
        watchValues,
        SESSIONSTORAGE_KEY: 'USED_GOODS_SCROLL_POSITION',
        apiUrl: API_URLS.PRODUCT.LIST.URL,
        apiKey: API_URLS.PRODUCT.LIST.QUERY_KEY,
        ITEMS_PER_PAGE: ITEMS_PER_PAGE,
        queryKey: watchValues,
    });

	useEffect(() => {
		if (!data && !isLoading) {
			fetchNextPage();
		}
	}, [data, fetchNextPage, isLoading]);
	
return (
		<>
		...
		...
		...
			<div className="px-6 pc:px-0">
				{isLoading ? (
					<ul className="grid mo:grid-cols-3 tablet:grid-cols-4 pc:grid-cols-5 gap-4">
						<CardSkeleton length={20} />
					</ul>
				) : allData.length !== 0 ? (
					<ul className="grid mo:grid-cols-1 tablet:grid-cols-4 pc:grid-cols-5 gap-4">
						{allData.map((item, index) => (
							<Card key={`${item.id}-${index}`} item={item} onClick={() => saveScrollPosition(index)} />
						))}
					</ul>
				) : (
					<div className="text-center text-sm text-cds-gray-500 py-20 px-10">
						<p className="font-extrabold text-lg text-black">검색 결과가 없습니다.</p>
						<p>일부 필터를 변경하여 다시 검색해주세요.</p>
					</div>
				)}

				<div ref={loader} className="text-center p-4">
					{/* {isFetchingNextPage && <Loading />} */}
					{isFetchingNextPage && (
						<ul className="grid mo:grid-cols-1 tablet:grid-cols-4 pc:grid-cols-5 gap-4">
							<CardSkeleton length={8} />
						</ul>
					)}
				</div>
			</div>
		</>
	);

결과 화면

참고

https://oliveyoung.tech/2023-10-04/useInfiniteQuery-scroll/

profile
프론트마스터를 꿈꾸는...

0개의 댓글