
TanStack Query를 공부하면서 무한 스크롤을 구현하는 과정이 헷갈려 정리하는 글이다.
처음에는 Intersection Observer API의 기능도 TanStack Query에서 처리해줄 수 있다고 생각했지만, 각각의 역할이 다르다는 것을 깨닫게 되었다.
이 글에서는 TanStack Query의 useInfiniteQuery와 Intersection Observer API를 어떻게 조합해야 하는지를 정리하고자 한다.
아래는 내가 실제로 구현하고자 하는 리스트의 UI를 캡쳐해서 첨부해보았다.

나는 공공 데이터를 활용한 약물 검색 API를 사용하여 리스트를 구현했다. 그런데 일반적인 페이지네이션 방식만으로는 비효율적인 문제가 발생했다.
✅ 기존 문제점
검색 결과 데이터가 너무 많음
예를 들어 업체이름을 "일동제약"으로 검색하면 1000개 이상의 결과가 나올 수도 있다.
이 모든 데이터를 한 번에 불러오면 네트워크 부하가 커지고, 브라우저 성능 저하가 발생할 가능성이 높다.
사용자가 필요한 데이터만 로드하고 싶다.
한 번에 모든 데이터를 가져오는 것이 아니라, 사용자가 스크롤을 내릴 때마다 필요한 데이터만 추가 로드하고 싶었다. 이 문제를 해결하기 위해 무한 스크롤 방식을 적용했다.
기존에는 Intersection Observer API를 활용하여 마지막 요소가 화면에 닿으면 새로운 데이터를 요청하는 방식을 사용했다.
TanStack Query의 useInfiniteQuery를 공부하면서, 처음에는 Intersection Observer API 없이도 무한 스크롤을 구현할 수 있을 거라고 생각했다.
이론적으로는 useInfiniteQuery만으로도 무한 스크롤이 가능하다고 했기 때문에, Intersection Observer API를 제거해도 될 것이라 생각했다..!
하지만, TanStack Query는 데이터 페칭을 담당할 뿐, 사용자의 스크롤 동작을 감지하는 기능은 제공하지 않는다.
즉, 무한 스크롤을 완전히 구현하려면 useInfiniteQuery와 Intersection Observer API를 함께 사용해야 한다.
기존에는 Intersection Observer API를 사용하여 마지막 요소가 화면에 닿을 때 새로운 데이터를 불러오도록 구현했다.
✅ 기존 코드 (Intersection Observer 적용)
const observer = useRef<IntersectionObserver | null>(null);
const lastElementRef = useCallback((node) => {
if (isLoading) return;
if (observer.current) observer.current.disconnect();
observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
fetchNextPage(); // 새로운 데이터 요청
}
});
if (node) observer.current.observe(node);
}, [isLoading, hasMore, fetchNextPage]);
✅ 기존 방식의 문제점
API 요청 자체는 fetchNextPage()로 관리되지만, 데이터를 어떻게 저장하고 상태를 유지할지는 직접 관리해야 했다.즉, 상태 관리가 복잡하고, API 요청 최적화가 부족했다.
이 문제를 해결하기 위해, TanStack Query의 useInfiniteQuery를 적용했다.
이제는 데이터 페칭과 상태 관리를 자동화할 수 있게 되었다.
✅ 개선된 코드 (TanStack Query 적용)
const {
data,
error,
isLoading,
fetchNextPage,
hasNextPage,
} = useInfiniteQuery({
queryKey: ['medicineSearch', appliedFilters],
queryFn: async ({ pageParam = 1 }) => {
const response = await medicineService({ page: pageParam });
return response;
},
getNextPageParam: (lastPage) => {
const nextPage = lastPage.pagination.currentPage + 1;
return nextPage <= lastPage.pagination.totalPages ? nextPage : undefined;
},
staleTime: 24 * 60 * 60 * 1000, // 24시간 동안 캐싱 유지
});
✅ TanStack Query로 개선된 점
하지만 여전히 스크롤 이벤트를 감지하는 기능은 없다. 즉, Intersection Observer API와 조합이 필요하다.
이제 TanStack Query에서 데이터를 가져오고, Intersection Observer로 트리거하는 방식으로 결합하면 된다.
const lastElementRef = useInfiniteScroll({
loading: isLoading,
hasMore,
onLoadMore: () => {
fetchNextPage();
},
});
이제, Intersection Observer API가 사용자의 스크롤을 감지하고 TanStack Query가 필요한 데이터를 불러오면서 상태를 자동 관리할 수 있다.
쉽게 설명하면
즉, Intersection Observer API는 신호를 보내는 역할을 하고, TanStack Query는 "그 신호를 받아서 데이터를 요청하는 역할을 한다.
무한 스크롤을 구현할 때 Intersection Observer API와 TanStack Query는 목적이 다르다.
각각의 역할을 구분하여 함께 사용해야 한다.
간단하게 표로 정리해보면 다음과 같다.
| 구분 | useInfiniteQuery | Intersection Observer API |
|---|---|---|
| 목적 | 데이터를 페칭하여 API 요청을 최적화 | 화면 요소가 보일 때 트리거 |
| 주요 기능 | fetchNextPage()로 데이터 요청 | onIntersect()로 특정 UI 요소 감지 |
| 사용 방식 | getNextPageParam으로 페이지네이션 자동 관리 | 특정 요소가 보일 때 이벤트 발생 |
| 적합한 상황 | 리스트형 API에서 데이터 요청을 관리할 때 | UI 요소가 화면에 나타날 때 실행 (Lazy Loading, 애니메이션 등) |
무한 스크롤을 구현할 때 TanStack Query만으로는 자동 로딩을 할 수 없다.
useInfiniteQuery는 데이터를 페칭하는 역할이고, Intersection Observer API를 활용하면 사용자가 스크롤할 때 자동으로 데이터를 불러오는 기능을 추가할 수 있다.
처음 TanStack Query를 공부했을 때, 무한 스크롤도 지원한다고 해서 Intersection Observer API를 대체할 수 있는 것이라고 생각했다.
하지만 실제로 적용해보니 두 개념이 완전히 다른 역할을 한다는 것을 깨달았다.
무한 스크롤을 구현하면서 데이터 페칭과 스크롤 감지를 어떻게 구분해야 하는지 헷갈렸던 이유는, Intersection Observer API의 개념이 명확하지 않았기 때문일 수도 있다.
이번 기회를 통해 Intersection Observer API에 대해서도 다시 공부할 필요성을 느꼈고,
TanStack Query의 무한 스크롤 기능은 데이터 페칭을 더 효율적으로 처리하는 도구라는 점을 확실히 이해하게 되었다.