React SSR의 진화: Next.js Page Router에서 App Router로의 여정
들어가며
안녕하세요! 오늘은 React SSR(Server-Side Rendering)의 진화, 특히 Next.js의 Page Router에서 App Router로 전환하면서 가능해진 스트리밍 SSR에 대해 깊이 있게 살펴보겠습니다.
“왜 Page Router에서는 스트리밍이 안 되고, App Router에서는 되는 거야?”라는 질문에 대한 명확한 답을 찾아가는 여정이 될 것입니다.
먼저 체감해보기: 전통적 SSR의 한계
🐌 Page Router의 SSR 경험
// pages/dashboard.js (Page Router)
export default function Dashboard({ user, analytics, notifications }) {
return (
<Layout>
<UserProfile data={user} />
<AnalyticsChart data={analytics} /> {/* 3초 걸림 */}
<NotificationList items={notifications} />
</Layout>
);
}
export async function getServerSideProps() {
// 모든 데이터를 병렬로 가져와도...
const [user, analytics, notifications] = await Promise.all([
fetchUser(), // 0.5초
fetchAnalytics(), // 3초 (병목!)
fetchNotifications() // 0.3초
]);
// 가장 느린 요청(3초)이 끝날 때까지 기다려야 함
return {
props: { user, analytics, notifications }
};
}사용자는 3초 동안 빈 화면을 보고 있어야 합니다!
✨ App Router의 스트리밍 SSR
// app/dashboard/page.js (App Router)
export default function Dashboard() {
return (
<Layout>
<Suspense fallback={<UserProfileSkeleton />}>
<UserProfile /> {/* 0.5초 후 표시 */}
</Suspense>
<Suspense fallback={<AnalyticsChartSkeleton />}>
<AnalyticsChart /> {/* 3초 후 표시 */}
</Suspense>
<Suspense fallback={<NotificationListSkeleton />}>
<NotificationList /> {/* 0.3초 후 표시 */}
</Suspense>
</Layout>
);
}
// 각 컴포넌트가 독립적으로 데이터 페칭
async function UserProfile() {
const user = await fetchUser();
return <UserProfileComponent data={user} />;
}사용자는 0.3초만에 첫 콘텐츠를 보기 시작합니다!

Page Router의 SSR: 왜 스트리밍이 불가능했나?
📦 모놀리식 렌더링 파이프라인
Page Router의 SSR은 이런 순서로 작동합니다:
1. 요청 수신
2. getServerSideProps 실행 (모든 데이터 페칭)
3. React 컴포넌트를 문자열로 렌더링
4. 완성된 HTML 전송
5. 클라이언트에서 Hydration코드로 표현하면:
// Next.js Page Router 내부 동작 (간소화)
async function renderPage(req, res) {
// 1단계: 모든 데이터 가져오기
const props = await getServerSideProps({ req, res });
// 2단계: 전체 페이지를 문자열로 렌더링
const html = ReactDOMServer.renderToString(
<App pageProps={props} />
);
// 3단계: 완성된 HTML 전송
res.send(`
<!DOCTYPE html>
<html>
<body>
<div id="__next">${html}</div>
<script>
window.__NEXT_DATA__ = ${JSON.stringify(props)}
</script>
</body>
</html>
`);
}🚫 renderToString의 한계
renderToString은 동기적으로 작동합니다:


// React 17까지의 서버 렌더링
const html = ReactDOMServer.renderToString(<App />);
// 이 시점에서 모든 컴포넌트가 완전히 렌더링되어야 함
// Suspense? 무시됨!만약 컴포넌트가 Promise를 throw하면?
// Page Router에서는 작동하지 않음!
function AsyncComponent() {
if (!data) {
throw fetchData(); // Promise를 throw
}
return <div>{data}</div>;
}
// 서버에서 에러 발생!React 18의 혁명: 스트리밍 가능한 SSR
🌊 renderToPipeableStream의 등장
React 18은 새로운 서버 렌더링 API를 도입했습니다:

