프로필 로고
2026-04-07

Next.js App Router에서 TanStack Query 사용하기

App Router 환경에서 QueryClient 초기화 전략, 서버 컴포넌트를 활용한 prefetch와 HydrationBoundary 연동, 그리고 pending 쿼리 dehydration을 통한 스트리밍 패턴을 정리한다.

  • Next.js
  • TanStack Query
  • RSC
  • hydration
  • Streaming

React Native에서 쓸 때와 비교

  1. React Native와 Next.js 모두 QueryClientProvider로 감싸고 useQuery로 데이터를 가져오는 기본 구조는 동일하다.
  2. 그러나 Next.js에는 서버에서 HTML을 미리 생성하는 SSR/SSG 환경이 존재하고, 이 지점에서 설정 방식이 크게 달라진다.

서버 컴포넌트를 “로더”로 이해하자

  1. 서버 컴포넌트는 초기 페이지 진입과 페이지 전환 모두에서 항상 서버에서만 실행된다는 보장이 있다.
  2. 반면 클라이언트 컴포넌트는 이름과 달리 서버에서도 실행될 수 있는데, SSR 과정에서 초기 렌더링 패스가 서버에서 이루어지기 때문이다. (서버 컴포넌트 정리글 참고)
  3. 따라서 서버 컴포넌트는 "데이터를 미리 가져오는 로더 단계", 클라이언트 컴포넌트는 "그 데이터를 소비하는 애플리케이션 단계"라고 구분하면 역할이 명확해진다.

QueryClient 초기 설정

  1. App Router에서도 QueryClientProvider로 앱을 감싸는 기본 구조는 동일하지만, useContext에 의존하므로 Provider 파일에 'use client'를 선언해야 한다.

  2. 서버에서는 요청마다 새 QueryClient를 만들어야 하고, 브라우저에서는 단 하나의 인스턴스를 재사용해야 한다.

  3. 브라우저에서 매번 새 인스턴스를 만들면 React가 Suspense 도중 클라이언트를 버리는 문제가 생기기 때문이다.

  4. 이를 isServer 플래그로 분기하여 처리한다.

    // app/providers.tsx
    'use client'
    import { isServer, QueryClient, QueryClientProvider } from '@tanstack/react-query'
     
    function makeQueryClient() {
      return new QueryClient({
        defaultOptions: { queries: { staleTime: 60 * 1000 } },
      })
    }
     
    let browserQueryClient: QueryClient | undefined = undefined
     
    function getQueryClient() {
      if (isServer) return makeQueryClient()
      if (!browserQueryClient) browserQueryClient = makeQueryClient()
      return browserQueryClient
    }
     
    export default function Providers({ children }) {
      const queryClient = getQueryClient()
      return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    }
  5. Providersapp/layout.tsx에서 앱 전체를 감싸는 형태로 배치한다.

왜 서버에서는 매 요청마다 새 QueryClient, 브라우저는 싱글톤일까?

서버는 사용자 격리를 위해 매번 새로, 브라우저는 Suspense 재시도에서 살아남기 위해 싱글톤으로 유지한다.

  1. QueryClient는 쉽게 말해 "쿼리 캐시 저장소”이다.
  2. 서버에서 QueryClient를 싱글톤으로 쓰면, A 사용자의 요청에서 캐싱된 데이터가 B 사용자 요청에도 남아있게 된다.
  3. 개인정보나 권한이 다른 데이터가 다른 사용자에게 노출될 수 있으므로, 서버는 요청마다 새로 만들어야 한다.
  4. 반대로 브라우저는 한 명의 사용자가 쓰므로, 싱글톤으로 캐시를 유지하는 것이 목적에 맞다.
  5. 브라우저에서도 매번 새로 만들면 문제가 생기는데, React가 렌더링 도중 Suspense를 만나면 그 렌더링을 잠깐 중단하고 다시 시도하기 때문이다.
  6. 이 재시도 과정에서 QueryClient가 새로 만들어지면, 이전에 시작된 쿼리 상태가 전부 사라져버린다.
  7. 결과적으로 쿼리가 무한히 다시 시작되는 루프에 빠질 수 있다.
  8. 따라서 브라우저에서는 browserQueryClient 변수에 한 번 만든 인스턴스를 저장하고 재사용한다.

QueryClient 생성 방식

