Docs
GUIDES & CONCEPTS
Advanced Server Rendering

Advanced Server Rendering

고급 서버 렌더링 가이드에 오신 것을 환영합니다. 이 가이드에서는 React Query를 스트리밍과 Server Components, 그리고 Next.js 앱 라우터와 함께 사용하는 방법을 배울 수 있습니다.

기본적인 React Query와 SSR 사용법을 배우고 싶다면 서버 렌더링 및 하이드레이션 가이드를 먼저 읽어보세요. 또한, 성능 및 요청 워터폴프리패칭 및 통합 라우터도 유용한 배경 지식을 제공합니다.

시작하기 전에, 'initialData' 접근 방식이 Server Components에서도 작동하지만, 이 가이드에서는 하이드레이션 API에 중점을 둘 것입니다.

Server Components & Next.js app router

이 가이드에서는 Server Components를 깊게 다루지는 않지만, 간단히 말하면 Server Components는 초기 페이지 보기와 페이지 전환 시 서버에서만 실행되는 컴포넌트입니다. 이는 Next.js의 getServerSideProps/getStaticProps와 Remix의 loader가 서버에서만 실행되는 것과 비슷합니다. 그러나 Server Components는 단순히 데이터를 반환하는 것 이상을 할 수 있습니다. React Query와 관련된 데이터 부분에 중점을 두어 보겠습니다.

서버 렌더링 가이드에서 프레임워크 로더에서 앱으로 프리패치된 데이터 전달하기를 배운 내용을 Server Components와 Next.js 앱 라우터에 적용하는 방법을 살펴보겠습니다. Server Components를 "단지" 다른 프레임워크 로더라고 생각하는 것이 좋습니다.

A quick note on terminology

지금까지 이 가이드에서는 서버클라이언트에 대해 이야기했습니다. 하지만, 이 용어들이 Server ComponentsClient Components와 일대일로 매칭되는 것은 아닙니다. Server Components는 서버에서만 실행되지만, Client Components는 실제로 두 장소에서 모두 실행될 수 있습니다. 이는 Client Components가 초기 서버 렌더링 단계에서도 렌더링될 수 있기 때문입니다.

이것을 생각하는 한 가지 방법은 Server Components가 "렌더링"되긴 하지만, 이는 "로더 단계"에서 발생하고, Client Components는 "어플리케이션 단계"에서 실행된다는 것입니다. 이 어플리케이션은 SSR 동안 서버에서 실행될 수도 있고, 예를 들어 브라우저에서도 실행될 수 있습니다.

Initial setup

React Query 설정의 첫 단계는 항상 queryClient를 생성하고 어플리케이션을 QueryClientProvider로 감싸는 것입니다. Server Components에서는 이 과정이 프레임워크에 따라 거의 동일하며, 파일 이름 규칙만 다를 수 있습니다:

// Next.js에서는 이 파일의 이름을: app/providers.jsx로 지정합니다.
"use client";
 
// QueryClientProvider는 useContext를 사용하기 때문에 'use client'를 상단에 추가해야 합니다.
import {
  isServer,
  QueryClient,
  QueryClientProvider,
} from "@tanstack/react-query";
 
function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        // SSR에서는 기본적으로 쿼리의 staleTime을 0이 아닌 값으로 설정하여
        // 클라이언트에서 즉시 재요청하는 것을 방지합니다.
        staleTime: 60 * 1000,
      },
    },
  });
}
 
let browserQueryClient: QueryClient | undefined = undefined;
 
function getQueryClient() {
  if (isServer) {
    // 서버: 항상 새로운 쿼리 클라이언트를 생성합니다.
    return makeQueryClient();
  } else {
    // 브라우저: 이미 클라이언트가 없으면 새로운 쿼리 클라이언트를 생성합니다.
    // React가 초기 렌더링 중에 멈추는 경우, 새 클라이언트를 다시 만들지 않도록 합니다.
    if (!browserQueryClient) browserQueryClient = makeQueryClient();
    return browserQueryClient;
  }
}
 
