TanStack Query v5 완벽 가이드 — React·Vue·Svelte·Solid 통합 서버 상태 관리의 표준

TanStack Query v5 완벽 가이드 — React·Vue·Svelte·Solid 통합 서버 상태 관리의 표준

이 글의 핵심

TanStack Query v5는 "서버 상태는 클라이언트 상태와 다르다"는 통찰 위에 세워진 사실상 표준 라이브러리입니다. React·Vue·Svelte·Solid·Angular 모두에서 동일한 철학으로 동작하고, 캐싱·백그라운드 리패치·낙관적 업데이트·SSR Hydration·Server Component 연동까지 현대 데이터 페칭의 모든 요구를 커버합니다. 이 글은 v5 최신 API 중심으로 실전 패턴을 정리합니다.

설치

pnpm add @tanstack/react-query
pnpm add -D @tanstack/react-query-devtools

QueryClient 설정

// app/providers.tsx
"use client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
import { useState } from "react"

export function Providers({ children }: { children: React.ReactNode }) {
  const [client] = useState(() => new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000,          // 1분
        gcTime: 5 * 60 * 1000,         // 5분 후 가비지 컬렉션
        retry: 2,
        refetchOnWindowFocus: true,
      },
      mutations: { retry: 1 },
    },
  }))

  return (
    <QueryClientProvider client={client}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  )
}

기본 조회: useQuery

import { useQuery } from "@tanstack/react-query"

type Post = { id: number; title: string; body: string }

async function fetchPosts(): Promise<Post[]> {
  const res = await fetch("/api/posts")
  if (!res.ok) throw new Error("Failed")
  return res.json()
}

export function PostList() {
  const { data, isLoading, isError, error, refetch, isFetching } = useQuery({
    queryKey: ["posts"],
    queryFn: fetchPosts,
  })

  if (isLoading) return <p>Loading…</p>
  if (isError) return <p>Error: {(error as Error).message}</p>

  return (
    <>
      <button onClick={() => refetch()} disabled={isFetching}>Refresh</button>
      <ul>{data!.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
    </>
  )
}

queryKey 규칙

  • 식별자 역할: 같은 key는 같은 쿼리
  • 계층적: ["posts", { status: "published", page: 2 }]
  • 직렬화 가능해야: 함수·DOM 금지

queryOptions: 타입 안전한 옵션 재사용 (v5 하이라이트)

// api/posts.ts
import { queryOptions } from "@tanstack/react-query"

export const postsQueryOptions = queryOptions({
  queryKey: ["posts"],
  queryFn: fetchPosts,
  staleTime: 60_000,
})

export const postQueryOptions = (id: number) =>
  queryOptions({
    queryKey: ["posts", id],
    queryFn: () => fetchPost(id),
    enabled: !!id,
  })
const { data } = useQuery(postsQueryOptions)
const { data: post } = useQuery(postQueryOptions(id))

// prefetch
await queryClient.prefetchQuery(postsQueryOptions)

한 번 정의하고 재사용해 prefetch·hydration·컴포넌트에서 타입 일관성 유지.

Mutation

import { useMutation, useQueryClient } from "@tanstack/react-query"

export function CreatePost() {
  const qc = useQueryClient()
  const { mutate, isPending } = useMutation({
    mutationFn: async (title: string) => {
      const res = await fetch("/api/posts", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ title }),
      })
      if (!res.ok) throw new Error()
      return res.json() as Promise<Post>
    },
    onSuccess: (created) => {
      qc.invalidateQueries({ queryKey: ["posts"] })
      qc.setQueryData<Post>(["posts", created.id], created)
    },
  })

  return (
    <button disabled={isPending} onClick={() => mutate("New Post")}>
      {isPending ? "Saving…" : "Create"}
    </button>
  )
}
  • invalidateQueries: 관련 캐시를 stale로 표시 → 다음 mount 시 자동 refetch
  • setQueryData: 즉시 캐시 업데이트

Optimistic Update

