Server Rendering에서의 React query

밍글·2025년 3월 2일
4

FE스터디

목록 보기
7/8

지난 포스팅에서 useSuspenseQuery와 useQuery의 차이점, 그리고 Next.js에서 활용할 때 왜 바로 사용하면 안 되는지 등등을 배웠다. 그러면서 CSR적인 해결책만 내놨었는데 이번에는 Hydration API로 Server Rendering환경에서의 Next.js에서 어떻게 동작하고 사용할 수 있는지를 다뤄보도록 하겠다.


⛔ 주의사항
이 포스팅은 단순 사용에 초점을 두기보다는 흐름 및 설명을 두었으며 Next.js렌더링 동작 과정을 이해했다는 가정하에 작성을 한 것이다. 그러므로 Next.js의 렌더링 동작 과정을 모른다면 가볍게 알아보고 본다면 이해가 더 쉬울 것이다.

Server Rendering

먼저 아래의 방식들을 이해하려면 서버 렌더링에 대해 간단하게 알아야 한다. 서버 렌더링은 사용자가 페이지를 로드하는 즉시 볼 수 있는 초기 HTML을 서버에서 생성하는 행위이다. 이는 페이지 요청 시 즉시 발생할 수 있으며(SSR), 이전 요청이 캐시 되었거나 빌드 시간에 미리 생성(SSG)할 수도 있다.

클라이언트 렌더링의 경우 페이지가 나타나기까지 3번의 과정이 필요하다.

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

서버 렌더링의 경우엔 위의 과정을 아래와 같이 변환한다.

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

서버 렌더링을 통해 내용이 채워져 있고 data가 초기화되어있는 html을 생성하기 위해서는 마크업을 생성/렌더링 하기 전에 해당 데이터를 미리 가져와야(prefetch)하며, 데이터를 직렬화 가능한 형식으로 dehydrate시켜 마크업에 포함시키고 클라이언트에서는 react query 캐시로 해당 데이터를 hydrate하여 새로운 fetch를 클라이언트에서 추가적으로 할 필요가 없도록 해야 한다.

initialData

서버에서 pre-fetching 후 Props로 내려주어 useQuery 등 훅의 옵션인 initialData에 데이터를 넣어주는 방식이다.

해당 방법은 간단하긴 하지만 아래와 같은 한계점이 존재하여 대부분 아래의 Hydration을 활용한다. 한계점은 다음과 같다.

🔥 한계점

  • 더 깊은 컴포넌트에서 useQuery를 호출하는 경우 해당 지점까지 initialData를 전달해야 한다.
  • 여러 위치에서 동일한 쿼리를 호출하는 경우 그중 하나만 initialData를 전달하는 것은 앱이 변경될 때 문제가 발생할 수 있다. useQuery에 initialData를 가진 컴포넌트를 제거하거나 이동하는 경우 더 깊이 중첩된 useQuery는 더 이상 데이터가 없을 수도 있다. initialData를 필요로 하는 모든 쿼리에 전달하는 것은 번거롭다.
  • 서버에서 쿼리가 언제 가져온 것인지 알 수 없기 때문에 dataUpdatedAt과 쿼리를 다시 가져와야 하는지 결정하는 방법은 페이지가 로드된 시점을 기준으로 한다.
  • 쿼리에 대해 이미 캐시에 데이터가 있는 경우 새로운 데이터가 이전 데이터보다 최신이라도 initialData는 이를 덮어쓰지 않는다. (getServerSideProps가 매번 호출되어 새 데이터를 가져오지만, initialData 옵션을 사용하기 때문에 클라이언트 캐시와 데이터는 절대 업데이트되지 않는다.)

Hydration API

React Query에서는 dehydrate와 hydrate 함수를 제공하여 이 과정을 간소화한다.

Hydrate?

hydrate는 클라이언트 측에서 직렬화된 상태를 받아 이를 React Query의 상태로 변환한다. 서버에서 미리 가져온 데이터를 클라이언트의 쿼리 캐시에 적용하여, 네트워크 요청 없이 데이터를 사용할 수 있게한다.

Dehydrate?

dehydrate는 서버에서 불러온 데이터를 클라이언트로 전송하기 위한 직렬화 과정을 말하며, HydrationBoundary는 이러한 직렬화된 데이터를 클라이언트에서 사용할 수 있도록 하기 위한 맥락에서 사용한다.

useQuery를 통해 데이터를 불러오는 과정에서 HydrationBoundary 내에 의해 직렬화되었던 데이터가 활용되며, 이러한 방식을 통해 데이터를 효율적으로 관리하고 네트워크 요청을 줄일 수 있다. 이는 Next.js의 서버사이드 렌더링(SSR) 및 정적 사이트 생성(SSG)의 이점과 결합되어, 보다 빠른 페이지 로드 속도와 개선된 사용자 경험을 제공한다.