export default function Providers({ children }) {
  // NOTE: React가 초기 렌더링 중에 멈추는 경우, useState를 사용하여
  // 쿼리 클라이언트를 초기화하지 않는 것이 좋습니다. 이는 클라이언트가
  // 초기 렌더링에서 사라질 수 있기 때문입니다.
  const queryClient = getQueryClient();
 
  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}
// Next.js에서는 이 파일의 이름을: app/layout.jsx로 지정합니다.
import Providers from "./providers";
 
export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <head />
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

이 부분은 SSR 가이드와 비슷하며, 파일을 두 개로 나누는 것만 다릅니다.

Prefetching and de/hydrating data

다음으로, 데이터 프리패칭과 하이드레이션을 어떻게 하는지 살펴보겠습니다. Next.js 페이지 라우터를 사용할 때의 예는 다음과 같습니다:

// 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는 더 깊은 자식에서 사용할 수 있습니다.
  // 데이터는 즉시 사용할 수 있습니다.
  //
  // 서버에서 이미 프리패치된 데이터가 있기 때문에 useSuspenseQuery 대신 useQuery를 사용합니다.
  // 만약 프리패치를 잊거나 제거하면, 클라이언트에서 데이터를 다시 요청할 수 있습니다.
  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>
  );
}

이 코드를 앱 라우터로 변환하는 것은 비슷하게 보입니다. 먼저, 서버 컴포넌트를 만들어서 프리패칭을 처리합니다:

// app/posts/page.jsx
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 (
    // 간단해졌네요! 직렬화는 props를 전달하는 것으로 해결됩니다.
    // HydrationBoundary는 클라이언트 컴포넌트이므로 하이드레이션은 여기서 발생합니다.
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Posts />
    </HydrationBoundary>
  );
}

다음으로, 클라이언트 컴포넌트 부분은 다음과 같습니다:

// app/posts/posts.jsx
"use client";
 
export default function Posts() {
  // 이 useQuery는 더 깊은 자식에서 사용할 수 있습니다.
  // 데이터는 즉시 사용할 수 있습니다.
  const { data } = useQuery({
    queryKey: ["posts"],
    queryFn: () => getPosts(),
  });
 
  // 이 쿼리는 서버에서 프리패치되지 않았으며 클라이언트에서 요청이 시작될 때까지 기다립니다.
  // 두 가지 패턴을 혼합해도 괜찮습니다.
  const { data: commentsData } = useQuery({
    queryKey: ["posts-comments"],
    queryFn: getComments,
  });
 
  // ...
}

위의 예제에서 한 가지 멋진 점은 Next.js에 특화된 부분은 파일 이름뿐이라는 것입니다. 다른 프레임워크에서도 Server Components가 지원된다면 거의 동일한 방식으로 작성할 수 있습니다.

SSR 가이드에서 <HydrationBoundary>를 모든 라우트에 넣는 것의 보일러플레이트를 없앨 수 있다고 언급했습니다. 그러나 Server Components에서는 이 방법을 사용할 수 없습니다.

NOTE: TypeScript 버전이 5.1.3 미만이고 @types/react 버전이 18.2.8 미만일 경우, async Server Components와 관련된 타입 오류가 발생할 수 있습니다. 최신 버전으로 업데이트하거나 이 문제가 해결될 때까지 기다려야 합니다.

Final words

Server Components와 Next.js 앱 라우터와 함께 React Query를 사용하는 것은 서버 컴포넌트가 로더처럼 작동하고 하이드레이션이 발생하는 방식을 이해하는 것이 핵심입니다. 데이터를 미리 가져와서 HydrationBoundary로 하이드레이션하면 클라이언트에서 데이터 요청을 피할 수 있습니다. 최상의 성능과 사용자 경험을 위해 데이터는 컴포넌트 트리에서 가능한 한 빨리 미리 가져오고, 데이터가 이미 있는 경우 불필요한 클라이언트 사이드 재요청을 피하세요.