React Native에서 쓸 때와 비교
- React Native와 Next.js 모두
QueryClientProvider로 감싸고useQuery로 데이터를 가져오는 기본 구조는 동일하다. - 그러나 Next.js에는 서버에서 HTML을 미리 생성하는 SSR/SSG 환경이 존재하고, 이 지점에서 설정 방식이 크게 달라진다.
서버 컴포넌트를 “로더”로 이해하자
- 서버 컴포넌트는 초기 페이지 진입과 페이지 전환 모두에서 항상 서버에서만 실행된다는 보장이 있다.
- 반면 클라이언트 컴포넌트는 이름과 달리 서버에서도 실행될 수 있는데, SSR 과정에서 초기 렌더링 패스가 서버에서 이루어지기 때문이다. (서버 컴포넌트 정리글 참고)
- 따라서 서버 컴포넌트는 "데이터를 미리 가져오는 로더 단계", 클라이언트 컴포넌트는 "그 데이터를 소비하는 애플리케이션 단계"라고 구분하면 역할이 명확해진다.
QueryClient 초기 설정
-
App Router에서도
QueryClientProvider로 앱을 감싸는 기본 구조는 동일하지만,useContext에 의존하므로 Provider 파일에'use client'를 선언해야 한다. -
서버에서는 요청마다 새
QueryClient를 만들어야 하고, 브라우저에서는 단 하나의 인스턴스를 재사용해야 한다. -
브라우저에서 매번 새 인스턴스를 만들면 React가
Suspense도중 클라이언트를 버리는 문제가 생기기 때문이다. -
이를
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> } -
이
Providers를app/layout.tsx에서 앱 전체를 감싸는 형태로 배치한다.
왜 서버에서는 매 요청마다 새 QueryClient, 브라우저는 싱글톤일까?
서버는 사용자 격리를 위해 매번 새로, 브라우저는 Suspense 재시도에서 살아남기 위해 싱글톤으로 유지한다.
- QueryClient는 쉽게 말해 "쿼리 캐시 저장소”이다.
- 서버에서 QueryClient를 싱글톤으로 쓰면, A 사용자의 요청에서 캐싱된 데이터가 B 사용자 요청에도 남아있게 된다.
- 개인정보나 권한이 다른 데이터가 다른 사용자에게 노출될 수 있으므로, 서버는 요청마다 새로 만들어야 한다.
- 반대로 브라우저는 한 명의 사용자가 쓰므로, 싱글톤으로 캐시를 유지하는 것이 목적에 맞다.
- 브라우저에서도 매번 새로 만들면 문제가 생기는데, React가 렌더링 도중
Suspense를 만나면 그 렌더링을 잠깐 중단하고 다시 시도하기 때문이다. - 이 재시도 과정에서 QueryClient가 새로 만들어지면, 이전에 시작된 쿼리 상태가 전부 사라져버린다.
- 결과적으로 쿼리가 무한히 다시 시작되는 루프에 빠질 수 있다.
- 따라서 브라우저에서는
browserQueryClient변수에 한 번 만든 인스턴스를 저장하고 재사용한다.
QueryClient 생성 방식
서버는 매 요청마다 새
QueryClient, 브라우저는 싱글톤으로 유지한다.
-
React Native에서는 앱이 한 번 실행되면 메모리가 유지되므로,
QueryClient를 모듈 최상단에 싱글톤으로 만들어도 안전하다.// React Native const queryClient = new QueryClient() export default function App() { return ( <QueryClientProvider client={queryClient}> <RootNavigator /> </QueryClientProvider> ) } -
하지만 Next.js 서버 환경에서는 여러 사용자의 요청이 동일한 Node.js 프로세스를 공유한다.
-
싱글톤
QueryClient를 서버에서 쓰면 A 사용자의 캐시가 B 사용자에게 유출될 수 있다. -
그래서 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()) -
여기서 매 요청이란, 클라이언트(브라우저)가 Next.js 서버에 보내는 HTTP 요청 한 건을 의미한다.
-
예를 들어 사용자 A가
/posts에 접속하면 그게 요청 1건, 사용자 B가 같은 페이지에 접속하면 또 다른 요청 1건이다. -
같은 사용자라도 페이지를 새로고침하면 새 요청이 된다.
데이터 프리패치와 Hydration
서버 컴포넌트는 프리패치 전용으로만 쓰고, 렌더링은 클라이언트 컴포넌트에 맡긴다.
-
React Native에서는 데이터 페칭이 항상 클라이언트(기기)에서 일어나므로,
useQuery가 마운트 시점에 요청을 보낸다. -
Next.js에서는 서버 컴포넌트에서 미리 데이터를
fetching해 HTML에 담아 보낼 수 있다. -
서버 컴포넌트에서
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> ) } -
클라이언트 컴포넌트에서는 동일한
queryKey로useQuery를 호출하면, 이미 캐시에 데이터가 있어 즉시 반환된다.// app/posts/posts.tsx 'use client' export default function Posts() { const { data } = useQuery({ <Tooltip text='서버의 key와 동일해야 캐시가 연결됨'>queryKey: ['posts']</Tooltip>, queryFn: getPosts }) // ... } -
브라우저가 HTML을 받으면,
HydrationBoundary가 서버의 캐시 상태를 클라이언트QueryClient에 복원한다. -
이 과정을 Hydration이라고 부른다.
-
덕분에 클라이언트의
useQuery는 마운트 즉시 캐시에서 데이터를 읽으므로 추가 네트워크 요청 없이 렌더링된다. -
이때 프리패치되지 않은 쿼리는 클라이언트에서 자연스럽게 추가 요청하면 되고, 두 패턴을 혼용하는 것은 완전히 정상이다.
-
서버 컴포넌트 내에서
fetchQuery로 가져온 데이터를 직접 렌더링하는 것은 피해야 한다. 클라이언트에서 쿼리가 재검증될 때 서버에서 렌더링한 값과 불일치가 생기기 때문이다.
프리패치와 Hydration의 실제 동작 흐름
서버가 캐시를 채워 클라이언트에 전달하고, 이후 갱신은 클라이언트가 독립적으로 처리한다.
- 먼저 전체 흐름을 한 문장으로 요약하면, "서버에서 데이터를 미리 가져와 캐시에 담고, 그 캐시 스냅샷을 클라이언트로 전달해서 클라이언트가 처음부터 데이터를 가진 척 시작하는 것"이다.
- 서버 컴포넌트에서
prefetchQuery를 실행하면 서버의 QueryClient 캐시에 데이터가 채워진다. dehydrate(queryClient)는 그 캐시를 JSON 형태로 직렬화한 스냅샷이다.HydrationBoundary는 그 스냅샷을 받아 클라이언트의 QueryClient 캐시에 복원(hydrate)한다.- 그 결과 클라이언트 컴포넌트에서
useQuery를 호출하는 순간, 이미 캐시에 데이터가 있으므로 네트워크 요청 없이 즉시 반환된다. - 이후 클라이언트에서
staleTime이 지나거나invalidateQueries를 호출하면, 그때는 서버 컴포넌트가 아닌 클라이언트에서 직접 API를 다시 호출한다. - 즉, 서버 컴포넌트는 "초기 데이터 공급"만 담당하고, 이후 캐시 관리는 전적으로 클라이언트 QueryClient가 맡는다.
HydrationBoundary는 그 안의 모든 Client Component에 캐시를 공유하므로, 하위 어느 깊이에서useQuery를 쓰더라도 같은 데이터를 즉시 받는다.HydrationBoundary를 어디에 얼마나 만드느냐는, 어느 페이지/영역에서 어떤 데이터를 프리패치할지에 따라 결정하면 된다.- 일반적으로 해당 데이터를 사용하는 클라리언트 컴포넌트의 가장 가까운 서버 컴포넌트 부모에 두는 것이 자연스럽다.
fetchQuery로 가져온 데이터를 서버에서 직접 렌더링하면 안 되는 이유
서버에서 렌더링한 값은 클라이언트 재검증 후 갱신이 불가능하므로, 렌더링은 Client Component에만 맡긴다.
-
아래 상황을 예시로 보자.
// 이렇게 하면 안 된다 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> ) } -
처음 페이지를 열 때는 서버의
posts.length와 클라이언트useQuery의 데이터가 일치하므로 문제없다. -
그런데 시간이 지나
staleTime이 지나면, 클라이언트의 React Query는 자동으로 API를 다시 호출한다. -
새 데이터로
Posts컴포넌트는 업데이트되지만, 서버에서 렌더링된게시글 수: {posts.length}는 Tanstack Query가 건드릴 수 없다. -
결과적으로 화면에 게시글 수와 실제 목록 수가 달라지는 불일치가 발생한다.
-
따라서 서버 컴포넌트는 데이터를 화면에 직접 출력하지 말고, 오직 프리패치 용도로만 사용해야 한다.
중첩 Server Component에서의 프리패치
중첩 프리패치는 가능하지만 waterfall에 주의하고, 필요하면 parallel routes로 해결한다
-
페이지 최상단에서 모든 데이터를 프리패치할 필요 없이, 각 서버 컴포넌트가 자신의 데이터를 책임지는 구조가 가능하다.
// 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> ) } -
PostsPage는 posts만,CommentsServerComponent는 comments만 각자 책임진다. -
즉, 각 서버 컴포넌트가 자신과 관련된 데이터만 프리패치하고, 각자
HydrationBoundary를 가지는 구조가 가능하다. -
단,
PostsPage에서await prefetchQuery가 끝난 후에야CommentsServerComponent가 실행되므로 중첩 구조는 서버 사이드 waterfall을 만들 수 있다.1. |> getPosts() 2. |> getComments() ← 순차 실행됨 -
이를 피하려면 Next.js의 parallel routes(병렬 라우트)를 활용하면 되고, 그러면 두 요청이 병렬로 실행된다.
서버 컴포넌트에서 단일 QueryClient 공유 방식 (주의 필요)
cache()로 공유하면 편리하지만 dehydrate 시 불필요한 쿼리까지 직렬화되므로, 규모가 크면 기본 방식이 낫다.
-
앞에서 봤던 것처럼 기본 방식은 서버 컴포넌트마다
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 }) ... ) } -
대신 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 }) -
유틸 함수 안에서도
getQueryClient()를 호출할 수 있어 편리하다. -
그런데
dehydrate(getQueryClient())를 호출하면, 그 QueryClient 안에 쌓인 모든 쿼리가 한꺼번에 직렬화된다. -
예를 들어 A 컴포넌트에서
posts를, B 컴포넌트에서comments를 프리패치했다면, A에서dehydrate할 때comments까지 같이 직렬화된다. -
이는 불필요한 데이터를 클라이언트로 보내는 오버헤드가 된다.
-
따라서 컴포넌트 수가 많고 각자 다른 데이터를 다룬다면, 기본 방식처럼 각자
new QueryClient()를 쓰는 것이 더 낫다.
스트리밍과 pending 쿼리 Dehydration
pending쿼리dehydration+useSuspenseQuery조합으로await없이 스트리밍이 가능해진다.
-
Next.js App Router는
<Suspense>경계를 기준으로 완성된 콘텐츠를 브라우저에 즉시 스트리밍한다. -
React Query v5.40.0부터는 아직 완료되지 않은
pending상태의 쿼리도dehydrate해서 클라이언트로 보낼 수 있다. -
이를 통해
await없이 프리패치를 시작하고, 데이터가 준비되는 대로 클라이언트에 스트리밍할 수 있다. -
이를 활성화하려면
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', }, }, }) } -
이 설정이 되면 서버 컴포넌트에서
prefetchQuery를await할 필요가 없다.// 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> ) } -
클라이언트에서는
useSuspenseQuery를 쓰면 서버에서 만들어진Promise를 이어받아 처리한다. -
useQuery를 써도Promise는 이어받지만,Suspense가 발동하지 않아pending상태로 렌더링되고 서버 사이드 렌더링이 되지 않는다.
스트리밍과 pending 쿼리 Dehydration이 왜 필요한가
pending 쿼리 dehydration은 "데이터를 기다리지 않고 HTML을 먼저 보내고, 데이터는 나중에 흘려보내는" 스트리밍을 가능하게 한다.
-
기존 방식에서는 서버 컴포넌트에서
await prefetchQuery를 해야만 데이터가 캐시에 채워지고, 그 이후에야dehydrate해서 클라이언트로 보낼 수 있었다. -
즉, 모든
await이 끝나야만 HTML이 브라우저로 전송되기 시작했다. -
Next.js는
<Suspense>경계를 기준으로 준비된 부분을 먼저 브라우저에 보내는 스트리밍을 지원한다. -
그런데
await으로 모든 데이터를 다 기다리면, 스트리밍의 이점이 사라진다. -
v5.40.0부터는 아직 완료되지 않은
pending쿼리도dehydrate해서 클라이언트로 보낼 수 있게 되었다. -
pending상태의 쿼리를dehydrate한다는 것은, "이 쿼리는 아직 데이터가 없지만Promise가 진행 중이다"라는 상태 자체를 직렬화해서 클라이언트에 넘긴다는 뜻이다. -
클라이언트는 그
Promise를 이어받아, 서버에서 데이터가 완성되는 대로 스트리밍으로 수신한다. -
덕분에
await없이 프리패치를 시작하고, HTML은 즉시 스트리밍되며, 데이터는 준비되는 대로 뒤따라온다. -
이를 활성화하려면 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 }) // ... } -
useSuspenseQuery를 쓰면Suspense가 발동되어, 데이터가 도착할 때까지 fallback UI를 보여주다가 완성되면 교체한다. -
useQuery를 쓰면Suspense가 발동하지 않아pending상태 그대로 렌더링되고, 서버 사이드 렌더링이 되지 않는다.
데이터 흐름 도식
-
React Native의 흐름
[싱글톤 QuerClient] │ [QueryClientProvider] │ [Screen / Component] │ useQuery() │ 캐시에 있음? ─── Yes ──→ 캐시 데이터 반환 │ No │ API 서버로 fetch] │ 캐시 저장 → 렌더링 -
Next.js App Router의 흐름
[ 서버 (요청 수신) ] │ getQueryClient() ← 요청마다 새 인스턴스 │ prefetchQuery() ← 서버에서 API 직접 호출 │ dehydrate(queryClient) ← 캐시를 JSON으로 직렬화 │ HydrationBoundary ← JSON을 HTML에 삽입하여 전송 │ ────────────────────────── 네트워크 ── │ [ 브라우저 (HTML 수신) ] │ HydrationBoundary ← JSON을 클라이언트 캐시로 복원 │ useQuery() ← 캐시 히트 → 추가 요청 없이 즉시 렌더링