// React 18의 스트리밍 SSR
import { renderToPipeableStream } from 'react-dom/server';
app.get('/', (req, res) => {
const { pipe } = renderToPipeableStream(
<App />,
{
bootstrapScripts: ['/main.js'],
onShellReady() {
// 초기 HTML 셸이 준비되면 스트리밍 시작
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
onAllReady() {
// 모든 Suspense 경계가 해결됨
},
onError(error) {
console.error(error);
}
}
);
});🎯 스트리밍의 실제 동작
function Page() {
return (
<html>
<body>
<nav>네비게이션</nav> {/* 즉시 전송 */}
<Suspense fallback={<div id="loading">로딩 중...</div>}>
<SlowComponent /> {/* 나중에 전송 */}
</Suspense>
<footer>푸터</footer> {/* 즉시 전송 */}
</body>
</html>
);
}스트리밍 SSR의 네트워크 전송 방식
🌐 왜 HTTP/2 Server Push가 아닌가?
많은 분들이 “스트리밍이면 HTTP/2 Server Push를 쓰는 거 아니야?”라고 생각하실 수 있습니다. 하지만 실제로는 HTTP/1.1의 Chunked Transfer Encoding을 사용합니다.

HTTP/2 Server Push의 문제점
// HTTP/2 Server Push의 이상적인 시나리오
server.on('stream', (stream, headers) => {
// HTML 요청 시 CSS/JS를 미리 푸시
if (headers[':path'] === '/index.html') {
stream.pushStream({ ':path': '/styles.css' }, (err, pushStream) => {
pushStream.respond({ ':status': 200 });
pushStream.end(cssContent);
});
}
});하지만 실제로는:
- 캐시 무시 문제: 클라이언트가 이미 리소스를 캐시했어도 서버가 알 수 없음
- 우선순위 제어 어려움: 브라우저가 필요한 순서대로 리소스를 요청하는 게 더 효율적
- 복잡성: 구현과 디버깅이 어려움
- 브라우저 지원 중단: Chrome은 HTTP/2 Server Push 지원을 제거
📡 HTTP/1.1 Chunked Transfer Encoding
React의 스트리밍 SSR은 검증된 기술인 Chunked Transfer Encoding을 사용합니다:


각 청크의 구조:
- 청크 크기 (16진수):
1a2,f8,2b5 - 청크 데이터: HTML 또는 JavaScript
- 청크 종료:
\r\n - 스트림 종료:
0\r\n\r\n
🔍 브라우저에서의 처리 과정
// 브라우저의 스트리밍 HTML 파서 (개념적 표현)
class StreamingHTMLParser {
constructor() {
this.buffer = '';
this.suspended = new Map(); // Suspense 경계 추적
}
processChunk(chunk) {
this.buffer += chunk;
// 완성된 HTML 요소가 있으면 즉시 렌더링
while (this.hasCompleteElement()) {
const element = this.parseNextElement();
if (element.tagName === 'template') {
// Suspense 교체를 위한 템플릿
this.suspended.set(element.id, element);
} else if (element.tagName === 'script' && element.textContent.includes('$RC')) {
// React의 교체 스크립트 실행
this.executeSuspenseReplacement(element);
} else {
// 일반 요소는 즉시 DOM에 추가
this.appendToDOM(element);
}
}
}
}📊 실제 네트워크 타임라인
// 개발자 도구에서 볼 수 있는 패턴
fetch('/dashboard')
.then(response => {
const reader = response.body.getReader();
const decoder = new TextDecoder();
function read() {
reader.read().then(({ done, value }) => {
if (done) {
console.log('스트림 완료');
return;
}
const chunk = decoder.decode(value, { stream: true });
console.log('청크 수신:', chunk.length, 'bytes');
// 청크 타임스탬프
// 0ms: 초기 HTML 셸 (5KB)
// 100ms: 빠른 컴포넌트 (2KB)
// 500ms: 중간 컴포넌트 (8KB)
// 2000ms: 느린 컴포넌트 (15KB)
read();
});
}
read();
});App Router: 스트리밍 SSR의 완전한 구현
🏗️ App Router의 아키텍처
App Router는 React 18의 기능을 완전히 활용하도록 설계되었습니다:
// app/layout.js
export default function RootLayout({ children }) {
return (
<html>
<body>
<Header /> {/* 즉시 렌더링 */}
{children} {/* 페이지별로 스트리밍 */}
</body>
</html>
);
}
// app/page.js
export default async function Page() {
// 서버 컴포넌트에서 직접 async/await 사용!
const data = await fetchData();
return (
<main>
<h1>홈페이지</h1>
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid />
</Suspense>
</main>
);
}🔑 핵심 차이점: Server Components
App Router의 가장 큰 혁신은 React Server Components (RSC)입니다:
// 서버 컴포넌트 (기본값)
async function ProductList() {
// 서버에서만 실행 - 번들에 포함 안 됨!
const products = await db.query('SELECT * FROM products');
return (
<div>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
// 클라이언트 컴포넌트 (명시적 선언)
'use client';
function ProductCard({ product }) {
const [liked, setLiked] = useState(false);
return (
<div onClick={() => setLiked(!liked)}>
{product.name}
</div>
);
}스트리밍 SSR의 내부 동작
📡 Progressive Enhancement
App Router의 스트리밍은 점진적으로 페이지를 향상시킵니다:
// 복잡한 대시보드 예제
export default function Dashboard() {
return (
<>
{/* 1단계: 즉시 표시되는 정적 콘텐츠 */}
<DashboardHeader />
{/* 2단계: 빠른 데이터 (0.1초) */}
<Suspense fallback={<StatsCardSkeleton />}>
<QuickStats />
</Suspense>
{/* 3단계: 중간 속도 데이터 (0.5초) */}
<Suspense fallback={<ChartSkeleton />}>
<RecentActivityChart />
</Suspense>
{/* 4단계: 느린 데이터 (2초) */}
<Suspense fallback={<TableSkeleton />}>
<DetailedAnalyticsTable />
</Suspense>
</>
);
}실제 전송되는 HTML 스트림:
<!-- 청크 1: 초기 셸 -->
<!DOCTYPE html>
<html>
<body>
<header>대시보드</header>
<div id="S:0">로딩 중...</div>
<div id="S:1">차트 로딩 중...</div>
<div id="S:2">테이블 로딩 중...</div>
<!-- 청크 2: QuickStats (100ms 후) -->
<template id="T:0">
<div class="stats">
<div>총 사용자: 1,234</div>
<div>활성 세션: 567</div>
</div>
</template>
<script>
$RC("T:0", "S:0"); // Suspense 경계 교체
</script>
<!-- 청크 3: RecentActivityChart (500ms 후) -->
<template id="T:1">
<canvas id="chart">...</canvas>
</template>
<script>
$RC("T:1", "S:1");
// 차트 초기화 코드
</script>
🧩 선택적 Hydration
App Router는 컴포넌트별로 독립적인 hydration을 수행합니다:


// 각 Suspense 경계가 독립적으로 hydrate됨
function Page() {
return (
<>
<Suspense fallback={<LoadingA />}>
<InteractiveComponentA /> {/* 준비되면 즉시 상호작용 가능 */}
</Suspense>
<Suspense fallback={<LoadingB />}>
<HeavyComponentB /> {/* A와 독립적으로 hydrate */}
</Suspense>
</>
);
}브라우저의 처리:
// React의 선택적 Hydration (개념적 표현)
class SelectiveHydration {
hydrateSuspenseBoundary(boundaryId, componentCode) {
// 1. 해당 경계의 정적 HTML 찾기
const boundary = document.getElementById(boundaryId);
// 2. 이벤트 리스너 임시 캡처
const pendingEvents = this.capturePendingEvents(boundary);
// 3. 컴포넌트 hydrate
ReactDOM.hydrateRoot(boundary, componentCode);
// 4. 캡처된 이벤트 재실행
pendingEvents.forEach(event => {
boundary.dispatchEvent(event);
});
}
}실전 패턴: 스트리밍 SSR 최적화
1️⃣ 데이터 폭포수 방지
// ❌ 나쁜 예: 순차적 로딩
async function BadPattern() {
const user = await fetchUser();
const preferences = await fetchUserPreferences(user.id); // user를 기다림
const recommendations = await fetchRecommendations(preferences); // preferences를 기다림
return <div>...</div>;
}
// ✅ 좋은 예: 병렬 로딩 + 스트리밍
function GoodPattern({ userId }) {
return (
<>
<Suspense fallback={<UserSkeleton />}>
<UserInfo userId={userId} />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations userId={userId} /> {/* 독립적으로 로드 */}
</Suspense>
</>
);
}2️⃣ 중요도별 콘텐츠 우선순위
// app/product/[id]/page.js
export default function ProductPage({ params }) {
return (
<div className="product-page">
{/* 핵심 정보는 블로킹 */}
<ProductEssentials productId={params.id} />
{/* 보조 정보는 스트리밍 */}
<div className="secondary-content">
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={params.id} />
</Suspense>
<Suspense fallback={<RelatedSkeleton />}>
<RelatedProducts productId={params.id} />
</Suspense>
</div>
</div>
);
}
// 핵심 정보는 페이지와 함께 전송
async function ProductEssentials({ productId }) {
const product = await fetchProduct(productId); // 빠른 쿼리
return (
<div>
<h1>{product.name}</h1>
<p>{product.price}</p>
<AddToCartButton productId={productId} />
</div>
);
}실무 체크리스트
📋 스트리밍 SSR 도입 시 고려사항
- 초기 셸의 중요성: 레이아웃과 네비게이션은 즉시 표시
- Suspense 경계 설계: 너무 많으면 오버헤드, 너무 적으면 효과 감소
- 에러 경계 배치: 부분 실패를 우아하게 처리
- SEO 고려: 중요한 메타데이터는 초기 응답에 포함
- 캐싱 전략: 스트리밍과 캐싱의 균형
🎯 언제 스트리밍 SSR을 사용할까?
// ✅ 스트리밍이 효과적인 경우
- 대시보드처럼 여러 독립적인 섹션이 있는 페이지
- 일부는 빠르고 일부는 느린 데이터 소스
- 사용자가 스크롤하면서 점진적으로 콘텐츠를 보는 경우
// ❌ 스트리밍이 불필요한 경우
- 모든 데이터가 빠르게 로드되는 단순한 페이지
- 전체 콘텐츠가 한 번에 필요한 경우 (예: PDF 생성)
- 레거시 시스템과의 호환성이 중요한 경우마무리
Page Router에서 App Router로의 전환은 단순한 API 변경이 아닙니다. React의 렌더링 모델에 대한 근본적인 재고찰이었죠.
Page Router의 한계:
renderToString의 동기적 특성- 모놀리식 데이터 페칭
- All-or-nothing 렌더링
App Router의 혁신:
- React 18의 스트리밍 기능 완전 활용
- Server Components로 번들 크기 감소
- 점진적 향상과 선택적 hydration
- 검증된 HTTP/1.1 Chunked Transfer Encoding 사용
스트리밍 SSR은 단순히 “빠른 SSR”이 아니라, 사용자가 콘텐츠를 소비하는 방식에 맞춘 SSR입니다.
여러분의 Next.js 앱도 이제 스트리밍의 힘을 활용해보세요. 사용자는 그 차이를 즉시 체감할 것입니다!