🔥 서버 렌더링과 하이드레이션

2817자
34분

이 가이드에서는 React Query와 서버 렌더링을 함께 사용하는 방법을 배웁니다.

프리페칭과 라우터 통합 가이드에서 몇 가지 배경 지식을 확인할 수 있습니다. 그 전에 성능과 요청 폭포 가이드를 확인하는 것도 좋습니다.

스트리밍, 서버 컴포넌트, 새로운 Next.js 앱 라우터와 같은 고급 서버 렌더링 패턴은 고급 서버 렌더링 가이드를 참조하세요.

코드만 보고 싶다면 아래의 전체 Next.js 페이지 라우터 예제 또는 전체 Remix 예제로 바로 건너뛸 수 있습니다.

서버 렌더링과 React Query

그렇다면 서버 렌더링이란 정확히 무엇일까요? 이 가이드의 나머지 부분에서는 여러분이 이 개념에 익숙하다고 가정하겠지만, React Query와 어떤 관련이 있는지 살펴보는 데 시간을 할애해 보겠습니다. 서버 렌더링이란 페이지가 로드되는 즉시 사용자에게 보여줄 수 있는 콘텐츠를 제공하기 위해 서버에서 초기 HTML을 생성하는 행위를 말합니다. 이는 페이지가 요청될 때 즉시 발생할 수 있습니다(SSR). 또한 이전 요청이 캐시되었거나 빌드 시점에 미리 생성되었기 때문에 발생할 수도 있습니다(SSG).

요청 폭포 가이드를 읽었다면 다음과 같은 내용을 기억하실 겁니다:

1. |-> 마크업 (콘텐츠 없음)
2.   |-> JS
3.     |-> 쿼리

text

클라이언트 렌더링 애플리케이션에서는 사용자에게 콘텐츠를 보여주기 전에 최소한 이 3번의 서버 왕복이 필요합니다. 서버 렌더링의 한 가지 관점은 위의 과정을 다음과 같이 바꾸는 것입니다:

1. |-> 마크업 (콘텐츠와 초기 데이터 포함)
2.   |-> JS

text

1. 이 완료되는 즉시 사용자는 콘텐츠를 볼 수 있고, 2. 가 완료되면 페이지는 상호작용이 가능하고 클릭할 수 있게 됩니다. 마크업에는 필요한 초기 데이터도 포함되어 있기 때문에 클라이언트에서는 3. 단계를 실행할 필요가 전혀 없습니다. 적어도 어떤 이유로 데이터를 다시 검증하고 싶을 때까지는 말이죠.

이는 모두 클라이언트 관점에서의 이야기입니다. 서버에서는 마크업을 생성/렌더링하기 전에 해당 데이터를 프리페치 해야 하고, 마크업에 포함시킬 수 있는 직렬화 가능한 형식으로 데이터를 탈수화(dehydrate) 해야 하며, 클라이언트에서는 클라이언트에서 새로운 페치를 피할 수 있도록 React Query 캐시에 해당 데이터를 수화(hydrate) 해야 합니다.

React Query로 이 세 단계를 어떻게 구현하는지 계속 읽어보세요.

Suspense에 대한 짧은 설명

이 가이드에서는 일반적인 useQuery API를 사용합니다. 권장하지는 않지만 항상 모든 쿼리를 프리페치한다는 전제 하에 useSuspenseQuery로 대체하는 것이 가능합니다. 장점은 클라이언트의 로딩 상태에 <Suspense>를 사용할 수 있다는 것입니다.

useSuspenseQuery를 사용할 때 쿼리를 프리페치하는 것을 잊어버리면 사용 중인 프레임워크에 따라 결과가 달라집니다. 경우에 따라 데이터가 서버에서 페치되고 일시 중단되지만 클라이언트에는 절대 수화되지 않아 다시 페치하게 됩니다. 이런 경우에는 서버와 클라이언트가 서로 다른 것을 렌더링하려고 했기 때문에 마크업 수화 불일치가 발생합니다.

초기 설정

React Query를 사용하는 첫 번째 단계는 항상 queryClient를 생성하고 애플리케이션을 <QueryClientProvider>로 감싸는 것입니다. 서버 렌더링을 수행할 때는 queryClient 인스턴스를 앱 내부의 React 상태(인스턴스 ref도 괜찮습니다)에서 생성하는 것이 중요합니다. 이렇게 하면 서로 다른 사용자와 요청 간에 데이터가 공유되지 않으면서도 컴포넌트 생명주기당 **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에서는 보통 0 이상의 기본 staleTime을 설정하여
            // 클라이언트에서 즉시 다시 페치하는 것을 피하고자 합니다
            staleTime: 60 * 1000,
          },
        },
      }),
  )
 
  return (
    <QueryClientProvider client={queryClient}>
      <Component {...pageProps} />
    </QueryClientProvider>
  )
}
 
