대시보드 페이지가 느렸다. 사용자 정보, 최근 활동, 통계 차트, 알림 목록 — 네 개의 API를 다 기다린 다음에야 화면이 그려졌다. 서버에서 모든 데이터를 fetch하고 완성된 HTML을 내려보내니까, 가장 느린 API가 전체 TTFB를 결정했다. 통계 API가 1.5초 걸리면, 사용자는 1.5초 동안 빈 화면을 봤다.
전통적인 해결책은 클라이언트 렌더링이다. 빈 껍데기를 먼저 보내고, 각 섹션이 독립적으로 데이터를 가져오게 하는 것. 그런데 이러면 SEO 이점을 잃고, CLS가 올라가고, 스켈레톤 UI가 여기저기서 번쩍거린다.
Streaming SSR은 이 딜레마를 해결한다.
Streaming SSR이 뭔가
전통적 SSR은 모든 데이터가 준비된 후 완성된 HTML을 한 번에 보낸다. Streaming SSR은 준비된 부분부터 HTML 청크를 순차적으로 보낸다.
전통 SSR: [----모든 API 대기----][HTML 전송]
Streaming: [HTML 시작 전송][API 1 완료→청크][API 2 완료→청크]...
사용자는 페이지의 상단(헤더, 네비게이션, 즉시 렌더링 가능한 부분)을 먼저 보고, 데이터가 필요한 섹션은 준비되는 대로 화면에 나타난다. 서버와의 연결은 하나, HTTP 응답도 하나. 청크가 순서대로 흘러올 뿐이다.
Next.js App Router에서의 구현
App Router는 React의 Suspense를 Streaming의 경계로 사용한다. loading.tsx를 만들거나, 컴포넌트를 직접 <Suspense>로 감싸면 된다.
기존 코드는 이랬다:
// app/dashboard/page.tsx — 기존 (blocking)
export default async function DashboardPage() {
const [user, activities, stats, notifications] = await Promise.all([
getUser(),
getActivities(),
getStats(), // 이게 1.5초
getNotifications(),
]);
return (
<div>
<UserProfile user={user} />
<RecentActivities activities={activities} />
<StatsChart stats={stats} />
<NotificationList notifications={notifications} />
</div>
);
}Promise.all이니까 병렬로 요청하긴 하지만, 네 개 전부 끝나야 HTML이 나간다.
Streaming으로 바꾸면:
// app/dashboard/page.tsx — streaming
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<div>
<Suspense fallback={<UserProfileSkeleton />}>
<UserProfile />
</Suspense>
<Suspense fallback={<ActivitiesSkeleton />}>
<RecentActivities />
</Suspense>
<Suspense fallback={<StatsChartSkeleton />}>
<StatsChart />
</Suspense>
<Suspense fallback={<NotificationSkeleton />}>
<NotificationList />
</Suspense>
</div>
);
}각 컴포넌트가 자체적으로 데이터를 fetch하는 async 서버 컴포넌트다:
// components/StatsChart.tsx
async function StatsChart() {
const stats = await getStats(); // 1.5초 걸려도 이 컴포넌트만 늦게 도착
return (
<section>
<h2>통계</h2>
<Chart data={stats} />
</section>
);
}이제 UserProfile이 200ms 만에 준비되면, 나머지를 기다리지 않고 바로 사용자에게 보여준다.
Suspense boundary를 어디에 그을 것인가
이게 가장 고민이 많았다. 극단적으로 모든 컴포넌트를 Suspense로 감쌀 수도 있고, 페이지 단위로 크게 감쌀 수도 있다.
처음에는 가능한 잘게 쪼갰다. 모든 데이터 의존 컴포넌트마다 Suspense를 붙였더니, 화면이 팝콘처럼 튀어나왔다. 여기 로드되고, 저기 로드되고, 레이아웃이 덜컥덜컥. 기술적으로는 맞는데 사용자 경험이 오히려 나빠졌다.
결국 이런 기준을 세웠다:
함께 보여야 의미가 있는 컴포넌트는 하나의 Suspense로 묶는다. 예를 들어 "사용자 이름"과 "사용자 아바타"가 따로 나타나면 어색하다. 이건 하나의 boundary 안에 넣어야 한다.
독립적으로 의미가 완결되는 섹션은 별도 Suspense로 분리한다. 통계 차트는 알림 목록 없이도 쓸모가 있다. 이런 건 분리해서 먼저 보여줘도 된다.
느린 데이터 소스는 반드시 분리한다. 하나의 느린 API가 전체를 블로킹하는 게 Streaming을 쓰는 이유니까.
// 좋은 예: 의미 단위로 묶기
<Suspense fallback={<ProfileSkeleton />}>
{/* 이 둘은 함께 나타나야 자연스럽다 */}
<UserAvatar />
<UserName />
<UserBio />
</Suspense>
<Suspense fallback={<StatsSkeleton />}>
{/* 이건 독립적으로 의미가 있다 */}
<StatsChart />
</Suspense>예상 못한 문제: 스켈레톤 레이아웃 시프트
스켈레톤 UI의 높이가 실제 콘텐츠와 다르면, 콘텐츠가 로드될 때 레이아웃이 밀린다. CLS가 올라간다.
스켈레톤의 높이를 실제 콘텐츠와 최대한 맞추는 게 중요한데, 데이터에 따라 높이가 달라지는 컴포넌트는 어쩔 수 없다. 이런 경우 min-height로 최소 높이만 잡아주고, CSS contain: layout으로 리플로우 범위를 제한했다.
.stats-section {
min-height: 320px;
contain: layout;
}완벽하진 않지만, 체감할 수 있는 수준으로 CLS를 줄였다.
결과
| 지표 | 변경 전 | 변경 후 |
|---|---|---|
| TTFB | 1.8초 | 0.4초 |
| FCP | 2.1초 | 0.6초 |
| LCP | 2.3초 | 1.2초 |
| CLS | 0.02 | 0.05 |
CLS가 소폭 올라간 건 트레이드오프다. 스켈레톤에서 실제 콘텐츠로 교체될 때 미세한 레이아웃 변화가 생긴다. 그래도 0.05면 "Good" 범위 안이다.
체감 성능은 수치 이상으로 좋아졌다. 페이지에 진입하자마자 뭔가가 보인다는 게 사용자 입장에서 완전히 다른 경험이다. "로딩 중"이 아니라 "이미 쓸 수 있는 상태"로 느껴진다.
Streaming SSR은 마법이 아니다. 데이터가 빨라지는 게 아니라, 빠른 것부터 먼저 보여주는 것이다. 그런데 그 "먼저 보여주는 것"이 사용자 경험에서는 엄청난 차이를 만든다.