const { mutate } = useMutation({
  mutationFn: updatePost,
  onMutate: async (newPost) => {
    await qc.cancelQueries({ queryKey: ["posts"] })
    const prev = qc.getQueryData<Post[]>(["posts"])
    qc.setQueryData<Post[]>(["posts"], (old = []) =>
      old.map((p) => (p.id === newPost.id ? newPost : p)),
    )
    return { prev }   // rollback 컨텍스트
  },
  onError: (_err, _newPost, context) => {
    if (context?.prev) qc.setQueryData(["posts"], context.prev)
  },
  onSettled: () => {
    qc.invalidateQueries({ queryKey: ["posts"] })
  },
})

낙관적 업데이트 + 실패 시 롤백 + 최종 서버 상태로 동기화. 오프라인·느린 네트워크에서 UX가 극적으로 개선.

무한 스크롤

import { useInfiniteQuery } from "@tanstack/react-query"

const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
  queryKey: ["posts", "infinite"],
  queryFn: ({ pageParam }) => fetchPostsPage(pageParam),
  initialPageParam: 0,
  getNextPageParam: (lastPage) => lastPage.nextCursor,
})

return (
  <>
    {data?.pages.flatMap((p) => p.posts).map((post) => (
      <article key={post.id}>{post.title}</article>
    ))}
    <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage}>
      {isFetchingNextPage ? "…" : hasNextPage ? "Load more" : "No more"}
    </button>
  </>
)

Intersection Observer와 결합해 자동 로드도 가능.

SSR + Hydration (Next.js App Router)

// app/posts/page.tsx (서버)
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query"
import { postsQueryOptions } from "@/api/posts"
import { PostList } from "./post-list"

export default async function Page() {
  const qc = new QueryClient()
  await qc.prefetchQuery(postsQueryOptions)

  return (
    <HydrationBoundary state={dehydrate(qc)}>
      <PostList />
    </HydrationBoundary>
  )
}
// app/posts/post-list.tsx (클라이언트)
"use client"
import { useQuery } from "@tanstack/react-query"
import { postsQueryOptions } from "@/api/posts"

