Changetodev's Blog

Notion Api 개발기 - TanstackQuery + ObserverDiv 무한스크롤

Created Date
Jun 8, 2025
group
프로젝트
state
완료
Tags
Next
notionapi
FSD
TanstackQuery
Thumbnail
1*cvx7D8ysHPfb6-Q3PsRzbQ.png
이번 프로젝트에서 내가 꼭 써봐야 겠다하는 라이브러리가 있었는데, 바로 TanStackQuery(구 react Query)이다.
이전에 React 프로젝트때 가볍게 사용했었는데 바뀌고 나서 입에 찹찹 안 달라붙긴 해도 SSR 과 또 어떤 이슈가 있을까…
이번에는 까먹었던 내용 정리도 하고 실제 유용하게 사용해보고 싶다.
 
물론 라이브러리는 사용하기 이전에 꼭!
왜?? 라는 근거를 댈수 있어야 해서 내 블로그에서는 어떻게 쓸 수 있을까 고민을 좀 많이 해봤다.
그럼 이제 무슨 라이브러리고 어떤 목적으로 쓸 수 있을지 확인해보자.

1. Tanstack Query 누구세요?

Tanstack Query는 클라이언트 상태가 아닌 "서버 상태(Server State)"를 효율적으로 관리하는 라이브러리이다.
서버의 상태가 처음에 들으면 애매한데 서버 상태란
클라이언트 상태 (Client State)
서버 상태 (Server State)
useState, useReducer 등으로 관리
fetch(), axios.get()으로 서버에서 받아오는 외부 데이터
즉시 변경 가능
비동기, 네트워크 지연, 에러 발생 가능
UI 조작 중심
서버의 데이터 동기화가 중요
즉, 서버에서 받아온 데이터를 어떻게 클라이언트에서 관리할지 도와주는 라이브러리라고 한다.
처음에는 관리라고 하니까 아 그러면 게시글 전체를 가져오는 대시보드에서 검색을 할 때 이미 전체 데이터가 있으니
여기서 필터 해서 서버 없이 다시 보여주는 그런것 할 수 있나보구나! 라고 생각했는데… 이건 잘못된 접근이다. 그럴거면 진짜 쿼리는 왜 필요하겠는가.
 
그런게 아니라면, 아니 어차피 필요한 데이터 가져와서 보여주거나 파싱하면 끝인데 뭣 하러 “관리”까지 해야 해요??
 
프론트엔드 관점에서 “데이터”는 화면을 보여줄 기준일 수도 있고, 특정 액션이 이후에 보여질 데이터가 달라질 수도 있다.
특정 데이터 삭제 후 Api재 호출, 무한 스크롤 등…
즉 TanstackQuery는 비동기 데이터를 기반으로 UI를 만드는 사람” 입장에서 귀찮은 걸 다 해주는 라이브러리이다.

2.Tanstack Query의 기능

TanstackQuery는 api를 호출하는 시점부터 시작해서 클라이언에 내려온 이후에도 여러가지 기능을 가지고 있다.
기능
설명
데이터 Fetch
API 호출을 자동으로 처리 (useQuery, useInfiniteQuery)
캐싱(Cache)
요청 결과를 메모리에 저장해서 중복 호출 방지
자동 리페치
창 포커스/네트워크 재연결 시 자동으로 데이터 새로고침
로딩/에러 상태 관리
isLoading, isError, error, data 등 상태 자동 추적
쿼리 키 관리
동일한 요청을 식별하고 구분할 수 있게 도와줌
데이터 동기화
invalidateQueries, refetchQueries로 서버 데이터와 클라이언트 상태 동기화
SSR/하이드레이션 지원
Next.js와의 궁합도 굿 👍
아직 Next를 배우고 있는 시간이라 SSR/하이드레이션은 정확히 이해 못했다…

3. TanstackQuery 주요 훅은 뭐가 있을까.

  1. useQuery : 데이터를 가져오는 1회성 훅.
  1. useMutation : 데이터를 생성/수정/삭제할 때 사용하는 훅
  1. useInfiniteQuery : 연속된 호출을 할 때 사용하는 훅
  1. useQueries : 여러 단일 쿼리를 병렬로 요청할 때 사용하는 훅
 
+유용하게 쓸 수있는 함수는 뭐가 있을까
  1. invalidateQueries() : 강제로 refetch를 유도하는 함수
  1. setQueryData() : 캐시를 수동 업데이트하는 함수
  1. getQueryData() : 현재 캐시된 데이터를 가져오는 함수
  1. prefetchQuery() : 미리 fetch 해두는 함수…?

4. 탐색 및 주의 사항

