DYO 공부하는 블로그

[Next] Next에서 Tanstack Query 사용하기 본문

React/Next.js

[Next] Next에서 Tanstack Query 사용하기

DYODa 2025. 11. 20. 22:10

0. 왜 Tanstack을 많이 쓰나요?

Tanstack Query 기능의 강력한 점을 정리하면

  • staleTimegcTime을 이용한 캐싱, 최신화 관리
  • refetchOnWindowFocus와 같은 브라우저 레벨의 유저 동작에서의 제어 옵션
  • select 등을 이용한 return 데이터 가공
  • isLoading, isPending, isError 등과 같은 다양한 상황에서 제공되는 상태
  • defaultSetting으로 전역적인 쿼리 클라이언트 동작 제어
  • onMutate, isError Callback 등으로 이벤트 발생 타이밍마다 작동하는 콜백 제어로 낙관적 업데이트 가능
  • queryClient 직접 제어를 통한 쿼리 무효화, refetching직접 제어 가능
  • 무한 스크롤 제어에 특히 강력한 InfinityQuery과 같은 특정 쿼리 동작에 특화된 함수

간략하게 정리한 기능만 정리한 내용이고, 이 외에도 수많은 옵션과 기능으로 비동기 요청을 처리할 수 있는 것은 물론 React, Vue, Solid 등의 다양한 플랫폼을 지원하고 있기 때문에 선호되고 있습니다.

 

데이터의 신선도

Tanstack의 기능 중에 가장 강력한 부분이 캐시(cache)이기 때문에 이 개념은 가져가시는 게 좋습니다. staleTimegcTime 으로 신선도를 유지하는 시간을 관리합니다.

TanStack Query는 캐시한 데이터를 신선(Fresh)하거나 상한(Stale) 상태로 구분해 관리합니다.

캐시된 데이터가 신선하다면 캐시된 데이터를 사용하고, 만약 데이터가 상했다면 서버에 다시 요청해 신선한새로운 데이터를 가져옵니다.

일종의 데이터 유통기한 정도로 생각하면 이해하기 쉽습니다.

 

코드 예시로 보는 Tanstack의 선언적 처리

또한 비동기 처리와 같은 사이드이펙트 처리를 할 때는 useEffect를 사용해 처리해야 하는데, Tanstack을 이용하면 선언적으로 관리하기 편리해집니다. async/await 구문을 사용하는 것처럼요. 실제 코드 구문으로 예시를 보겠습니다.

  • Tanstack을 사용하지 않는 비동기 처리
export const Someting(id) {
    const [data, setData] = useState<SomeType[]>([]);

    useEffect(() => {
        async function getData() {
            try{
                const res = await fetch(`/api/library?isbn13=${isbn13}`);
                const data = await res.json();
                setData(data)
            }catch{
                console.log("Someting: getData 실행 오류");
            }
        }
    }, [])

    if(data.length <= 0)
        return (<div>데이터가 없습니다.</div>)

    return (
        <ul>
            {data.map(() => (...))}
        </ul>
    )
}

state 기반으로 처리하는 익숙한 형태죠? 비동기 처리는 사이드이펙트로 처리하기 때문에 useEffect 내부에 async funtion을 선언해 처리하는 것이 일반적입니다.

  • Tanstack을 사용하는 예시
export const Someting(id) {
    const {data, isLoading, isError} = useGetData()

    if(isLoading) return (<div>로딩 중입니다.</div>)

    if(isError) return (<div>오류가 발생했습니다.</div>)

    return (
        <ul>
            {data.map(() => (...))}
        </ul>
    )
}

Tanstack은 모든 Query를 hook으로 분리해 관리합니다. 때문에 사용하지 않았을 때와 비교했을 때 컴포넌트를 훨씬 선언적이고 절차 기반인 것처럼 관리할 수 있고, 상태에 따른 분기를 간단하게 처리할 수 있습니다.

