
TanStack Query(이전의 React Query)는 서버로부터 데이터 페칭, 캐싱, 동기화 및 업데이트를 자동화 해주는 라이브러리이다.
서버와의 데이터 동기화뿐만 아니라 캐싱으로 성능 최적화 또한 간편해서 이전 프로젝트에서 사용한 경험이 있는데, 이번에는 TanStack Query의 useInfiniteQuery 훅을 사용하여 무한 스크롤 기능을 구현해 보았다.

(공식문서) https://tanstack.com/query/latest/docs/framework/react/reference/useInfiniteQuery
useInfiniteQuery 훅은 useQuery 훅과 유사한 옵션과 반환값들을 사용하지만, 무한 스크롤과 관련된 추가적인 옵션, 반환값들이 있다.
const {
fetchNextPage,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
...result
} = useInfiniteQuery({
queryKey,
queryFn: ({ pageParam }) => fetchPage(pageParam),
initialPageParam: 1,
...options,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
lastPage.nextCursor,
getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) =>
firstPage.prevCursor,
})
data: 서버에서 응답으로 받아온 데이터fetchNextPage: 다음 페이지의 데이터를 가져오는 함수. 스크롤을 더 내리거나 "더 보기" 버튼을 클릭할 때 호출hasNextPage: 더 가져올 다음 페이지가 있는지(boolean) 여부 getNextPageParam 함수가 undefined 또는 null을 반환하면 false로 설정됨isFetchingNextPage: 다음 페이지의 데이터를 가져오는 중인지(boolean) 여부queryKey: 캐싱과 데이터 관리에 사용되는 고유한 키. 배열 형태로 제공되어야 함.queryFn: 데이터를 가져오는 비동기 함수initialPageParam: 처음으로 데이터를 요청할 때 사용할 초기 페이지 파라미터. 첫 페이지의 데이터 페칭 시에 사용됨.getNextPageParam: 새로운 페이지의 데이터를 가져온 후 다음 페이지를 가져올 때 사용될 파라미터를 반환하는 함수. 이 함수는 마지막 페이지의 데이터(lastPage)와 전체 페이지의 배열(allPages)을 인수로 받아서 다음 페이지의 파라미터를 반환함. 반환값이 undefined 또는 null이면 더 이상 가져올 페이지가 없음을 나타냄.무한스크롤은 스크롤이 페이지의 끝에 도달했을 때 자동으로 다음 데이터를 불러오는 방식인데, 이때 스크롤이 바닥 끝에 도달했음을 감지하는 방법으로 onScroll 이벤트를 활용하는 방법과 IntersectionObserver API를 사용하는 방법이 있다. 각 방식의 작동 원리와 장단점을 살펴보자.
onScroll 이벤트를 이용하는 방식onScroll 이벤트는 사용자가 스크롤할 때마다 호출되는 이벤트 핸들러를 설정하는 방식이다.
구현 방식:
스크롤 이벤트 핸들러 추가:
window 객체에 대해 onScroll 이벤트 등록스크롤 위치 확인:

scrollTop(스크롤된 위치)과 scrollHeight(전체 콘텐츠 높이), clientHeight(보이는 영역 높이)를 이용해 계산한다.예제 코드:
import React, { useEffect } from 'react';
const InfiniteScrollComponent: React.FC = () => {
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.target as HTMLDivElement;
if (target.scrollHeight - target.scrollTop === target.clientHeight) {
// 스크롤이 바닥에 도달했을 때 데이터 로드 함수 호출
loadMoreData();
}
};
const loadMoreData = () => {
// 데이터 로딩 함수
console.log("Load more data...");
};
return (
<div onScroll={handleScroll} style={{ height: '400px', overflowY: 'scroll' }}>
{/* 콘텐츠 */}
</div>
);
};
장점:
단점:
IntersectionObserver를 이용하는 방식IntersectionObserver API는 특정 요소가 다른 요소와 교차되거나, 뷰포트(보이는 영역)와 교차할 때 이를 감지하는 방법이다.