Prefetch

특정 데이터가 필요하거나 필요할 것으로 예상될 때, prefetching을 사용하여 미리 그 데이터를 캐시에 저장할 수 있다. 다양한 prefetching 패턴이 있다.

  1. 이벤트 핸들러에서
  2. 컴포넌트 내에서
  3. 라우터 통합을 통해
  4. 서버 렌더링 중

4번은 후술할 목차에서 다룰 것이다. prefetching의 한 가지 특별한 용도는 요청 폭포(Request Waterfalls)를 방지하는 것이다.

✨ 요청 폭포?
요청 폭포란 데이터 가져오기가 순차적으로 발생하여 성능 저하를 일으키는 상황이다.

// 요청 폭포가 발생하는 컴포넌트 구조
function BlogPage({ postId }) {
  // 포스트 데이터를 가져옴 - 첫 번째 요청
  const { data: post, isLoading: postLoading } = useQuery({
    queryKey: ['post', postId],
    queryFn: () => fetchPost(postId)
  });
  if (postLoading) return <Loading />;
return (
    <div>
      <PostDetails post={post} />
      {/* 포스트가 로드된 후에만 Comments 컴포넌트가 렌더링됨 */}
      <Comments postId={postId} />
    </div>
  );
}
function Comments({ postId }) {
  // 포스트 렌더링 후 두 번째 요청이 시작됨
  const { data: comments, isLoading } = useQuery({
    queryKey: ['comments', postId],
    queryFn: () => fetchComments(postId)
  });
if (isLoading) return <Loading />;
return (
    <div>
      {comments.map(comment => (
        <CommentItem key={comment.id} comment={comment} />
      ))}
    </div>
  );
}

이런식으로 순차적 요청을 해버리면 총 로딩 시간은 포스트 로딩 시간 + 댓글 로딩 시간이기 때문에 성능이 저하된다.

prefetchQuery

useQuery 등으로 필요하거나 렌더링되기 전에 쿼리를 미리 가져오는 데 사용할 수 있는 비동기 메서드이다.

이러기 때문에 뒤에 후술하듯이 Next.js에서 SSR을 사용할 때 데이터를 미리 가져오는 메서드로 사용하며, 서버에서 데이터를 미리 생성한 후 이후의 fetch에서는 이미 완성된 초기 데이터를 사용한다. 초기 페이지 로드 시 필요한 데이터를 미리 서버에서 불러와 클라이언트에 전달하는 것이다.

기본 사항

  • 기본적으로 이 함수들은 queryClient에 구성된 기본 staleTime을 사용하여 캐시의 기존 데이터가 신선한지 또는 다시 가져와야 하는지 결정한다.
  • 여기서의 staleTime은 prefetch에만 사용되므로, useQuery 호출에도 설정해야 한다.
  • 팁 : 서버에서 prefetching을 하는 경우, 각 prefetch 호출에 특정 staleTime을 전달하지 않아도 되도록 해당 queryClient에 대해 0보다 높은 기본 staleTime을 설정하는 것이 좋다.
  • prefetch된 쿼리에 대한 useQuery 인스턴스가 없으면, gcTime에 지정된 시간 후에 삭제되고 가비지 컬렉션이 된다.
  • 또한 CSR 한정으로 Suspense와 함께 사용이 가능하다. 데이터를 로드하지만 컴포넌트를 중단시키지 않기 때문이다.

fetchQuery와는 어떤 차이점이 있을까?

사실상 기능은 동일하다. 다만 차이가 있다면 에러를 포함한 결과값, 데이터를 미리 가져오느냐 아니냐의 차이가 있다.

fetchQuery는 쿼리를 가져오고 캐싱하는 데 사용할 수 있는 비동기 메서드이다.

fetchQueryprefetchQuery와 달리 실패할 경우 에러를 던지며 결과값에 대한 return을 할 수 있다. 이는 서버에서 받은 queryData를 통해 jotai의 hydrateAtom 초기화나 SSR에서의 error boundary를 이용할 때 유용하다. fetchQuery는 실패한 경우에도 에러를 던질 수 있어 더 유연한 사용이 가능하다.

useQuery와 옵션은 대부분 비슷하지만 해당 부분들은 제외된다.

제외되는 부분 → 이들은 useQuery와 useInfiniteQuery에만 사용된다.
enabled, refetchInterval, refetchIntervalInBackground, refetchOnWindowFocus, refetchOnReconnect, refetchOnMount, notifyOnChangeProps, throwOnError, select, suspense, placeholderData

