Next.js App Router에서 TanStack Query를 사용할 때 발생하는 구조적 복잡성과 공식적인 해결 패턴

ClydeHan·2025년 5월 21일
13

Tanstack Query v5 LOGO Image

개요

서버 컴포넌트 기반 Next.js App Router에서 데이터를 미리 패칭한 뒤, TanStack Query를 통해 클라이언트 상태로 하이드레이션하여 사용하는 방법과 그 구조적 배경에 대해 정리한다.

  • 이 문서에서는 TanStack QueryReact Query를 동일한 의미로 사용한다.
  • TanStack Query는 React Query의 후속 이름이며, 현재는 공식적으로 통합된 명칭이다.

문제

TanStack Query는 기본적으로 CSR(Client Side Rendering) 기반의 상태 관리 도구이다. 반면, Next.js App Router는 SSR(Server Side Rendering)Server Components에 기반한 프레임워크이다.

이 둘이 결합되면, 데이터를 언제/어디서/어떻게 패칭하고, 이를 클라이언트에서 어떤 방식으로 재사용할 것인지에 대한 구조적 충돌이 발생한다.

이 충돌은 특히 서버에서 미리 패칭한 데이터를 클라이언트에서도 동일하게 캐시된 상태로 사용하고자 할 때 가장 두드러진다.


구체적인 충돌 지점

1. Next.js의 서버 중심 데이터 로딩 방식 (App Router + Server Components)

  • Next.js는 서버에서 먼저 데이터를 패칭하여 초기 HTML에 포함하는 방식을 지향한다. 이는 빠른 TTFBSEO 최적화에 유리하다.

💡 TTFB (Time To First Byte)
브라우저가 서버에 요청을 보내고 나서 첫 번째 바이트를 받을 때까지 걸리는 시간을 의미한다.
짧은 TTFB는 서버가 빠르게 응답했다는 것을 의미하며, 웹사이트 성능이 좋다는 신호로 해석된다.

  • 이를 위해 async Server Component 내부에서 await fetch() 또는 쿼리 함수를 호출해 데이터를 가져온다.
  • 그러나 이렇게 패칭된 데이터는 일반적으로 prop 또는 context를 통해 하위 컴포넌트로 전달되며, React Query의 캐시로는 연결되지 않는다.
  • 그 결과, 클라이언트에서 React Query의 useQuery()를 호출해도 이 데이터를 "이미 존재하는 캐시"로 인식하지 못한다.

2. TanStack Query의 CSR 중심 클라이언트 캐시 시스템

  • TanStack Query는 클라이언트에서 useQuery()가 호출될 때, 해당 queryKey로 캐시된 데이터가 없으면 자동으로 fetch를 시작한다.
  • 서버에서 동일한 데이터를 이미 가져온 상태라도, React Query는 클라이언트에서 초기 쿼리를 실행할 때, 서버에서 전달된 데이터를 직접 하이드레이션하지 않는 이상 이를 캐시로 인식하지 않기 때문에 fetch를 다시 시도하게 된다.
  • 이로 인해 불필요한 중복 요청과 함께, 클라이언트에서 로딩 상태가 발생하는 문제가 발생한다.

결과적으로 발생하는 구조적 문제

  • 서버에서 이미 데이터를 가져왔음에도, 클라이언트에서는 동일한 데이터를 다시 fetch하게 된다. 이는 네트워크 낭비이며, UX에도 부정적인 영향을 준다.
  • 클라이언트에서는 데이터가 이미 있다고 가정할 수 없기 때문에, 로딩 UI가 잠깐 보이는 현상이 발생한다.
  • 이중 요청과 로딩 문제를 방지하기 위해서는 React Query에게 서버에서 데이터를 이미 가져왔다는 사실을 인지시켜야 한다.

해결 패턴: HydrationBoundary를 이용한 공식 프리패칭 전략

// app/page.tsx
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query'
import { queryKey, fetchData } from '@/lib/api';

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

  await queryClient.prefetchQuery({
    queryKey,
    queryFn: fetchData,
  });

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

코드 설명

  • getQueryClient()서버에서만 작동하는 독립적인 queryClient 인스턴스를 반환한다. 이는 요청 간 데이터 공유를 방지한다.
  • prefetchQuery()서버에서 데이터를 미리 가져와 queryClient의 캐시에 저장한다.
  • dehydrate(queryClient)는 이 queryClient의 상태를 직렬화 가능한 형태로 변환하여 HydrationBoundary에 전달할 수 있게 만든다.
  • HydrationBoundary는 클라이언트에서 해당 데이터를 초기 캐시 상태로 인식하게 만든다.
  • 최종적으로, ClientComponent 내의 useQuery()서버에서 미리 가져온 데이터가 캐시에 있다고 인식하고 fetch를 생략한다.

원리

요청 워터폴 문제와 SSR 최적화

클라이언트 렌더링 기반의 일반적인 요청 흐름은 다음과 같다.

1. |-> 마크업 (데이터 없음)
2.   |-> JavaScript 로드
3.     |-> useQuery 실행 → fetch

초기 렌더링에 필요한 데이터가 클라이언트에서 다시 요청되기 때문에, 사용자에게 콘텐츠가 보이기까지 최소 3단계의 네트워크 왕복이 필요하다.

반면, SSR을 적용하면 다음과 같은 구조로 최적화할 수 있다.

1. |-> 마크업 (초기 데이터 포함)
2.   |-> JavaScript 로드 및 하이드레이션

초기 데이터가 이미 마크업에 포함되어 있으므로, 클라이언트는 추가 fetch 없이 즉시 콘텐츠를 렌더링할 수 있다.


서버 렌더링과 React Query의 통합 전략

HydrationBoundary를 활용한 프리패칭 전략은 단순한 코드 최적화 기법이 아니라, 서버 중심 렌더링(SSR)과 클라이언트 중심 캐시 시스템을 자연스럽게 연결하기 위한 핵심적인 기법이다. 이를 정확히 이해하기 위해서는 React Query와 SSR이 어떤 방식으로 통합되는지 개념적으로 살펴볼 필요가 있다.

TanStack Query 공식 문서에서는 이 과정을 다음의 세 단계로 설명하고 있다.

  1. 서버에서 데이터를 사전 패칭(prefetch)

    React Query의 prefetchQuery()를 통해, 서버에서 필요한 데이터를 먼저 가져온다.

  2. 가져온 데이터를 직렬화 가능한 형태로 변환(dehydrate)

    dehydrate() 함수를 사용해 React Query의 내부 캐시 상태를 직렬화할 수 있는 형태로 변환한다. 이 데이터는 HTML 마크업과 함께 클라이언트로 전달된다.

  3. 클라이언트에서 캐시 복원(hydrate)

    클라이언트는 HydrationBoundary를 통해 이 데이터를 React Query의 초기 캐시로 인식하고, 동일한 쿼리를 다시 요청하지 않는다.

이 전략의 핵심은 초기 페이지 렌더링 시점에 데이터를 포함한 마크업을 제공함으로써, 클라이언트가 추가 요청 없이 콘텐츠를 바로 렌더링할 수 있도록 하는 데 있다.


참고문헌

0개의 댓글