서버는 매 요청마다 새 QueryClient, 브라우저는 싱글톤으로 유지한다.

  1. React Native에서는 앱이 한 번 실행되면 메모리가 유지되므로, QueryClient를 모듈 최상단에 싱글톤으로 만들어도 안전하다.

    // React Native
    const queryClient = new QueryClient()
     
    export default function App() {
      return (
        <QueryClientProvider client={queryClient}>
          <RootNavigator />
        </QueryClientProvider>
      )
    }
  2. 하지만 Next.js 서버 환경에서는 여러 사용자의 요청이 동일한 Node.js 프로세스를 공유한다.

  3. 싱글톤 QueryClient를 서버에서 쓰면 A 사용자의 캐시가 B 사용자에게 유출될 수 있다.

  4. 그래서 Next.js에서는 요청마다 새 QueryClient를 생성해야 하며, 이를 위해 React.cache로 요청 단위 격리한다.

    // Next.js (app router) - lib/query-client.ts
    import { cache } from 'react'
    import { QueryClient } from '@tanstack/react-query'
     
    export const getQueryClient = cache(() => new QueryClient())
  5. 여기서 매 요청이란, 클라이언트(브라우저)가 Next.js 서버에 보내는 HTTP 요청 한 건을 의미한다.

  6. 예를 들어 사용자 A가 /posts에 접속하면 그게 요청 1건, 사용자 B가 같은 페이지에 접속하면 또 다른 요청 1건이다.

  7. 같은 사용자라도 페이지를 새로고침하면 새 요청이 된다.

데이터 프리패치와 Hydration

서버 컴포넌트는 프리패치 전용으로만 쓰고, 렌더링은 클라이언트 컴포넌트에 맡긴다.

  1. React Native에서는 데이터 페칭이 항상 클라이언트(기기)에서 일어나므로, useQuery가 마운트 시점에 요청을 보낸다.

  2. Next.js에서는 서버 컴포넌트에서 미리 데이터를 fetching해 HTML에 담아 보낼 수 있다.

  3. 서버 컴포넌트에서 prefetchQuery를 실행하고, dehydrate한 상태를 HydrationBoundary에 넘긴다.

    // app/posts/page.tsx (Server Component)
    import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
    import Posts from './posts'
     
    export default async function PostsPage() {
      const queryClient = new QueryClient()
     
      await queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: getPosts })
     
      return (
        <<Tooltip text='그 JSON을 HTML 안에 심어 클라이언트로 전송한다.'>HydrationBoundary</Tooltip> state={<ToolTip text='서버의 캐시 상태를 JSON으로 직렬화한다.'>dehydrate(queryClient)</Tooltip>}>
          <Posts /> {/* 클라이언트 컴포넌트*/}
        </HydrationBoundary>
      )
    }
  4. 클라이언트 컴포넌트에서는 동일한 queryKeyuseQuery를 호출하면, 이미 캐시에 데이터가 있어 즉시 반환된다.

    // app/posts/posts.tsx
    'use client'
    export default function Posts() {
      const { data } = useQuery({ <Tooltip text='서버의 key와 동일해야 캐시가 연결됨'>queryKey: ['posts']</Tooltip>, queryFn: getPosts })
      // ...
    }
  5. 브라우저가 HTML을 받으면, HydrationBoundary가 서버의 캐시 상태를 클라이언트 QueryClient에 복원한다.

  6. 이 과정을 Hydration이라고 부른다.

  7. 덕분에 클라이언트의 useQuery는 마운트 즉시 캐시에서 데이터를 읽으므로 추가 네트워크 요청 없이 렌더링된다.

  8. 이때 프리패치되지 않은 쿼리는 클라이언트에서 자연스럽게 추가 요청하면 되고, 두 패턴을 혼용하는 것은 완전히 정상이다.

  9. 서버 컴포넌트 내에서 fetchQuery로 가져온 데이터를 직접 렌더링하는 것은 피해야 한다. 클라이언트에서 쿼리가 재검증될 때 서버에서 렌더링한 값과 불일치가 생기기 때문이다.

프리패치와 Hydration의 실제 동작 흐름

