Docs
GUIDES & CONCEPTS
Server Rendering & Hydration

Server Rendering & Hydration

이 가이드에서는 서버 렌더링과 함께 React Query를 사용하는 방법을 배울 수 있습니다.

배경 지식을 원하시면 Prefetching & Router Integration 가이드를 참조하세요. Performance & Request Waterfalls 가이드를 먼저 읽어보는 것도 좋습니다.

스트리밍, 서버 컴포넌트, 새로운 Next.js 앱 라우터와 같은 고급 서버 렌더링 패턴에 대한 정보는 Advanced Server Rendering guide를 참조하세요.

코드 예제를 바로 보고 싶으시면 아래의 Full Next.js pages router example 또는 Full Remix example으로 건너뛰시면 됩니다.

Server Rendering & React Query

서버 렌더링이란 무엇일까요? 이 가이드는 서버 렌더링의 개념을 알고 있다고 가정하고 진행됩니다. 그러나 React Query와의 관계를 이해하기 위해 잠시 살펴보겠습니다. 서버 렌더링은 서버에서 초기 HTML을 생성하여 사용자가 페이지를 로드하자마자 콘텐츠를 볼 수 있게 하는 과정입니다. 이 작업은 페이지 요청 시 동적으로 수행될 수 있습니다 (SSR). 또한 이전 요청이 캐시되었거나 빌드 시점에서 미리 수행될 수 있습니다 (SSG).

Request Waterfalls 가이드를 읽어보셨다면, 다음과 같은 내용을 기억할 수도 있습니다:

1. |-> Markup (without content)
2.   |-> JS
3.     |-> Query

클라이언트 렌더링 어플리케이션에서는 화면에 콘텐츠를 표시하기 전에 최소한 3회의 서버 왕복이 필요합니다. 서버 렌더링을 통해 위의 과정을 다음과 같이 변형할 수 있습니다:

1. |-> Markup (with content AND initial data)
2.   |-> JS

**1.**이 완료되면 사용자는 콘텐츠를 볼 수 있고, **2.**가 끝나면 페이지는 상호작용이 가능하고 클릭할 수 있습니다. 마크업에 초기 데이터가 포함되어 있으므로 **3.**은 클라이언트에서 전혀 실행될 필요가 없으며, 최소한 데이터 검증을 원할 때까지는 그렇습니다.

이 모든 것은 클라이언트 측의 관점입니다. 서버 측에서는 마크업을 생성/렌더링하기 전에 데이터를 prefetch(사전 가져오기)해야 하며, 이 데이터를 마크업에 삽입할 수 있는 직렬화 가능한 형식으로 dehydrate(비활성화)해야 합니다. 클라이언트 측에서는 이 데이터를 React Query 캐시에 hydrate(활성화)하여 클라이언트에서 새로운 요청을 하지 않도록 합니다.

이러한 세 가지 단계를 React Query와 함께 구현하는 방법을 계속 읽어보세요.

A quick note on Suspense

이 가이드는 일반 useQuery API를 사용합니다. 반드시 권장되는 방법은 아니지만, 모든 쿼리를 항상 사전 가져오기(prefetch) 하면 useSuspenseQuery로 대체하는 것이 가능합니다. 이 경우 클라이언트에서 <Suspense>를 사용하여 로딩 상태를 처리할 수 있는 장점이 있습니다.

useSuspenseQuery를 사용할 때 쿼리를 사전 가져오는 것을 잊으면, 사용 중인 프레임워크에 따라 결과가 달라질 수 있습니다. 일부 경우에는 데이터가 서버에서 가져오고는 클라이언트로 전달되지 않아 클라이언트에서 다시 가져오게 됩니다. 이 경우, 서버와 클라이언트가 서로 다른 내용을 렌더링하려고 하기 때문에 마크업 수화(mismatch)가 발생할 수 있습니다.

Initial setup