물론 fetch문을 queryFn에 작성해야 하긴 하지만, 로직을 분리함으로써 재사용성을 높이고 비즈니스 로직이 하나의 책임만을 가지도록 설계하기 쉽습니다.

 


 

0. 언제 Next의 fetch 를 쓰고 언제 TanStack Query를 써야 되는가.

RSC(React Server Components)로 작성해야 할 때와 RCC(React Client Components)로 작성해야 할 때는 구분해야 한다.

  • 정적, 갱신이 자주 일어나지 않을 데이터(RSC) : fetch() + Next 캐시 / ISR 활용 필요 시 클라이언트에서 useQuery는 disabled + initialData만 사용.
  • 상호작용 필요(event) / 자주 갱신(RCC)-채팅창 : 클라이언트의 useQuery 또는 useSuspenseQuery 중심으로 낙관적 업데이트, 무효화, Infinite QueryTQ의 장점을 활용
  • 혼합(Hybrid) : 서버에서 1차 패치 → 클라이언트에서 HydrationBoundary로 이어받아 최신화

부연 설명 : 엄밀히 따지자면 혼합(Hybrid) 방식은 클라이언트 컴포넌트입니다. 서버 레벨에서 사전 로드된 데이터를 넘겨줄 뿐이지 번들에 포함됩니다.

 


 

1. 기본 설정

  1. Query Provider 선언

디폴트 옵션이 정의된 Query Provider로부터 쿼리 클라이언트를 받아 사용합니다.

// TanstackProvider.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

function TanstackProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            refetchOnWindowFocus: false,
            staleTime: 1000 * 60 * 5,
            gcTime: 1000 * 60 * 5,
            retry: 2,
            refetchIntervalInBackground: false,
            retryDelay: 1000,
          },
          mutations: {
            retry: 2,
            retryDelay: 1000,
          }
        },
      })
  );

  return (
    <>
      <QueryClientProvider client={queryClient}>
        {children}
        <ReactQueryDevtools client={queryClient} />
      </QueryClientProvider>
    </>
  );
}

export default TanstackProvider;
  1. Root Layout 컨텐츠 요소의 부모 요소로 작성

Root 레이아웃에서 정의하면 외부 파일 @/feature/api/useDataFetching.ts 에서 참조하더라도 Query Client 에서 정의된 기본 설정을 따릅니다.

// app/layout.tsx

// ... 생략

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ko">
        <body
          className={`${geistSans.variable} ${geistMono.variable} antialiased`}
        >
         <TanstackProvider>
          {children}
             </TanstackProvider>
        </body>
    </html>
  );
}
  1. 쿼리 키 팩토리 ( 메서드명은 규칙에 따라 통합 )
// queryKeyFactory.ts
export const queryKeys = {
    missions: {
        all: () => ['missions'] as const,
        byId: (id: number) => ['missions', id] as const,
        search: (q: string) => ['missions', 'search', q] as const,
    }
}

위와 같은 팩토리가 필요한 이유는 invalidateQuerys(관련 쿼리키로 조회한 내용의 stale을 초기화)등의 명령어를 사용할 때 해당 쿼리키의 구조를 정확하게 알지 않아도 사용 가능하도록 하기 위해 사용한다.

예시를 들면 다음과 같다.

const qc = useQueryClient(); // 쿼리 클라이언트를 가져옴
const uid = useAuthStore((s) => s.user?.id) ? "" // 스토어로부터 유저 정보를 가져옴

const {mutate: reviewMutate} = useSetReview({onMutate:() => {
    qc.invalidQuerys(queryKeys.missions.byId(userId))
}}) 

위의 코드 스니펫 예시에서는 리뷰를 작성했을 때 리뷰를 갱신하는 게 아닌 미션 목록을 갱신하고 있다. 하지만 mission 목록의 queryKey를 어떻게 작성했는지 알 필요가 없다.

 


 

2. 쿼리 키 팩토리와 연결해 useQuery를 사용하는 실사용 예시

  1. 쿼리 키 팩토리