export function PostList() {
  const { data } = useQuery(postsQueryOptions)
  return <ul>{data!.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
}

서버에서 prefetch → 직렬화된 캐시를 클라이언트에 hydrate → 클라이언트는 loading 없이 바로 렌더 + 필요시 refetch.

React Server Components + TanStack Query

// app/feed/page.tsx (Server)
import { getQueryClient } from "@/lib/query"
import { postsQueryOptions } from "@/api/posts"
import { HydrationBoundary, dehydrate } from "@tanstack/react-query"
import Feed from "./feed"

export default async function Page() {
  const qc = getQueryClient()
  await qc.prefetchQuery(postsQueryOptions)
  return (
    <HydrationBoundary state={dehydrate(qc)}>
      <Feed />
    </HydrationBoundary>
  )
}

getQueryClient()는 서버 컴포넌트 렌더 당 싱글톤을 보장하는 헬퍼:

// lib/query.ts
import { cache } from "react"
import { QueryClient } from "@tanstack/react-query"

export const getQueryClient = cache(() => new QueryClient())

queryKey 관리: Query Key Factory

// api/queryKeys.ts
export const queryKeys = {
  posts: {
    all: ["posts"] as const,
    list: (filters?: PostFilters) => ["posts", "list", filters] as const,
    detail: (id: number) => ["posts", "detail", id] as const,
    comments: (id: number) => ["posts", id, "comments"] as const,
  },
}
queryClient.invalidateQueries({ queryKey: queryKeys.posts.all })
queryClient.setQueryData(queryKeys.posts.detail(1), updatedPost)

대형 앱에서 invalidation scope를 체계적으로 관리.

Suspense 통합

import { useSuspenseQuery } from "@tanstack/react-query"
import { Suspense } from "react"

function Posts() {
  const { data } = useSuspenseQuery(postsQueryOptions)
  return <ul>{data.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
}

export default function Page() {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <Posts />
    </Suspense>
  )
}

useSuspenseQuery는 data가 반드시 존재한다고 타입이 보장돼 null 체크 제거.

에러 바운더리

import { ErrorBoundary } from "react-error-boundary"
import { QueryErrorResetBoundary } from "@tanstack/react-query"

<QueryErrorResetBoundary>
  {({ reset }) => (
    <ErrorBoundary onReset={reset} FallbackComponent={Fallback}>
      <Posts />
    </ErrorBoundary>
  )}
</QueryErrorResetBoundary>

실시간·폴링

useQuery({
  ...queryOptions,
  refetchInterval: (query) =>
    query.state.data?.status === "processing" ? 1000 : false,
  refetchIntervalInBackground: true,
})

동적 간격 + 백그라운드 동작까지 세밀 제어.

DevTools

<ReactQueryDevtools initialIsOpen={false} position="bottom-right" />
  • 모든 쿼리 상태·데이터·타임라인 시각화
  • 수동 invalidate·refetch
  • 네트워크 시뮬레이션

개발 생산성 측면에서 Redux DevTools에 필적하는 수준.

서버 액션 (Next.js 14+)

"use client"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { createPostAction } from "./actions"

export function Form() {
  const qc = useQueryClient()
  const { mutate } = useMutation({
    mutationFn: createPostAction,
    onSuccess: () => qc.invalidateQueries({ queryKey: ["posts"] }),
  })
  return (
    <form action={(formData) => mutate(formData)}>
      <input name="title" required />
      <button>Save</button>
    </form>
  )
}

Server Action + Optimistic Update 조합도 자연스럽게 가능.

실전 베스트 프랙티스

  1. staleTime을 의식적으로: 기본 0은 너무 공격적. 60초~5분 기본값 권장
  2. queryOptions로 재사용: 같은 쿼리를 prefetch·SSR·훅에서 공유
  3. queryKey factory: 대형 앱에서 invalidation 범위 통일
  4. persister로 오프라인: @tanstack/query-persist-client로 localStorage 저장
  5. 네트워크 모드: networkMode: "offlineFirst"로 오프라인 대응
  6. select: 쿼리 결과에서 필요한 부분만 구독해 리렌더 감소

트러블슈팅

쿼리가 계속 refetch됨

  • staleTime: 0이 기본이라 의도적으로 피하고 싶다면 명시적 지정
  • refetchOnMount·refetchOnWindowFocus 조정

queryKey 변경에도 같은 데이터

  • 배열 순서·내부 값 동일해야 동일 키
  • JSON.stringify 직렬화 가능한 값만

Hydration mismatch

  • 서버 prefetch 한 key와 클라이언트 useQuery key가 정확히 일치해야 함
  • queryOptions 재사용으로 해결

mutation 성공 후 UI 반영 안 됨

  • invalidateQueries 또는 setQueryData 중 하나 반드시 호출
  • optimistic update는 onMutate에서 cancel + set

TypeScript 타입 좁힘 어려움

  • useSuspenseQuery 사용 시 data가 non-null
  • 또는 data 가드 후 접근

체크리스트

  • QueryClient에 staleTime·gcTime·retry 기본값 지정
  • queryOptions로 재사용 가능한 쿼리 정의
  • queryKey factory로 invalidation 관리
  • SSR prefetch + HydrationBoundary 적용
  • Optimistic Update로 UX 개선
  • DevTools로 실제 캐시 동작 관찰
  • Persist로 오프라인/새로고침 유지
  • Suspense/Error Boundary로 UI 상태 단순화

마무리

TanStack Query는 “서버 상태 관리”라는 문제 정의 자체를 바꿔 현대 웹 프레임워크의 사실상 표준이 되었습니다. v5의 queryOptions·useSuspenseQuery·향상된 타입 추론이 대형 앱 개발 경험을 한 단계 끌어올렸고, React Server Component·Server Action과의 결합도 매끄럽습니다. 2026년 현재 Next.js·Remix·TanStack Start·Vue Nuxt·SvelteKit·SolidStart 모두 TanStack Query 통합 가이드를 1급으로 제공하며, 이 라이브러리를 잘 쓰는 것만으로도 전역 상태 관리의 70-80%가 자연스럽게 정리됩니다. 지금 SWR이나 수동 useEffect + fetch 패턴을 쓰고 있다면 강력하게 이주를 권장합니다.

관련 글

  • React Hooks 완벽 가이드
  • Zustand 완벽 가이드
  • SWR 완벽 가이드
  • React Server Components 가이드