React Query를 Suspense, SSR, hydration 관점에서 보면 무엇이 달라질까

Frontend

React Query를 어느 정도 써본 뒤에는 단순 useQuery 사용법보다 더 큰 질문이 생깁니다.

  • 첫 화면 로딩을 더 좋게 만들 수는 없을까?
  • 서버에서 미리 가져온 데이터를 클라이언트 캐시와 자연스럽게 연결할 수 있을까?
  • Suspense를 쓰면 코드와 UX가 어떻게 달라질까?

이 질문은 결국 같은 주제로 모입니다. 데이터를 언제, 어디서, 어떤 형태로 준비해서 사용자에게 보여줄 것인가입니다.

React Query는 원래 클라이언트 캐시 도구로 많이 알려졌지만, 실제로는 SSR, hydration, Suspense와 만나면서 초기 렌더링 품질까지 같이 다룰 수 있게 됩니다.

한눈에 보면

먼저 아주 짧게 정리하면 이렇습니다.

  • SSR: 서버에서 먼저 데이터를 준비해 초기 HTML에 반영하는 것
  • hydration: 서버에서 준비한 결과를 클라이언트가 이어받아 인터랙션을 붙이는 것
  • Suspense: 비동기 대기 상태를 UI 구조 안에서 선언적으로 다루는 방식

그리고 React Query는 이 셋 사이에서:

  • 서버 prefetch
  • dehydrated state 전달
  • 클라이언트 cache 복원
  • suspense 기반 query

를 이어주는 역할을 할 수 있습니다.

즉, 핵심은 fetch 도구 하나가 아니라 초기 렌더링과 이후 캐시를 같은 데이터 모델로 연결하는 것입니다.

먼저 SSR, hydration, Suspense를 각각 구분해보자

이 셋은 같이 자주 언급되지만, 해결하는 문제가 다릅니다.

SSR

서버에서 먼저 화면을 만들기 위한 데이터까지 준비하는 것입니다.

장점은:

  • 첫 화면이 빨리 완성되어 보이고
  • SEO가 좋아지며
  • 빈 로딩 화면을 줄일 수 있다는 점입니다

hydration

서버가 만든 HTML 위에 클라이언트가 이벤트와 상태를 붙이는 과정입니다.

React Query 문맥에서는 한 단계 더 들어가서, 서버에서 prefetch한 query cache를 클라이언트에서 이어받는 것까지 함께 의미할 때가 많습니다.

Suspense

비동기 대기 상태를 if (isLoading) 분기보다 더 선언적으로 표현하는 방식입니다.

즉:

  • 데이터가 준비될 때까지 fallback을 보여주고
  • 준비되면 해당 UI를 렌더링하게 합니다

왜 이 셋을 같이 봐야 할까?

실제 사용자 경험에서는 이 셋이 자연스럽게 연결됩니다.

  • 서버에서 먼저 데이터를 채워두면 첫 화면이 좋아지고
  • 클라이언트가 그 데이터를 다시 이어받아야 중복 요청이 줄고
  • 비동기 경계는 Suspense로 더 자연스럽게 다룰 수 있습니다

즉, React Query를 이 관점에서 본다는 것은 "로딩 상태 하나"가 아니라 초기 진입부터 이후 상호작용까지의 데이터 흐름 전체를 보는 것입니다.

가장 기본적인 SSR + hydration 흐름

서버에서 query를 미리 가져오고, 이를 클라이언트에 넘긴 뒤, 클라이언트는 같은 queryKey로 캐시를 이어받습니다.

import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
 
export default async function Page() {
  const queryClient = new QueryClient();
 
  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  });
 
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <PostListClient />
    </HydrationBoundary>
  );
}

그리고 클라이언트 컴포넌트는 평소처럼 useQuery를 씁니다.

'use client';
 
import { useQuery } from '@tanstack/react-query';
 
