Next.js RSC + Streaming으로 TTFB를 350ms에서 60ms로 단축하는 방법
운영 중인 서비스의 Core Web Vitals를 들여다보다가 TTFB가 400ms를 훌쩍 넘는 페이지를 발견한 적이 있습니다. DB 쿼리는 각각 빠른데 왜 이렇게 느리지 싶어서 살펴봤더니, 세 개의 독립적인 쿼리가 직렬로 대기하고 있었습니다. Promise.all로 병렬화하면 해결되겠구나 싶었는데, 막상 TTFB는 크게 달라지지 않았습니다. 문제는 코드 한 줄이 아니라 아키텍처 수준에 있었습니다.
RSC + Streaming을 제대로 이해하고 나서야 수치가 드라마틱하게 달라졌습니다. 이 글을 읽고 나면 TTFB가 "가장 느린 쿼리가 끝나는 시간"이 아니라 "정적 쉘을 렌더링하는 시간"과 같아지도록 페이지를 설계하는 방법을 직접 적용해볼 수 있습니다. Next.js App Router를 기준으로 설명하며, React와 App Router를 써본 분이라면 바로 따라올 수 있는 내용입니다.
핵심 개념
전통 SSR과 Streaming SSR, 뭐가 다른가
솔직히 처음엔 "스트리밍이라고 해봤자 얼마나 다르겠어" 싶었습니다. 그런데 Network 탭을 열고 청크가 하나씩 도착하는 걸 보고 나서 생각이 완전히 바뀌었습니다.
전통 SSR은 서버가 데이터를 전부 모아서 완성된 HTML 하나를 돌려주는 구조입니다. 문제는 "전부"라는 단어에 있습니다. DB 쿼리 세 개가 각각 50ms, 100ms, 300ms라면 사용자는 300ms가 지나고 나서야 첫 바이트를 받습니다. 가장 느린 쿼리가 나머지를 인질로 잡는 셈입니다.
| 방식 | TTFB 결정 요인 | 사용자 경험 |
|---|---|---|
| 전통 SSR | 가장 느린 데이터 페치 완료 시점 | 흰 화면 → 한 번에 전체 렌더 |
| Streaming SSR | 정적 쉘 렌더링 시간 | 레이아웃 즉시 표시 → 콘텐츠 순차 등장 |
| PPR | CDN 엣지 레이턴시 (20~80ms) | 캐시된 쉘 즉시 → 동적 구간 스트리밍 |
TTFB(Time to First Byte): 브라우저가 서버에 요청을 보낸 시점부터 응답의 첫 번째 바이트를 수신하는 시점까지의 시간. Google의 Core Web Vitals 권장 기준은 800ms 이하, 이상적으로는 200ms 이하입니다.
RSC가 클라이언트에 실제로 보내는 것
RSC(React Server Components)가 전달하는 건 완성된 HTML이 아닙니다. React Flight 프로토콜이라는 직렬화 형식으로 인코딩된 컴포넌트 트리입니다. (이 포맷의 내부 구조는 이 글에서는 상세히 다루지 않지만, 깊이 파고들고 싶다면 renderToPipeableStream 관련 문서가 좋은 출발점입니다.) 클라이언트 런타임은 이 스트림 청크를 받으면서 점진적으로 UI를 조합합니다.
// app/dashboard/_components/MetricsPanel.tsx
// 'use client'가 없으면 기본적으로 Server Component
async function MetricsPanel() {
const metrics = await getMonthlyMetrics(); // DB 직접 호출
// db 모듈은 클라이언트 번들에서 완전히 제외됨
return <MetricsGrid data={metrics} />;
}이 구조가 가져다주는 이점은 두 가지입니다. db 같은 서버 전용 코드가 클라이언트 번들에서 빠지고, DB 호출이 서버에서 직접 이루어지므로 네트워크 왕복이 없습니다.
Suspense 경계가 스트리밍을 작동시키는 방식
Suspense는 원래 코드 스플리팅을 위해 만들어진 API인데, 서버 스트리밍에서 핵심 역할을 맡게 됐습니다.
HTTP 레벨에서 보면 이렇게 동작합니다. 서버는 Transfer-Encoding: chunked 응답을 열고, Suspense 경계 밖의 정적 쉘을 첫 번째 청크로 즉시 flush합니다. 경계 안의 컴포넌트가 데이터를 기다리는 동안 fallback UI(스켈레톤 등)가 함께 전송됩니다. 데이터가 준비되면 서버가 해당 경계의 실제 콘텐츠를 추가 청크로 보내고, 클라이언트 런타임이 스켈레톤을 교체합니다.
// app/dashboard/page.tsx
export default function DashboardPage() {
return (
<DashboardLayout>
{/* DashboardLayout은 정적 — 첫 청크에 즉시 포함됨 */}
<Suspense fallback={<MetricsSkeleton />}>
<MetricsPanel /> {/* 내부 fetch 완료 시 별도 청크로 전송 */}
</Suspense>
</DashboardLayout>
);
}Suspense가 없으면 서버는 MetricsPanel의 데이터가 나올 때까지 아무것도 보내지 않습니다. Suspense가 있으면 레이아웃이 즉시 전달되고, 데이터는 나중에 채워집니다.
실전 적용
세 가지 예시를 복잡도 순으로 정리했습니다. 자신이 당장 마주한 상황과 가장 비슷한 것부터 시작해보시면 좋습니다.
예시 1: 데이터 헤비 대시보드 — 병렬 스트리밍
여러 위젯이 각자 독립적인 데이터를 필요로 하는 대시보드는 RSC Streaming 효과가 가장 두드러지는 유형입니다. 각 위젯을 독립 Suspense 경계로 감싸면 병렬 스트리밍이 가능합니다.
// app/dashboard/page.tsx
export default function DashboardPage() {
return (
<DashboardLayout>
{/* DashboardLayout은 정적 — 요청 즉시 flush */}
<Suspense fallback={<MetricsSkeleton />}>
<MetricsPanel /> {/* DB 쿼리 A: ~100ms */}
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart /> {/* DB 쿼리 B: ~150ms */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentTransactions /> {/* DB 쿼리 C: ~300ms */}
</Suspense>
</DashboardLayout>
);
}// app/dashboard/_components/MetricsPanel.tsx
// 집계 데이터 — 캐시 가능, Server Component
async function MetricsPanel() {
const metrics = await getMonthlyMetrics();
return <MetricsGrid data={metrics} />;
}| 구성 요소 | 역할 | 전송 시점 |
|---|---|---|
DashboardLayout |
네비게이션, 사이드바 | 요청 즉시 |
<MetricsSkeleton /> 등 |
로딩 플레이스홀더 | Layout과 함께 |
<MetricsPanel /> |
집계 지표 | 쿼리 A 완료 후 |
<RecentTransactions /> |
트랜잭션 목록 | 쿼리 C 완료 후 |
쿼리 A·B·C는 서로를 기다리지 않고 병렬로 실행됩니다. 전통 SSR이었다면 TTFB = 300ms+(대기), 이 구조에서는 레이아웃이 즉시 전달됩니다. 쿼리 C가 아무리 느려도 나머지 위젯이 먼저 보입니다.
예시 2: 이커머스 상품 상세 페이지 — PPR로 정적/동적 분리
상품 페이지는 흥미로운 구조를 가지고 있습니다. 제품명·이미지·설명은 거의 변하지 않지만, 재고와 가격은 실시간 데이터가 필요합니다. PPR(Partial Prerendering)은 이 둘을 빌드 타임에 분리해줍니다.
ProductHero와 ProductDescription이 정적으로 분류되는 이유는 외부 데이터 페치 없이 params.id만으로 빌드 타임에 생성 가능하기 때문입니다. 반면 LivePrice는 noStore()로 캐싱을 비활성화했기 때문에 매 요청마다 origin에서 페치됩니다. 이 차이가 캐시 전략의 분기점입니다.
// next.config.ts
export default {
experimental: { ppr: 'incremental' },
};// app/product/[id]/page.tsx
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<>
{/* 정적 쉘 — 빌드 타임에 생성, CDN에서 서빙 */}
<ProductHero id={params.id} />
<ProductDescription id={params.id} />
{/* 동적 구간 — origin에서 스트리밍 */}
<Suspense fallback={<PriceSkeleton />}>
<LivePrice id={params.id} />
</Suspense>
<Suspense fallback={<StockSkeleton />}>
<StockStatus id={params.id} />
</Suspense>
</>
);
}// app/product/[id]/_components/LivePrice.tsx
import { unstable_noStore as noStore } from 'next/cache';
async function LivePrice({ id }: { id: string }) {
noStore(); // 캐시 비활성화 — 매 요청마다 최신 가격 페치
const price = await fetchCurrentPrice(id);
return <PriceDisplay price={price} />;
}이 구조가 완성되면 ProductHero는 CDN 엣지에서 20~80ms 안에 전달되고, 가격·재고 정보는 그 직후 스트리밍됩니다. origin 서버 처리 시간이 TTFB에 전혀 영향을 주지 않습니다.
PPR (Partial Prerendering): 빌드 시점에 정적 HTML 쉘과
postponedStateblob을 함께 생성하는 Next.js 기능입니다. 요청 시 CDN이 쉘을 즉시 전송하고, 동시에 origin 서버가 동적 구간만 스트리밍합니다.experimental.ppr플래그 하에서 안정화 방향으로 발전하고 있습니다.
예시 3: 워터폴 방지 — Promise 선행 시작 패턴
저도 초반에 이 실수를 꽤 오래 했습니다. Suspense 경계를 잘 나눠놓고도 컴포넌트 내부에서 워터폴을 만드는 경우입니다. 특히 이런 패턴이 자주 보입니다.
// 나쁜 예: 직렬 워터폴 — 총 500ms 소요
// app/profile/[userId]/page.tsx
async function UserProfilePage({ params }: { params: { userId: string } }) {
const user = await getUser(params.userId); // 300ms 대기
const posts = await getUserPosts(user.id); // 그 다음 200ms 대기
// user.id === params.userId 인데, 굳이 user를 기다린 뒤 posts를 시작함
return <Profile user={user} posts={posts} />;
}getUserPosts가 user.id를 받기 때문에 의존성이 있어 보이지만, 사실 params.userId를 그대로 전달하면 두 fetch는 완전히 독립적입니다.
// 좋은 예: Promise 선행 시작 — 총 300ms 소요
// app/profile/[userId]/page.tsx
async function UserProfilePage({ params }: { params: { userId: string } }) {
const userPromise = getUser(params.userId); // 즉시 시작
const postsPromise = getUserPosts(params.userId); // 즉시 시작 (user를 기다리지 않음)
const [user, posts] = await Promise.all([userPromise, postsPromise]);
return <Profile user={user} posts={posts} />;
}이렇게 구조를 바꾸는 것만으로 200ms를 절약할 수 있습니다. 코드가 작아 보여도 실제 사용자 경험에서 꽤 두드러지는 차이입니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| TTFB 단축 | 정적 쉘이 즉시 전송되어 "느린 쿼리 = 느린 TTFB" 공식에서 벗어남 |
| 번들 크기 감소 | Server Component 코드는 클라이언트 번들에서 완전히 제외 |
| 데이터 소스 근접 페칭 | DB·내부 API를 서버에서 직접 호출해 네트워크 왕복 제거 |
| SEO 호환 | 스트리밍 중에도 검색 엔진이 콘텐츠 인덱싱 가능 |
| 병렬 데이터 페칭 | 독립적인 Suspense 경계가 병렬로 데이터를 가져옴 |
단점 및 주의사항
처음 도입할 때 가장 많이 당하는 문제가 리버스 프록시 버퍼링과 에러 처리입니다. 저는 Nginx 설정 때문에 로컬에서 멀쩡하던 스트리밍이 프로덕션에서 전혀 동작하지 않는 상황을 겪었습니다. Network 탭에서 청크가 한 번에 몰려 도착한다면 프록시 설정을 먼저 의심해볼 만합니다.
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 차단 페치 | 페이지 최상단 await가 스트리밍을 무력화 |
데이터 의존 구간을 Suspense 경계 안으로 이동 |
| 에러 처리 복잡도 | 쉘 flush 후 에러는 HTTP 상태 코드 변경 불가 | ErrorBoundary 필수, 사전 에러 복구 로직 설계 |
| 리버스 프록시 버퍼링 | Nginx 기본 설정이 청크를 버퍼링해 스트리밍 차단 | proxy_buffering off; proxy_cache off; 설정 필수 |
| Flight 페이로드 팽창 | Server→Client props가 크면 hydration 지연 | props 전달 최소화, 대형 데이터 직렬화 검토 |
| 환경 차이 | 로컬과 프로덕션의 스트리밍 동작이 다를 수 있음 | RUM 기반 실측 필수 |
| Suspense 설계 부담 | 경계가 너무 세밀하면 수십 개 로딩 UI가 동시 출현 | 정보 구조와 데이터 의존성을 함께 고려한 설계 |
RUM (Real User Monitoring): 실제 사용자 브라우저에서 수집한 성능 데이터.
@vercel/speed-insights나web-vitals라이브러리로 수집할 수 있습니다. 로컬 측정값은 프로덕션과 의미 있는 차이가 있어 신뢰하기 어렵습니다.
실무에서 가장 흔한 실수
-
레이아웃 컴포넌트에서 전역 데이터를
await로 차단하는 패턴 —app/layout.tsx에서 세션이나 사용자 정보를await로 불러오면 모든 하위 페이지의 TTFB가 그 시간만큼 늘어납니다. 레이아웃에 꼭 필요한 데이터라면 Suspense로 감싸거나 클라이언트 측으로 이동하는 방향을 검토해볼 수 있습니다. -
Nginx 설정을 그대로 두고 "스트리밍이 안 된다"고 결론 내리는 경우 — Network 탭에서 청크가 분리되어 보이지 않는다면
proxy_buffering off; proxy_cache off;설정부터 확인해볼 만합니다. 코드 문제가 아닐 가능성이 높습니다. -
Server Component에 클라이언트 로직을 붙이려다 생기는 혼란 — RSC 트리에서
'use client'경계를 어디에 두느냐에 따라 번들 크기가 크게 달라집니다. 클라이언트 상태나 이벤트 핸들러가 필요한 부분만 분리하는 습관이 중요합니다.
마치며
RSC + Streaming을 적용하면서 가장 크게 달라진 인식은, 성능 개선이 "빠른 코드"가 아니라 "올바른 아키텍처 경계"에서 온다는 점입니다. 쿼리를 최적화하는 것도 중요하지만, 느린 쿼리가 빠른 레이아웃을 막지 못하게 구조를 짜는 것이 TTFB 수치를 실제로 바꿔놓았습니다.
저는 이 순서대로 적용했고, 각 단계마다 눈에 보이는 변화를 확인할 수 있었습니다.
-
가장 먼저 할 일은 기존 페이지에서 가장 느린 데이터 fetch를 찾아
<Suspense fallback={<Skeleton />}>으로 감싸보는 것입니다. 이 하나만으로도 해당 구간이 나머지 렌더링을 차단하지 않게 됩니다. DevTools Network 탭에서 청크가 분리되어 도착하는지 확인해보시면 좋습니다. -
만약 컴포넌트 내부에 직렬 fetch가 있다면 의존성을 다시 살펴볼 수 있습니다. "이 fetch가 정말 앞 fetch의 결과를 필요로 하는가?"를 따져보면,
Promise.all로 묶을 수 있는 경우가 생각보다 많습니다. -
변경 전후를 수치로 비교하기 위해
pnpm add @vercel/speed-insights로 RUM을 붙여두는 것을 권장합니다. 로컬에서 빠르게 느껴져도 실제 사용자 데이터는 다를 수 있고, 그래프로 TTFB가 350ms에서 60ms로 꺾이는 것을 보는 순간 이 방향이 맞다는 확신이 생깁니다.
참고 자료
- React Server Components Streaming Performance Guide 2026 | SitePoint
- 8 Next.js Streaming Tactics That Slash TTFB | Medium (Neurobyte)
- The Ultimate Guide to Improving Next.js TTFB: From 800ms to <100ms | CatchMetrics
- Partial Prerendering (PPR) in Production: Architecture Patterns (2026 Edition) | samcheek.com
- How We Reduced TTFB by 60% Using Server Actions in Next.js 15 | Medium
- 6 React Server Component Performance Pitfalls in Next.js | LogRocket Blog
- How to Avoid Waterfalls in React Suspense | sergiodxa
- Guides: Streaming | Next.js 공식 문서
- Guides: PPR Platform Guide | Next.js 공식 문서
- How Streaming Helps Build Faster Web Applications | Vercel Blog
- React Server Components in Production: Benefits, Pitfalls and Best Practices 2026 | Growin
- Mastering Next.js 15 Streaming and Suspense: A Performance Guide | untergletscher.com