대량의 데이터를 한 번에 모두 가져와 화면에 렌더링하면,
와 같은 문제가 발생한다.
특히 피드, 커뮤니티, 상품 목록 등 리스트가 길어질수록 이슈가 커진다.
이를 해결하기 위해 무한스크롤(Infinite Scroll) 패턴을 사용한다.
이렇게 하면
이 글에서는 React 환경에서 Tanstack Query의 useInfiniteQuery를 활용해, 효율적이고 확장성 높은 무한스크롤 패턴을 구현하는 방법을 다룬다.
클라이언트에서 useInfiniteQuery 사용
➡️ TanStack Query의 useInfiniteQuery로 페이지 단위 데이터를 관리
스크롤 이벤트 또는 트리거 감지로 추가 데이터 요청
➡️ 사용자가 리스트 하단에 도달할 때만 다음 데이터를 요청
불러온 데이터를 누적해서 렌더링
➡️ 기존 데이터와 새로운 데이터를 합쳐서 화면에 보여줌
모든 데이터 로딩이 끝나면 더이상 서버에 요청을 보내지 않음.
먼저 useInfiniteQuery에 대해 간략하게 설명하자면,
useInfiniteQuery는 TanStack Query(react-query)에서 제공하는 훅으로,
스크롤 기반 페이지네이션(무한 스크롤) 데이터 요청을 쉽게 관리할 수 있다.
const {
data: recommendCommunityList,
fetchNextPage,
hasNextPage,
isFetchingNextPage
} = useRecommendListInfiniteQuery(recommendListRequest);
const getRecommendList = async (request: RecommendListRequest): Promise<RecommendListResponse> => {
const res = await customAxios.post<RecommendListResponse>('community/recommend/list', request);
return res.data;
};
export const useRecommendListInfiniteQuery = (
baseRequest: RecommendListRequest
): UseSuspenseInfiniteQueryResult<InfiniteData<RecommendListResponse>, Error> => {
return useSuspenseInfiniteQuery<RecommendListResponse, Error>({
queryKey: ['recommendList', baseRequest.searchKeyword],
queryFn: ({ pageParam }) =>
getRecommendList({
...baseRequest,
pageable: {
page: pageParam as number,
size: PAGE_SIZE,
sort: []
}
}),
getNextPageParam: (lastPage) => {
return lastPage.isLast ? undefined : lastPage.currentPage + 1;
},
initialPageParam: 1
});
};
쿼리가 처음 실행될 떄 initialPageParam=1로 첫 페이지 요청
이후 추가 데이터가 필요할 때마다 getNextPageParam에서 계산된 pageParam 값을 사용해 queryFn을 통해 다음 페이지 데이터를 페칭
isLast가 true가 될 때까지, 즉 더이상 받아올 데이터가 없을 때까지 반복
역할:
실제 데이터 페칭(서버 통신)을 담당하는 함수
동작:
PageParam(현재 요청할 페이지 번호) 값을 받아 API 요청 파라미터에 반영
여기서는 getRecommendList()가 실제 서버에 POST 요청을 보내고, 받은 데이터를 반환
역할:
다음 페이지를 요청할 떄 사용할 pageParam 값을 반환하는 함수
동작:
마지막으로 받아온 페이지(lastPage)의 정보를 참고하여
lastPage.isLast가 true면 undefined 반환(추가 요청 중단)
아니라면 현재 페이지 번호 + 1을 반환하여 다음 페이지 요청
역할:
쿼리 최초 시행 시 사용할 초기 페이지 번호
동작:
첫 요청은 1페이지로 지정함
{recommendCommunityList?.pages.map((page, i) => (
<div key={i}>
{page.content.map((community) => (
<RecommendItem key={community.communityId} {...community} />
))}
</div>
))}
useInfiniteQuery를 사용할 때의 응답 데이터 구조

{
pageParams: [1, 2, ...], // 각 페이지 요청 파라미터
pages: [
{ // 1페이지 응답
content: [ ... ], // 아이템 배열
currentPage: 1,
totalPage: false,
...
},
{ // 2페이지 응답
content: [ ... ], // 아이템 배열
currentPage: 2,
totalPage: false,
...
},
...
]
}
즉, pages 배열 안에 각 페이지별 응답이 누적 저장된다.
서버에서 여러 페이지의 데이터를 분할해서 보내주기 때문에, 클라이언트는 pages 배열을 반복적으로 순회해서 각 페이지의 content들을 전부 이어붙여 렌더링해야 한다.
<InfiniteScrollTrigger
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={fetchNextPage}
/>
export const InfiniteScrollTrigger = ({ hasNextPage, isFetchingNextPage, onLoadMore }: Props) => {
const { ref, inView } = useInView({
threshold: 0,
rootMargin: '350px'
});
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
onLoadMore();
}
}, [inView, hasNextPage, isFetchingNextPage, onLoadMore]);
if (!hasNextPage) return null;
return (
<div ref={ref} className="leading-10 h-10 text-center">
<Typography variant="caption-medium">Loading more...</Typography>
</div>
);
};
const { ref, inView } = useInView({
threshold: 0,
rootMargin: '350px'
});
ref : 감지하고 싶은 DOM에 붙이는 참조 객체
inView : 그 DOM이 화면 안에 들어오면 true, 아니면 false
rootMargin: 350px
➡️ 실제로는 화면 아래에서 350px 전에 미리 감지해서 true가 된다
threshold: 0
➡️ 트리거의 아주 일부만 화면에 들어와도 감지하겠다는 뜻
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
onLoadMore();
}
}, [inView, hasNextPage, isFetchingNextPage, onLoadMore]);
위의 세 조건이 모두 충족될 때만 onLoadMore() 실행 -> 다음 페이지 요청
return (
<div ref={ref} className="leading-10 h-10 text-center">
<Typography variant="caption-medium">Loading more...</Typography>
</div>
);
// API 요청
const {
data: recommendCommunityList,
fetchNextPage,
hasNextPage,
isFetchingNextPage
} = useRecommendListInfiniteQuery(recommendListRequest);
return (
<div>
<header className="px-4 py-2.5 flex gap-2">
<button className="w-6 " onClick={() => navigate(-1)}>
<img src={arrowLeft} alt="arrow Left" />
</button>
<SearchInput keyword={keyword} setKeyword={setKeyword} onReset={() => setKeyword('')} />
</header>
<main className="h-full">
{keyword.length < 1 && (
<Typography variant="h6" className="text-[18px] leading-[26px] pt-5 pb-3.5 px-4 text-start">
Recommended Open Community
</Typography>
)}
<>
{/* 아이템 렌더링: 누적 데이터 flat map으로 리스트 출력 */}
{recommendCommunityList?.pages.length && recommendCommunityList?.pages[0]?.content.length ? (
recommendCommunityList?.pages?.map((page, i) => (
<div key={i}>
{page.content.map((community) => (
<RecommendItem
key={`${community.communityType}-${community.communityName}-${community.communityId}`}
communityType={community.communityType}
communityId={community.communityId}
circleCommunityId={community.circleCommunityId}
logoImageUrl={community.logoImageUrl}
memberCount={community.memberCount}
communityName={community.communityName}
contents={community.contents}
/>
))}
</div>
))
) : (
<Empty />
)}
</>
{/* 트리거 감지 */}
<InfiniteScrollTrigger
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={fetchNextPage}
/>
</main>
</div>
);
};