TanstackQuery를 아 이래서 써야 하는 구나! 하는 케이스를 좀 찾아보다 매우 흥미로운 기능을 발견했다.
바로 useInfiniteQuery로 무한 스크롤을 구현할 수 있다는 것이다.
 
내 블로그는 각 카테고리의 포스팅을 PostCard를 구현해서 보여주는데 한번에 너무 많은 카드들이 랜더링 되거나 SSR에서 내려주면 조금 문제가 있다 생각해서
무한 스크롤을 이참에 구현해보자 생각했는데 아주 좋은 실험 대상이다.
 
TanstackQuery를 바로 적용하기 이전에 Next는 조심스럽게 다가가야 하기 때문에 G님에게 Next에서 TanstackQuery를 쓰기 전에 꼭 유의해야 할 점을 물어봤다.
 
🚨 TanStack Query in Next.js – 주의점 정리 1. App Router에서는 <QueryClientProvider>를 클라이언트 컴포넌트에서만 사용해야 함

5. 실제 적용

1) povider 작성 및 layout.tsx 적용.

  • 내 프로젝트는 FSD 구조를 사용하고 있어 provider 코드는 shared에 작성하였다.
    • features에서 주로 사용할 확률이 높기 때문
  • tanstakQuert-devtools를 개발 환경에서 적용하여 캐싱 된 데이터가 뭔지 확인도 할 수 있다.
notion image
//shared/client/proivders/tanstackQuery/ReactQueryProvider.tsx "use client" import { ReactNode, useState } from "react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" //개발환경에서만 사용 import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; type QueryProviderType={ children : ReactNode } export default function ReactQueryProvider({children}:QueryProviderType){ const [queryClient] = useState(()=>new QueryClient()) return( <QueryClientProvider client={queryClient}> {children} {process.env.NODE_ENV === 'development' && ( <ReactQueryDevtools initialIsOpen={false} /> )} </QueryClientProvider> ) }
 
// /app/layout.tsx export default function RootLayout({children}: Readonly<{children: React.ReactNode;}>) { return ( <html lang="ko"> <body> <Analytics /> <ReactQueryProvider> <DarkModeProvider> <div className="grid-layout-main"> <div className="area-header"><Header /></div> <div className="area-side"><SideNavigator /></div> <main className="area-contents">{children}</main> <footer className="area-footer"><Footer /></footer> </div> </DarkModeProvider> </ReactQueryProvider> </body> </html> ); }

2) 반복 호출을 위한 라우트 api 생성 및 호출 로직

  • 클라이언트에서 fetch를 통해 라우트 api를 호출해야 하기에 호출 로직 및 api를 만든다.
  • 라우트 Api는 notionApi를 페이지 별로 분리하여 호출해야 하기 때문에 기존 로직을 일부 수정한다.
// features/client/postInfiniteScroll/model/getInfiniteScroll.ts /** * 무한 스크롤 포스트 목록 조회 * @param category 카테고리 * @param pageSize 페이지 사이즈 * @param cursor 커서 * @returns 포스트 목록, 다음 커서, 더 있는지 여부 */ export async function getInfinitePostList( category:string, pageSize:number, cursor?:string){ // 쿼리파라미터로 undefined 보내기 const baseUrl = new URL( `/api/postsList`, window.location.origin); baseUrl.searchParams.set("category", category); baseUrl.searchParams.set("pageSize", pageSize.toString()); if (cursor) { baseUrl.searchParams.set("cursor", cursor);} const res = await fetch(baseUrl); if (!res.ok) throw new Error('에러 발생!'); const data =await res.json(); return { postList : data.postList, nextCursor: data.nextCursor, hasMore: data.hasMore } }
  • 중요한 부분은 GET방식으로 인한 쿼리 파라미터로 undefined를 보낼 시 받는 쪽에서는
    • undefined라는 string으로 받는다. URL 객체를 이용해서 파라미터를 전달해야 한다.
      (기존에는 백틱을 이용하여 파라미터 값을 넣었었음.)
      추가로 window.location.origin은 CSR 컴포넌트에서만 접근이 가능하다.
       
// /api/postLst/route.ts export async function GET(req: NextRequest){ const {searchParams} = new URL(req.url); const category = searchParams.get('category'); // 팁: parseInt는 명시적으로 10진수로 인식하라고 선언 해야함 ,10 const pageSize = parseInt(searchParams.get('pageSize')||'6',10); const startCursor = searchParams.get('cursor') ??undefined; const validCategories = ['tech', 'study', 'project'] as const; type Category = typeof validCategories[number]; if (category && validCategories.includes(category as Category)) { const safeCategory = category as Category; const {postList, nextCursor, hasMore} = await GetPostList(safeCategory, pageSize, startCursor); return NextResponse.json( { postList, nextCursor, hasMore }, {status:200} ) } else{ return NextResponse.json( { error: `'${category}'는 유효하지 않은 category입니다.` }, { status: 400 } ); } }
  • 이 부분에서는 응답 코드에 대해 중요한 포인트가 두개있다.
    • tanstackQuery에서 값을 구조분해할당 하기 위해 객채로 리턴 할 것.
    • Next Api Route의 응답 코드 리턴 방식.(관련 내용은 별도로 작성 예정)
    •  