서버가 캐시를 채워 클라이언트에 전달하고, 이후 갱신은 클라이언트가 독립적으로 처리한다.

  1. 먼저 전체 흐름을 한 문장으로 요약하면, "서버에서 데이터를 미리 가져와 캐시에 담고, 그 캐시 스냅샷을 클라이언트로 전달해서 클라이언트가 처음부터 데이터를 가진 척 시작하는 것"이다.
  2. 서버 컴포넌트에서 prefetchQuery를 실행하면 서버의 QueryClient 캐시에 데이터가 채워진다.
  3. dehydrate(queryClient)는 그 캐시를 JSON 형태로 직렬화한 스냅샷이다.
  4. HydrationBoundary는 그 스냅샷을 받아 클라이언트의 QueryClient 캐시에 복원(hydrate)한다.
  5. 그 결과 클라이언트 컴포넌트에서 useQuery를 호출하는 순간, 이미 캐시에 데이터가 있으므로 네트워크 요청 없이 즉시 반환된다.
  6. 이후 클라이언트에서 staleTime이 지나거나 invalidateQueries를 호출하면, 그때는 서버 컴포넌트가 아닌 클라이언트에서 직접 API를 다시 호출한다.
  7. 즉, 서버 컴포넌트는 "초기 데이터 공급"만 담당하고, 이후 캐시 관리는 전적으로 클라이언트 QueryClient가 맡는다.
  8. HydrationBoundary는 그 안의 모든 Client Component에 캐시를 공유하므로, 하위 어느 깊이에서 useQuery를 쓰더라도 같은 데이터를 즉시 받는다.
  9. HydrationBoundary를 어디에 얼마나 만드느냐는, 어느 페이지/영역에서 어떤 데이터를 프리패치할지에 따라 결정하면 된다.
  10. 일반적으로 해당 데이터를 사용하는 클라리언트 컴포넌트의 가장 가까운 서버 컴포넌트 부모에 두는 것이 자연스럽다.

fetchQuery로 가져온 데이터를 서버에서 직접 렌더링하면 안 되는 이유

서버에서 렌더링한 값은 클라이언트 재검증 후 갱신이 불가능하므로, 렌더링은 Client Component에만 맡긴다.

  1. 아래 상황을 예시로 보자.

    // 이렇게 하면 안 된다
    export default async function PostsPage() {
      const queryClient = new QueryClient()
      const posts = await queryClient.fetchQuery({ queryKey: ['posts'], queryFn: getPosts })
     
      return (
        <HydrationBoundary state={dehydrate(queryClient)}>
         <Tooltip text='fetchQuery로 가져온 데이터를 서버에서 직접 렌더링한 경우'> <div>게시글 수: {posts.length}</div> </Tooltip>{/* 서버에서 직접 렌더링 */}
          <Posts /> {/* 내부에서 useQuery로 같은 데이터 사용 */}
        </HydrationBoundary>
      )
    }
  2. 처음 페이지를 열 때는 서버의 posts.length와 클라이언트 useQuery의 데이터가 일치하므로 문제없다.

  3. 그런데 시간이 지나 staleTime이 지나면, 클라이언트의 React Query는 자동으로 API를 다시 호출한다.

  4. 새 데이터로 Posts 컴포넌트는 업데이트되지만, 서버에서 렌더링된 게시글 수: {posts.length}는 Tanstack Query가 건드릴 수 없다.

  5. 결과적으로 화면에 게시글 수와 실제 목록 수가 달라지는 불일치가 발생한다.

  6. 따라서 서버 컴포넌트는 데이터를 화면에 직접 출력하지 말고, 오직 프리패치 용도로만 사용해야 한다.

중첩 Server Component에서의 프리패치

중첩 프리패치는 가능하지만 waterfall에 주의하고, 필요하면 parallel routes로 해결한다

  1. 페이지 최상단에서 모든 데이터를 프리패치할 필요 없이, 각 서버 컴포넌트가 자신의 데이터를 책임지는 구조가 가능하다.

    // app/posts/page.tsx
    export default async function PostsPage() {
      const queryClient = new QueryClient()
      await queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: getPosts })
     
      return (
        <HydrationBoundary state={dehydrate(queryClient)}>
          <Posts />
          <CommentsServerComponent /> {/* 얘가 자기 데이터를 직접 프리패치 */}
        </HydrationBoundary>
      )
    }
     
    // app/posts/comments-server.tsx
    export default async function CommentsServerComponent() {
      const queryClient = new QueryClient()
      await queryClient.prefetchQuery({ queryKey: ['comments'], queryFn: getComments })
     
      return (
        <HydrationBoundary state={dehydrate(queryClient)}>
          <Comments />
        </HydrationBoundary>
      )
    }
  2. PostsPage는 posts만, CommentsServerComponent는 comments만 각자 책임진다.

  3. 즉, 각 서버 컴포넌트가 자신과 관련된 데이터만 프리패치하고, 각자 HydrationBoundary를 가지는 구조가 가능하다.

  4. 단, PostsPage에서 await prefetchQuery가 끝난 후에야 CommentsServerComponent가 실행되므로 중첩 구조는 서버 사이드 waterfall을 만들 수 있다.

    1. |> getPosts()
    2.   |> getComments()  ← 순차 실행됨
  5. 이를 피하려면 Next.js의 parallel routes(병렬 라우트)를 활용하면 되고, 그러면 두 요청이 병렬로 실행된다.