typescript

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에서는 보통 0 이상의 기본 staleTime을 설정하여
            // 클라이언트에서 즉시 다시 페치하는 것을 피하고자 합니다
            staleTime: 60 * 1000,
          },
        },
      }),
  )
 
  return (
    <QueryClientProvider client={queryClient}>
      <Outlet />
    </QueryClientProvider>
  )
}
 
typescript

initialData로 빠르게 시작하기

가장 빠르게 시작하는 방법은 프리페칭과 관련하여 React Query를 전혀 사용하지 않고 탈수화/수화 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,
  })
 
  // ...
}
 
typescript

이것은 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,
  })
 
  // ...
}
 
typescript

설정은 최소한이며 일부 경우에는 이것이 빠른 해결책이 될 수 있지만, 전체 접근 방식과 비교할 때 고려해야 할 몇 가지 장단점이 있습니다:

  • 트리의 더 깊은 곳에 있는 컴포넌트에서 useQuery를 호출하는 경우 initialData를 해당 지점까지 전달해야 합니다.
  • 여러 위치에서 동일한 쿼리로 useQuery를 호출하는 경우 initialData를 한 곳에만 전달하는 것은 취약할 수 있으며 앱이 변경될 때 깨질 수 있습니다. initialData가 있는 useQuery를 제거하거나 이동하면 더 깊숙한 곳에 있는 useQuery에는 더 이상 데이터가 없을 수 있기 때문입니다. 모든 쿼리에 필요한 initialData를 전달하는 것도 번거로울 수 있습니다.
  • 서버에서 쿼리가 페치된 시점을 알 수 있는 방법이 없기 때문에 dataUpdatedAt과 쿼리를 다시 페치해야 하는지 여부는 페이지가 로드된 시점을 기준으로 판단합니다.
  • 쿼리에 대한 데이터가 이미 캐시에 있는 경우 새 데이터가 이전 데이터보다 더 최신이더라도 initialData는 절대 이 데이터를 덮어쓰지 않습니다.
    • 이것이 특히 나쁜 이유를 이해하려면 위의 getServerSideProps 예제를 고려해 보세요. 페이지를 여러 번 앞뒤로 이동하면 getServerSideProps가 매번 호출되어 새 데이터를 가져오지만 initialData 옵션을 사용하기 때문에 클라이언트 캐시와 데이터는 절대 업데이트되지 않습니다.

전체 수화 솔루션을 설정하는 것은 간단하며 이러한 단점이 없습니다. 이것이 문서의 나머지 부분에서 중점적으로 다룰 내용입니다.

하이드레이션 API 사용하기

약간의 추가 설정만으로도 프리로드 단계에서 queryClient를 사용하여 쿼리를 프리페치하고, queryClient의 직렬화된 버전을 앱의 렌더링 부분에 전달하여 거기에서 재사용할 수 있습니다. 이렇게 하면 위의 단점을 피할 수 있습니다. 전체 Next.js 페이지 라우터 및 Remix 예제로 건너뛰어도 좋지만, 일반적인 수준에서 다음과 같은 추가 단계가 필요합니다:

  • 프레임워크 로더 함수에서 const queryClient = new QueryClient(options)를 생성합니다.
  • 로더 함수에서 프리페치하려는 각 쿼리에 대해 await queryClient.prefetchQuery(...)를 수행합니다.
    • 가능한 경우 await Promise.all(...)을 사용하여 쿼리를 병렬로 페치하는 것이 좋습니다.
    • 프리페치되지 않는 쿼리가 있어도 괜찮습니다. 이러한 쿼리는 서버 렌더링되지 않고, 대신 애플리케이션이 대화형이 된 후 클라이언트에서 페치됩니다. 이는 사용자 상호작용 후에만 표시되거나 페이지 하단에 있어 더 중요한 콘텐츠를 차단하지 않는 콘텐츠에 적합할 수 있습니다.
  • 로더에서 dehydrate(queryClient)를 반환하는데, 프레임워크마다 이를 반환하는 정확한 문법은 다릅니다.
  • 프레임워크 로더에서 가져온 dehydratedState를 사용하여 트리를 <HydrationBoundary state={dehydratedState}>로 감쌉니다. dehydratedState를 가져오는 방법도 프레임워크마다 다릅니다.
    • 이는 각 경로에 대해 수행할 수 있으며, 상용구를 피하기 위해 애플리케이션의 맨 위에서 수행할 수도 있습니다. 예제를 참조하세요.

