React

[React] React Query 알아보기 03 - React Query with SSR

joseph0926 2024. 11. 24. 09:59

React 앱의 전통적인 방식과 변화

기본적으로 React는 SPA입니다.

이 말은 즉 초기에 HTML 껍데기와 js 번들을 제공하고 이후에는 js를 이용하여 컨텐츠를 렌더링하는 방식을 사용한다는 뜻입니다.

하지만 최근들어 NextJs가 많은 인기를 얻고, 다시 MPA에 대한 관심이 올라갔습니다.

이러한 변화에 맞게 리액트 쿼리를 서버에서 사용하는 방법과 그 이점을 알아보려합니다.

 

Server Components에서 React Query의 존재 의미

서버 컴포넌트는 기본적으로 컴포넌트 자체에서 데이터를 가져오는 기능을 제공합니다.

export default async function App() {
  const { data } = await fetchData();

  // ...
}

 

여기서 생기는 의문은 Server Components가 컴포넌트 자체에서 데이터를 가져오는 기능을 제공한다면, React Query는 어떤 이점을 제공할까요?

 

앞선 블로그 글에서도 언급하였지만, React Query는 단순한 데이터 페칭 라이브러리가 아닙니다.

React Query의 주요 이점은 화면에 표시되는 UI를 데이터베이스와 같은 외부 시스템과 동기화할 수 있다는 점입니다.

이를 통해 명시적인 사용자 상호작용 없이도 데이터가 업데이트됩니다.

 

결과적으로, 서버 렌더링의 빠른 초기 페이지 속도와 React Query가 제공하는 클라이언트의 UX를 결합하면 더욱 나은 경험을 제공할 수 있습니다.

 

React Query와 서버 사이드 렌더링

1. QueryClient 생성 및 관리

 

React Query를 사용하려면 먼저 `QueryClient` 인스턴스를 생성하고 이를 `QueryClientProvider` 컴포넌트에 전달해야 합니다.  

단, 이때 중요한 점은 각 사용자와 요청 간 데이터를 공유하지 않도록 QueryClient를 컴포넌트 내부에서 생성해야 한다는 것입니다.

 

이는 기존에 배운 내용과 매우 반대되는 내용입니다.

원래 `QueryClient`는 데이터를 공유하기 위해 컴포넌트 트리 밖에 정의하였습니다.

하지만 SSR 환경에서는 반대입니다.

 

SSR 환경에서 QueryClient를 컴포넌트 내부에서 생성해야 하는 이유

 

SSR 환경에서는 여러 사용자의 요청이 동시에 처리되기 때문에, QueryClient를 컴포넌트 외부에서 생성하고 공유하면 다음과 같은 문제가 발생할 수 있습니다

 

- 데이터 섞임: 하나의 QueryClient 인스턴스가 여러 요청을 동시에 처리하면서 데이터가 섞일 수 있습니다.

- 보안 문제: 한 사용자의 데이터가 다른 사용자에게 노출될 수 있습니다.

따라서, 각 요청마다 독립적인 QueryClient 인스턴스를 생성하여 이러한 문제를 방지해야 합니다.

 

대신 `useRef` 훅을 사용하여 QueryClient를 한 번만 생성하고, 컴포넌트가 재렌더링될 때마다 동일한 인스턴스를 재사용하도록 합니다.

'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

export default function Providers({ children }) {
  const queryClientRef = React.useRef();

  if (!queryClientRef.current) {
    queryClientRef.current = new QueryClient();
  }

  return (
    <QueryClientProvider client={queryClientRef.current}>
      {children}
    </QueryClientProvider>
  );
}

 

2. 앱의 루트 구성

 

다음으로, 프로젝트의 Root에서 앱 전체를 `Providers`로 감쌉니다.

import Providers from './providers';