서버 컴포넌트에서 단일 QueryClient 공유 방식 (주의 필요)

cache()로 공유하면 편리하지만 dehydrate 시 불필요한 쿼리까지 직렬화되므로, 규모가 크면 기본 방식이 낫다.

  1. 앞에서 봤던 것처럼 기본 방식은 서버 컴포넌트마다 new QueryClient()를 새로 만드는 것이다.

    // app/posts/page.tsx
    export default async function PostsPage() {
      const queryClient = new QueryClient()  // 1개
    	await queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: getPosts })
    	...
    	
    	)
    }
     
    // app/posts/comments-server.tsx
    export default async function CommentsServerComponent() {
      const queryClient = new QueryClient()  // 2개
      await queryClient.prefetchQuery({ queryKey: ['comments'], queryFn: getComments })
    	...
    	  
      )
    }
  2. 대신 React의 cache()를 쓰면, 하나의 요청 스코프 안에서 모든 서버 컴포넌트가 동일한 QueryClient를 공유할 수 있다.

    // app/getQueryClient.ts
    import { QueryClient } from '@tanstack/react-query'
    import { cache } from 'react'
     
    // cache()는 요청 단위로 격리되므로 사용자 간 데이터 누수가 없다
    const getQueryClient = cache(() => new QueryClient())
    export default getQueryClient
    // 어떤 Server Component에서든 이렇게 같은 인스턴스를 꺼내 쓸 수 있다
    const queryClient = getQueryClient()
    await queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: getPosts })
  3. 유틸 함수 안에서도 getQueryClient()를 호출할 수 있어 편리하다.

  4. 그런데 dehydrate(getQueryClient())를 호출하면, 그 QueryClient 안에 쌓인 모든 쿼리가 한꺼번에 직렬화된다.

  5. 예를 들어 A 컴포넌트에서 posts를, B 컴포넌트에서 comments를 프리패치했다면, A에서 dehydrate할 때 comments까지 같이 직렬화된다.

  6. 이는 불필요한 데이터를 클라이언트로 보내는 오버헤드가 된다.

  7. 따라서 컴포넌트 수가 많고 각자 다른 데이터를 다룬다면, 기본 방식처럼 각자 new QueryClient()를 쓰는 것이 더 낫다.

스트리밍과 pending 쿼리 Dehydration

pending 쿼리 dehydration + useSuspenseQuery 조합으로 await 없이 스트리밍이 가능해진다.

  1. Next.js App Router는 <Suspense> 경계를 기준으로 완성된 콘텐츠를 브라우저에 즉시 스트리밍한다.

  2. React Query v5.40.0부터는 아직 완료되지 않은 pending 상태의 쿼리도 dehydrate해서 클라이언트로 보낼 수 있다.

  3. 이를 통해 await 없이 프리패치를 시작하고, 데이터가 준비되는 대로 클라이언트에 스트리밍할 수 있다.

  4. 이를 활성화하려면 QueryClient 설정에서 pending 쿼리도 dehydrate하도록 옵션을 추가해야 한다.

    // app/get-query-client.ts
    import { QueryClient, defaultShouldDehydrateQuery, isServer } from '@tanstack/react-query'
     
    function makeQueryClient() {
      return new QueryClient({
        defaultOptions: {
          queries: { staleTime: 60 * 1000 },
          dehydrate: {
            shouldDehydrateQuery: (query) =>
              defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
          },
        },
      })
    }
  5. 이 설정이 되면 서버 컴포넌트에서 prefetchQueryawait할 필요가 없다.

    // app/posts/page.tsx
    export default function PostsPage() {
      const queryClient = getQueryClient()
     
      queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: getPosts }) // await 없음
     
      return (
        <HydrationBoundary state={dehydrate(queryClient)}>
          <Posts />
        </HydrationBoundary>
      )
    }
  6. 클라이언트에서는 useSuspenseQuery를 쓰면 서버에서 만들어진 Promise를 이어받아 처리한다.

  7. useQuery를 써도 Promise는 이어받지만, Suspense가 발동하지 않아 pending 상태로 렌더링되고 서버 사이드 렌더링이 되지 않는다.