흥미로운 세부 사항은 실제로 세 개의 queryClient가 관련되어 있다는 것입니다. 프레임워크 로더는 렌더링 전에 발생하는 일종의 "프리로딩" 단계의 형태로, 이 단계에는 프리페칭을 수행하는 자체 queryClient가 있습니다. 이 단계의 탈수화된 결과는 서버 렌더링 프로세스와 클라이언트 렌더링 프로세스 모두에 전달되며, 각각 자체 queryClient를 가지고 있습니다. 이렇게 하면 둘 다 동일한 데이터로 시작하여 동일한 마크업을 반환할 수 있습니다.

서버 컴포넌트는 또 다른 형태의 "프리로딩" 단계로, React 컴포넌트 트리의 일부를 "프리로드"(사전 렌더링)할 수도 있습니다. 자세한 내용은 고급 서버 렌더링 가이드를 참조하세요.

전체 Next.js 페이지 라우터 예제

앱 라우터 문서는 고급 서버 렌더링 가이드를 참조하세요.

초기 설정:

// _app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
 
export default function MyApp({ Component, pageProps }) {
  const [queryClient] = React.useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            // SSR에서는 보통 0 이상의 기본 staleTime을 설정하여
            // 클라이언트에서 즉시 다시 페치하는 것을 피하고자 합니다
            staleTime: 60 * 1000,
          },
        },
      }),
  )
 
  return (
    <QueryClientProvider client={queryClient}>
      <Component {...pageProps} />
    </QueryClientProvider>
  )
}
 
typescript

각 경로에서:

// 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>
  )
}
 
typescript

전체 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에서는 보통 0 이상의 기본 staleTime을 설정하여
            // 클라이언트에서 즉시 다시 페치하는 것을 피하고자 합니다
            staleTime: 60 * 1000,
          },
        },
      }),
  )
 
  return (
    <QueryClientProvider client={queryClient}>
      <Outlet />
    </QueryClientProvider>
  )
}
 
typescript

각 경로에서는 중첩된 경로에서도 괜찮습니다:

// 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>
  )
}
 
typescript

상용구 줄이기 (선택사항)

모든 경로에서 이 부분이 많은 상용구처럼 보일 수 있습니다:

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

이 접근 방식에는 문제가 없지만, 이 상용구를 제거하고 싶다면 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() { ... }
 
typescript

Remix에서는 이것이 조금 더 복잡하므로, use-dehydrated-state 패키지를 확인해 보는 것이 좋습니다.

의존 쿼리 프리페칭

프리페칭 가이드에서는 의존 쿼리를 프리페치하는 방법을 배웠지만, 프레임워크 로더에서는 어떻게 해야 할까요? 의존 쿼리 가이드에서 가져온 다음 코드를 고려해 보세요:

// 사용자 가져오기
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,
})
 
typescript

이를 프리페치하여 서버 렌더링할 수 있게 하려면 어떻게 해야 할까요? 다음은 예제입니다:

// 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) } }
}
 
typescript

물론 이는 더 복잡해질 수 있지만, 이러한 로더 함수는 그저 자바스크립트이기 때문에 언어의 모든 기능을 사용하여 로직을 구축할 수 있습니다. 서버 렌더링하려는 모든 쿼리를 프리페치해야 합니다.

에러 처리

React Query는 기본적으로 우아한 기능 저하 전략을 사용합니다. 이는 다음을 의미합니다:

  • queryClient.prefetchQuery(...)는 절대 에러를 던지지 않습니다.
  • dehydrate(...)에는 실패한 쿼리가 아닌 성공한 쿼리만 포함됩니다.

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

좋은 기본값이긴 하지만 때로는 이것이 원하는 바가 아닐 수 있습니다. 중요한 콘텐츠가 누락된 경우 상황에 따라 404 또는 500 상태 코드로 응답하고 싶을 수 있습니다. 이러한 경우에는 실패 시 에러를 던지는 queryClient.fetchQuery(...)를 대신 사용하여 적절한 방식으로 처리할 수 있습니다.

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

다시 시도를 피하기 위해 실패한 쿼리를 탈수화된 상태에 포함하고 싶다면 shouldDehydrateQuery 옵션을 사용하여 기본 함수를 재정의하고 자체 로직을 구현할 수 있습니다:

dehydrate(queryClient, {
  shouldDehydrateQuery: (query) => {
    // 이렇게 하면 실패한 쿼리를 포함한 모든 쿼리가 포함되지만,
    // `query`를 검사하여 자체 로직을 구현할 수도 있습니다
    return true
  },
})
 
typescript

직렬화

Next.js에서 return { props: { dehydratedState: dehydrate(queryClient) } }를 하거나 Remix에서 return json({ dehydratedState: dehydrate(queryClient) })를 할 때 queryClientdehydratedState 표현이 프레임워크에 의해 직렬화되어 마크업에 포함되고 클라이언트로 전송될 수 있습니다.

기본적으로 이러한 프레임워크는 안전하게 직렬화/구문 분석할 수 있는 것들만 지원하므로 undefined, Error, Date, Map, Set, BigInt, Infinity, NaN, -0, 정규식 등을 지원하지 않습니다. 이는 쿼리에서 이러한 값을 반환할 수 없다는 것을 의미합니다. 이러한 값을 반환하는 것이 원하는 바라면 superjson 또는 유사한 패키지를 확인해 보세요.

사용자 정의 SSR 설정을 사용하는 경우 이 단계를 직접 처리해야 합니다. 처음에는 JSON.stringify(dehydratedState)를 사용하고 싶을 수 있지만, 이는 기본적으로 <script>alert('Oh no..')</script>와 같은 것을 이스케이프하지 않기 때문에 애플리케이션에 XSS 취약점을 쉽게 노출시킬 수 있습니다. superjson도 값을 이스케이프하지 않으며 사용자 정의 SSR 설정에서 단독으로 사용하기에는 안전하지 않습니다(출력을 이스케이프하는 추가 단계를 추가하지 않는 한). 대신 Serialize JavaScript 또는 devalue와 같은 라이브러리를 사용하는 것이 좋습니다. 이 라이브러리들은 기본적으로 XSS 주입에 안전합니다.

요청 폭포에 대한 참고사항

성능과 요청 폭포 가이드에서 서버 렌더링이 더 복잡한 중첩된 폭포 중 하나를 어떻게 변경하는지 다시 살펴보겠다고 언급했습니다. 특정 코드 예제로 돌아가 보겠지만, 다시 한 번 상기시키자면 <Feed> 컴포넌트 내부에 코드 분할된 <GraphFeedItem> 컴포넌트가 있습니다. 이는 피드에 그래프 항목이 포함된 경우에만 렌더링되며 이 두 컴포넌트는 각자 자체 데이터를 페치합니다. 클라이언트 렌더링을 사용하면 다음과 같은 요청 폭포가 발생합니다:

1. |> 마크업 (콘텐츠 없음)
2.   |> <Feed>용 JS
3.     |> getFeed()
4.       |> <GraphFeedItem>용 JS
5.         |> getGraphDataById()
text

서버 렌더링의 장점은 위의 내용을 다음과 같이 바꿀 수 있다는 것입니다:

1. |> 마크업 (콘텐츠와 초기 데이터 포함)
2.   |> <Feed>용 JS
2.   |> <GraphFeedItem>용 JS
text

쿼리가 더 이상 클라이언트에서 페치되지 않고 대신 해당 데이터가 마크업에 포함되었다는 점에 주목하세요. 이제 JS를 병렬로 로드할 수 있는 이유는 <GraphFeedItem>이 서버에서 렌더링되었기 때문에 클라이언트에서도 이 JS가 필요할 것임을 알고 있고, 마크업에 해당 청크에 대한 스크립트 태그를 삽입할 수 있기 때문입니다. 서버에서는 여전히 다음과 같은 요청 폭포가 있을 것입니다:

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

피드를 페치하기 전에는 그래프 데이터도 페치해야 하는지 알 수 없습니다. 이들은 의존 쿼리입니다. 이는 일반적으로 지연 시간이 낮고 더 안정적인 서버에서 발생하기 때문에 큰 문제가 되지 않는 경우가 많습니다.

놀랍게도 폭포가 대부분 평탄화되었습니다! 하지만 한 가지 문제가 있습니다. 이 페이지를 /feed 페이지라고 부르고, /posts와 같은 다른 페이지도 있다고 가정해 봅시다. URL 표시줄에 www.example.com/feed를 직접 입력하고 엔터를 누르면 이러한 훌륭한 서버 렌더링의 이점을 모두 얻을 수 있지만, 대신 www.example.com/posts를 입력한 다음 /feed로 가는 링크를 클릭하면 다음과 같이 다시 돌아갑니다:

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