React Query를 사용하는 첫 번째 단계는 항상 queryClient를 생성하고 어플리케이션을 <QueryClientProvider>로 감싸는 것입니다. 서버 렌더링을 할 때는 **어플리케이션 내에서 React 상태(인스턴스 참조도 괜찮음)**에 queryClient 인스턴스를 생성하는 것이 중요합니다. 이렇게 하면 데이터가 서로 다른 사용자와 요청 사이에 공유되지 않고, 컴포넌트 라이프사이클당 한 번만 queryClient가 생성됩니다.

Next.js 페이지 라우터:

// _app.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
 
// 절대 이렇게 하면 안 됩니다:
// const queryClient = new QueryClient()
//
// 파일의 루트 레벨에서 queryClient를 생성하면 캐시가 모든 요청 간에 공유되고
// 모든 데이터가 모든 사용자에게 전달됩니다.
// 성능에 나쁘고 민감한 데이터가 유출될 수 있습니다.
 
export default function MyApp({ Component, pageProps }) {
  // 대신 이렇게 하세요. 이렇게 하면 각 요청마다 별도의 캐시가 생성됩니다:
  const [queryClient] = React.useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            // SSR에서는 클라이언트에서 즉시 재가져오지 않도록
            // 기본 staleTime을 0보다 크게 설정하는 것이 일반적입니다
            staleTime: 60 * 1000,
          },
        },
      })
  );
 
  return (
    <QueryClientProvider client={queryClient}>
      <Component {...pageProps} />
    </QueryClientProvider>
  );
}

Remix:

// app/root.tsx
import { Outlet } from "@remix-run/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
 
export default function MyApp() {
  const [queryClient] = React.useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            // SSR에서는 클라이언트에서 즉시 재가져오지 않도록
            // 기본 staleTime을 0보다 크게 설정하는 것이 일반적입니다
            staleTime: 60 * 1000,
          },
        },
      })
  );
 
  return (
    <QueryClientProvider client={queryClient}>
      <Outlet />
    </QueryClientProvider>
  );
}

Get started fast with initialData

가장 빠른 방법은 prefetching과 관련하여 React Query를 전혀 사용하지 않고 dehydrate/hydrate API를 사용하지 않는 것입니다. 대신, 원시 데이터를 useQueryinitialData 옵션으로 전달하면 됩니다. 다음은 Next.js 페이지 라우터와 getServerSideProps를 사용하는 예제입니다.

export async function getServerSideProps() {
  const posts = await getPosts();
  return { props: { posts } };
}
 
function Posts(props) {
  const { data } = useQuery({
    queryKey: ["posts"],
    queryFn: getPosts,
    initialData: props.posts,
  });
 
  // ...
}

이 방법은 getStaticProps나 이전의 getInitialProps와도 잘 작동하며, 이와 유사한 함수가 있는 다른 프레임워크에서도 같은 패턴을 적용할 수 있습니다. 다음은 Remix를 사용할 때의 동일한 예제입니다:

export async function loader() {
  const posts = await getPosts();
  return json({ posts });
}
 
function Posts() {
  const { posts } = useLoaderData<typeof loader>();
 
  const { data } = useQuery({
    queryKey: ["posts"],
    queryFn: getPosts,
    initialData: posts,
  });
 
  // ...
}

설정이 간단하고 일부 경우에는 빠른 솔루션이 될 수 있지만, 전체 접근 방식과 비교할 때 몇 가지 단점이 있습니다:

  • 컴포넌트 트리의 더 깊은 곳에서 useQuery를 호출하는 경우, initialData를 그 지점까지 전달해야 합니다.
  • 동일한 쿼리로 useQuery를 여러 위치에서 호출하는 경우, initialData를 하나의 위치에만 전달하는 것은 불안정할 수 있으며, 앱이 변경되면 문제가 발생할 수 있습니다. initialData를 가진 useQuery가 있는 컴포넌트를 제거하거나 이동하면, 더 깊이 중첩된 useQuery는 더 이상 데이터를 가지지 않을 수 있습니다. 모든 쿼리에 initialData를 전달하는 것도 번거로울 수 있습니다.
  • 쿼리가 서버에서 언제 가져왔는지 알 방법이 없으므로, dataUpdatedAt과 쿼리의 리패칭 필요 여부는 페이지가 로드된 시점을 기준으로 판단됩니다.
  • 쿼리에 이미 캐시된 데이터가 있는 경우, 새 데이터가 기존 데이터보다 신선하더라도 initialData는 이 데이터를 덮어쓰지 않습니다.
    • 왜 이 점이 특히 문제가 되는지 이해하려면, 위의 getServerSideProps 예제를 고려해 보세요. 페이지를 여러 번 왔다 갔다 하면 getServerSideProps가 매번 호출되어 새 데이터를 가져오지만, initialData 옵션을 사용하기 때문에 클라이언트 캐시와 데이터는 절대 업데이트되지 않습니다.