스트리밍과 pending 쿼리 Dehydration이 왜 필요한가

pending 쿼리 dehydration은 "데이터를 기다리지 않고 HTML을 먼저 보내고, 데이터는 나중에 흘려보내는" 스트리밍을 가능하게 한다.

  1. 기존 방식에서는 서버 컴포넌트에서 await prefetchQuery를 해야만 데이터가 캐시에 채워지고, 그 이후에야 dehydrate해서 클라이언트로 보낼 수 있었다.

  2. 즉, 모든 await이 끝나야만 HTML이 브라우저로 전송되기 시작했다.

  3. Next.js는 <Suspense> 경계를 기준으로 준비된 부분을 먼저 브라우저에 보내는 스트리밍을 지원한다.

  4. 그런데 await으로 모든 데이터를 다 기다리면, 스트리밍의 이점이 사라진다.

  5. v5.40.0부터는 아직 완료되지 않은 pending 쿼리도 dehydrate해서 클라이언트로 보낼 수 있게 되었다.

  6. pending 상태의 쿼리를 dehydrate한다는 것은, "이 쿼리는 아직 데이터가 없지만 Promise가 진행 중이다"라는 상태 자체를 직렬화해서 클라이언트에 넘긴다는 뜻이다.

  7. 클라이언트는 그 Promise를 이어받아, 서버에서 데이터가 완성되는 대로 스트리밍으로 수신한다.

  8. 덕분에 await 없이 프리패치를 시작하고, HTML은 즉시 스트리밍되며, 데이터는 준비되는 대로 뒤따라온다.

  9. 이를 활성화하려면 QueryClient 설정에 pending 쿼리도 dehydrate하도록 옵션을 추가하고, 서버 컴포넌트에서 await을 제거하면 된다.

    // pending 쿼리도 dehydrate 허용
    dehydrate: {
      shouldDehydrateQuery: (query) =>
        defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
    }
    // await 없이 프리패치 시작, 함수 자체도 async 불필요
    export default function PostsPage() {
      const queryClient = getQueryClient()
     
      queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: getPosts }) // await 없음
     
      return (
        <HydrationBoundary state={dehydrate(queryClient)}>
          <Posts />
        </HydrationBoundary>
      )
    }
    // 클라이언트는 useSuspenseQuery로 서버의 Promise를 이어받음
    'use client'
    export default function Posts() {
      const { data } = useSuspenseQuery({ queryKey: ['posts'], queryFn: getPosts })
      // ...
    }
  10. useSuspenseQuery를 쓰면 Suspense가 발동되어, 데이터가 도착할 때까지 fallback UI를 보여주다가 완성되면 교체한다.

  11. useQuery를 쓰면 Suspense가 발동하지 않아 pending 상태 그대로 렌더링되고, 서버 사이드 렌더링이 되지 않는다.

데이터 흐름 도식

  1. React Native의 흐름

    [싱글톤 QuerClient]
            │
    [QueryClientProvider]
            │
    [Screen / Component]
            │
         useQuery()
            │
      캐시에 있음? ─── Yes ──→ 캐시 데이터 반환
            │
           No
            │
      API 서버로 fetch]
            │
      캐시 저장 → 렌더링
    
  2. Next.js App Router의 흐름

    [ 서버 (요청 수신) ]
            │
      getQueryClient()          ← 요청마다 새 인스턴스
            │
      prefetchQuery()           ← 서버에서 API 직접 호출
            │
      dehydrate(queryClient)    ← 캐시를 JSON으로 직렬화
            │
      HydrationBoundary         ← JSON을 HTML에 삽입하여 전송
            │
    ────────────────────────── 네트워크 ──
            │
    [ 브라우저 (HTML 수신) ]
            │
      HydrationBoundary         ← JSON을 클라이언트 캐시로 복원
            │
      useQuery()                ← 캐시 히트 → 추가 요청 없이 즉시 렌더링