export function PostListClient() {
  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  });
 
  return (
    <ul>
      {data?.items.map((post: { id: number; title: string }) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

이 흐름의 장점은 명확합니다.

  • 서버에서 첫 데이터를 이미 준비해두고
  • 클라이언트는 그 결과를 캐시로 이어받으며
  • 같은 query를 다시 처음부터 빈 상태로 읽지 않아도 됩니다

즉, 첫 화면과 이후 클라이언트 캐시가 끊기지 않습니다.

왜 dehydrate/hydrate가 필요한가?

서버에서 query를 prefetch했다고 해서 클라이언트가 자동으로 그 캐시를 아는 것은 아닙니다. 그래서 서버 쪽 QueryClient의 상태를 직렬화해서 내려주고, 클라이언트에서 다시 복원하는 과정이 필요합니다.

이때 자주 보는 것이:

  • prefetchQuery
  • dehydrate
  • HydrationBoundary

입니다.

즉, 이 셋은 "서버 query cache를 클라이언트로 옮기는 다리"라고 보면 이해가 쉽습니다.

Suspense를 쓰면 뭐가 달라질까?

기본 useQuery는 보통 이렇게 씁니다.

const { data, isPending } = useQuery(...);
 
if (isPending) {
  return <Spinner />;
}
 
return <Content data={data} />;

이 방식도 충분히 좋습니다. 다만 컴포넌트가 많아지고 비동기 경계가 겹치면, if (isLoading) 분기가 여기저기 퍼지기 쉽습니다.

Suspense를 쓰면 이 경계를 컴포넌트 바깥으로 끌어올릴 수 있습니다.

<Suspense fallback={<PostListSkeleton />}>
  <PostList />
</Suspense>

그리고 내부에서는 useSuspenseQuery를 쓸 수 있습니다. 다만 여기서 중요한 점이 하나 더 있습니다. Suspense는 로딩 상태를 처리해주지만, query 에러까지 대신 처리해주지는 않습니다. 그래서 실제로는 ErrorBoundary와 함께 두는 편이 더 완전한 패턴입니다.

<ErrorBoundary fallback={<PostListErrorFallback />}>
  <Suspense fallback={<PostListSkeleton />}>
    <PostList />
  </Suspense>
</ErrorBoundary>

즉:

  • Suspense: 로딩 경계
  • ErrorBoundary: 에러 경계

로 역할을 나눠서 보는 편이 좋습니다.

'use client';
 
import { useSuspenseQuery } from '@tanstack/react-query';
 
export function PostList() {
  const { data } = useSuspenseQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  });
 
  return (
    <ul>
      {data.items.map((post: { id: number; title: string }) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

이 방식의 장점은:

  • 로딩 상태를 UI 구조 차원에서 배치할 수 있고
  • 데이터가 준비되지 않았을 때의 책임을 상위 fallback으로 밀어올릴 수 있으며
  • 비동기 경계를 더 선언적으로 구성할 수 있고
  • ErrorBoundary와 같이 둘 때 로딩/에러 책임을 더 분명하게 나눌 수 있다는 점입니다

Suspense가 항상 더 좋은가?

그렇지는 않습니다.

아래 상황에서는 일반 useQuery가 더 단순할 수 있습니다.

  • 로딩 상태를 아주 세밀하게 제어해야 할 때
  • 기존 팀 코드가 이미 isLoading, isFetching 기준으로 잘 정리돼 있을 때
  • 특정 화면만 작게 개선하면 충분할 때

반대로 아래 상황에서는 Suspense가 매력적일 수 있습니다.

  • 로딩 UI를 라우트 단위나 섹션 단위로 명확히 끊고 싶을 때
  • 여러 비동기 컴포넌트를 구조적으로 나누고 싶을 때
  • App Routerloading.tsx, 스트리밍 관점과 자연스럽게 연결하고 싶을 때

즉, Suspense는 무조건 최신 방식이라서 쓰는 것이 아니라 비동기 경계를 더 높은 레벨에서 선언하고 싶을 때 잘 맞습니다.

SSR을 하면 클라이언트 refetch는 없어질까?

이 부분도 자주 오해됩니다.

서버에서 이미 prefetch했다 하더라도, 클라이언트는 그 데이터를 언제까지 신선하다고 볼지 다시 판단합니다. 즉 staleTime이 0에 가깝다면 hydrate 직후 바로 refetch가 일어날 수도 있습니다.

그래서 SSR + hydration을 쓸 때는 보통 이 질문을 같이 봐야 합니다.

  • 이 데이터는 얼마나 오래 신선하다고 볼 수 있는가
  • hydrate 직후 곧바로 재요청해도 되는가
  • 첫 화면의 안정감이 더 중요한가, 최신성이 더 중요한가

예를 들어:

useQuery({
  queryKey: ['posts'],
  queryFn: getPosts,
  staleTime: 1000 * 30,
});

이렇게 하면 서버에서 받아온 값을 바로 낡은 것으로 보지 않고, 일정 시간은 신선한 데이터로 취급할 수 있습니다.

App Router에서 loading.tsx와 Suspense는 어떻게 연결될까?

App Router는 라우트 단위 loading.tsx를 제공하고, React 자체는 Suspense를 제공합니다. 이 둘은 경쟁 관계라기보다 계층이 다릅니다.

  • loading.tsx: 라우트 세그먼트 단위 fallback
  • Suspense: 컴포넌트 단위 fallback

즉:

  • 페이지 전체 초기 로딩은 loading.tsx
  • 페이지 내부 특정 위젯/리스트 로딩은 Suspense

처럼 조합할 수 있습니다.

이때 React Query는 내부 비동기 데이터를 어떤 경계에 걸칠지 결정하는 쪽에 가깝습니다.

hydration이 잘 맞는 화면과 아닌 화면

SSR + hydration이 늘 정답은 아닙니다.

잘 맞는 화면

  • 첫 진입 품질이 중요한 화면
  • 서버에서 먼저 그려두는 이점이 큰 화면
  • 이후 클라이언트 캐시 재사용도 가치가 큰 화면

예를 들면:

  • 공개 목록 페이지
  • 상세 페이지
  • 초기 대시보드 진입 화면

굳이 과할 수 있는 화면

  • 클라이언트 인터랙션 이후에만 의미가 생기는 데이터
  • 진입 즉시 꼭 필요하지 않은 부가 위젯
  • 사용 빈도가 낮고 prefetch 비용이 더 큰 데이터

즉, hydration은 "가능하면 다 하자"가 아니라 초기 경험과 이후 캐시 재사용 가치가 모두 있을 때 선택하는 편이 좋습니다.

실무에서 자주 부딪히는 고민

1. 서버 fetch와 React Query fetch가 이중으로 보인다

이 경우는 대개 역할이 섞인 것입니다.

  • 서버에서만 충분한 데이터인지
  • 이후 클라이언트 캐시까지 필요한 데이터인지

를 먼저 다시 나눠보는 편이 좋습니다.

2. hydration을 넣었는데 생각보다 체감이 없다

그럴 수 있습니다.

  • 원래 데이터가 작고 빠르거나
  • 이후 재사용 가치가 작거나
  • staleTime이 너무 짧아 바로 다시 refetch되면

복잡도 대비 이점이 작을 수 있습니다.

3. Suspense로 바꿨는데 오히려 디버깅이 어렵다

이것도 자연스러운 현상입니다. 팀이 아직 Suspense 흐름에 익숙하지 않다면, 일반 useQuery 상태 분기가 더 명확할 수 있습니다.

즉, Suspense는 기능적으로 가능해서 쓰는 것이 아니라 팀의 비동기 UI 설계 방식과 맞을 때 도입하는 편이 좋습니다.

추천하는 실전 접근 순서

실무에서는 보통 아래 순서가 무난합니다.

  1. 먼저 일반 useQuery와 명확한 loading/error 분기로 안정적으로 운영한다
  2. 첫 화면 품질이 중요한 일부 화면에서 SSR + hydration을 적용해본다
  3. 비동기 경계가 복잡한 구간에만 Suspense를 선택적으로 도입한다
  4. 모든 화면에 일괄 적용하기보다, 효과가 큰 곳부터 확장한다

즉, Suspense, SSR, hydration은 한 번에 다 도입하는 세트가 아니라 문제가 보이는 지점에 맞춰 단계적으로 쓰는 도구로 보는 편이 좋습니다.

정리하면

React QuerySuspense, SSR, hydration 관점에서 본다는 것은 단순 훅 사용법보다 더 큰 그림을 보는 것입니다.

핵심은 이렇습니다.

  • SSR은 첫 화면 품질을 위한 것이고
  • hydration은 서버에서 준비한 데이터를 클라이언트 캐시로 이어주는 것이며
  • Suspense는 비동기 로딩 경계를 더 선언적으로 다루게 해줍니다

그리고 React Query는 이 세 흐름을 데이터 캐시 관점에서 연결합니다.

결국 중요한 질문은 "Suspense를 쓸 수 있는가"가 아니라, 이 화면에서 초기 렌더링 품질, 캐시 재사용, 로딩 UX를 어떻게 설계할 것인가입니다. 그 질문에 답하다 보면 어디에 SSR이 필요하고, 어디에 hydration이 필요하며, 어디서 Suspense가 정말 의미 있는지가 훨씬 분명해집니다.

같이 보면 좋은 글