SPA에서는 서버 렌더링이 초기 페이지 로드에만 적용되고 이후의 탐색에는 적용되지 않기 때문입니다.

최신 프레임워크는 종종 초기 코드와 데이터를 병렬로 페치하여 이 문제를 해결하려고 합니다. 따라서 Next.js 또는 Remix를 이 가이드에서 설명한 프리페칭 패턴과 함께 사용하면서 의존 쿼리를 프리페치하는 방법을 포함한다면 실제로는 다음과 같이 보일 것입니다:

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

이것이 훨씬 더 낫지만, 이를 더 개선하려면 서버 컴포넌트로 단일 왕복으로 평탄화할 수 있습니다. 고급 서버 렌더링 가이드에서 방법을 알아보세요.

팁, 트릭 및 주의사항

쿼리의 최신 여부는 서버에서 페치된 시점부터 측정됩니다

쿼리는 dataUpdatedAt에 따라 최신 여부를 판단합니다. 여기서 주의할 점은 이것이 제대로 작동하려면 서버에 정확한 시간이 있어야 한다는 것이지만, UTC 시간이 사용되므로 시간대는 고려되지 않습니다.

staleTime의 기본값은 0이므로 페이지 로드 시 쿼리는 기본적으로 백그라운드에서 다시 페치됩니다. 특히 마크업을 캐시하지 않는 경우 이 이중 페치를 피하기 위해 더 높은 staleTime을 사용할 수 있습니다.

오래된 쿼리의 이 재페치는 CDN에서 마크업을 캐시할 때 완벽하게 일치합니다! 페이지 자체의 캐시 시간을 상당히 높게 설정하여 서버에서 페이지를 다시 렌더링하는 것을 피할 수 있지만, 쿼리의 staleTime을 더 낮게 구성하여 사용자가 페이지를 방문하는 즉시 데이터가 백그라운드에서 다시 페치되도록 할 수 있습니다. 페이지는 1주일 동안 캐시하고 싶지만, 페이지 로드 시 데이터가 1일 이상 오래된 경우 자동으로 다시 페치하고 싶을 수 있습니다.

서버의 높은 메모리 사용량

모든 요청마다 QueryClient를 생성하는 경우 React Query는 이 클라이언트에 대해 격리된 캐시를 생성하며, 이는 gcTime 기간 동안 메모리에 보존됩니다. 이로 인해 해당 기간 동안 요청 수가 많은 경우 서버의 메모리 사용량이 높아질 수 있습니다.

서버에서 gcTime의 기본값은 Infinity이며, 이는 수동 가비지 컬렉션을 비활성화하고 요청이 완료되면 자동으로 메모리를 비웁니다. Infinity가 아닌 gcTime을 명시적으로 설정하는 경우 조기에 캐시를 비우는 것은 사용자의 책임입니다.

gcTime을 0으로 설정하는 것은 수화 오류를 발생시킬 수 있으므로 피하세요. 이는 수화 경계가 렌더링에 필요한 데이터를 캐시에 배치하지만, 가비지 컬렉터가 렌더링이 완료되기 전에 데이터를 제거하면 문제가 발생할 수 있기 때문입니다. 더 짧은 gcTime이 필요한 경우 앱이 데이터를 참조할 충분한 시간을 제공하기 위해 2 * 1000으로 설정하는 것이 좋습니다.

요청이 처리되고 탈수화된 상태가 클라이언트로 전송된 후 캐시를 비우고 메모리 사용량을 줄이기 위해 queryClient.clear() 호출을 추가할 수 있습니다.

또는 더 작은 gcTime을 설정할 수 있습니다.

Next.js 재작성에 대한 주의사항

Next.js의 재작성 기능자동 정적 최적화 또는 getStaticProps를 함께 사용하는 경우 문제가 있습니다. 이로 인해 React Query에서 두 번째 수화가 발생합니다. 그 이유는 Next.js가 클라이언트에서 재작성을 구문 분석해야 하며 수화 후에 모든 파라미터를 수집하여 router.query에서 제공할 수 있도록 해야 하기 때문입니다.

그 결과 수화 데이터의 모든 참조 동등성이 누락되어, 예를 들어 데이터가 컴포넌트의 속성으로 사용되거나 useEffect/useMemo의 의존성 배열에 사용되는 곳에서 트리거됩니다.