서론
최근 회사 프로젝트를 Pages Router에서 App Router로 마이그레이션하는 작업을 진행했습니다. 그 과정에서 단순히 코드 구조만 바뀐 것이 아니라, TTFB(Time To First Byte) 와 TTI(Time To Interactive) 같은 핵심 지표에서 눈에 띄는 개선을 경험할 수 있었습니다.
“왜 이렇게 빨라진 걸까?”
궁금증이 생겨 직접 Pages Router, App Router, Suspense, TanStack Query, Client Fetch 방식들을 비교 테스트했고, 그 결과를 정리해 보려고 합니다.
테스트
테스트 환경
데이터 페칭 시간을 시뮬레이션 하기 위해 의도적으로 지연을 넣었습니다.
1// lib/api.ts2const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));3
4export async function fetchPosts() {5 await delay(3000); // 3초 지연6 return [7 { id: 1, title: 'First Post', body: '...' },8 { id: 2, title: 'Second Post', body: '...' },9 ];10}11
12export async function fetchComments() {13 await delay(2000); // 2초 지연14 return [15 { id: 1, text: 'Great post!' },16 ];17}18
19export async function fetchUser() {20 await delay(1000); // 1초 지연21 return { id: 1, name: 'John' };22}Pages Router: 6+@초
문제 : 모든 데이터를 순차적으로 기다립니다.
1// pages/index.tsx2export const getServerSideProps = async () => {3 const user = await fetchUser(); // 1초4 const posts = await fetchPosts(); // 3초5 const comments = await fetchComments(); // 2초6 // 총 6초 후 페이지 전송7 8 return { props: { user, posts, comments } };9};1클릭-> 요청 -> 서버처리 -> HTML 생성 -> 응답 전송 -> 브라우저 파싱 -> Hydration위와 같은 플로우로 브라우저에 렌더링이 됩니다. 총 6초가 지난 후 바로 렌더링이 되야하지만, 약 +6초가 더 흐른 뒤 렌더링이 됩니다.
App Router Basic: 6초
그러면 위와 비슷한 방식으로 App Router는 어떨까요?
1// app/page.tsx2export default async function Page() {3 const user = await fetchUser(); // 1초 대기4 const posts = await fetchPosts(); // 3초 대기5 const comments = await fetchComments(); // 2초 대기6 // 총 6초 후 렌더링7 8 return <div>...</div>;9}여전히 6초가 걸리지만, Page Router보다는 브라우저에 렌더링 되는 시간은 현저히 줄어들었습니다. 이는, Page Router의 렌더링 파이프라인 효율의 차이가 있는 걸 알 수 있습니다.
App Router + Suspense 3초
핵심 : 각 컴포넌트를 독립적으로 처리합니다.
1// app/page.tsx2export default function Page() {3 return (4 <div>5 <Suspense fallback={<Loading />}>6 <User /> {/* 1초 후 표시 */}7 </Suspense>8 9 <Suspense fallback={<Loading />}>10 <Comments /> {/* 2초 후 표시 */}11 </Suspense>12 13 <Suspense fallback={<Loading />}>14 <Posts /> {/* 3초 후 표시 */}15 </Suspense>16 </div>17 );18}19
20async function User() {21 const user = await fetchUser();22 return <div>{user.name}</div>;23}24
25async function Posts() {26 const posts = await fetchPosts();27 return <div>{posts.map(...)}</div>;28}App Router와 Suspense를 함께 사용하면 페이지 전체가 모든 데이터를 기다릴 필요 없이 부분 단위로 렌더링할 수 있습니다.
Suspense란?
React의 Suspense는 비동기 작업(데이터 fetching, lazy loading 등)이 끝날 때까지 컴포넌트 렌더링을 잠시 보류하고, 그 동안 로딩 UI를 보여주는 기능입니다. (ex. Skeleton UI) 즉, 데이터를 기다리는 동안 페이지 전체가 멈추지 않고, 필요한 컴포넌트만 독립적으로 로딩 상태를 표시할 수 있습니다.
App Router + Suspense: 서버 스트리밍(Server Streaming)
Next.js 13의 App Router는 Suspense와 결합하면 서버에서 HTML을 먼저 렌더링하고, 데이터가 준비되는 컴포넌트만 스트리밍으로 보내는 방식을 지원합니다.
11. 브라우저가 요청을 보냄22. 서버는 HTML 구조를 먼저 전송33. 각 Suspense 블록이 데이터 준비가 되면 순차적으로 전송44. 브라우저는 도착한 HTML을 바로 렌더링 → TTFB, TTI 개선이 방식 덕분에 모든 데이터를 기다리지 않아도 화면 일부를 먼저 보여줄 수 있고, 사용자는 더 빠르게 인터랙션할 수 있습니다. 즉, 이전 Pages Router 방식처럼 “모든 데이터 준비 → 전체 렌더링” 구조에서 벗어나, 부분 스트리밍 + 병렬 데이터 fetching 구조로 바뀌는 것입니다.
React-Query의 Prefetch 사용 : 0.초
하지만, 사용자들은 3초도 기다려주지 않습니다. 저희한테 필요한건 0.초입니다.
| prefetch | no prefetch |
|---|---|
![]() | ![]() |
1// app/page.tsx2'use client';3import { useQueryClient } from '@tanstack/react-query';4
5export default function Home() {6 const queryClient = useQueryClient();7 8 const handlePrefetch = () => {9 queryClient.prefetchQuery({10 queryKey: ['posts'],11 queryFn: fetchPosts,12 });13 };14 15 return (16 <Link 17 href="/posts"18 onMouseEnter={handlePrefetch} // 마우스 올리면 미리 가져옴19 >20 Posts 보기21 </Link>22 );23}1// app/posts/page.tsx2'use client';3import { useQuery } from '@tanstack/react-query';4
5export default function PostsPage() {6 const { data: posts } = useQuery({7 queryKey: ['posts'],8 queryFn: fetchPosts,9 });10 11 return <div>{posts?.map(...)}</div>;12}prefetch 사용 시 : 사용자가 다음에 필요한 데이터를 미리 데이터를 가져와 캐시에 저장합니다. -> 페이지 진입 즉시 데이터 표시 (로딩 없음) prefetch 미사용 시 : 페이지 진입 시점에서야 데이터를 가져오기 시작하므로 로딩 UI가 노출됩니다.
즉, react-query의 prefetch를 사용하거나, prefetch용 서버를 구현해 데이터를 미리 가지고 오면 사용자는 데이터 로딩을 기다리지 않고, 미리 캐싱된 데이터로 데이터를 출력하여 로딩 UI가 노출되지 않습니다.
TTFB(Time To First Byte) 와 TTI(Time To Interactive) 같은 핵심 지표에서도 눈에 띄는 개선을 경험할 수 있습니다.
client-only는 클라이언트에서 데이터를 요청하는 것이라, no-prefetch와 똑같다고 생각하시면 됩니다.
어떤 전략을 써야할까?
Page Router, App Router 및 ISR, CSR, SSR 렌더링 마다 장,단점이 있습니다. 물론, 위와 같이 prefetch를 적용하고 useSuspenseQuery를 클라이언트 컴포넌트에 배치하여 읽기를 요청하면서 사용하거나, 의도적으로 로딩시간을 줄 수 있는 방법도 있습니다.
prefetch를 사용하게 되면, Hydration mismatch를 주의해야합니다.
또한, prefetch를 통해 Hydration 전략과 Suspense, ErrorBoundary를 통핸 에러 핸들링의 대한 전략도 팀원들과 고려하면 좋을 것 같습니다 !
코드 참고하기-> NEXTJS-SSR-COMPARISON
참고 Next.js App Router에서 prefetchQuery와 Suspense로 뚜루루뚜루 데이터 스트리밍하기 Codegen으로 React Hook 자동 생성하기