//queryKeys.ts

export const queryKeys = {
  ask: {
    all: ['ask'] as const,
    detail: (id: string) => [...queryKeys.ask.all, id] as const,
  },
  book: {
    all: ['book'] as const,
    detail: (id: string) => [...queryKeys.book.all, id] as const,
  },
  bookmark: {
    all: ['bookmark'] as const,
    detail: (id: string) => [...queryKeys.bookmark.all, id] as const,
    byUser: (userId: string) => [...queryKeys.bookmark.all, userId] as const,
  },
  comment: {
    all: ['comment'] as const,
    detail: (id: string) => [...queryKeys.comment.all, id] as const,
  },
}

mutation 상호작용에 따라 캐시해두었던 데이터를 queryKey 기반으로 invalidate (query를 무효화하고 재갱신)하기 위해 파일을 분리해 관리합니다.

  1. 요청 repo (DB에 따라, 기능에 따라 분리) - Optional
// book.repo.ts

// 여기서의 fetch는 next의 fetch가 아닌 window 빌트인 fetch
// axios를 사용하기 원한다면 변경도 가능함.
export const bookRepo = {
  getBook: async (isbn13: string) => {
    const res = await fetch(`/api/library?isbn13=${isbn13}`);
    if (!res.ok) throw new Error("failed");
    return res.json();
  },
}

export default bookRepo
  1. useQuery 훅 정의
// hook/useBookFetching.ts

import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "@/queryKeys";
import bookRepo from "@/repo/book.repo";

export const useGetBookDetail = (isbn13: string) => {
  return useQuery({
    queryKey: queryKeys.book.detail(isbn13),
    queryFn: () => bookRepo.getBook(isbn13),
    staleTime: 1000 * 60, // Optional
  });
};
  1. 컴포넌트에서 사용
// test_query/page.tsx

"use client"; // tanstack query는 RCC에서만 실행되어야 합니다.

import { useGetBookDetail } from "@/hook/useBookFetching";

function Page() {
  // 중요! 조건문으로 처리하거나 useEffect 내부에 넣을 수 없고 최상단에 정의해야 함.
  const { data, isLoading, error } = useGetBookDetail("9788936433598");

  console.log(data);
  return <div>Pa</div>;
}

export default Page;

 


 

3. Dehydrate / HydrationBoundary 패턴 ( Hybrid )

왜 사용할까?

서버에서 SSR로 데이터와 함께 초기 로딩해주는 것은 빠르고, SEO 최적화에 도움이 된다. 그런데 댓글 목록과 같이 SEO에 포함되기를 원하면서, 유저가 댓글을 작성했을 때 목록에 즉시 반영되도록 하려면 어떻게 해야 할까?

SSR - 로딩이 빠름, SEO 최적화에 도움이 됨.

CSR - 유저 상호작용에 대해 즉시 반영되어야 하는 경우에 효과적

이런 경우에는 CSR로 댓글 목록 컴포넌트를 작성할 수도 있지만, SSR 방식으로 초기 로드하는 방법이 있다. Dehydrate / HydrationBoundary 패턴이다.

 

어떻게 동작하는가.

  1. 서버 사이드에서 SSR 방식으로 queryClient에 prefetch로 데이터를 주입한다.
async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;

  const queryClient = new QueryClient();

  try {
    await Promise.all([
      queryClient.prefetchQuery({
        queryKey: queryKeys.comment.get(id),
        queryFn: () => getComments({ id }),
      }),
      queryClient.prefetchQuery({
        queryKey: queryKeys.post.id(id),
        queryFn: () => getPost(id),
      }),
    ]);
  } catch (error) {
    console.error("Prefetch error:", error);
  }
  1. HydrationBoundary에 queryClient를 넘겨 준다.
  return (
    <div className="w-full flex flex-col items-center min-h-[calc(100vh-140px)] pt-4">
      <div className="w-[80%] md:w-[60%] flex flex-col min-h-[calc(100vh-140px)] py-15 gap-4">
        {/* 게시글 영역 */}
        <HydrationBoundary state={dehydrate(queryClient)}>
          <PostContent id={id} />
          {/* 댓글 작성 영역 */}
          <CommentWrite id={id} />
          {/* 댓글 영역 */}
          <PostCommnet id={id} />
        </HydrationBoundary>
      </div>
    </div>
  );
}
  1. 하위 요소에서 서버의 queryClient 데이터를 넘겨 받아 캐시 데이터에 추가한다.

