Next.js App Router에서 TanStack Query v5 prefetchInfiniteQuery와 React 19 use()로 무한 스크롤 스트리밍 SSR 구현하기
무한 스크롤을 클라이언트에서만 구현해본 분들이라면 한 번쯤 이런 경험을 해보셨을 겁니다. 페이지가 처음 로드될 때 스켈레톤이 깜빡이고, 네트워크 탭엔 폭포수처럼 쏟아지는 연속 요청들, 그리고 SEO 크롤러는 빈 HTML만 보게 되는 상황. "이거 서버에서 미리 채워줄 수 없나?" 싶어지는 순간이 오죠.
저도 처음엔 Pages Router의 getServerSideProps에서 첫 페이지만 넘겨주는 방식으로 버텼는데, 그게 Next.js App Router + TanStack Query v5를 만나면서 이야기가 달라졌습니다. 기존엔 서버에서 데이터를 넘겨도 결국 클라이언트가 추가 요청을 다시 보내야 했다면, 이제는 서버에서 여러 페이지를 채운 채로 HTML이 스트리밍되어 전달되고, 클라이언트는 첫 네트워크 요청 없이 즉시 스크롤 가능한 상태로 시작할 수 있습니다. prefetchInfiniteQuery의 pages 옵션과 React 19의 use() API가 그 핵심입니다.
이 글을 읽기 전에:
useInfiniteQuery를 써본 경험이 있는 중급 이상 독자를 대상으로 합니다. React Server Component, Suspense, TanStack Query의 기본 개념이 있으면 훨씬 따라오기 편하며, 처음이라면 TanStack Query 공식 문서를 먼저 살펴보시면 좋습니다.
핵심 개념
prefetchInfiniteQuery — 무한 스크롤의 서버 측 짝꿍
TanStack Query를 쓰다 보면 prefetchQuery는 익숙한데, 무한 스크롤에서도 SSR이 필요할 때 막히는 경우가 있습니다. 바로 그럴 때를 위해 나온 게 prefetchInfiniteQuery입니다. useInfiniteQuery가 클라이언트에서 페이지를 하나씩 쌓아가는 구조라면, prefetchInfiniteQuery는 서버에서 그 구조를 미리 만들어줍니다.
await queryClient.prefetchInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }: { pageParam: number }) => fetchPosts(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage: PostsResponse) => lastPage.nextCursor ?? undefined,
pages: 3, // 서버에서 첫 3페이지를 순서대로 프리페치
})여기서 pages: 3 옵션이 핵심입니다. 이게 없으면 첫 페이지만 채워지는데, 이 옵션으로 지정한 수만큼의 페이지를 서버에서 연달아 패칭해 캐시에 쌓아줍니다. 클라이언트에서 useInfiniteQuery가 같은 queryKey로 붙으면 서버에서 채워둔 캐시를 그대로 이어받아, 네트워크 요청 없이 즉시 렌더링됩니다.
getNextPageParam— 현재 페이지 응답을 받아 다음 페이지의pageParam을 계산하는 함수입니다.prefetchInfiniteQuery가pages개수만큼 반복 패칭할 때 이 함수로 다음 커서를 얻습니다. 서버와 클라이언트의 이 함수가 일치하지 않으면 데이터 중복이나 누락이 생길 수 있어 주의가 필요합니다.
데이터가 서버에서 클라이언트로 흐르는 전체 구조는 이렇습니다:
Server Component
└─ prefetchInfiniteQuery (N페이지 패칭)
└─ dehydrate(queryClient)
└─ HydrationBoundary (직렬화된 캐시 전달)
└─ useInfiniteQuery (캐시 소비 — 네트워크 요청 없음)
└─ 추가 스크롤 시 fetchNextPage이 흐름 하나를 이해하면 아래 세 가지 패턴이 자연스럽게 읽힙니다.
React 19 use() — Promise를 렌더 중에 직접 읽기
솔직히 use() 훅을 처음 봤을 때 "이게 무슨 마법인가" 싶었습니다. 컴포넌트 안에서 await 없이 Promise를 읽을 수 있다고요?
use()는 React 19에서 공식 API로 등장했습니다. 참고로, React 18.x의 Canary 채널에서 실험적으로 먼저 존재했기 때문에 18버전을 쓰면서 시도해봤다가 작동이 달랐던 기억이 있으신 분도 있을 겁니다. 안정적으로 사용하려면 React 19가 필요합니다.
// 서버 컴포넌트에서 Promise를 만들어 prop으로 내려보냄
export default function Page() {
const dataPromise = fetchPosts(0) // await하지 않는 것이 핵심
return (
<Suspense fallback={<Skeleton />}>
<PostListClient dataPromise={dataPromise} />
</Suspense>
)
}
// 클라이언트 컴포넌트에서 use()로 소비
'use client'
function PostListClient({ dataPromise }: { dataPromise: Promise<PostsResponse> }) {
const initialData = use(dataPromise) // resolve될 때까지 suspend
// ...
}use()가 Promise를 받으면 React는 가장 가까운 Suspense boundary에서 렌더링을 잠시 멈추고, Promise가 resolve되면 재개합니다. 기존 훅과 달리 조건문이나 반복문 안에서도 호출할 수 있어 코드 구조가 훨씬 자유로워집니다.
스트리밍 SSR — 서버가 HTML을 한 번에 전송하는 대신, 준비된 UI 블록부터 순차적으로 보내는 방식입니다. Suspense boundary가 resolve될 때마다 해당 청크가 클라이언트로 스트리밍되어, 사용자는 전체 로딩 대신 부분적으로 완성된 페이지를 먼저 볼 수 있습니다.
실전 적용
패턴 A: 명시적 프리페치 + HydrationBoundary — 프로덕션 권장 패턴
가장 안정적이고 프로덕션에서 검증된 패턴입니다. 서버 컴포넌트에서 데이터를 프리페치하고, 직렬화해서 클라이언트 컴포넌트로 넘겨주는 구조입니다. 저는 처음에 pages: 1로만 써봤다가 "아, 이게 여러 페이지를 미리 채워주는구나"를 깨달은 건 pages: 3으로 올려보고 나서였습니다.
// app/posts/page.tsx (Server Component)
import { QueryClient, dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { Suspense } from 'react'
import { PostList } from '@/components/PostList'
import { PostsSkeleton } from '@/components/PostsSkeleton'
import { fetchPosts } from '@/lib/api'
export default async function PostsPage() {
const queryClient = new QueryClient()
await queryClient.prefetchInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }: { pageParam: number }) => fetchPosts(pageParam),
initialPageParam: 0,
getNextPageParam: (last: PostsResponse) => last.nextCursor ?? undefined,
pages: 2, // 초기 2페이지를 서버에서 미리 채움
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={<PostsSkeleton />}>
<PostList />
</Suspense>
</HydrationBoundary>
)
}// components/PostList.tsx (Client Component)
'use client'
import { useEffect } from 'react'
import { useInfiniteQuery } from '@tanstack/react-query'
import { useInView } from 'react-intersection-observer'
import { fetchPosts } from '@/lib/api'
import type { PostsResponse } from '@/types'
export function PostList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }: { pageParam: number }) => fetchPosts(pageParam),
initialPageParam: 0,
getNextPageParam: (last: PostsResponse) => last.nextCursor ?? undefined,
staleTime: 60 * 1000, // 1분간 fresh 유지 — 페이지 이동 후 복귀 시 재요청 방지
})
const { ref, inView } = useInView()
useEffect(() => {
if (inView && hasNextPage) fetchNextPage()
}, [inView, hasNextPage, fetchNextPage])
return (
<>
{data?.pages.flatMap((p) => p.items).map((post) => (
<PostCard key={post.id} post={post} />
))}
<div ref={ref}>
{isFetchingNextPage && <Spinner />}
</div>
</>
)
}| 코드 포인트 | 설명 |
|---|---|
pages: 2 |
서버에서 0번째, 1번째 페이지를 순서대로 패칭해 캐시에 적재 |
HydrationBoundary |
서버 캐시를 직렬화해 클라이언트 QueryClient로 복원 |
staleTime 설정 |
클라이언트 네비게이션 후 돌아왔을 때 불필요한 재요청 방지 |
useInView + useEffect |
스크롤 감지 시 fetchNextPage 트리거 — Intersection Observer 기반 |
패턴 B: Promise 스트리밍 + use() — 느린 데이터를 블로킹 없이 처리
느린 초기 데이터 요청이 있을 때, 서버가 응답을 기다리는 동안 다른 UI는 먼저 스트리밍하고 싶은 상황에서 유용합니다. 저는 처음에 이 패턴을 패턴 A의 드롭인 대체재처럼 썼다가 낭패를 봤는데, 둘은 목적이 다릅니다. 이 패턴은 await 없이 Promise를 그대로 내려보내는 것이 핵심이라 서버 컴포넌트 함수 자체에 async가 필요 없습니다.
// app/posts/page.tsx (Server Component)
// async 없음 — await하지 않고 Promise 자체를 클라이언트로 넘기는 것이 이 패턴의 핵심
export default function PostsPage() {
const dataPromise = fetchPosts(0) // Promise만 생성, 서버가 블로킹되지 않음
return (
<div>
<Header /> {/* 즉시 스트리밍 */}
<Suspense fallback={<PostsSkeleton />}>
<PostListClient dataPromise={dataPromise} />
</Suspense>
</div>
)
}// components/PostListClient.tsx (Client Component)
'use client'
import { use } from 'react'
import { useInfiniteQuery } from '@tanstack/react-query'
import type { PostsResponse } from '@/types'
interface Props {
dataPromise: Promise<PostsResponse>
}
export function PostListClient({ dataPromise }: Props) {
const initialData = use(dataPromise) // Promise resolve까지 suspend
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }: { pageParam: number }) => fetchPosts(pageParam),
initialPageParam: 0,
initialData: {
pages: [initialData],
pageParams: [0],
},
getNextPageParam: (last: PostsResponse) => last.nextCursor ?? undefined,
})
// ... 나머지 렌더링 로직
}⚠️ initialData 패턴의 주의사항 —
use(dataPromise)로 얻은 첫 페이지를useInfiniteQuery의initialData에 넘기는 이 구조는 TanStack Query의 캐시·Hydration 메커니즘을 우회합니다.initialData는 쿼리 캐시에 등록되지 않기 때문에, 페이지 이동 후 복귀 시 stale/refetch 동작이 패턴 A와 다르게 작동할 수 있습니다. 느린 데이터를 비블로킹으로 스트리밍하는 목적으로만 쓰고, 캐시 재활용이 중요한 시나리오라면 패턴 A를 선택하는 것을 권장합니다.
직렬화 제약 — 서버에서 클라이언트로 Promise를 prop으로 전달할 때, React는 직렬화 가능한 값(JSON 호환 타입)만 지원합니다. 클래스 인스턴스나 함수를 Promise 결과에 포함시키면 오류가 발생하므로, 순수 데이터 객체만 전달하는 것을 권장합니다.
패턴 C: ReactQueryStreamedHydration — 수동 프리페치 없는 자동화
매번 서버 컴포넌트마다 prefetchInfiniteQuery를 작성하기 번거롭다면, @tanstack/react-query-next-experimental 패키지의 ReactQueryStreamedHydration을 레이아웃에 한 번만 설정하는 방법도 있습니다.
단, 이 패키지는 현재 experimental 상태입니다. TanStack 팀에서 안정화 작업을 진행 중이나, 공식 패키지로 격상된 시점은 이 글 작성 기준으로 미정입니다. 프로덕션 도입 전에는 공식 릴리즈 노트를 통해 현황을 확인해보시면 좋습니다.
// app/layout.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental'
// QueryClient는 컴포넌트 외부에서 한 번만 생성해야 합니다
const queryClient = new QueryClient()
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ReactQueryStreamedHydration>
{children}
</ReactQueryStreamedHydration>
</QueryClientProvider>
)
}// 이후 어떤 Client Component에서든 수동 prefetch 없이 SSR + 스트리밍이 동작
'use client'
import { useSuspenseInfiniteQuery } from '@tanstack/react-query'
import type { PostsResponse } from '@/types'
export function PostList() {
const { data } = useSuspenseInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }: { pageParam: number }) => fetchPosts(pageParam),
initialPageParam: 0,
getNextPageParam: (last: PostsResponse) => last.nextCursor ?? undefined,
})
// ...
}설정이 간편해서 기본 패턴으로 쓰고 싶어지지만, 클라이언트 네비게이션 시 서버 컴포넌트가 재실행되지 않는 경우 중첩된 useSuspenseQuery에서 waterfall이 생길 수 있어, 복잡한 데이터 의존 구조에서는 패턴 A가 더 안전합니다.
참고로, 아이템 수가 수만 건을 넘기 시작하면 DOM 노드가 무거워지는 또 다른 문제가 생깁니다. 그건 TanStack Virtual과 가상화로 별도로 다뤄야 할 이야기인데, 다음 글에서 이어가겠습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 초기 로딩 성능 | 서버에서 여러 페이지를 프리페치해 클라이언트 요청 폭포수를 제거 |
| SEO 대응 | 서버 렌더링된 HTML이 검색 엔진 크롤러에 노출됨 |
| 점진적 스트리밍 | Suspense boundary 단위로 UI가 순차 전송되어 체감 속도 개선 |
| 캐시 재활용 | 서버 캐시를 클라이언트가 그대로 이어받아 중복 네트워크 요청 없음 |
| use() 유연성 | 조건문·반복문 안에서도 호출 가능해 코드 구조 자유도가 높음 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| Hydration mismatch | 서버·클라이언트 렌더 결과 불일치 시 오류 | Suspense boundary 안에 날짜, 랜덤값 등 비결정적 데이터 사용 자제 |
| initialData 캐시 우회 | 패턴 B에서 initialData가 쿼리 캐시를 거치지 않아 stale/refetch 동작이 달라질 수 있음 | 캐시 재활용이 중요하다면 패턴 A(HydrationBoundary) 사용 |
| experimental 패키지 위험 | ReactQueryStreamedHydration은 아직 안정 버전 아님 | 프로덕션 도입 전 릴리즈 노트 확인, 복잡한 페이지는 패턴 A 병행 |
| Promise 직렬화 제약 | use() 패턴에서 클래스 인스턴스·함수 전달 불가 | 순수 데이터 객체(JSON 호환)만 Promise 결과로 반환 |
Hydration mismatch — 서버에서 만들어진 HTML과 클라이언트에서 React가 다시 그린 DOM이 다를 때 발생하는 오류입니다. 주로 서버·클라이언트 간 시간 또는 랜덤값이 다를 때 생기며,
suppressHydrationWarning어트리뷰트나useEffect내 클라이언트 전용 값 처리로 대응할 수 있습니다.
실무에서 가장 흔한 실수
-
staleTime을 설정하지 않은 채 프리페치 사용 — 저도 이걸 한 번 놓쳤다가 배포 후 페이지를 오갈 때마다 재요청이 쏟아지는 걸 보고 당황했습니다. 서버에서 채운 캐시가 클라이언트 마운트 직후 stale 처리되어 즉시 재요청이 발생하는 게 원인입니다. 최소staleTime: 60 * 1000정도는 설정해두는 것을 권장합니다. -
서버와 클라이언트의
getNextPageParam로직 불일치 — 두 곳에 각각 작성하다 보면 커서 계산 방식이 미묘하게 달라지기 쉽습니다.queryOptions객체를 공통 모듈로 분리해 서버와 클라이언트 양쪽에서 그대로 가져다 쓰면 이 문제를 깔끔하게 피할 수 있습니다. -
ReactQueryStreamedHydration에 지나치게 의존 — 설정이 간편해서 기본 패턴으로 쓰고 싶어지지만, 클라이언트 네비게이션 시나리오가 복잡해지면 waterfall이 숨어 있다가 나타납니다. 복잡한 페이지라면 명시적prefetchInfiniteQuery패턴이 더 예측 가능합니다.
마치며
세 패턴을 살펴봤는데, 선택 기준은 생각보다 단순합니다. 캐시 재활용과 stale 관리가 중요하다면 패턴 A, 느린 데이터를 비블로킹으로 스트리밍하는 게 목적이라면 패턴 B, 간단한 페이지나 프로토타이핑이라면 패턴 C로 시작해보시면 됩니다.
저는 이 조합으로 넘어간 뒤 클라이언트 첫 요청이 사라지는 것을 네트워크 탭에서 확인했을 때 꽤 신기했습니다. 스켈레톤이 깜빡이던 자리에 데이터가 이미 채워진 채로 페이지가 열리는 경험이 생각보다 인상적이거든요.
지금 바로 시작해볼 수 있는 3단계:
- 기존
useInfiniteQuery컴포넌트에 패턴 A를 붙여보는 것부터 시작해보시면 좋습니다.@tanstack/react-queryv5가 설치되어 있다면, 해당 페이지의 Server Component에prefetchInfiniteQuery와HydrationBoundary를 추가하는 것만으로 클라이언트 첫 요청이 사라지는 것을 네트워크 탭에서 바로 확인하실 수 있습니다. staleTime과pages수를 조정하면서 성능 트레이드오프를 직접 체감해보는 것을 권장합니다.pages: 1과pages: 3의 차이,staleTime: 0과staleTime: 60000의 차이를 Lighthouse나 Chrome DevTools Network 탭으로 비교해보시면 각 설정의 의미가 훨씬 명확하게 다가옵니다.- 느린 데이터가 있는 페이지라면 패턴 B의 스트리밍 방식을 실험해보시면 좋습니다. 서버 컴포넌트에서
await없이 Promise를 만들어 Client Component로 내려보내고,Suspense로 감싸보면 Header가 먼저 그려지고 목록이 뒤따라 채워지는 점진적 렌더링을 눈으로 확인하실 수 있습니다.
비슷한 패턴을 이미 적용해보셨거나, 다른 시행착오가 있으셨다면 댓글로 알려주시면 좋겠습니다. 상황마다 다르게 적용한 경험들이 쌓이면 이 글도 더 풍부해질 것 같습니다.
다음 글: TanStack Virtual과
useInfiniteQuery를 연동해 수만 건 아이템의 무한 스크롤에서 DOM 노드 수를 일정하게 유지하는 가상화 패턴 구현기
참고 자료
- Infinite Scroll and Streaming Data with TanStack Query + React 19 | Makers' Den
- Infinite Queries | TanStack Query React 공식 문서
- Prefetching & Router Integration | TanStack Query React 공식 문서
- Advanced Server Rendering | TanStack Query React 공식 문서
- use – React 공식 문서
- React v19 릴리즈 노트 | react.dev
- The Next.js 15 Streaming Handbook | freeCodeCamp
- React TanStack Query Nextjs Suspense Streaming Example | TanStack Query Docs
- usePrefetchInfiniteQuery | TanStack Query React 공식 문서