-
[TanStack Query] useInfiniteQuery를 활용한 무한스크롤Study (etc)/Troubleshooting 2025. 12. 5. 18:16

올 ChatGPT 좀 잘 치는데? 이 트러블슈팅은 롤페 프로젝트에서 수행되었습니다.
GitHub - Team-Exiters/Roll-Pe_FE
Contribute to Team-Exiters/Roll-Pe_FE development by creating an account on GitHub.
github.com
문제 상황 개요
❓ 문제 상황
'초대 받은 롤페' 페이지와 '내 롤페' 페이지에서 수많은 롤페 리스트를 그대로 제공해 사용성이 저하됨

롤페 Figma 펌 위 이미지는 '초대 받은 롤페' 페이지와 '내 롤페' 페이지의 Figma 디자인이다.
백엔드 팀원들이 기존 MVP 개발 과정에서는 UI만 확인하고 페이지네이션이 없는 전체 리스트 제공만 구현했었고,
따라서 기존 MVP에서는 20개든 40개든 모든 리스트를 한 번에 불러와 제공하고 있었다.
이는 롤페 데이터가 많아지면 많아질수록 API를 통해 불러와야 하는 데이터의 양이 늘어나 성능을 저해할 우려가 있었고,
어디가 리스트의 끝인지 모를, 사용성이 저해되는 문제가 발생했다.
문제 해결 목표
📈 문제 해결 목표
TanStack Query의 useInfiniteQuery를 활용해 두 페이지의 롤페 리스트에 무한스크롤을 구현한다.팀원들과 리팩토링 논의를 하는 과정에서 이 리스트에 '더보기' 버튼을 두어 20개 단위로 끊는 무한 스크롤을 구현하기로 결정했다.
문제 해결 과정
Infinite Queries
기존 API 캐싱이 없던 롤페에 TanStack Query를 도입해 useQuery와 useMutation을 사용해 fetching을 구현하는 패턴으로 리팩토링을 진행하던 차에, 페이지네이션이 적용된 API Response를 어떻게 활용해야할까 고민을 많이 하던 차에, TanStack Query의 공식 문서에서 아래와 같은 것을 발견했다.
Infinite Queries | TanStack Query React Docs
Rendering lists that can additively "load more" data onto an existing set of data or "infinite scroll" is also a very common UI pattern. TanStack Query supports a useful version of useQuery called use...
tanstack.com
Infinite Queries라는 아주 훌륭한 녀석이다.
Infinite Queries(무한 쿼리)는 자주 사용되는 일반적인 UI 패턴인 '기존 데이터 집합에 추가적인 데이터 로드', '무한 스크롤' 렌더링에 사용되는 리스트를 쿼리하기 위해 useInfiniteQuery라는 useQuery를 지원한다.
useQuery에서 제공하는 객체 옵션
data
- 무한한 쿼리 데이터를 포함하는 객체
data.pages
- 가져온 페이지를 포함하는 배열
data.pageParams
- 페이지를 가져오는 데 사용되는 페이지 매개변수를 포함하는 배열
fetchNextPage (필수) / fetchPreviousPage
- 다음 페이지 / 이전 페이지를 fetch 하기 위한 객체다.
initialPageParam (필수)
- 초기 페이지 파라미터를 지정한다.
getNextPageParam / getPreviousPageParam
- 이 두 옵션은 로드할 추가 데이터가 있는지에 대한 여부, 해당 데이터를 가져올 정보를 확인하는 데 모두 사용할 수 있다.
hasNextPage (boolean)
- 다음 페이지 존재 여부에 대한 boolean 값으로, 이 값이 'true'일 경우 getNextPageParam이 null 혹은 undefined가 아니라는 뜻이다.
hasPreviousPage (boolean)
- 이전 페이지 존재 여부에 대한 boolean 값으로, 이 값이 'true'일 경우 getPreviousPageParam이 null 혹은 undefined가 아니라는 뜻이다.
isFetchingNextPage / isFetchingPreviousPage (boolean)
- 이 두 boolean 값은 useQuery에서 사용하는 isLoading과 같이 '다음 페이지 로딩중', '이전 페이지 로딩중' 의 상태를 나타내는 값이다.
- 'pending' 혹은 'error'를 반환하는 state 옵션을 사용할 수도 있다.
예제 설명
cursor라는 쿼리 파라미터를 통해 3개의 페이지를 반환하도록 구현된 다음과 같은 API를 Fetch한다고 가정해보자.
fetch('/api/projects?cursor=0') // { data: [...], nextCursor: 3} fetch('/api/projects?cursor=3') // { data: [...], nextCursor: 6} fetch('/api/projects?cursor=6') // { data: [...], nextCursor: 9} fetch('/api/projects?cursor=9') // { data: [...] }위 데이터를 활용해 '더 보기' UI를 만들 때 다음과 같은 순서로 진행한다.
- useInfiniteQuery가 기본적으로 첫 번째 데이터 그룹을 요청할 때까지 대기
- getNextPageParam에서 다음 쿼리(다음 페이지)에 대한 정보 반환
- fetchNextPage 함수 호출하여 다음 페이지 요청
const fetchProjects = async ({ pageParam }) => { const res = await fetch('/api/projects?cursor=' + pageParam) return res.json() } const { data, error, status fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, } = useInfiniteQuery({ queryKey: ['projects'], queryFn: fetchProjects, initialPageParam: 0, getNextPageParam: (lastPage, pages) => lastPage.nextCursor, })이 무한 쿼리를 UI 컴포넌트에 활용하면 아래와 같이 사용할 수 있다.
// 1. 상태에 따라 상태 렌더링 return status === 'pending' ? ( <p>Loading...</p> ) : status === 'error' ? ( <p>Error: {error.message}</p> ) : ( <> // 2. data.pages를 순회하며 페이지별 데이터 리스트 렌더링 {data.pages.map((group, i) => ( <React.Fragment key={i}> {group.data.map((project) => ( // 한 페이지에 해당하는 리스트는 data.pages[n]에 있다. <p key={project.id}>{project.name}</p> ))} </React.Fragment> ))} <div> // 3. 더보기 버튼을 클릭했을 때 다음 페이지 fetch <button onClick={() => fetchNextPage()} // 4. 다음 페이지가 존재하지 않거나 fetching이 진행중이면 disabled disabled={!hasNextPage || isFetching} > // 5. isFetchingNextPage의 값을 통해 다음 페이지를 호출중이라면 '로딩중' // 5 - 1. hasNextPage를 통해 다음 페이지가 존재한다는 것을 알면 '추가 로딩' 가능 {isFetchingNextPage ? 'Loading more...' : hasNextPage ? 'Load More' : 'Nothing more to load'} </button> </div> <div>{isFetching && !isFetchingNextPage ? 'Fetching...' : null}</div> </> )기본적인 사용법 외에도
- 페이지를 역순으로 표시하고 싶을 경우
- select 옵션 내 pages.reverse() 를 활용
- 무한 쿼리를 수동으로 업데이트하고 싶을 경우
- pages.slice()를 활용해 개별 삭제 가능
- 첫 번째 페이지만 남기고 싶은 경우
- 사용하는 리스트 API 응답이 커서(페이지 파라미터)를 전달하지 않는 경우
...등등에 대한 처리가 가능하니, 위의 경우들은 TanStack Query의 공식문서를 확인하자.
나의 경우, 기본적인 기능만으로도 구현이 가능한 경우이기에 본 포스팅에는 기본적인 예제만 다루도록 하겠다.
1. API 호출 Hook 구현
기존 MVP 개발 단계에서는 TanStack Query를 사용하지 않았기에, API 호출 로직에 따른 Hook을 구현하는 것부터 시작했다.
1 - 1. 롤페 리스트 호출 함수
const fetchInfiniteUserRollpeList = async (queryParam: RollpeReqeustQueryParam, page: number) => { return await axiosInstanceAuth.get(`/api/paper/user?page=${page}&type=${queryParam}`).then((response) => { return Promise.resolve(response.data.data); }).catch((error) => { return Promise.reject(error); }); } export async function getInfiniteUserRollpeList(queryParam: RollpeReqeustQueryParam, page: number): Promise<RollpeListProps> { try { const response = await fetchInfiniteUserRollpeList(queryParam, page); return response; } catch (error) { if (error && typeof error === 'object' && 'response' in error) { const axiosError = error as any; const apiError = { message: axiosError.response?.data?.message || "롤페 리스트를 불러오는데 실패했습니다", code: axiosError.response?.data?.code, statusCode: axiosError.response?.status, }; throw apiError; } throw { message: "네트워크 오류가 발생했습니다", statusCode: 0, }; } }추후 페이지네이션이 구현될 API를 감안하여 queryParam은 페이지네이션이 구현되지 않은 '초대 받은 롤페'와 '내 롤페' 이외의 파라미터도 받을 수 있도록 했다.
useInfiniteQuery는 커서를 제공하지 않는 API 응답에 대해서도 적용할 수 있지만, API 리팩토링을 약속받았기 때문에 커서를 제공받는 응답에 대해서만 구현했다.
1 - 2. 롤페 리스트 호출용 Custom Hook
import { useInfiniteQuery } from "@tanstack/react-query"; import { RollpeListProps } from "@/public/utils/types"; import { getInfiniteUserRollpeList } from "@/public/lib/apis/rollpeUser.api"; import { RollpeReqeustQueryParam } from "@/public/utils/types"; export const useInfiniteRollpeList = (queryParam: RollpeReqeustQueryParam) => { return useInfiniteQuery<RollpeListProps>({ // 1. 쿼리 키 지정 queryKey: ["infiniteRollpeList", queryParam], // 2. 실행 될 함수 queryFn: ({ pageParam = 1 }) => getInfiniteUserRollpeList(queryParam, pageParam as number), // 3. 초기 커서 파라미터 initialPageParam: 1, getNextPageParam: (lastPage) => { //페이지 파라미터 분리 return convertPageParam(lastPage); }, }); }; // 페이지 파라미터 분리 함수 const convertPageParam = (lastPage: RollpeListProps) => { const nextUrl = lastPage.next; if (!nextUrl) return undefined; const url = new URL(nextUrl); const page = url.searchParams.get("page"); return page ? Number(page) : undefined; }우리 API는 커서를 다음, 혹은 이전 호출할 URL 자체를 보내고 있어서, 그 URL에서 page 파라미터만 분리하는 로직을 추가했다.
무한 스크롤을 구현했기 때문에 이전 페이지를 호출할 일은 없어서 PreviousPage에 대한 처리는 추가하지 않았다.
1 - 3. 리스트 컴포넌트에서 활용
export const InfiniteRollpeList: React.FC<RollpeListProps> = ({ type }) => { const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteRollpeList(type); return isLoading ? ( <Loading /> ) : ( data && ( <RollpeListWrapper> <div className={"count-wrapper"}> <em> 총 {data.pages[0].count}개{data.pages[0].resultText} </em> </div> <RollpeListContainer> {data.pages.map((page) => page.results.map((rollpe: Rollpe) => ( <RollpeListItem key={rollpe.code} {...rollpe} /> )) )} </RollpeListContainer> {hasNextPage && ( <ButtonMore text={"더보기"} onClickHandler={fetchNextPage} /> )} </RollpeListWrapper> ) ); };useInfiniteQuery가 제공하는 isLoading을 활용해 로딩중에는 로딩 화면을 렌더링하도록 했다.
롤페 리스트는 pages 배열 안에 존재하므로 리스트 렌더링을 이중으로 진행해줘야 했다.
더보기 버튼은 hasNextPage boolean으로 다음 페이지가 존재할 경우에만 렌더링 되도록 조건을 지정했으며,
더보기 버튼의 onClickHandler로 fetchNextPage를 전달하여 1 증가한 커서 파라미터로 다음 페이지를 불러와 붙이도록 했다.
문제 해결 결과
‼️ 두 페이지에 더보기 버튼을 통한 무한 스크롤 구현 성공

제목이 정상이 아닌데 이거;; (이상한 제목은 무시해달라)이렇게 초기 로딩때만 로딩 화면이 렌더링되고, 더보기 버튼을 눌렀을 때에는 로딩 화면 없이 바로 값이 반영되는 모습을 볼 수 있다.
전체 리스트의 개수가 22개이기 때문에 2페이지를 호출하고 나면 hasNextPage boolean의 값이 false가 되어 더보기 버튼이 더이상 렌더링 되지 않는 모습도 확인할 수 있다.
롤페 프로젝트를 리팩토링해가며 가능하다면 최대한 useEffect, useState를 직접 컴포넌트에서 사용하기보다는 Hook으로 분리하는 전략을 취하고 있는데, 컴포넌트에서 상태 로직을 하나의 훅으로 빼내어 단위 테스트가 편리하고, 같은 상태를 나타내는 여러 state를 여러 번 작성하지 않아도 되니 편리한 것 같다. 더불어 비즈니스 로직과 UI 컴포넌트가 확실히 분리되니 API Response 수정이나 로직 변경에 있어서도 간편히 대응할 수 있는 부분이 가장 마음에 드는 것 같다.
관심사의 분리, 넌 정말 최고야
'Study (etc) > Troubleshooting' 카테고리의 다른 글
[Storybook] Claude Code를 활용해 Storybook을 만들어보자 (0) 2025.11.27 [Next.js] 온보딩 페이지의 LCP를 개선해보자. (0) 2025.11.11 [Node.js & bun.js] lightningCSS 네이티브 바이너리 이슈 (5) 2025.08.06 [Axios] withCredentials과 CORS (1) 2023.12.29 [git] 원격 저장소의 파일명이 바뀌지 않아!! (0) 2023.08.02