구현 방식:
관찰 대상 설정:
IntersectionObserver 설정:
IntersectionObserver를 생성하고, 대상 요소 관찰예제 코드:
import React, { useEffect, useRef } from 'react';
const InfiniteScrollComponent: React.FC = () => {
const loadMoreRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
// 요소가 뷰포트에 진입했을 때 데이터 로드 함수 호출
loadMoreData();
}
});
if (loadMoreRef.current) {
observer.observe(loadMoreRef.current);
}
return () => {
if (loadMoreRef.current) {
observer.unobserve(loadMoreRef.current);
}
};
}, []);
const loadMoreData = () => {
// 데이터 로딩 함수
console.log("Load more data...");
};
return (
<div>
{/* 콘텐츠 */}
<div ref={loadMoreRef} style={{ height: '20px', background: 'transparent' }} />
</div>
);
};
장점:
단점:
Polyfill(기본적으로 지원하지 않는 이전 브라우저에서 최신 기능을 제공하는 데 필요한 코드)이 필요할 수 있다.성능 면에서 유리한 IntersectionObserver를 사용하여 무한스크롤을 구현해보자.
데모앱 구현을 위해 포켓몬 데이터를 제공하는 PokeAPI를 활용했다.
https://pokeapi.co/
useInfiniteQuery 훅을 사용해 포켓몬 데이터를 무한 스크롤로 불러온다.getNextPageParam 함수를 통해 마지막 페이지가 아닌 경우 다음 페이지를 가져오도록 설정한다.const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ["pokemon"],
queryFn: fetchPokemon,
initialPageParam: 0,
getNextPageParam: (lastPage) => {
return lastPage.isLast ? undefined : lastPage.nextPage;
},
});
useRef를 사용해 페이지 하단에 관찰할 DOM 요소를 참조한다. 유저가 이 요소에 도달했을 때 다음 페이지를 로드하도록 설정 할것이다.
function App() {
...
const observerElem = useRef<HTMLDivElement | null>(null);
return (
<div className="main">
...
<div ref={observerElem}></div>
</div>
);
}
export default App;
useEffect를 사용해 IntersectionObserver를 설정하고, 관찰할 요소(observerElem)가 뷰포트에 들어오는지 감시한다.observerCallbackIntersectionObserver가 관찰 중인 요소(observerElem)가 뷰포트에 들어왔을 때 호출된다.entry.isIntersecting) 다음 페이지가 존재할 경우(hasNextPage), fetchNextPage를 호출해 데이터를 추가로 불러온다.unobserve를 호출const observerCallback = useCallback(
(entries: IntersectionObserverEntry[]) => {
const [entry] = entries;
if (entry.isIntersecting && hasNextPage) {
fetchNextPage();
}
},
[fetchNextPage, hasNextPage]
);
// Intersection Observer 설정 및 해제
useEffect(() => {
const currentElem = observerElem.current;
const observer = new IntersectionObserver(observerCallback, {
root: null, // 대상 요소가 뷰포트와 교차할 때 트리거 되도록 설정
rootMargin: "0px", // root 요소의 마진값
threshold: 1, // 관찰 대상 요소가 root와 얼마나 교차해야 콜백이 호출될지를 지정
});
if (currentElem) {
observer.observe(currentElem);
}
// 컴포넌트가 언마운트되거나 observerCallback이 변경될 때 관찰을 중지
return () => {
if (currentElem) {
observer.unobserve(currentElem);
}
};
}, [observerCallback]);
import React, { useCallback, useEffect, useRef } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { PokemonResult } from "./types";
import { fetchPokemon } from "./api";
import "./App.css";
function App() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ["pokemon"],
queryFn: fetchPokemon,
initialPageParam: 0,
getNextPageParam: (lastPage) => {
return lastPage.isLast ? undefined : lastPage.nextPage;
},
});
const observerElem = useRef<HTMLDivElement | null>(null);
const observerCallback = useCallback(
(entries: IntersectionObserverEntry[]) => {
const [entry] = entries;
if (entry.isIntersecting && hasNextPage) {
fetchNextPage();
}
},
[fetchNextPage, hasNextPage]
);
// Intersection Observer 설정 및 해제
useEffect(() => {
const currentElem = observerElem.current;
const observer = new IntersectionObserver(observerCallback, {
root: null,
rootMargin: "0px",
threshold: 1,
});
if (currentElem) {
observer.observe(currentElem);
}
return () => {
if (currentElem) {
observer.unobserve(currentElem);
}
};
}, [observerCallback]);
return (
<div className="main">
<h1 className="title">Pokemon</h1>
<div className="pokemon-list">
{data?.pages.map((page, pageIndex) => (
<React.Fragment key={pageIndex}>
{page.results.map((pokemon: PokemonResult, index: number) => (
<div key={index} className="pokemon-card">
<h3>{pokemon.name}</h3>
<img src={pokemon.image} alt={pokemon.name} />
<p>{pokemon.type}</p>
</div>
))}
</React.Fragment>
))}
{isFetchingNextPage && <p>Loading more...</p>}
</div>
<div ref={observerElem}></div>
</div>
);
}
export default App;