3) 컴포넌트 생성

  • 이번 목표는 무한 스크롤임으로 JSX쪽에 옵저버div를 처리하였다.
// @features/client/postInfiniteScroll/ui/InfiniteScrollPostCard.tsx 'use client' import { useEffect, useRef } from "react"; import { useInfiniteQuery } from '@tanstack/react-query'; import { Post } from "@shared-common/types"; import { PostCard } from "@entities/postCard"; import { getInfinitePostList } from "../model/getInfinitePostList"; const PAGE_SIZE = 6; type InfiniteScrollPostCardType = {category : string;} export default function InfiniteScrollPostCard({category}:InfiniteScrollPostCardType){ //인식이 되면 쿼리가 콜될 div 지정 const observerRef = useRef<HTMLDivElement | null>(null); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } = useInfiniteQuery({ queryKey : ['postList', category], queryFn : ({pageParam=''})=> getInfinitePostList(category,PAGE_SIZE, pageParam ), initialPageParam: '', getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextCursor : undefined, }); useEffect(()=>{ const observer = new IntersectionObserver((entries)=>{ if(entries[0].isIntersecting && hasNextPage && !isFetchingNextPage){ fetchNextPage(); } }, { threshold: 0.5, root: null }); if (observerRef.current) observer.observe(observerRef.current); return () => observer.disconnect(); },[hasNextPage, isFetchingNextPage, fetchNextPage]); if(status === 'pending') return( <div className="w-full text-center text-sm text-gray-400">데이터를 가져오는 중입니다...</div> ) if(status === 'error') return( <div className="w-full text-center text-sm text-gray-400">데이터를 불러오지 못했습니다.</div> ) return( <div className="w-full h-full p-2 sm:p-[30px]"> <div className="pb-[3rem] overflow-y-scroll overflow-x-hidden h-full flex flex-wrap justify-center gap-5 mx-auto"> {data?.pages.map((page)=>( page.postList.map((item:Post) => ( <div className="post-card-wrapper" key={item.pageId}> <PostCard key={item.pageId} pageId={item.pageId} createDate={item.createDate} group={item.group} state={item.state} tags={item.tags} title={item.title} thumbnail={item.thumbnail} /> </div> )) ))} <div ref={observerRef} className="w-full text-center text-sm text-gray-400"> {isFetchingNextPage ? '불러오는 중...' : '모든 데이터를 불러왔습니다.'} </div> </div> </div> ) }
 
우선 useInfiniteQuery 를 살펴보면 다음과 같다.
훅에서 제공하는 값들을 구조 분해 할당을 하면 다음과 같은 결과를 받을 수 있다.
 

A. 📦data 관련 값.

항목
설명
data
페이지네이션된 데이터 전체. 내부는 data.pages[] 배열 형태
data.pages
각각의 페이지 데이터 (예: API 호출 결과 배열) n 번째 호출마다 Api 응답 결과를 배열에 축적한다.
data.pageParams
각 페이지 요청 시 사용된 pageParam 값들의 배열
notion image

  • 나에 경우에는 NotionApi의 다음 글부터 가져오기 기능을 위해 nextCursor라는 페이지 값을 사용하는데 처음에는 빈값이고 그 다음에는 마지막으로 불러와진 페이지의 값을 가지고 있다.
notion image
Api 응답 결과가 축적되기 때문에 컴포넌트 랜더링은 두 번의 반복문을 실행해야 한다.
{data?.pages.map((page)=>( page.postList.map((item:Post) => ( <div className="post-card-wrapper" key={item.pageId}> <PostCard key={item.pageId} pageId={item.pageId} createDate={item.createDate} group={item.group} state={item.state} tags={item.tags} title={item.title} thumbnail={item.thumbnail} /> </div> )) ))}

B. 🔁데이터 요청 함수

항목
설명
fetchNextPage()
다음 페이지 요청 (→ 내부적으로 getNextPageParam 사용)
fetchPreviousPage()
이전 페이지 요청 (→ getPreviousPageParam이 정의돼 있을 경우에만)
refetch()
전체 데이터를 다시 가져옴 (initialPageParam부터 시작)
나에 경우에는 옵저버div 가 인식되고 다음 페이지가 있고 다음 페이지가 요청 중이 아닌 경우를 판단하여
fetchNextPage() 를 호출하였다.
if(entries[0].isIntersecting && hasNextPage && !isFetchingNextPage){ fetchNextPage(); }

