서론
최근 회사 프로젝트를 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 개선