반면 prefetchQuery는 공식문서의 설명에서도 fetchQuery와는 다르게 여기서만 렌더링되기 전에 미리 가져온다는 설명을 볼 수 있다. prefetchQuery를 통해 가져오는 쿼리에 대한 데이터가 이미 캐싱 되어 있으면 데이터를 가져오지 않는다. prefetchQuery는 항상 성공한 쿼리만 dehydrate를 해준다. 반환값을 보면 Promise<TData>가 아닌 Promise<void>인 것을 보면 알 수 있다. 그러기 때문에 결과가 필요 없이 쿼리만 가져오고 싶다면 이 메서드를 사용해야 한다.

Server Rendering & Hydration

활용예시

필자의 경우에는 useQuery API를 사용하였는데 공식문서 말로는 모든 쿼리를 항상 prefetch하는 한 useSuspenseQuery로 대체하는 것도 가능하다고 한다. useSuspenseQuery를 사용할 때 쿼리를 프리페치하는 것을 잊으면, 결과는 사용 중인 프레임워크에 따라 달라진다. 일부 경우, 데이터는 서버에서 Suspend되어 가져와지지만 클라이언트로 하이드레이션되지 않고, 클라이언트에서 다시 가져오게 된다. 이런 경우 서버와 클라이언트가 서로 다른 것을 렌더링하려고 했기 때문에 마크업 하이드레이션 불일치가 발생한다.

그러므로 웬만하면 useQuery API로 사용해보자

사용방법의 흐름은 다음과 같다.

  • 프레임워크 로더 함수에서 const queryClient = new QueryClient(options)를 생성한다.
  • 로더 함수에서 prefetch하려는 각 쿼리에 대해 await queryClient.prefetchQuery(...) 실행한다.
    • 가능한 경우 await Promise.all(...)을 사용하여 쿼리를 병렬로 가져오는 것이 좋다고 한다.
    • prefetch되지 않은 쿼리가 있어도 괜찮다. 이들은 서버 렌더링되지 않고, 대신 애플리케이션이 인터랙티브해진 후 클라이언트에서 가져와진다. 이는 사용자 상호작용 후에만 표시되는 콘텐츠나, 더 중요한 콘텐츠의 로딩을 차단하지 않기 위해 페이지 아래쪽에 있는 콘텐츠에 좋다.
  • 로더에서 dehydrate(queryClient)를 반환한다. 이를 반환하는 정확한 구문은 프레임워크마다 다르다.
  • 프레임워크 로더에서 가져온 dehydratedState를 사용하여 트리를 <HydrationBoundary state={dehydratedState}>로 감싼다. dehydratedState를 얻는 방법도 프레임워크마다 다르다.
    • 이는 각 라우트마다 수행하거나, 보일러플레이트를 줄이기 위해 애플리케이션 최상단에서 수행할 수 있다.

대학생 때 활용했던 코드는 다음과 같았다.

