Domain-Oriented Data Fetching in App Router
이 포스트는 [Domain-Oriented Data Fetching in React Query]에서 소개한 구조를 바탕으로, Next.js App Router 환경에서 React Query와 기본 fetch API를 조화롭게 사용하는 방법에 대한 확장 설명이다. 특히 서버 컴포넌트 중심의 렌더링 패턴과 React Query의 캐싱·하이드레이션 기능을 어떻게 통합적으로 활용할 수 있는지를 구체적으로 다룬다.
본 글은 일종의 부록 역할을 하며, 기본적인 도메인 중심 데이터 패칭 구조나 Fetcher, Query 클래스 구조 등에 대한 설명은 앞서 소개한 포스트를 참고하는 것을 권장한다. 이 글에서는 해당 구조를 Next.js의 서버 사이드 렌더링 및 클라이언트 하이드레이션 흐름에 어떻게 녹여낼 수 있는지를 중점적으로 설명한다.
ServerSidePrefetchQuery
React Query는 기본적으로 클라이언트 사이드에서 데이터를 패칭하고 캐싱하는 라이브러리이지만, Next.js와 함께 사용할 경우 서버 사이드에서 데이터를 사전 패칭(prefetch) 한 뒤 클라이언트로 전달하고, 클라이언트에서 해당 데이터를 Hydration함으로써 초기 렌더링 성능과 사용자 경험을 개선할 수 있다. 이를 위해 @tanstack/react-query는 HydrationBoundary와 dehydrate, prefetchQuery 등의 API를 제공한다.
코드에서 getServerQueryClient는 서버 환경에서 재사용 가능한 QueryClient 인스턴스를 생성한다. 이 인스턴스는 Next.js의 cache() 함수로 래핑되어 빌드나 요청마다 생성되지 않고, 동일한 서버 렌더링 사이클 내에서 재사용된다. 기본적으로 각 쿼리의 staleTime은 1분으로 설정되어 있어, 1분 동안은 동일한 쿼리에 대해 서버에서 다시 호출하지 않고 캐싱된 데이터를 반환하게 된다.
import { DefaultError, dehydrate, FetchQueryOptions, HydrationBoundary, QueryClient, QueryKey } from '@tanstack/react-query'
import { cache } from 'react'
const getServerQueryClient = cache(() => {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1분
},
},
})
})
prefetchQuery 함수는 이 QueryClient를 사용해 서버에서 쿼리를 미리 실행하고, 이후 getQueryData()를 통해 결과를 추출하여 반환한다. 이는 Next.js의 서버 컴포넌트나 generateMetadata, generateStaticParams 같은 함수 내에서 데이터 의존성을 선언적으로 미리 가져올 수 있게 해준다. 이로써 클라이언트 측에서는 동일한 쿼리를 다시 실행할 필요 없이 즉시 캐싱된 데이터를 활용할 수 있으며, 사용자 입장에서는 빠르고 깜빡임 없는 렌더링을 경험할 수 있다.
import { DefaultError, FetchQueryOptions, QueryClient, QueryKey } from '@tanstack/react-query'
import { getServerQueryClient } from "./"
export async function prefetchQuery<TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey>(
queryOptions: FetchQueryOptions<TQueryFnData, TError, TData, TQueryKey>
): Promise<TQueryFnData> {
const queryClient = getServerQueryClient();
await queryClient.prefetchQuery(queryOptions);
return queryClient.getQueryData(queryOptions.queryKey) as TQueryFnData;
}
마지막으로 ServerSideQueryProvider 컴포넌트는 React 컴포넌트 트리의 루트 근처에 배치되어, HydrationBoundary를 통해 클라이언트 렌더링 시점에 서버에서 생성된 React Query 캐시 상태를 안전하게 주입(hydrate)한다. 이를 통해 React Query는 클라이언트에서도 동일한 캐시 상태를 인식하고 이어서 사용할 수 있다. 특히 Next.js의 App Router 구조에서는 이 컴포넌트를 layout.tsx 또는 페이지 컴포넌트의 루트에서 한 번만 감싸주면 되므로, 설정이 간결하고 재사용성이 높다.
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { getServerQueryClient } from "./"
export function ServerSideQueryProvider({
children,
}: {
children: React.ReactNode
}) {
const queryClient = getServerQueryClient()
return (
<HydrationBoundary state={dehydrate(queryClient)}>
{children}
</HydrationBoundary>
)
}
Executing Queries in Server Components
Next.js App Router 환경에서는 서버 컴포넌트에서 React Query 쿼리를 직접 실행하고, 그 결과를 클라이언트에 하이드레이션된 상태로 전달할 수 있다. 특히 prefetchQuery를 활용하면 쿼리를 실행함과 동시에 React Query 내부 캐시에 결과를 저장하고, 이후 HydrationBoundary를 통해 클라이언트에 안전하게 데이터를 넘겨줄 수 있다.
아래 예시는 도메인 쿼리 객체(InterviewQuery)를 사용해 인터뷰 세션 데이터를 서버에서 미리 가져오고, 일부는 prop으로 직접 넘기며(session), 나머지는 React Query를 통해 클라이언트에서 재활용하는 구조를 보여준다:
type PageProps: { params: Promise<{ interviewId: string }>};
export async function generateMetadata({ params }: PageProps) {
const { interviewId } = params;
const withMessages = true;
try {
// queryFn 호출 - Next.js fetch 메모이제이션 적용
const interview = await interviewQuery.getInterviewById(interviewId, withMessages).queryFn();
return {
title: interview.title || '인터뷰',
description: interview.position || '면접 정보',
};
} catch (error) {
return {
title: '인터뷰',
description: '면접 정보를 불러올 수 없습니다.',
};
}
}
export default async function Home({ params }: PageProps) {
const interviewQuery = new InterviewQuery();
// 동일한 엔드포인트에 대한 호출 - 이미 generateMetadata에서 요청이 캐시됨
const { session } = await prefetchQuery(
interviewQuery.getInterviewById(interviewId, withMessages)
);
return (
<ServerSideQueryProvider>
<ServerTest session={session} />
<ClientTest />
</ServerSideQueryProvider>
);
}
이 구조의 핵심은 서버 컴포넌트에서 데이터를 한 번만 요청하고, 여러 방식으로 재사용할 수 있다는 점이다. ServerTest는 session을 prop으로 직접 받기 때문에 React Query에 의존하지 않고도 렌더링 가능하며, 반면 ClientTest는 같은 쿼리 키로 useQuery()를 호출해도 이미 서버에서 하이드레이션된 상태이므로 추가 네트워크 요청 없이 캐시된 데이터를 활용한다.
이를 통해 데이터 요청 횟수를 줄이고 컴포넌트의 관심사를 분리할 수 있다. 즉, “서버는 데이터를 가져오고, 클라이언트는 소비한다”는 역할 분담이 자연스럽게 이루어지는 구조다.
Integration of React Query and App Router Fetch Cache
이러한 리퀘스트 메모이제이션이 자동으로 적용되는 이유는 fetcher가 fetch나 ky를 감싸면서 Next.js 앱 라우터가 제공하는 캐싱 메커니즘과 자연스럽게 연동되기 때문이다. 즉, fetcher 내부에서 호출되는 HTTP 요청이 앱 라우터의 fetch API로 처리되면서, Next.js는 요청 결과를 자동으로 캐시하고 재검증 정책을 적용할 수 있다. 덕분에 동일한 쿼리 키를 사용하는 서버와 클라이언트 컴포넌트가 중복된 네트워크 요청 없이 데이터를 공유할 수 있다.
예를 들어, getInterviewById 메서드에서는 next 옵션에 revalidate와 tags를 지정하여 60초마다 데이터 재검증이 이루어지도록 설정한다. 또한 ISR 태그를 활용해 특정 세션과 연관된 데이터 변경 시 효율적으로 캐시를 무효화할 수 있다. 이처럼 앱 라우터의 캐시 정책과 React Query의 쿼리 키 체계가 결합되어, 불필요한 중복 요청 발생을 효과적으로 방지한다.
public getInterviewById(sessionId: string, withMessages: boolean = false) {
return this.queryOptions({
queryKey: InterviewQuery.keyFactory.getInterviewById(sessionId),
queryFn: () =>
this.fetcher.get("interview/{sessionId}", {
query: { withMessages },
path: { sessionId },
}, {
next: {
revalidate: 60, // 60초마다 재검증
tags: [`interview:${sessionId}`], // ISR 태그 설정 (선택사항)
},
}),
});
}
결과적으로, 이 구조는 서버에서 데이터 요청을 한 번만 수행하고, 클라이언트에서는 캐시된 데이터를 활용하여 네트워크 비용을 최소화한다. 또한 데이터 요청과 소비가 명확히 분리되어 컴포넌트의 관심사를 깔끔하게 유지할 수 있다.
Pass Raw Data Between Server Components
Next.js의 서버 컴포넌트끼리는 props를 통해 데이터를 주고받을 수 있지만, 이때 직렬화 가능한 순수 데이터(raw data)만 전달해야 한다. 즉, Interview와 같은 도메인 클래스 인스턴스는 props로 넘기면 안 되며, 그 원본 데이터인 서버 응답(JSON 형태의 plain object)을 전달해야 한다.
아래는 InterviewSession 데이터를 받아 도메인 객체로 감싸 사용하는 ServerTest 컴포넌트의 예시다. 주목할 점은 AnotherServerComponent로 데이터를 넘길 때도 interview 인스턴스가 아니라, 여전히 원본 session 데이터를 넘긴다는 점이다.
export function ServerTest({
session
}: {
session: components["schemas"]["InterviewSession"]
}) {
const interview = new Interview(session)
return (
<div>
<h2>Server Test</h2>
<div>{interview.progressStatus}</div>
<div>{interview.createdAt.format()}</div>
<AnotherServerComponent session={session} />
</div>
);
}
결론 : 현실적 제약 속에서의 실용적 타협
지금까지 살펴본 구조는 Next.js App Router와 React Query를 결합하여 서버와 클라이언트 간의 데이터 패칭, 캐싱, 하이드레이션을 보다 효율적으로 관리하는 방법을 제시한다. 다만, 완벽한 솔루션은 아니기에 실제 프로젝트에서는 데이터 일관성 유지, 캐시 무효화 처리, 직렬화 한계 등 다양한 현실적인 제약과 맞닥뜨리게 된다.
결국에는 이러한 한계 속에서 프로젝트 요구사항과 팀의 경험을 고려해 적절한 타협점과 균형을 찾는 것이 중요하다. 이 글에서 소개한 패턴은 그런 고민을 해결하는 데 참고할 만한 실용적인 예시로 활용될 수 있을 것이다.
블로그의 정보
Ayden's journal
Beard Weard Ayden