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,
};
};
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되지 않도록 설정
});
const allData = data?.pages.flatMap((page) => page.data) || [];
각 페이지의 data만 쏙쏙 모아 하나의 배열로 만들어주기
뷰단에서 편하게 allData를 map 돌려서 그릴 수 있음
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도 함께 저장해서, 돌아왔을 때 동일한 상황인지 판단
추후 페이지 복원 시 이 정보를 가지고 페이지를 얼만큼 불러올지, 어디까지 스크롤할지 결정
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() 호출
이 로직 덕분에 사용자는 스크롤을 내릴 때마다 데이터를 계속 불러오게 됨
에러거나 더 이상 데이터가 없거나 로딩 중이면 옵저버를 붙이지 않음
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가 변했으면, “다른 문맥”이라고 보고 세션 스토리지 정보를 삭제
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로 돌림
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>
</>
);