이 경우에는 반드시 서버의 queryClientqueryKey와 클라이언트의 queryKey가 일치해야 한다. (queryKey 기반한 캐시를 받아오기 때문)

  const { data: comments, isLoading } = useGetComments({ id, page: 1, size: 30 });

    // 아래에서 data를 자유롭게 사용

 

동작 요약

  1. 서버 컴포넌트에서 새로운 queryClient를 생성
  2. 해당 queryClient에 서버에서 조회한 데이터를 넘긴다.
  3. hydrateBoundary에 해당 queryClient를 넘긴다.
  4. 전역으로 선언된 queryClient는 해당 쿼리키와 데이터를 받아 오고, 하위의 클라이언트 컴포넌트에서 같은 쿼리키를 사용하면 서버에서 넘겨 준 데이터를 사용할 수 있다.
  5. mutate 발생 시에 Tanstack Query로 일관성 있게 관리하면 된다.

 

캐시 처리의 queryKey 문제도 개선됩니다.

캐시를 관리하는 데 정말 도움이 됩니다. Next에서 fetch는 두 종류로 나누어 사용하죠. 서버단 Next fetch와 클라이언트단 window fetch 양쪽으로 나뉠 경우에 캐시 관리가 정말 곤란해집니다. 클라이언트의 fetchTantack을 이용하고, 서버단 fetchNext Fetch를 이용했다고 가정해 봅시다.

TanstackqueryKey를 이용해 캐시를 관리할 수 있고, Next Fetchtagpath 를 이용해 캐시를 관리합니다.

양쪽을 revalidate 하는 예시는 다음과 같습니다.

// 클라이언트
queryClient.invalidateQueries(['post', id]) // ['post', id] 구조를 가진 모든 요청을 invalidate

//서버
revalidatePath('/blog/post-1') // 해당 path의 모든 요청이 revalidate
revalidateTag('post-1') // 단일 string

post 하나에 대해 양쪽에서 관리해야 한다면 이중으로 요청해야 되는 문제가 있습니다. 이때 유틸 함수를 통해 관리하면 되는 것 아닌가요? 라고 생각할 수 있는데, 이 경우도 완벽하게 호환되지는 않습니다.

invalidateQueries 는 배열형 데이터를 받고 [’post’, id]를 인수로 전달할 경우 [’post’, id]가 queryKey 인 모든 요청을 초기화합니다. 그런데 [’post’]라고 전달하는 경우 [’post’, …]로 시작하는 모든 query 요청을 초기화합니다.

revalidateTag 는 tag 단일 string만 초기화할 수 있으므로, 완벽하게 대체하기에는 적절하지 않습니다.

 

마무리

Dehydrate / HydrationBoundary(Hybrid) 패턴이 모든 문제를 해결할 수 있는 마법처럼 느껴지지만 실제로 적용할 때는 고려해봐야 합니다. 이 패턴을 적용할 경우 코드량이 늘어나고 코드의 복잡도가 증가할 수 있습니다.

  1. 이 패턴을 사용하는 요청은 서버단, 클라이언트단 이중화로 작성되어야 함.
  2. dehydrate 패턴을 상위 요소에서 수행해야 함.
  3. 하위 요소의 queryKey와 반드시 일치시켜주어야 정상적으로 작동함.

위와 같은 이유로 복잡도가 증가할 수 있는 문제도 있습니다.

'React > Next.js' 카테고리의 다른 글

[Next.js] Next 정리  (1) 2025.09.14