
Streaming SSR: 점진적 페이지 렌더링으로 체감 속도 개선
SSR 페이지가 데이터 로딩 때문에 3초간 빈 화면을 보여주고 있었다. Streaming SSR로 준비된 부분부터 먼저 보여주니 체감 속도가 극적으로 개선됐다.

SSR 페이지가 데이터 로딩 때문에 3초간 빈 화면을 보여주고 있었다. Streaming SSR로 준비된 부분부터 먼저 보여주니 체감 속도가 극적으로 개선됐다.
느리다고 느껴서 감으로 최적화했는데 오히려 더 느려졌다. 프로파일러로 병목을 정확히 찾는 법을 배운 이야기.

텍스트에서 바이너리로(HTTP/2), TCP에서 UDP로(HTTP/3). 한 줄로서기 대신 병렬처리 가능해진 웹의 진화. 구글이 주도한 QUIC 프로토콜 이야기.

HTML 파싱부터 DOM, CSSOM 생성, 렌더 트리, 레이아웃(Reflow), 페인트(Repaint), 그리고 합성(Composite)까지. 브라우저가 화면을 그리는 6단계 과정과 치명적인 렌더링 성능 최적화(CRP) 가이드.

습관적으로 모든 변수에 `useMemo`를 감싸고 있나요? 그게 오히려 성능을 망치고 있습니다. 메모이제이션 비용과 올바른 최적화 타이밍.

대시보드 페이지를 열면 3초간 빈 화면이 보인다. 그 다음 갑자기 모든 컨텐츠가 한꺼번에 나타난다. 유저 입장에서는 페이지가 느린 건지, 죽은 건지 알 수가 없다.
전통적인 SSR은 이렇게 작동한다. 서버에서 모든 데이터를 다 가져올 때까지 기다렸다가, 완성된 HTML을 한 번에 보낸다. 레스토랑으로 치면 주문한 요리가 전부 다 나올 때까지 테이블에 아무것도 서빙하지 않는 격이다. 파스타는 5분 만에 준비됐는데, 스테이크가 15분 걸린다면? 손님은 20분 동안 빈 테이블만 바라본다.
이게 너무 답답해서 찾아본 게 Streaming SSR이었다. 준비된 것부터 먼저 보내자는 아이디어. 파스타 나오면 파스타부터 서빙하고, 스테이크는 나중에 추가하는 방식이다.
기존 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로 감싸서 나중에 보낸다.
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} />;
}
이제 렌더링이 점진적으로 일어난다.
물리적 완료 시간은 여전히 3초지만, 체감 속도는 0초다. 즉시 뭔가를 보여주기 때문이다.
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를 쓰는 것보다 간단하다.
기존 코드의 또 다른 문제는 데이터를 순차적으로 가져온다는 점이었다. 이건 네트워크 워터폴이다. 레스토랑 비유로 치면, 파스타가 다 익을 때까지 기다렸다가 스테이크를 굽기 시작하는 격이다.
// 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% 단축.
전통적인 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 중이지만, 사용자 경험에 영향을 주지 않는다.
Streaming SSR의 가장 큰 장점은 TTFB(Time To First Byte) 개선이다. 전통적인 SSR은 모든 데이터를 기다리기 때문에 TTFB가 느리다.
전통적 SSR:물리적으로는 여전히 6초가 걸리지만, 심리적으로는 100ms로 느껴진다. 엘리베이터 비유를 추가하자면, 전통적 SSR은 버튼을 눌러도 아무 반응이 없다가 6초 후 문이 열린다. Streaming SSR은 버튼을 누르면 즉시 불이 들어오고, 층수 표시가 바뀌면서 진행 상황을 보여준다.
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로 줄어든다.
스트리밍이 항상 답은 아니다. 효과가 있는 경우와 없는 경우를 구분해야 한다.
효과 있음:내 대시보드는 명확히 효과가 있는 케이스였다. 상단 Header는 빠르지만(캐시됨), Analytics와 Notifications는 DB 쿼리 때문에 느렸다.
이론은 그렇고, 실제로 얼마나 개선됐을까? Lighthouse로 측정해봤다.
Before (Traditional SSR):TTFB가 3200ms → 120ms로 크게 개선됐다. 사용자가 "뭔가 나타났다"고 느끼는 시점이 3.3초에서 0.18초로 줄었다. 이게 체감 속도다.
하지만 LCP는 여전히 2.8초다. 왜냐하면 가장 큰 컨텐츠(Analytics 차트)가 느린 데이터에 의존하기 때문이다. 물리적 완료 시간은 크게 안 줄었지만, 체감 속도는 극적으로 개선됐다.
전통적인 SSR은 완벽주의자다. 모든 게 준비될 때까지 아무것도 보여주지 않는다. Streaming SSR은 현실주의자다. 준비된 것부터 보여주고, 나머지는 점진적으로 채운다.
핵심은 체감 속도는 첫 화면이 나타나는 시점으로 결정된다는 점이다. 3초짜리 완벽한 페이지보다, 0.1초에 나타나서 3초에 걸쳐 완성되는 페이지가 훨씬 빠르게 느껴진다.
TTFB가 크게 개선된다는 사례가 있다. 3.2초에서 0.12초로 줄어드는 수준의 개선도 보고된다. 페이지가 빨라진 게 아니라, 빠르게 느껴지게 만든 것이다. 결국 사용자가 느끼는 게 전부다.