TanStack Query에서 Next.js의 Streaming 활용하기
prefetchQuery와 useSuspenseQuery를 사용한 스트리밍 구현
서론
Next.js의 Streaming UI를 TanStack Query에서 어떻게 활용할 수 있는지 찾아보며 정리한 글입니다.
Next.js에서 Streaming SSR의 도입
복잡한 웹 애플리케이션일수록 초기에 필요한 데이터 양이 커집니다. 일반 SSR
방식은 서버가 모든 데이터를 준비한 뒤 한꺼번에 HTML을 내려보내기 때문에, 대기 시간이 길어지면 사용자는 빈 화면을 오래 보게 됩니다.
반면, Streaming SSR에서는 일부 데이터가 준비되는 즉시 서버가 HTML 일부를 전달함으로써 사용자가 더 빠르게 페이지를 볼 수 있도록 합니다.
Next.js의 App Router는 React 18의 서버 컴포넌트와 Suspense, 그리고 Streaming 기능을 결합하여 손쉽게 Streaming SSR을 구현할 수 있도록 지원하고 있습니다.
TanStack Query와의 결합
React Query는 클라이언트 컴포넌트에서 데이터 패칭과 캐싱을 간편하게 할 수 있게 해주지만, Next.js app router의 서버 컴포넌트와 연동해서 스트리밍 SSR을 구현하려면 몇 가지 추가적인 설정이 필요합니다.
1. HTTP Streaming과 Streaming SSR
HTTP Streaming은 서버가 응답을 일정한 크기의 청크로 나누어 순차 전송하는 방식입니다.
이때 Transfer-Encoding: chunked
헤더를 사용합니다. HTML의 일부가 준비되면 즉시 전송하여 클라이언트가 이를 렌더링할 수 있게 하고, 나머지 부분은 이후 데이터가 준비되는 대로 추가로 전송합니다.
Next.js App Router에서는 React의 서버 컴포넌트 렌더링 결과를 이러한 스트리밍 방식으로 클라이언트에 전송합니다. 이로써 사용자 입장에서는 페이지가 점진적으로 렌더링되어, “아직 모든 데이터를 준비하지 않았어도” 빠르게 화면을 볼 수 있게 됩니다.
2. React 18의 renderToPipeableStream
React 18에서 제공하는 renderToPipeableStream
은 Node.js 환경에서 스트림 기반 SSR을 구현하기 위한 API입니다.
렌더링된 결과를 청크 단위로 내보내며, Next**.js** 내부에서 활용해 Streaming SSR을 수행합니다.
import { renderToPipeableStream } from 'react-dom/server';
const stream = renderToPipeableStream(<App />, {
onShellReady() {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html');
stream.pipe(res);
},
onShellError(err) {
res.statusCode = 500;
res.send('<h1>Something went wrong</h1>');
},
onAllReady() {
// 모든 UI 렌더링이 완료된 시점
},
onError(err) {
// 스트리밍 중 발생하는 에러 처리
},
});
Next.js는 내부적으로 이러한 스트림을 활용해 서버 컴포넌트가 준비되는 대로 부분 렌더링 결과를 클라이언트로 전송합니다.
3. Next.js App Router에서의 전반적인 흐름
1.서버 컴포넌트(예: app/page.tsx
)가 HTTP 요청을 받아, 필요한 데이터를 직접 가져옵니다.
2.React 18의 Suspense
를 통해 아직 준비되지 않은 컴포넌트는 fallback
으로 처리하고, 이미 준비된 컴포넌트는 바로 스트리밍합니다.
3.클라이언트는 받은 HTML을 점진적으로 렌더링하며, 클라이언트 컴포넌트가 필요로 하는 js 번들은 하이드레이션 시점에 로드됩니다.
4.최종적으로 데이터가 전부 준비되면 Suspense 경계를 해제하여 UI가 완성됩니다.
React Query를 활용한 Streaming SSR 구현
React Query와 Next.js App Router를 함께 사용하면, 서버 컴포넌트에서 데이터를 미리 가져온 뒤 해당 데이터를 클라이언트 컴포넌트에서 React Query가 캐싱 및 UI 렌더링을 담당하도록 할 수 있습니다.
이때 Streaming SSR은 서버에서 클라이언트로 전달되는 과정에서 일부만 먼저 전송, 렌더링되고, 나머지는 나중에 전송되어 점진적으로 UI가 완성됩니다.
1. page.tsx
: 서버 컴포넌트에서 데이터 Fetch + Suspense
// app/page.tsx (RSC)
export default async function Home() {
return (
<Suspense fallback={<Skeleton />} >
<PrefetchPostList />
</Suspense>
)
}
// app/PrefetchPostList.tsx (RSC)
export async function PrefetchMemosContainer() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery(
queryKey: ['postList'],
queryFn: getPostList,
);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostList />
</HydrationBoundary>
);
}
TanStack Query의 prefetchQuery를 호출하는 서버 컴포넌트인 PrefetchPostList를 <Suspense>로 감싸 호출합니다.
PrefetchPostList
에서는QueryClient
를 생성한 후, 데이터를 prefetch합니다.
이후 dehydrate
를 통해 서버에서 미리 불러온 데이터를 직렬화하여 클라이언트 컴포넌트로 전달합니다.
2. PostList.tsx
: 클라이언트 컴포넌트에서 useSuspenseQuery를 사용
// app/component/PostList.tsx
'use client';
import { use } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query';
export default function PostList() {
const { posts } = useSuspenseQuery({
queryKey: ['postList'],
queryFn: getPostList
});
return (
<ul>
{posts?.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
서버에서 미리 불러온 데이터를 useSuspensQuery
를 사용해 불러온 후 렌더링합니다.
이렇게 Next.js의 Streaming을 TanStack Query와 함께 사용할 수 있습니다.
회고
이전 프로젝트를 진행하며 사용자의 마이페이지를 SSR로 구현하였습니다. 하지만 CSR에 비해 FCP가 10배정도 느렸습니다.
그렇게 빠르다고 외운 SSR의 배신이었는데, 역시 공식문서를 제대로 찾아보지 않아 그랬던 것이었습니다..
레퍼런스
- https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming
- https://ko.react.dev/reference/react-dom/server/renderToPipeableStream
- https://tanstack.com/query/latest/docs/framework/react/guides/prefetching#prefetchquery-and-prefetchinfinitequery
- 참고 블로그 - React Query와 streaming 하기