전체 hydration 솔루션을 설정하는 것은 간단하며 이러한 단점이 없습니다. 문서의 나머지 부분에서는 이 방법에 중점을 두겠습니다.

Using the Hydration APIs

조금만 추가 설정을 하면, queryClient를 사용하여 미리 로드 단계에서 쿼리를 미리 가져오고, 그 queryClient의 직렬화된 버전을 앱의 렌더링 부분에 전달하여 재사용할 수 있습니다. 이 방법은 위에서 언급한 단점을 피할 수 있습니다. 전체 Next.js 페이지 라우터 및 Remix 예제를 보려면 아래로 스크롤하면 되지만, 일반적인 단계는 다음과 같습니다:

  • 프레임워크 로더 함수에서 const queryClient = new QueryClient(options)를 생성합니다.
  • 로더 함수에서 await queryClient.prefetchQuery(...)를 사용하여 미리 가져오고 싶은 쿼리 각각에 대해 호출합니다.
    • 가능한 경우, await Promise.all(...)을 사용하여 쿼리를 병렬로 가져오는 것이 좋습니다.
    • 미리 가져오지 않은 쿼리가 있어도 괜찮습니다. 이러한 쿼리는 서버에서 렌더링되지 않으며, 대신 어플리케이션이 상호작용 가능해진 후 클라이언트에서 가져옵니다. 이는 사용자 상호작용 후에만 표시되는 콘텐츠나 페이지 하단에 있는 콘텐츠에 유용할 수 있습니다.
  • 로더에서 dehydrate(queryClient)를 반환합니다. 이 구문은 프레임워크마다 약간 다를 수 있습니다.
  • <HydrationBoundary state={dehydratedState}>로 트리를 감쌉니다. 여기서 dehydratedState는 프레임워크 로더에서 가져온 것입니다. dehydratedState를 가져오는 방법도 프레임워크마다 다를 수 있습니다.
    • 이 작업은 각 라우트별로 하거나, 어플리케이션 상단에서 공통으로 처리하여 보일러플레이트를 줄일 수 있습니다. 예제를 참조하세요.

흥미로운 점은 실제로 세 개queryClient가 연관되어 있다는 것입니다. 프레임워크 로더는 렌더링 전에 발생하는 "미리 로드" 단계의 한 형태이며, 이 단계에는 미리 가져오는 역할을 하는 queryClient가 있습니다. 이 단계의 탈수 결과는 서버 렌더링 프로세스클라이언트 렌더링 프로세스 두 곳에 전달되며, 각각의 queryClient를 갖고 있습니다. 이는 두 프로세스가 동일한 데이터로 시작하여 동일한 마크업을 반환하도록 보장합니다.

서버 컴포넌트는 "미리 로드" 단계의 또 다른 형태로, React 컴포넌트 트리의 일부를 "미리 렌더링"할 수도 있습니다. 고급 서버 렌더링 가이드에서 자세히 읽어보세요.

Full Next.js pages router example

앱 라우터 문서는 고급 서버 렌더링 가이드에서 확인하세요.

초기 설정:

// _app.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
 
export default function MyApp({ Component, pageProps }) {
  const [queryClient] = React.useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            // SSR을 사용할 때는 일반적으로 기본 staleTime을 0보다 크게 설정하여
            // 클라이언트에서 즉시 다시 가져오는 것을 방지합니다.
            staleTime: 60 * 1000,
          },
        },
      })
  );
 
  return (
    <QueryClientProvider client={queryClient}>
      <Component {...pageProps} />
    </QueryClientProvider>
  );
}