C.🚦 상태관련

항목
설명
status
"pending", "error", "success" 중 하나
isLoading
처음 쿼리 실행 중인지 여부
isFetching
모든 종류의 fetch 중 여부 (기존 데이터 유지된 상태에서도 fetch 중이면 true)
isFetchingNextPage
fetchNextPage() 호출로 인해 다음 페이지를 요청 중인지 여부
isFetchingPreviousPage
fetchPreviousPage() 호출로 인해 이전 페이지 요청 중인지 여부
isError
에러가 발생했는지 여부
isSuccess
성공적으로 데이터를 가져왔는지 여부
error
에러 객체 (발생 시 내용 포함)
앞서 언급한 대로 다음 페이지를 가져오고 있는 중인지 판별을 위해 isFetchingNextPage 를 사용하였고,
임시로 status의 값을 통해 에러, 로딩, 완료를 표시하였다.
if(status === 'pending') return( <div className="w-full text-center text-sm text-gray-400"> 데이터를 가져오는 중입니다... </div> ) if(status === 'error') return( <div className="w-full text-center text-sm text-gray-400"> 데이터를 불러오지 못했습니다. </div> )

D. 📌페이지 여부 체크

항목
설명
hasNextPage
getNextPageParam()의 결과가 존재하는 경우 true
hasPreviousPage
getPreviousPageParam()의 결과가 존재하는 경우 true
마찬가지로 더 가져올 페이지가 있는지 참고하는 변수로 사용하였다.

6. ObserverDiV

화면상에 해당 div가 노출이 된다면 자동으로 다음 데이터를 fetch해오기 위한 div다
이걸 구현하기 위해서는 Api 및 훅을 알아야한다.
 

🧩 1. IntersectionObserver

브라우저의 내장 API로 어떤 DOM 요소가 뷰포트 안에 들어왔는지를 감지해주는 도구
new IntersectionObserver(callbackFn, options);
  • callbackFn: 요소가 뷰에 들어왔을 때 실행되는 함수
    • entries : 함수의 매개변수로 관찰 대상에 대한 정보 목록 배열
    • entries[0].isIntersecting : boolean으로 요소가 뷰 포트에 들어왔는지 판별
  • options: 언제 감지할지 설정
    • threshold : 0~1 사이 소수 값으로 해당 div가 얼마나 보이면 인식으로 처리할지 하는 값.
  • 해당 객채를 등록하면 참조가 가능한 함수가 있다.
    • .observe() : 인스턴스에 어떤 DOM 요소를 감시할지 등록하는 함수.
    • .disconnect() : 모든 감시 대상(DOM 요소)을 해제.

🧩 2. useRef

React의 내장 훅 중 하나로, 컴포넌트의 "변하지 않는 값"이나 "DOM 요소"를 참조할 때" 사용
기능
설명
💾 값 보관소
렌더링 사이에도 값이 유지됨 (state처럼 사라지지 않음)
🔁 리렌더 없음
값을 변경해도 컴포넌트가 리렌더링되지 않음
🔗 DOM 참조
ref.current에 DOM 요소를 할당하면, 해당 DOM을 직접 조작 가능
const observerRef = useRef<HTMLDivElement | null>(null);

그래서 최종 구현한 useEffect쪽을 분석하면 아래와 같다.
useEffect(()=>{ // 감시대상을 어떻게 처리할지 콜백함수 와함깨 객채등록 const observer = new IntersectionObserver((entries)=>{ if(entries[0].isIntersecting && hasNextPage && !isFetchingNextPage){ fetchNextPage(); } }, { threshold: 0.5, root: null }); // 옵버저div가 랜더링이 되면 DOM요소 감시 등록 if (observerRef.current) observer.observe(observerRef.current); // useEffect를 통해 새로 랜더링될때마다 중복감시& 메모리누수 방지. return () => observer.disconnect(); // 최신 fetchNextPage 참조 보장을 위해 의존성 배열에 포함 },[hasNextPage, isFetchingNextPage, fetchNextPage]);

 
후기.
막상 SSR에서 값을 미리 가져오거나 하이드레이션 관련 내용을 보고싶었는데
그냥 TanstackQuery 새로운 훅을 써보고 DOM 감시 로직을 짜보는 경험이였다.
아마 대시보드 구성할 때는 좀 의미 있게 쓸 수 있지 않을까 싶다.