
최근 회사 프로젝트를 Pages Router에서 App Router로 마이그레이션하는 작업을 진행했습니다.
그 과정에서 단순히 코드 구조만 바뀐 것이 아니라, TTFB(Time To First Byte)와 TTI(Time To Interactive) 같은 핵심 지표에서 눈에 띄는 개선을 경험할 수 있었습니다.
“왜 이렇게 빨라진 걸까?”
궁금증이 생겨 직접 Pages Router, App Router, Suspense, TanStack Query, Client Fetch 방식들을 비교 테스트했고,
그 결과를 정리해 보려고 합니다.

데이터 페칭 시간을 시뮬레이션 하기 위해 의도적으로 지연을 넣었습니다.
// lib/api.ts
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
export async function fetchPosts() {
await delay(3000); // 3초 지연
return [
{ id: 1, title: 'First Post', body: '...' },
{ id: 2, title: 'Second Post', body: '...' },
];
}
export async function fetchComments() {
await delay(2000); // 2초 지연
return [
{ id: 1, text: 'Great post!' },
];
}
export async function fetchUser() {
await delay(1000); // 1초 지연
return { id: 1, name: 'John' };
}

문제 : 모든 데이터를 순차적으로 기다립니다.
// pages/index.tsx
export const getServerSideProps = async () => {
const user = await fetchUser(); // 1초
const posts = await fetchPosts(); // 3초
const comments = await fetchComments(); // 2초
// 총 6초 후 페이지 전송
return { props: { user, posts, comments } };
};
클릭-> 요청 -> 서버처리 -> HTML 생성 -> 응답 전송 -> 브라우저 파싱 -> Hydration
위와 같은 플로우로 브라우저에 렌더링이 됩니다.
총 6초가 지난 후 바로 렌더링이 되야하지만, 약 +6초가 더 흐른 뒤 렌더링이 됩니다.

그러면 위와 비슷한 방식으로 App Router는 어떨까요?
// app/page.tsx
export default async function Page() {
const user = await fetchUser(); // 1초 대기
const posts = await fetchPosts(); // 3초 대기
const comments = await fetchComments(); // 2초 대기
// 총 6초 후 렌더링
return <div>...</div>;
}
여전히 6초가 걸리지만, Page Router보다는 브라우저에 렌더링 되는 시간은 현저히 줄어들었습니다.
이는, Page Router의 렌더링 파이프라인 효율의 차이가 있는 걸 알 수 있습니다.

핵심 : 각 컴포넌트를 독립적으로 처리합니다.
// app/page.tsx
export default function Page() {
return (
<div>
<Suspense fallback={<Loading />}>
<User /> {/* 1초 후 표시 */}
</Suspense>
<Suspense fallback={<Loading />}>
<Comments /> {/* 2초 후 표시 */}
</Suspense>
<Suspense fallback={<Loading />}>
<Posts /> {/* 3초 후 표시 */}
</Suspense>
</div>
);
}
async function User() {
const user = await fetchUser();
return <div>{user.name}</div>;
}
async function Posts() {
const posts = await fetchPosts();
return <div>{posts.map(...)}</div>;
}
App Router와 Suspense를 함께 사용하면 페이지 전체가 모든 데이터를 기다릴 필요 없이 부분 단위로 렌더링할 수 있습니다.
React의 Suspense는 비동기 작업(데이터 fetching, lazy loading 등)이 끝날 때까지 컴포넌트 렌더링을 잠시 보류하고, 그 동안 로딩 UI를 보여주는 기능입니다. (ex. Skeleton UI)
즉, 데이터를 기다리는 동안 페이지 전체가 멈추지 않고, 필요한 컴포넌트만 독립적으로 로딩 상태를 표시할 수 있습니다.
Next.js 13의 App Router는 Suspense와 결합하면 서버에서 HTML을 먼저 렌더링하고, 데이터가 준비되는 컴포넌트만 스트리밍으로 보내는 방식을 지원합니다.
1. 브라우저가 요청을 보냄
2. 서버는 HTML 구조를 먼저 전송
3. 각 Suspense 블록이 데이터 준비가 되면 순차적으로 전송
4. 브라우저는 도착한 HTML을 바로 렌더링 → TTFB, TTI 개선
이 방식 덕분에 모든 데이터를 기다리지 않아도 화면 일부를 먼저 보여줄 수 있고, 사용자는 더 빠르게 인터랙션할 수 있습니다.
즉, 이전 Pages Router 방식처럼 “모든 데이터 준비 → 전체 렌더링” 구조에서 벗어나, 부분 스트리밍 + 병렬 데이터 fetching 구조로 바뀌는 것입니다.
하지만, 사용자들은 3초도 기다려주지 않습니다. 저희한테 필요한건 0.초입니다.
| prefetch | no prefetch |
|---|---|
![]() | ![]() |
// app/page.tsx
'use client';
import { useQueryClient } from '@tanstack/react-query';
export default function Home() {
const queryClient = useQueryClient();
const handlePrefetch = () => {
queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
};
return (
<Link
href="/posts"
onMouseEnter={handlePrefetch} // 마우스 올리면 미리 가져옴
>
Posts 보기
</Link>
);
}
// app/posts/page.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
export default function PostsPage() {
const { data: posts } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
return <div>{posts?.map(...)}</div>;
}
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를 통핸 에러 핸들링의 대한 전략도 팀원들과 고려하면 좋을 것 같습니다 !
참고
Next.js App Router에서 prefetchQuery와 Suspense로 뚜루루뚜루 데이터 스트리밍하기
Codegen으로 React Hook 자동 생성하기
좋은 예제들이네요. Streaming 을 통해서, 콘텐츠를 순차적(?) 또는 준비되면 보여주는 것은 정말 좋은 기능이라고 생각합니다.
그런데, SEO가 중요한 경우에는 중요한 콘텐츠는 최대한 빠르게 구성하도록 구조를 잘 짜야한다고 알고 있습니다. react-query 를 사용하면 좀 더 빠르게 성능 개선이 가능하나 결과적으로 클라이언트측에서 데이터 페칭 후 콘텐츠를 보여주기 때문에 SEO 측면에서는 안좋다는 생각이 듭니다. dehydrated 를 사용해보는것도 좋은 방법이겠네요.
좋은 글 잘 보고 가요!
안녕하세요
한 가지 궁금한게 있어서 댓글 남겨요!
App router에서는 Suspense를 사용하지 않아도 자동적으로 컴포넌트 단위 Streaming이 되는 걸로 알고 있습니다혹시 제가 잘못 알고 있는걸까요?