export default function Index({ dehydratedState }: { dehydratedState: DehydratedState }) {
  const router = useRouter();
  const hostNickname = decodeURIComponent(router.query.name as string);
  const hostSuffixArray = useGetSuffixArray(hostNickname) as string[];
  
  return (
    <HydrationBoundary state={dehydratedState}>
      <main className="bg--layout">
        <div className="flex flex-col justify-center p-7 mb-20">
        {// ... 생략}
        </div>
        <Footer />
      </main>
    </HydrationBoundary>
  );
}

// 통계 데이터 표시 컴포넌트
function StatisticsContent({ hostNickname, hostSuffixArray }) {
  const resetInfo = useUserStore.use.resetInfo();
  const router = useRouter();
  const { data, error, isLoading } = useQuery({
    queryKey: ["host-stats"],
    queryFn: useGetStatistic,
  });
  
  // 에러 처리는 useEffect으로 분리하였다.
  useEffect(() => {
    if (error) {
    // ... 생략
    }
  }, [error]);
  
  if (isLoading) return <ProgressCompo />;
  
  return data?.data ? (
    <WhiteBox className="font-Neo" isStatistic>
      <StatisticForm
        data={data.data}
        hostNickname={hostNickname}
        hostSuffixArray={hostSuffixArray}
      />
    </WhiteBox>
  ) : null;
}

export async function getServerSideProps(context) {
  const { name } = context.params;
  const queryClient = new QueryClient();

  try {
  await queryClient.prefetchQuery({
    queryKey: ["host-stats"],
    queryFn: useGetStatistic,
  });

    return {
      props: {
        dehydratedState: dehydrate(queryClient),
      },
    };
  } catch (error) {
    // 서버 측 에러 처리
  }
}

공식문서에 따르면 흥미로운 세부 사항은 실제로 세 개의 queryClient가 관련된다는 것이다. 프레임워크 로더는 렌더링 전에 발생하는 일종의 "프리로딩" 단계이며, 이 단계에는 프리페칭을 수행하는 자체 queryClient가 있다. 이 단계의 dehydrate된 결과는 서버 렌더링 프로세스와 클라이언트 렌더링 프로세스 모두에 전달되며, 각각은 자체 queryClient를 가진다. 이는 둘 다 동일한 데이터로 시작하여 동일한 마크업을 반환할 수 있도록 보장한다.

이…이게 무슨 소리지? 싶어서 나름 혼자서 정리를 해보았다.

이걸 좀 쉽게 풀어보자

SSR 프레임워크에서 React Query를 사용할 때 실제로 세 단계의 처리 과정과 세 개의 queryClient가 존재한다.

  1. 프리로딩 단계(첫 번째 queryClient)

    [사용자 요청][서버][로더 함수 실행][프리로딩용 queryClient 생성][prefetchQuery로 데이터 로드][dehydrate 실행]

    필요한 데이터를 미리 가져오기 위하여 페이지 렌더링 전에 실행된다. 이를 통해 가져온 데이터의 스냅샷을 생성한다.(dehydratedState)

  2. 서버 렌더링 단계(두 번째 queryClient)

    [dehydratedState][새 queryClient 생성][HydrationBoundary로 감싸기][React 컴포넌트 렌더링][HTML 생성]

    브라우저로 보낼 초기 HTML 만들기 위해 실제 HTML을 생성하는 단계이다. 첫 번쨰 단계에서 가져온 데이터를 재사용하므로 refetch를 하지 않는다.

  3. 클라이언트 하이드레이션 단계(세 번째 queryClient)

    [브라우저가 HTML 받음][dehydratedState 추출][새 queryClient 생성][HydrationBoundary로 감싸기][React 활성화]

    정적 HTML을 인터랙티브 앱으로 전환하기 위해 브라우저에서 실행된다. 서버에서 가져온 동일한 데이터로 시작하며 이는 서버와 클라이언트가 일치한다.

이를 통해서 다음과 같은 특징들을 가질 수 있다.

  1. 동일한 데이터 보장 : 서버와 클라이언트가 동일한 데이터로 렌더링되어 hydration 오류를 방지한다.
  2. 성능 최적화 : 클라이언트에서 불필요한 재요청을 방지한다.
  3. 단계 분리 : 각 단계를 명확히 분리한다.

쉽게 말해, 서버에서 데이터를 한 번 가져와서 그 "결과물"을 서버 렌더링과 클라이언트 초기화 모두에 재사용하는 구조라고 보면 된다.

에러 처리를 하고 싶다면?

React Query는 기본적으로 graceful degradation전략을 사용하는데 이는 다음을 의미한다.

  1. queryClient.prefetchQuery(...)는 절대 에러를 발생시키지 않는다.
  2. dehydrate(...)는 실패한 쿼리를 제외하고 성공한 쿼리만 포함한다.
    이로 인해 실패한 쿼리는 클라이언트에서 재시도되며, 서버 렌더링 결과에는 전체 콘텐츠 대신 로딩 상태가 포함된다.

하지만 중요한 콘텐츠가 누락된 경우 상황에 따라 404나 500 상태 코드로 응답하고 싶을 수도 있다. 이런 경우에는 queryClient.fetchQuery(...)를 대신 사용해야 한다. 이 방법은 실패할 때 에러를 발생시켜 적절한 방식으로 처리할 수 있게 해주기 때문이다. 예시는 다음과 같이 작성할 수 있다.

// SSR에서의 예시
export async function getServerSideProps() {
  const queryClient = new QueryClient();
  
  try {
    // 이 데이터는 반드시 있어야 함 - 없으면 404 응답
    await queryClient.fetchQuery({
      queryKey: ['critical-data'],
      queryFn: fetchCriticalData
    });
    
    return {
      props: {
        dehydratedState: dehydrate(queryClient)
      }
    };
  } catch (error) {
    // 중요 데이터 로드 실패 시 404 반환
    return {
      notFound: true // 404 페이지 렌더링
    };
  }
}

어떤 이유로 재시도를 피하기 위해 실패한 쿼리를 디하이드레이트된 상태에 포함시키고 싶다면, shouldDehydrateQuery 옵션을 사용하여 기본 함수를 재정의하고 자체 로직을 구현할 수 있다. 아래는 공식문서 코드이다.

dehydrate(queryClient, {
  shouldDehydrateQuery: (query) => {
    // This will include all queries, including failed ones,
    // but you can also implement your own logic by inspecting `query`
    return true
  },
})

참고자료

Prefetching & Router Integration | TanStack Query React Docs

Server Rendering & Hydration | TanStack Query React Docs

서버에서 React Query prefetching 한 데이터 사용하기

SSR에서 React-Query 사용하기

Next.js에서 fetch와 tanstack-query 효율적으로 사용하기

profile
예비 초보 개발자의 기록일지

0개의 댓글

관련 채용 정보