export default function RootLayout({ children }) {
  return (
    <html lang='ko'>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

 

3. 서버에서 데이터를 클라이언트로 전달하기

 

서버에서 데이터를 가져와 클라이언트로 전달하는 가장 간단한 방법은 React Query의 initialData 옵션을 사용하는 것입니다.

import { fetchRepoData } from './api';
import Repo from './Repo';

export default async function Home() {
  const data = await fetchRepoData();

  return (
    <main>
      <Repo initialData={data} />
    </main>
  );
}

 

이렇게 서버에서 전달된 `initialData`는 클라이언트 컴포넌트에서 Prop으로 받아 `useQuery` 훅을 통해 캐시에 저장됩니다.

'use client';

import { useQuery } from '@tanstack/react-query';
import { fetchRepoData } from './api';

export default function Repo({ initialData }) {
  const { data } = useQuery({
    queryKey: ['repoData'],
    queryFn: fetchRepoData,
    staleTime: 10 * 1000,
    initialData,
  });

  return (
    <>
      <h1>{data.name}</h1>
      <p>{data.description}</p>
      <strong>👀 {data.subscribers_count}</strong>{' '}
      <strong>✨ {data.stargazers_count}</strong>{' '}
      <strong>🍴 {data.forks_count}</strong>
    </>
  );
}

 

initialData의 한계

React Query에서 `initialData`는 정적 사이트 생성(SSG) 환경에서 유용하게 동작하지만, 서버에서 요청마다 동적으로 페이지를 렌더링해야 하는 경우에는 문제가 발생합니다.  

`initialData`는 QueryCache 항목이 처음 생성될 때만 적용되기 때문에, 동적 페이지(매 요청마다 다른 데이터)를 렌더링할 때는 `initialData`로 인해 캐시에 초기 데이터만 설정되고, 이후의 데이터 변경 사항을 반영하지 못합니다.

 

이는 서버 측에서 데이터를 가져와 `initialData`로 클라이언트에 전달하면, 클라이언트에서는 해당 데이터가 이미 캐시에 존재한다고 판단하여 추가적인 데이터 페칭을 하지 않기 때문입니다. 그러나 실제로는 각 요청마다 다른 데이터를 받아와야 하므로, 데이터의 불일치가 발생할 수 있습니다.

 

예를 들어, `useState`의 동작과 유사하게 생각할 수 있습니다

function Profile({ initialUser }) {
  const [user, setUser] = useState(initialUser);

  // ...
}

 

- 초기 렌더링 시 `user`는 `initialUser`로 설정됩니다.

- 하지만 `initialUser` Prop이 변경되고 컴포넌트가 다시 렌더링되더라도 `user` 상태는 변경되지 않습니다. 이는 `initialUser`가 초기 값으로만 사용되기 때문입니다.

 

따라서 `initialData`를 사용하면 최초에 설정된 데이터만 사용하게 되어, 이후 데이터가 변경되거나 다른 데이터를 받아와야 하는 경우에 문제가 발생합니다.

 

해결 방법: `initialData` 대신 캐시를 활용하기

이 문제를 해결하기 위해 캐시를 직렬화하여 클라이언트로 전송하는 방법을 사용할 수 있습니다. 서버에서 데이터를 페칭한 후, 단순히 데이터를 전달하는 대신 React Query의 Query Cache 전체를 직렬화하여 클라이언트에 전달합니다.

 

이렇게 하면 각 요청마다 최신 데이터가 담긴 새로운 캐시를 생성하여, 클라이언트에서 해당 캐시를 복원(hydrate)할 수 있습니다. 이를 통해 `initialData`의 문제를 피하고 데이터의 일관성을 유지할 수 있습니다.

 

이 과정은 두 단계로 이루어집니다

 

1. 서버에서 캐시를 직렬화하여 전송하기

2. 클라이언트에서 직렬화된 캐시를 복원(hydrate)하기

 

React Query는 이를 쉽게 처리할 수 있는 두 가지 API를 제공합니다

 

- dehydrate: Query Client의 캐시를 직렬화합니다.

- HydrationBoundary: 클라이언트에서 직렬화된 캐시를 복원합니다.

 

1. QueryClient 생성 및 데이터 미리 가져오기(prefetching)

 

서버에서 데이터 페칭을 할 때, 새로운 QueryClient 인스턴스를 생성하고 모든 데이터를 이 QueryClient를 통해 미리 가져옵니다. 이렇게 하면 서버 측에서 Query Cache에 데이터가 저장되며, 이후에 이 캐시를 직렬화하여 클라이언트로 전달할 수 있습니다.

import { QueryClient } from '@tanstack/react-query';

export default async function Home() {
  const queryClient = new QueryClient();

  // 서버에서 데이터 미리 가져오기
  await queryClient.prefetchQuery({
    queryKey: ['repoData'],
    queryFn: fetchRepoData,
    staleTime: 10 * 1000,
  });

  // ...
}

 

- queryClient.prefetchQuery: 지정된 `queryKey`와 `queryFn`을 사용하여 데이터를 미리 가져오고, 그 결과를 Query Cache에 저장합니다.

 

2. 캐시 직렬화 및 복원

서버 측에서 dehydrate를 사용하여 Query Client의 캐시를 직렬화합니다. 이 직렬화된 캐시는 클라이언트로 전달되어야 합니다.

import {
  QueryClient,
  dehydrate,
  HydrationBoundary,
} from '@tanstack/react-query';
import { fetchRepoData } from './api';
import Repo from './Repo';

export default async function Home() {
  const queryClient = new QueryClient();

  // 서버에서 데이터 미리 가져오기
  await queryClient.prefetchQuery({
    queryKey: ['repoData'],
    queryFn: fetchRepoData,
    staleTime: 10 * 1000,
  });

  // Query Client의 캐시를 직렬화
  const dehydratedState = dehydrate(queryClient);

  return (
    <main>
      {/* 직렬화된 캐시를 HydrationBoundary를 통해 복원 */}
      <HydrationBoundary state={dehydratedState}>
        <Repo />
      </HydrationBoundary>
    </main>
  );
}

 

- dehydrate(queryClient): Query Client의 현재 캐시 상태를 직렬화 가능한 형태로 변환합니다. 이 데이터는 JSON으로 표현될 수 있으며, 클라이언트로 안전하게 전달할 수 있습니다.

- HydrationBoundary: 클라이언트 측에서 직렬화된 캐시를 복원하여 Query Client에 적용합니다. 이를 통해 클라이언트는 서버에서 미리 가져온 데이터를 즉시 사용할 수 있습니다.

 

클라이언트에서의 동작

 

클라이언트 측에서는 `HydrationBoundary`를 통해 전달된 `dehydratedState`를 사용하여 Query Client의 캐시를 복원합니다. 이렇게 하면 `Repo` 컴포넌트는 이미 캐시에 저장된 데이터를 사용하게 되며, 추가적인 데이터 페칭 없이도 최신 데이터를 렌더링할 수 있습니다.

 

React Query와 Streaming

React의 스트리밍 서버 렌더링 및 부분 하이드레이션(Partial Hydration)

과거에 React에서 서버 사이드 렌더링(SSR)을 사용할 때는 React 트리 전체를 HTML로 직렬화(serialize)한 다음 클라이언트로 보내서 클라이언트에서 하이드레이션(hydration)을 수행해야 했습니다.

 

React 18부터는 스트리밍 서버 렌더링(Streamed Server Rendering)부분 하이드레이션(Partial Hydration)을 지원합니다.  

이는 React가 서버에서 HTML을 일부만 생성하고, 나머지 부분을 준비가 되는 대로 클라이언트로 스트리밍할 수 있다는 것을 의미합니다.

 

스트리밍과 부분 하이드레이션의 이점

- 빠른 렌더링: 클라이언트는 전체 React 트리가 준비될 때까지 기다리지 않고, 준비된 부분부터 렌더링과 하이드레이션을 시작할 수 있습니다.

- 조기 상호작용: 사용자는 페이지의 일부 콘텐츠를 더 빨리 볼 수 있고, 준비된 UI와 상호작용할 수 있습니다.

 

React Query와 스트리밍 서버 렌더링

기존에 리액트 쿼리 로직은 아래와 같습니다.

import {
  QueryClient,
  dehydrate,
  HydrationBoundary,
} from '@tanstack/react-query';
import { fetchRepoData } from './api';
import Repo from './Repo';

export default async function Home() {
  const queryClient = new QueryClient();

  await queryClient.prefetchQuery({
    queryKey: ['repoData'],
    queryFn: fetchRepoData,
    staleTime: 10 * 1000,
  });

  return (
    <main>
      <HydrationBoundary state={dehydrate(queryClient)}>
        <Repo />
      </HydrationBoundary>
    </main>
  );
}

 

위 코드에서, 서버가 `prefetchQuery`를 기다리기 때문에 클라이언트는 전체 데이터가 준비될 때까지 아무것도 표시하지 않습니다.

 

스트리밍을 활용한 개선된 접근 방식

스트리밍을 활용하면, 준비된 부분은 즉시 렌더링하고 나머지는 준비가 되는 대로 스트리밍합니다

 

1. 서버에서 `prefetchQuery` 기다리지 않기

서버 컴포넌트에서 `await`를 사용하지 않고 `prefetchQuery`를 기다리지 않고, 비동기로 실행합니다. 이렇게 하면 데이터 페칭이 진행되는 동안 다른 UI를 먼저 렌더링할 수 있습니다.

export default async function Home() {
  const queryClient = new QueryClient();

  // 기다리지 않고 데이터 페칭
  queryClient.prefetchQuery({
    queryKey: ['repoData'],
    queryFn: fetchRepoData,
    staleTime: 10 * 1000,
  });

  return (
    <main>
      <Navbar />
      <HydrationBoundary state={dehydrate(queryClient)}>
        <Repo />
      </HydrationBoundary>
      <Footer />
    </main>
  );
}

 

2. `Suspense` 경계 추가

 

- `Suspense` 컴포넌트를 사용하여, 데이터가 준비되지 않은 동안 표시할 `fallback` UI를 제공합니다.

- 예를 들어, `RepoSkeleton` 컴포넌트를 `fallback`으로 설정하여 데이터가 로드되기 전까지 플레이스홀더를 표시할 수 있습니다.

export default async function Home() {
  const queryClient = new QueryClient();

  queryClient.prefetchQuery({
    queryKey: ['repoData'],
    queryFn: fetchRepoData,
    staleTime: 10 * 1000,
  });

  return (
    <main>
      <Navbar />
      <HydrationBoundary state={dehydrate(queryClient)}>
        <React.Suspense fallback={<RepoSkeleton />}>
          <Repo />
        </React.Suspense
      </HydrationBoundary>
      <Footer />
    </main>
  );
}

 

3. 대기 중인 쿼리를 클라이언트로 전송 허용

- 기본적으로 React Query는 성공 상태(`success`)인 쿼리만 직렬화(`dehydrate`)합니다.

- 이제 쿼리가 대기 상태(`pending`)에서도 직렬화되도록 `QueryClient`의 `defaultOptions`를 업데이트합니다.

import {
  QueryClient,
  dehydrate,
  defaultShouldDehydrateQuery,
} from '@tanstack/react-query';

export default async function Home() {
  const queryClient = new QueryClient({
    defaultOptions: {
      dehydrate: {
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === 'pending',
      },
    },
  });

  queryClient.prefetchQuery({
    queryKey: ['repoData'],
    queryFn: fetchRepoData,
    staleTime: 10 * 1000,
  });

  return (
    <main>
      <Navbar />
      <HydrationBoundary state={dehydrate(queryClient)}>
        <React.Suspense fallback={<RepoSkeleton />}>
          <Repo />
        </React.Suspense>
      </HydrationBoundary>
      <Footer />
    </main>
  );
}

 

결과

- `Navbar`, `Footer`, `RepoSkeleton`은 즉시 렌더링됩니다.

- `Repo` 데이터가 준비되면 React는 `RepoSkeleton`을 실제 데이터로 대체합니다.

- 데이터는 클라이언트의 QueryCache에 저장되어 React Query가 이를 최신 상태로 유지합니다.

 

TIP: Next.js 전용 실험적 플러그인

React Query는 Next.js 전용 플러그인인 react-query-next-experimental을 제공합니다.

 

사용 방법

1. `ReactQueryStreamedHydration`를 `QueryClientProvider`의 자식으로 렌더링합니다.

'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental';

export default function Providers({ children }) {
  const queryClientRef = React.useRef();

  if (!queryClientRef.current) {
    queryClientRef.current = new QueryClient();
  }

  return (
    <QueryClientProvider client={queryClientRef.current}>
      <ReactQueryStreamedHydration>{children}</ReactQueryStreamedHydration>
    </QueryClientProvider>
  );
}

 

2. 클라이언트 컴포넌트에서 `useSuspenseQuery`를 사용합니다.

'use client';

import { useSuspenseQuery } from '@tanstack/react-query';
import { fetchRepoData } from './api';

export default function Repo() {
  const { data } = useSuspenseQuery({
    queryKey: ['repoData'],
    queryFn: fetchRepoData,
    staleTime: 10 * 1000,
  });

  return (
    <>
      <h1>{data.name}</h1>
      <p>{data.description}</p>
      <strong>👀 {data.subscribers_count}</strong>{' '}
      <strong>✨ {data.stargazers_count}</strong>{' '}
      <strong>🍴 {data.forks_count}</strong>
    </>
  );
}

 

플러그인을 사용하면 `HydrationBoundary`와 `dehydrate`를 직접 설정할 필요가 없습니다. 서버에서 페칭된 데이터는 클라이언트의 QueryCache로 자동으로 전송됩니다.