각 라우트에서:

// pages/posts.jsx
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
  useQuery,
} from "@tanstack/react-query";
 
// getServerSideProps로도 사용 가능
export async function getStaticProps() {
  const queryClient = new QueryClient();
 
  await queryClient.prefetchQuery({
    queryKey: ["posts"],
    queryFn: getPosts,
  });
 
  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  };
}
 
function Posts() {
  // 이 useQuery는 <PostsRoute>의 더 깊은 자식에서 발생할 수 있으며,
  // 데이터는 즉시 사용 가능합니다.
  const { data } = useQuery({ queryKey: ["posts"], queryFn: getPosts });
 
  // 이 쿼리는 서버에서 미리 가져오지 않았으며 클라이언트에서
  // 가져오기 시작하지 않을 것입니다. 두 가지 패턴을 혼합하는 것도 괜찮습니다.
  const { data: commentsData } = useQuery({
    queryKey: ["posts-comments"],
    queryFn: getComments,
  });
 
  // ...
}
 
export default function PostsRoute({ dehydratedState }) {
  return (
    <HydrationBoundary state={dehydratedState}>
      <Posts />
    </HydrationBoundary>
  );
}

Full Remix example

초기 설정:

// app/root.tsx
import { Outlet } from "@remix-run/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
 
export default function MyApp() {
  const [queryClient] = React.useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            // SSR을 사용할 때는 일반적으로 기본 staleTime을 0보다 크게 설정하여
            // 클라이언트에서 즉시 다시 가져오는 것을 방지합니다.
            staleTime: 60 * 1000,
          },
        },
      })
  );
 
  return (
    <QueryClientProvider client={queryClient}>
      <Outlet />
    </QueryClientProvider>
  );
}

각 라우트에서, 중첩된 라우트에서도 동일하게 처리할 수 있습니다:

// app/routes/posts.tsx
import { json } from "@remix-run/node";
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
  useQuery,
} from "@tanstack/react-query";
 
export async function loader() {
  const queryClient = new QueryClient();
 
  await queryClient.prefetchQuery({
    queryKey: ["posts"],
    queryFn: getPosts,
  });
 
  return json({ dehydratedState: dehydrate(queryClient) });
}
 
function Posts() {
  // 이 useQuery는 <PostsRoute>의 더 깊은 자식에서도 발생할 수 있으며,
  // 데이터는 즉시 사용 가능합니다.
  const { data } = useQuery({ queryKey: ["posts"], queryFn: getPosts });
 
  // 이 쿼리는 서버에서 미리 가져오지 않았으며 클라이언트에서
  // 가져오기 시작하지 않을 것입니다. 두 가지 패턴을 혼합하는 것도 괜찮습니다.
  const { data: commentsData } = useQuery({
    queryKey: ["posts-comments"],
    queryFn: getComments,
  });
 
  // ...
}
 
export default function PostsRoute() {
  const { dehydratedState } = useLoaderData<typeof loader>();
  return (
    <HydrationBoundary state={dehydratedState}>
      <Posts />
    </HydrationBoundary>
  );
}

Optional - Remove boilerplate

모든 라우트에 다음과 같은 부분이 포함되어 있으면 번거롭게 느껴질 수 있습니다:

export default function PostsRoute({ dehydratedState }) {
  return (
    <HydrationBoundary state={dehydratedState}>
      <Posts />
    </HydrationBoundary>
  );
}

이 접근 방식이 잘못된 것은 아니지만, 이 보일러플레이트를 없애고 싶다면, 다음과 같이 Next.js에서 설정을 수정할 수 있습니다:

// _app.tsx
import {
  HydrationBoundary,
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'
 
export default function MyApp({ Component, pageProps }) {
  const [queryClient] = React.useState(() => new QueryClient())
 
  return (
    <QueryClientProvider client={queryClient}>
      <HydrationBoundary state={pageProps.dehydratedState}>
        <Component {...pageProps} />
      </HydrationBoundary>
    </QueryClientProvider>
  )
}
 
// pages/posts.tsx
// HydrationBoundary가 포함된 PostsRoute를 제거하고 대신 Posts를 직접 내보내세요:
export default function Posts() { ... }

Remix에서는 이 작업이 조금 더 복잡하지만, use-dehydrated-state (opens in a new tab) 패키지를 확인하는 것이 좋습니다.

Prefetching dependent queries

Prefetching 가이드에서는 의존 쿼리 미리 가져오기를 배웠지만, 프레임워크 로더에서 이를 어떻게 처리할까요? 다음은 의존 쿼리 가이드에서 가져온 코드입니다:

// 사용자 가져오기
const { data: user } = useQuery({
  queryKey: ["user", email],
  queryFn: getUserByEmail,
});
 
const userId = user?.id;
 
// 그런 다음 사용자의 프로젝트 가져오기
const {
  status,
  fetchStatus,
  data: projects,
} = useQuery({
  queryKey: ["projects", userId],
  queryFn: getProjectsByUser,
  // userId가 존재할 때까지 쿼리가 실행되지 않음
  enabled: !!userId,
});

이 쿼리를 서버 렌더링을 위해 미리 가져오려면 어떻게 해야 할까요? 다음은 예시입니다:

// Remix의 경우, 이 코드를 loader로 이름을 바꿉니다
export async function getServerSideProps() {
  const queryClient = new QueryClient();
 
  const user = await queryClient.fetchQuery({
    queryKey: ["user", email],
    queryFn: getUserByEmail,
  });
 
  if (user?.userId) {
    await queryClient.prefetchQuery({
      queryKey: ["projects", userId],
      queryFn: getProjectsByUser,
    });
  }
 
  // Remix의 경우:
  // return json({ dehydratedState: dehydrate(queryClient) })
  return { props: { dehydratedState: dehydrate(queryClient) } };
}

물론 이 작업은 더 복잡해질 수 있지만, 이러한 로더 함수는 단순히 JavaScript이기 때문에 언어의 전체적인 기능을 사용하여 로직을 구축할 수 있습니다. 서버 렌더링을 원하는 모든 쿼리를 미리 가져오는 것을 잊지 마세요.

Error handling

React Query는 기본적으로 우아한 장애 처리 전략을 채택합니다. 이는 다음을 의미합니다:

  • queryClient.prefetchQuery(...)는 오류를 발생시키지 않습니다.
  • dehydrate(...)는 실패한 쿼리를 포함하지 않고 성공한 쿼리만 포함합니다.

이로 인해 클라이언트에서 실패한 쿼리가 재시도되고, 서버 렌더링된 결과에는 전체 콘텐츠 대신 로딩 상태가 포함됩니다.

이러한 기본 설정은 좋지만, 때로는 원하지 않을 수 있습니다. 중요한 콘텐츠가 누락된 경우, 상황에 따라 404 또는 500 상태 코드로 응답하고 싶을 수 있습니다. 이런 경우에는 queryClient.fetchQuery(...)를 사용하여 실패 시 오류를 발생시키고 적절하게 처리할 수 있습니다.

let result;
 
try {
  result = await queryClient.fetchQuery(...);
} catch (error) {
  // 오류를 처리합니다. 프레임워크 문서를 참조하세요.
}
 
// 여기에서 유효하지 않은 `result`를 확인하고 처리할 수도 있습니다.

만약 실패한 쿼리도 포함하여 재시도를 방지하고 싶다면, 기본 함수를 오버라이드하고 자신만의 로직을 구현할 수 있는 shouldDehydrateQuery 옵션을 사용할 수 있습니다:

dehydrate(queryClient, {
  shouldDehydrateQuery: (query) => {
    // 모든 쿼리, 실패한 쿼리도 포함됩니다.
    // `query`를 검사하여 자신만의 로직을 구현할 수도 있습니다.
    return true;
  },
});

Serialization

Next.js에서 return { props: { dehydratedState: dehydrate(queryClient) } }를 하거나, Remix에서 return json({ dehydratedState: dehydrate(queryClient) })를 하게 되면, dehydratedState는 프레임워크에 의해 직렬화되어 마크업에 포함되고 클라이언트로 전달됩니다.

기본적으로 이러한 프레임워크는 안전하게 직렬화/파싱 가능한 값만 지원하며, 따라서 undefined, Error, Date, Map, Set, BigInt, Infinity, NaN, -0, 정규 표현식 등의 값은 지원하지 않습니다. 이는 쿼리에서 이러한 값을 반환할 수 없음을 의미합니다. 이러한 값을 반환하고자 한다면, superjson (opens in a new tab) 같은 패키지를 확인해 보세요.

사용자 정의 SSR 설정을 사용하는 경우, 이 단계를 직접 처리해야 합니다. 첫 번째 직감으로 JSON.stringify(dehydratedState)를 사용할 수 있지만, 기본적으로 <script>alert('Oh no..')</script>와 같은 값을 이스케이프하지 않기 때문에 XSS 취약성이 쉽게 발생할 수 있습니다. superjson (opens in a new tab) 또한 값을 이스케이프하지 않으며, 사용자 정의 SSR 설정에서 단독으로 사용하기에 안전하지 않습니다 (출력 이스케이프 단계를 추가하지 않는 한). 대신 Serialize JavaScript (opens in a new tab)devalue (opens in a new tab)와 같은 XSS 주입에 대해 안전한 라이브러리를 사용하는 것을 권장합니다.

A note about request waterfalls

성능 및 요청 워터폴 가이드에서 서버 렌더링이 더 복잡한 중첩된 워터폴 중 하나를 어떻게 변경하는지 다시 살펴보겠다고 했습니다. 구체적인 코드 예제를 확인해 보세요. 복습 차원에서, <Feed> 컴포넌트 내부에 <GraphFeedItem> 컴포넌트를 코드 분할하는 예제를 다룹니다. 이는 피드가 그래프 항목을 포함할 때만 렌더링되며, 이 두 컴포넌트 모두 자신의 데이터를 가져옵니다. 클라이언트 렌더링의 경우, 다음과 같은 요청 워터폴이 발생합니다:

1. |> Markup (내용 없음)
2.   |> JS for <Feed>
3.     |> getFeed()
4.       |> JS for <GraphFeedItem>
5.         |> getGraphDataById()

서버 렌더링의 경우, 위의 흐름을 다음과 같이 개선할 수 있습니다:

1. |> Markup (내용 및 초기 데이터 포함)
2.   |> JS for <Feed>
2.   |> JS for <GraphFeedItem>

쿼리가 더 이상 클라이언트에서 가져오는 것이 아니라 마크업에 포함되어 있습니다. <GraphFeedItem>이 서버에서 렌더링되었기 때문에 클라이언트에서도 이 JS가 필요하다는 것을 알 수 있고, 마크업에 스크립트 태그를 삽입할 수 있습니다. 서버에서는 여전히 다음과 같은 요청 워터폴이 발생합니다:

1. |> getFeed()
2.   |> getGraphDataById()

피드를 가져오기 전에는 그래프 데이터를 가져와야 하는지 알 수 없으므로 의존성 쿼리입니다. 이 과정은 서버에서 일어나며, 서버는 일반적으로 대기 시간이 낮고 더 안정적입니다. 따라서 큰 문제가 되지 않습니다.

훌륭하게도 워터폴을 대부분 평평하게 만들었습니다! 그러나 한 가지 단점이 있습니다. 이 페이지를 /feed 페이지라고 가정하고, /posts와 같은 다른 페이지가 있다고 합시다. URL 바에 www.example.com/feed를 직접 입력하고 Enter를 누르면, 이러한 서버 렌더링의 모든 장점을 얻을 수 있습니다. 하지만 www.example.com/posts를 입력한 후 /feed링크를 클릭하면 다시 다음과 같습니다:

1. |> JS for <Feed>
2.   |> getFeed()
3.     |> JS for <GraphFeedItem>
4.       |> getGraphDataById()

이는 SPA의 경우 서버 렌더링이 초기 페이지 로드에만 작동하며, 이후의 탐색에는 작동하지 않기 때문입니다.

현대 프레임워크는 초기 코드와 데이터를 병렬로 가져오는 방식으로 이를 해결하려고 합니다. 따라서 이 가이드에서 설명한 프리패칭 패턴을 사용하여 Next.js 또는 Remix를 사용하는 경우, 종속 쿼리를 프리패칭하는 방법을 포함하면 실제로 다음과 같이 됩니다:

1. |> JS for <Feed>
1. |> getFeed() + getGraphDataById()
2.   |> JS for <GraphFeedItem>

이는 훨씬 낫지만, 이를 더욱 개선하여 서버 컴포넌트를 사용하여 단일 라운드트립으로 평탄화할 수 있습니다. 이 방법에 대해서는 고급 서버 렌더링 가이드에서 확인하세요.

Tips, Tricks and Caveats

Staleness is measured from when the query was fetched on the server

쿼리는 dataUpdatedAt이 언제였는지에 따라 신선도로 간주됩니다. 이 기능이 제대로 작동하려면 서버가 정확한 시간을 가져야 하지만, UTC 시간이 사용되므로 타임존은 고려되지 않습니다.

기본적으로 staleTime0으로 설정되어 있기 때문에, 페이지 로드 시 기본적으로 쿼리는 백그라운드에서 다시 가져와집니다. 마크업을 캐시하지 않는 경우 이중 가져오기를 피하려면 더 높은 staleTime을 사용하는 것이 좋습니다.

쿼리의 신선도 확인과 재가져오기는 CDN에서 마크업을 캐시할 때 완벽하게 맞아떨어집니다! 페이지 자체의 캐시 시간을 상당히 길게 설정하여 서버에서 페이지를 다시 렌더링할 필요가 없게 하면서, 사용자가 페이지를 방문할 때 데이터가 백그라운드에서 즉시 재가져와지도록 쿼리의 staleTime을 낮게 설정할 수 있습니다. 예를 들어, 페이지는 일주일 동안 캐시하되 데이터는 하루가 지나면 자동으로 페이지 로드 시 재가져오기 원할 수 있습니다.

High memory consumption on server

매 요청마다 QueryClient를 생성하는 경우, React Query는 이 클라이언트를 위한 독립된 캐시를 생성하며, 이는 gcTime 기간 동안 메모리에 유지됩니다. 이로 인해 해당 기간 동안 요청이 많으면 서버에서 높은 메모리 소비가 발생할 수 있습니다.

서버에서는 기본적으로 gcTimeInfinity로 설정되어 있어 수동 가비지 수집이 비활성화되며 요청이 완료되면 자동으로 메모리가 정리됩니다. 명시적으로 비-무한 gcTime을 설정하는 경우, 캐시를 일찍 정리하는 책임이 사용자에게 있습니다.

gcTime0으로 설정하면 하이드레이션 오류가 발생할 수 있으므로 피하는 것이 좋습니다. 이는 Hydration Boundary가 렌더링을 위해 필요한 데이터를 캐시에 저장하지만, 가비지 수집기가 렌더링이 완료되기 전에 데이터를 제거하면 문제가 발생할 수 있기 때문입니다. 짧은 gcTime이 필요하다면, 앱이 데이터를 참조할 충분한 시간을 보장하기 위해 2 * 1000으로 설정하는 것이 좋습니다.

캐시가 더 이상 필요하지 않게 되면 queryClient.clear()를 호출하여 메모리 소비를 줄일 수 있습니다.

대안으로, 더 작은 gcTime을 설정할 수도 있습니다.

Caveat for Next.js rewrites

Next.js의 재작성 기능 (opens in a new tab)Automatic Static Optimization (opens in a new tab) 또는 getStaticProps와 함께 사용하는 경우, React Query에 의해 두 번째 하이드레이션이 발생합니다. 이는 Next.js가 클라이언트에서 재작성 파라미터를 파싱 (opens in a new tab)하고 하이드레이션 후에 모든 파라미터를 수집하여 router.query에 제공해야 하기 때문입니다.

그 결과, 모든 하이드레이션 데이터에 대해 참조 동등성이 누락되며, 이는 예를 들어 데이터가 컴포넌트의 props로 사용되거나 useEffect/useMemo의 종속성 배열에 포함될 때 문제가 발생할 수 있습니다.