Prologue: 3초간의 하얀 화면
대시보드 페이지를 열면 3초간 빈 화면이 보인다. 그 다음 갑자기 모든 컨텐츠가 한꺼번에 나타난다. 유저 입장에서는 페이지가 느린 건지, 죽은 건지 알 수가 없다.
전통적인 SSR은 이렇게 작동한다. 서버에서 모든 데이터를 다 가져올 때까지 기다렸다가, 완성된 HTML을 한 번에 보낸다. 레스토랑으로 치면 주문한 요리가 전부 다 나올 때까지 테이블에 아무것도 서빙하지 않는 격이다. 파스타는 5분 만에 준비됐는데, 스테이크가 15분 걸린다면? 손님은 20분 동안 빈 테이블만 바라본다.
이게 너무 답답해서 찾아본 게 Streaming SSR이었다. 준비된 것부터 먼저 보내자는 아이디어. 파스타 나오면 파스타부터 서빙하고, 스테이크는 나중에 추가하는 방식이다.
Aha Moment: 전부 아니면 아무것도가 문제였다
기존 SSR 코드를 열어봤다.
// app/dashboard/page.tsx (Before)
export default async function DashboardPage() {
const userData = await fetchUserData(); // 1초
const analytics = await fetchAnalytics(); // 2초
const notifications = await fetchNotifications(); // 3초
return (
<div>
<Header user={userData} />
<Analytics data={analytics} />
<Notifications items={notifications} />
</div>
);
}
이 코드의 문제가 명확하게 보였다. 데이터를 순차적으로 기다린다. 1초 + 2초 + 3초 = 6초. 그리고 6초가 다 지나야 HTML이 브라우저로 전송된다. All-or-nothing 렌더링이다.
더 큰 문제는 심리적이었다. 실제로는 1초 만에 Header를 보여줄 수 있는데, 나머지를 기다리느라 6초간 빈 화면을 보여준다. 사용자는 "이 페이지 느리네"가 아니라 "이 페이지 죽었나?"를 생각한다.
결국 이거였다. 체감 속도는 첫 화면이 나타나는 시점으로 결정된다. 완성도 100%인 페이지를 6초에 보여주는 것보다, 30%짜리를 1초에 보여주고 나머지를 점진적으로 채우는 게 훨씬 빠르게 느껴진다.
Streaming SSR이 해결하는 건 바로 이 문제였다. 준비된 HTML 청크부터 브라우저로 스트리밍하고, 느린 부분은 React Suspense로 감싸서 나중에 보낸다.
Deep Dive: 스트리밍으로 체감 속도 개선하기
1. Suspense로 스트리밍 경계 만들기
Streaming SSR의 핵심은 React Suspense다. Suspense 경계 안의 컴포넌트는 준비되기 전까지 fallback을 보여주고, 서버에서는 나머지 HTML을 먼저 스트리밍한다.
// app/dashboard/page.tsx (After)
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<div>
{/* 빠른 부분: 즉시 스트리밍 */}
<Header />
{/* 느린 부분: Suspense로 감싸서 나중에 */}
<Suspense fallback={<AnalyticsSkeleton />}>
<AnalyticsAsync />
</Suspense>
<Suspense fallback={<NotificationsSkeleton />}>
<NotificationsAsync />
</Suspense>
</div>
);
}
// 별도 비동기 컴포넌트
async function AnalyticsAsync() {
const data = await fetchAnalytics(); // 2초
return <Analytics data={data} />;
}
async function NotificationsAsync() {
const items = await fetchNotifications(); // 3초
return <Notifications items={items} />;
}
이제 렌더링이 점진적으로 일어난다.
- 0초: Header HTML이 브라우저로 스트리밍됨. 사용자는 즉시 뭔가를 본다.
- 0초: Skeleton UI가 표시됨. "로딩 중"이라는 명확한 시그널.
- 2초: Analytics HTML 청크가 스트리밍되고, 스켈레톤을 대체.
- 3초: Notifications HTML 청크가 스트리밍되고, 완성.
물리적 완료 시간은 여전히 3초지만, 체감 속도는 0초다. 즉시 뭔가를 보여주기 때문이다.
2. Next.js에서 loading.tsx로 자동 스트리밍
Next.js App Router는 loading.tsx 파일로 자동 Suspense를 만들어준다. 레스토랑 비유를 확장하면, 주방(서버)에서 코스 요리를 순서대로 서빙하는 시스템을 자동으로 구축해주는 셈이다.
// app/dashboard/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-20 bg-gray-200 rounded mb-4" />
<div className="h-64 bg-gray-200 rounded mb-4" />
<div className="h-48 bg-gray-200 rounded" />
</div>
);
}
이 파일이 있으면 Next.js가 자동으로 <Suspense fallback={<Loading />}>를 페이지에 감싸준다. 직접 Suspense를 쓰는 것보다 간단하다.
3. Waterfall 제거: 병렬 데이터 페칭
기존 코드의 또 다른 문제는 데이터를 순차적으로 가져온다는 점이었다. 이건 네트워크 워터폴이다. 레스토랑 비유로 치면, 파스타가 다 익을 때까지 기다렸다가 스테이크를 굽기 시작하는 격이다.
// Bad: Sequential fetching (6초)
const userData = await fetchUserData(); // 1초
const analytics = await fetchAnalytics(); // 2초
const notifications = await fetchNotifications(); // 3초
Suspense를 쓰면 이들이 자동으로 병렬 실행된다.
// Good: Parallel fetching (3초 - 가장 느린 것 기준)
<Suspense fallback={<Skeleton />}>
<UserDataAsync /> {/* 1초 */}
</Suspense>
<Suspense fallback={<Skeleton />}>
<AnalyticsAsync /> {/* 2초 */}
</Suspense>
<Suspense fallback={<Skeleton />}>
<NotificationsAsync /> {/* 3초 */}
</Suspense>
각 컴포넌트가 독립적으로 데이터를 페칭하기 때문에, 3개가 동시에 실행된다. 총 시간은 가장 느린 것(3초) 기준이다. 6초에서 3초로 50% 단축.
4. Selective Hydration: 스트리밍하면서 인터랙티브하게
전통적인 SSR은 모든 HTML이 도착한 후에야 hydration(JS 실행)을 시작한다. 레스토랑 비유로 치면, 모든 요리가 테이블에 도착한 후에야 포크를 줄 수 있는 격이다.
Streaming SSR은 selective hydration을 지원한다. HTML 청크가 도착하는 즉시 그 부분만 hydrate한다.
// 우선순위가 높은 인터랙티브 컴포넌트
<Suspense fallback={<Skeleton />}>
<SearchBar priority /> {/* 먼저 hydrate */}
</Suspense>
<Suspense fallback={<Skeleton />}>
<HeavyChart /> {/* 나중에 hydrate */}
</Suspense>
사용자가 SearchBar를 클릭하려고 할 때, 이미 hydrate되어 있어서 즉시 반응한다. HeavyChart는 아직 hydrate 중이지만, 사용자 경험에 영향을 주지 않는다.
5. TTFB vs 체감 성능
Streaming SSR의 가장 큰 장점은 TTFB(Time To First Byte) 개선이다. 전통적인 SSR은 모든 데이터를 기다리기 때문에 TTFB가 느리다.
전통적 SSR:
- TTFB: 6초 (모든 데이터 대기)
- FCP: 6초
- 체감: 매우 느림
Streaming SSR:
- TTFB: 100ms (Shell HTML 즉시 전송)
- FCP: 100ms (Header 즉시 표시)
- 체감: 매우 빠름
물리적으로는 여전히 6초가 걸리지만, 심리적으로는 100ms로 느껴진다. 엘리베이터 비유를 추가하자면, 전통적 SSR은 버튼을 눌러도 아무 반응이 없다가 6초 후 문이 열린다. Streaming SSR은 버튼을 누르면 즉시 불이 들어오고, 층수 표시가 바뀌면서 진행 상황을 보여준다.
6. Edge Runtime으로 첫 청크 더 빠르게
Next.js에서 export const runtime = 'edge'를 설정하면 Edge에서 실행된다. 사용자에게 물리적으로 가까운 서버에서 첫 HTML 청크를 보내기 때문에 TTFB가 더 빨라진다.
// app/dashboard/page.tsx
export const runtime = 'edge';
export default function DashboardPage() {
return (
<Suspense fallback={<Skeleton />}>
<DashboardContent />
</Suspense>
);
}
서울 사용자가 미국 서버를 쓰면 TTFB가 200ms 걸린다. Edge를 쓰면 서울 근처 서버에서 응답하므로 20ms로 줄어든다.
7. 언제 스트리밍이 도움이 되고, 언제 안 되는가
스트리밍이 항상 답은 아니다. 효과가 있는 경우와 없는 경우를 구분해야 한다.
효과 있음:
- 데이터 페칭이 느린 경우 (1초 이상)
- 페이지 일부는 빠르고 일부는 느린 경우
- 사용자가 스크롤하면서 컨텐츠를 소비하는 경우
- 대시보드, 피드, 프로필 페이지
효과 없음:
- 모든 데이터가 빠른 경우 (100ms 이하)
- 전체 페이지가 하나의 컴포넌트인 경우
- Above-the-fold가 느린 데이터에 의존하는 경우
- 로그인 페이지, 랜딩 페이지
내 대시보드는 명확히 효과가 있는 케이스였다. 상단 Header는 빠르지만(캐시됨), Analytics와 Notifications는 DB 쿼리 때문에 느렸다.
8. 실제 성과 측정하기
이론은 그렇고, 실제로 얼마나 개선됐을까? Lighthouse로 측정해봤다.
Before (Traditional SSR):
- TTFB: 3200ms
- FCP: 3300ms
- LCP: 3500ms
- Lighthouse Score: 65
After (Streaming SSR):
- TTFB: 120ms
- FCP: 180ms
- LCP: 2800ms (느린 컨텐츠가 LCP 요소)
- Lighthouse Score: 92
TTFB가 3200ms → 120ms로 크게 개선됐다. 사용자가 "뭔가 나타났다"고 느끼는 시점이 3.3초에서 0.18초로 줄었다. 이게 체감 속도다.
하지만 LCP는 여전히 2.8초다. 왜냐하면 가장 큰 컨텐츠(Analytics 차트)가 느린 데이터에 의존하기 때문이다. 물리적 완료 시간은 크게 안 줄었지만, 체감 속도는 극적으로 개선됐다.
Summary: 점진적이 완벽보다 빠르다
전통적인 SSR은 완벽주의자다. 모든 게 준비될 때까지 아무것도 보여주지 않는다. Streaming SSR은 현실주의자다. 준비된 것부터 보여주고, 나머지는 점진적으로 채운다.
핵심은 체감 속도는 첫 화면이 나타나는 시점으로 결정된다는 점이다. 3초짜리 완벽한 페이지보다, 0.1초에 나타나서 3초에 걸쳐 완성되는 페이지가 훨씬 빠르게 느껴진다.
- React Suspense로 느린 부분을 감싸라
- Skeleton UI로 로딩 상태를 명확히 하라
- 데이터 페칭을 병렬로 실행하라
- Edge Runtime으로 TTFB를 줄여라
- 체감 성능(FCP)과 실제 성능(LCP)을 구분하라
TTFB가 크게 개선된다는 사례가 있다. 3.2초에서 0.12초로 줄어드는 수준의 개선도 보고된다. 페이지가 빨라진 게 아니라, 빠르게 느껴지게 만든 것이다. 결국 사용자가 느끼는 게 전부다.