Next.js Streaming이 안 될 때: Suspense와 'use client' 경계 설계법
저도 'use client'를 레이아웃 상단에 붙이고 "왜 Streaming이 안 되지?"라고 한참 삽질한 적이 있어요. <Suspense>도 써봤고, 스켈레톤도 만들었는데 막상 Network 탭을 보면 HTML이 한 번에 쾅 도착하더라고요. 뭔가 잘못됐다는 건 알겠는데 어디서부터 잘못된 건지 파악이 안 됐습니다. 그 원인을 찾고 나서 페이지 로딩 체감이 확 달라졌어요.
문제는 <Suspense>, 'use client', Streaming 이 세 가지를 각각 이해하는 것과, 이 셋이 어떻게 맞물려야 하는지를 이해하는 게 완전히 다른 얘기라는 겁니다. 각 개념은 공식 문서에 잘 나와 있는데, 세 개가 서로 어떤 관계를 맺어야 하는지는 직접 부딪혀보기 전까지 잘 안 보이거든요.
<Suspense> 경계와 'use client' 경계를 어디에 두느냐가, 단순히 두 API를 쓰는 것과 Streaming이 실제로 동작하는 것의 차이입니다. 이 글에서는 그 배치 원리와 실무에서 자주 마주치는 함정들을 코드와 함께 살펴볼게요. Next.js App Router + React 18/19 환경이 기준입니다.
핵심 개념
Streaming이 실제로 어떻게 동작하는가
Streaming의 핵심은 서버가 HTML을 완성한 뒤 한 번에 보내는 게 아니라, 준비된 부분부터 청크 단위로 흘려보낸다는 것입니다. HTTP Chunked Transfer Encoding을 통해서요. React 18의 renderToPipeableStream이 이걸 가능하게 했습니다. 이전의 renderToString은 전체 렌더링이 완료될 때까지 기다렸다가 HTML 문자열을 한 덩어리로 반환했는데, renderToPipeableStream은 준비된 청크를 Node.js 스트림으로 브라우저에 밀어넣을 수 있어요. Next.js App Router는 이를 기본값으로 씁니다.
[서버] [브라우저]
Layout + 정적 Shell ─────────────→ 즉시 수신, 페인트 시작
(TTFB 최소화)
<Suspense fallback={<Skeleton/>}> → Skeleton 렌더링
<SlowDataComponent/> → 데이터 준비되면 swap
</Suspense>각 <Suspense> 경계가 독립적인 스트리밍 단위가 됩니다. 단, 이게 실제로 동작하려면 조건이 있어요. <Suspense> 안의 컴포넌트가 async Server Component이거나, use(promise)로 suspend해야 합니다. 그냥 <Suspense>로 감싸기만 한다고 청크가 나뉘진 않아요.
데이터가 준비되면 React는 직렬화된 컴포넌트 트리와 DOM 포인터를 함께 보내 클라이언트가 정확한 위치에 삽입할 수 있게 합니다. 이 과정이 Selective Hydration과 맞물려 있어요.
Streaming이란? 서버가 렌더링을 완료하기 전에도 브라우저가 콘텐츠를 받아 화면에 그릴 수 있도록, HTML을 조각조각 전송하는 방식입니다. 덕분에 첫 번째 바이트(TTFB)가 가장 느린 데이터 쿼리와 무관하게 빨라집니다.
'use client' 경계가 Streaming을 죽이는 방식
'use client'는 단순히 "이 컴포넌트는 클라이언트에서 실행된다"는 선언이 아닙니다. 이 경계를 기준으로 컴포넌트 트리가 서버/클라이언트로 분리되고, 경계 아래 모든 컴포넌트는 클라이언트 JS 번들에 포함됩니다.
여기서 실수가 발생합니다. 레이아웃 상단에 'use client'를 달아버리면, 그 하위 트리 전체가 클라이언트 컴포넌트가 됩니다. Server Component가 없으니 Streaming이 제대로 활성화될 리 없어요.
// 🚫 이렇게 하면 Streaming 효과가 사라집니다
'use client';
export default function Layout({ children }: { children: React.ReactNode }) {
return <main>{children}</main>; // 하위 트리 전체가 클라이언트 번들로
}
// ✅ 인터랙티브한 리프 컴포넌트에만 선택적으로
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<main>
<NavBar /> {/* Server Component — 번들 미포함 */}
{children}
</main>
);
}React 19 use() 훅으로 달라진 것
React 19에서 use() 훅이 정식 릴리즈되면서 Server→Client 데이터 전달 패턴이 한층 깔끔해졌습니다. Server Component에서 await 없이 Promise를 만들어 Client Component에 넘기면, Client Component가 use(promise)로 받아 Suspense와 통합됩니다.
// Server Component — await 없이 Promise를 흘려보냅니다
export default function Page() {
const dataPromise = fetchData(); // await 하지 않음!
return (
<Suspense fallback={<Skeleton />}>
<ClientComponent dataPromise={dataPromise} />
</Suspense>
);
}
// Client Component — use()로 Suspense 트리거
'use client';
import { use } from 'react';
interface Data {
title: string;
}
export function ClientComponent({ dataPromise }: { dataPromise: Promise<Data> }) {
const data = use(dataPromise); // 준비 안 됐으면 Suspense까지 suspend
return <div>{data.title}</div>;
}onClick 같은 이벤트 핸들러를 가진 컴포넌트는 Server Component가 될 수 없어요. 이벤트 처리 자체가 브라우저 환경에서만 동작하기 때문입니다. 그래서 인터랙션이 필요한 컴포넌트에는 'use client'가 필요하고, 바로 여기에 use()를 쓰는 패턴이 맞아떨어집니다.
왜 Server Component에서 Promise를 생성해야 할까요? Client Component 내부에서
use(fetchData())처럼 쓰면 렌더링마다 새 Promise가 생겨 무한 루프에 빠집니다. Server Component에서 생성된 Promise는 리렌더링에 걸쳐 안정적으로 유지됩니다.
실전 적용
예시 1: 대시보드 — 병렬 스트리밍으로 여러 섹션 처리
실무에서 가장 많이 보이는 패턴입니다. 대시보드처럼 독립적인 데이터 패칭이 여러 개 필요한 화면에서 특히 효과적이에요.
// app/dashboard/page.tsx (Server Component)
import { Suspense } from 'react';
import { DashboardStats } from './DashboardStats';
import { RecentActivity } from './RecentActivity';
import { StatsSkeleton, ActivitySkeleton } from './Skeletons';
export default function DashboardPage() {
// 두 Promise를 동시에 시작 — await 없이
const statsPromise = fetchStats();
const activityPromise = fetchRecentActivity();
return (
<main>
{/* Shell — 데이터와 무관하게 즉시 스트리밍 */}
<h1>대시보드</h1>
<QuickActions /> {/* Server Component, 데이터 불필요 */}
{/* 독립적인 스트리밍 단위 두 개 — 병렬 처리 */}
<div className="grid grid-cols-2 gap-4">
<Suspense fallback={<StatsSkeleton />}>
<DashboardStats promise={statsPromise} />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity promise={activityPromise} />
</Suspense>
</div>
</main>
);
}// app/dashboard/DashboardStats.tsx (Client Component)
'use client';
import { use } from 'react';
interface StatsData {
totalUsers: number;
}
// 새로고침 핸들러 — 실제 구현에서는 router.refresh()나 revalidatePath로 교체
function refreshStats() {
window.location.reload();
}
export function DashboardStats({ promise }: { promise: Promise<StatsData> }) {
const stats = use(promise);
return (
<div className="card">
<span>{stats.totalUsers.toLocaleString()}</span>
<button onClick={refreshStats}>새로고침</button>
</div>
);
}| 포인트 | 설명 |
|---|---|
await 없는 Promise 생성 |
두 패칭이 동시에 시작되어 가장 느린 것에 종속되지 않음 |
별도 <Suspense> 경계 |
stats와 activity가 독립적으로 스트리밍됨 |
'use client'는 리프에만 |
인터랙션(버튼)이 필요한 컴포넌트에만 선언 |
예시 2: useSearchParams CSR Bailout 방지
솔직히 이 문제로 저도 한 번 새벽에 당황했던 기억이 납니다. useSearchParams()를 쓰는 컴포넌트를 <Suspense>로 감싸지 않으면 페이지 전체가 Client-Side Rendering으로 강제 전환됩니다. 검색 페이지 하나 때문에 전체 앱의 Streaming이 죽는 거예요.
// 🚫 CSR Bailout 발생 — 페이지 전체가 클라이언트 렌더링으로 전환
'use client';
export default function SearchPage() {
const params = useSearchParams(); // 이 한 줄이 문제
return <div>검색 결과: {params.get('q')}</div>;
}// ✅ 올바른 패턴 — useSearchParams 로직을 분리하고 Suspense로 감싸기
// SearchContent.tsx (Client Component — 작은 단위로 분리)
'use client';
function SearchContent() {
const params = useSearchParams();
return <div>검색 결과: {params.get('q')}</div>;
}
// page.tsx (Server Component)
import { Suspense } from 'react';
export default function SearchPage() {
return (
<>
<h1>검색</h1> {/* 즉시 렌더링 */}
<Suspense fallback={<div>검색 중...</div>}>
<SearchContent /> {/* useSearchParams 격리 */}
</Suspense>
</>
);
}CSR Bailout이란? Next.js가 특정 동적 API(
useSearchParams,cookies,headers등)를 감지했을 때 해당 페이지 전체를 정적 렌더링에서 클라이언트 사이드 렌더링으로 강제 전환하는 동작입니다. Next.js 15.2 이상에서는next build --debug-prerender플래그로 어느 페이지에서 발생하는지 추적할 수 있습니다.
예시 3: 중첩 Suspense로 순차적 콘텐츠 로딩 설계
상품 상세 페이지처럼 콘텐츠 간에 시각적 우선순위가 있는 경우에 유용합니다. 핵심 정보 → 리뷰 → 추천 상품 순서로 점진적으로 채워지는 경험을 줄 수 있어요.
// app/products/[id]/page.tsx
export default function ProductPage() {
return (
<>
{/* LCP 요소 — Suspense 바깥에 배치, 즉시 렌더링 */}
<ProductHero />
<ProductInfo />
{/* 첫 번째 스트림 — 리뷰 */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews />
{/* 두 번째 스트림 — Reviews resolve 이후 fallback 해소 */}
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>
</Suspense>
</>
);
}중첩 Suspense의 폴백 해소 순서가 직관적이지 않을 수 있는데요. 바깥 <Suspense>가 resolve되기 전까지 안쪽 <Suspense>는 DOM에 마운트조차 되지 않습니다. 즉, Reviews가 완전히 준비되어 바깥 경계가 해소된 다음에야 Recommendations의 패칭이 시작돼요. 이게 의도된 순차 로딩이면 좋은 패턴이지만, 병렬로 동시에 시작하고 싶다면 두 <Suspense>를 형제 노드로 분리해야 합니다.
예시 4: loading.tsx + 컴포넌트 레벨 Suspense 2-tier 전략
라우트 전환 시의 전체 폴백과, 컴포넌트별 세분화된 로딩을 함께 운용하는 방식입니다. 현재 Next.js 생태계에서 표준으로 자리잡은 패턴이에요.
app/
dashboard/
loading.tsx ← 라우트 전환 시 전체 페이지 폴백 (자동 Suspense 래핑)
page.tsx ← 개별 섹션은 <Suspense>로 세분화
components/
SlowWidget.tsx ← 자체 <Suspense> 경계 보유// app/dashboard/loading.tsx
export default function Loading() {
return <DashboardPageSkeleton />; // 라우트 전환 시 표시
}
// app/dashboard/page.tsx
export default function DashboardPage() {
return (
<div>
<StaticHeader /> {/* 즉시 렌더링 */}
{/* loading.tsx와 별개로, 섹션별 세분화 */}
<Suspense fallback={<WidgetSkeleton />}>
<SlowWidget />
</Suspense>
</div>
);
}장단점 분석
장점
| 항목 | 내용 |
|---|---|
| TTFB 단축 | 정적 shell을 즉시 전송하므로 첫 바이트 응답 시간이 가장 느린 쿼리와 무관해짐 |
| FCP 개선 | 브라우저가 shell을 즉시 페인트하므로 FCP가 데이터 패칭 시간에서 분리됨 |
| Selective Hydration | 각 Suspense 경계를 독립적으로 수화하여 사용자 상호작용에 더 빠르게 반응 |
| Waterfall 방지 | 여러 느린 컴포넌트를 병렬로 스트리밍, 직렬 대기 없음 |
| UX 향상 | 스켈레톤 UI로 레이아웃 시프트(CLS) 없는 점진적 콘텐츠 표시 |
Selective Hydration이란? 각 Suspense 경계를 독립적인 수화 단위로 처리하는 React의 기능입니다. 사용자가 특정 컴포넌트와 상호작용하려 할 때 해당 부분을 우선 수화하므로, 전체 수화가 완료되기 전에도 인터랙션이 가능합니다.
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
'use client' 범위 오용 |
상위 레이아웃에 선언 시 하위 트리 전체가 클라이언트 번들에 포함, Streaming 효과 소멸 | 인터랙티브한 리프 컴포넌트에만 선택적으로 적용 |
| LCP 요소 Suspense 배치 | Hero 이미지, 대형 텍스트 등 LCP 요소가 Suspense 내부에 있으면 LCP가 데이터 지연에 종속 | LCP 요소는 정적 shell 또는 Suspense 바깥에 배치 |
| 단일 Suspense 경계 | 페이지 전체를 하나로 감싸면 가장 느린 컴포넌트가 전체를 블로킹 | 독립적인 데이터 단위마다 별도 경계 설계 |
| 과도한 Suspense 세분화 | 너무 잘게 나누면 콘텐츠가 순차적으로 "팝인"되는 시각적 불쾌감 | 관련 섹션은 하나의 경계로 묶어 함께 표시 |
use() 훅 Promise 재생성 |
use(fetchData())처럼 렌더마다 새 Promise 생성 시 무한 루프 |
Promise를 Server Component 또는 컴포넌트 외부에서 생성 후 전달 |
notFound() 상태 코드 문제 |
Streaming 모드에서 notFound() 호출이 200 OK를 반환할 수 있음 |
notFound()를 사용하는 Server Component는 Suspense 바깥에 유지 |
실무에서 가장 흔한 실수
'use client'를app/layout.tsx또는 공통 레이아웃 파일에 선언해 Server Component 트리를 통째로 클라이언트로 전환하는 경우useSearchParams()를 사용하는 컴포넌트를<Suspense>로 감싸지 않아 페이지 전체가 CSR Bailout되는 경우- Hero 배너, 상품 대표 이미지 등 LCP에 직결되는 요소를 느린 데이터를 기다리는
<Suspense>내부에 함께 배치하는 경우
마치며
<Suspense>와'use client'경계를 의도적으로 설계하는 것이, 단순히 두 API를 쓰는 것과 실제로 Streaming을 활성화하는 것의 차이입니다.
지금 바로 시작해볼 수 있는 3단계가 있습니다.
- 현재 프로젝트에서
'use client'위치를 먼저 점검해볼 수 있습니다.grep -r "'use client'" ./app명령으로 어디에 선언됐는지 확인하고, 레이아웃이나 큰 컨테이너에 붙어 있다면 인터랙티브한 리프 컴포넌트로 옮기는 방향을 검토해볼 수 있어요. - 느린 데이터 패칭이 있는 Server Component를
<Suspense>로 감싸고await를 제거해보는 것도 좋습니다.const dataPromise = fetchData()형태로 바꾸고, 하위 Client Component에서use(dataPromise)로 소비하는 패턴을 적용하면 Network 탭에서 청크가 나뉘어 도착하는 걸 눈으로 확인할 수 있습니다. useSearchParams()를 쓰는 컴포넌트가 있다면 분리하고<Suspense>로 감싸주는 게 좋습니다. Next.js 15.2 이상이라면next build --debug-prerender플래그로 CSR Bailout이 발생하는 페이지를 먼저 찾는 것이 효율적인 출발점이 됩니다.
참고 자료
- React 공식 문서 —
<Suspense> - React 공식 문서 —
use()훅 - React 공식 문서 — Server Components
- Next.js 공식 — Streaming 가이드
- Next.js 공식 — loading.js 파일 컨벤션
- Next.js 공식 — useSearchParams CSR Bailout 해결
- React 18 Suspense SSR 아키텍처 논의 (GitHub)
- Next.js 15 Streaming Handbook — freeCodeCamp
- React Server Components: Concepts and Patterns — Contentful
- Data Fetching in 2025: Streaming, Suspense, Deferred Fetching — Medium