C
Next.js/렌더링/Lesson 07

Streaming + Suspense — 페이지를 한 덩어리가 아니라 부분부분 전송

35분·theory
이 챕터
4/5
TypeScript

Streaming + Suspense — 페이지를 한 덩어리가 아니라 부분부분 전송

💡 왜 배워야 할까요? — 가장 느린 fetch 가 페이지 전체를 막는다

🎯 Server Component 에서 `await fetch()` 하면 그 컴포넌트가 끝나야 페이지가 그려집니다. 헤더는 즉시 보여도 좋은데, 댓글 fetch 5초 때문에 헤더까지 5초 기다림.
💼 경계로 감싸면 그 안만 따로 로딩되고 나머지는 즉시 노출 — '점진적 HTML 스트리밍'.
Next.js 는 같은 폴더의 `loading.tsx` 를 자동으로 `` 으로 감쌉니다. 폴더 단위 로딩 UX.
🔗 여러 fetch 를 병렬로 띄우면서 (Promise.all 아님) 각각의 도착에 맞춰 점진 표시 — 가장 빠른 부분부터 사용자에게 보여줌.
📈 React 18+ 의 핵심 — 'waterfall 없는 데이터 로딩' 가능.
🏢 실무에서는
쇼핑 페이지: 상품 정보(빠름) + 리뷰(중간) + 추천 상품(느림). 옛 방식이면 추천 상품 fetch 가 끝나야 전체 페이지가 떠서 사용자가 4~5초 빈 화면을 봅니다. Suspense 로 셋을 분리하면: 상품 정보 0.3초 → 리뷰 1초 → 추천 3초 — 각 부분이 도착 즉시 노출. 체감 속도가 극적으로 개선.

Suspense 경계 · loading.tsx · 점진 스트리밍

1. Suspense 경계의 작동 원리

tsx
import { Suspense } from 'react';

export default function Page() {
  return (
    <div>
      <Header />  {/* 즉시 렌더 */}
      <Suspense fallback={<ReviewSkeleton />}>
        <Reviews />  {/* Reviews 가 await 하는 동안 ReviewSkeleton 표시 */}
      </Suspense>
      <Suspense fallback={<RecommendSkeleton />}>
        <Recommend />  {/* Recommend 도 별도로 로딩 */}
      </Suspense>
    </div>
  );
}

async function Reviews() {
  const reviews = await fetch('https://api/reviews').then(r => r.json());
  return <ul>{reviews.map(r => <li key={r.id}>{r.text}</li>)}</ul>;
}
  • Header 는 즉시 HTML 로 전송.
  • Reviews·Recommend 자리는 fallback 으로 채움.
  • 각 컴포넌트의 fetch 가 끝나면 그 자리에 HTML 이 추가 스트리밍됨 (closing </html> 후에도 추가 가능).

2. loading.tsx — 폴더 단위 자동 Suspense

code
app/posts/
├── page.tsx
└── loading.tsx  ← page.tsx 전체가 자동으로 <Suspense fallback={<Loading />}> 으로 감싸짐

페이지 단위로 로딩 처리하는 가장 빠른 방법. 별도 import 불필요.

3. 명시적 vs loading.tsx

도구적합한 상황
loading.tsx페이지 전체가 데이터 대기 — 빈 페이지 + 스피너로 충분
<Suspense> 직접페이지의 일부만 느림 — 나머지는 즉시 보여주고 싶음

실무에서는 둘 다 함께 사용. loading.tsx 로 큰 그림, 페이지 안에서 로 부분 분할.

4. 점진 스트리밍의 가시적 효과

code
시간 →
0.1초: <html><body><Header />...스켈레톤들...</body>
0.3초:                ...Reviews 도착...
1.0초:                                 ...Recommend 도착...

사용자: '뭔가 빠르게 뜨네' (Header 0.1초) → '아 리뷰 떴네' (0.3초) → '추천도 뜸' (1초). 옛 방식이면 1초 빈 화면 후 한 번에 다 뜨는 것 대신.

5. waterfall 회피 — 병렬 fetch + 병렬 Suspense

tsx
// ❌ 직렬 waterfall — Reviews 끝나야 Recommend 시작
export default async function Page() {
  const reviews = await getReviews();
  const recommend = await getRecommend();
  return <div>{reviews.length} 리뷰, {recommend.length} 추천</div>;
}

// ✅ Suspense 로 병렬화 — 둘이 동시에 fetch 시작
export default function Page() {
  return (
    <>
      <Suspense fallback={<S1 />}><Reviews /></Suspense>
      <Suspense fallback={<S2 />}><Recommend /></Suspense>
    </>
  );
}
// Reviews 와 Recommend 가 각자 async 컴포넌트 — 동시에 시작

6. 에러 경계와 짝 — error.tsx

tsx
<ErrorBoundary fallback={<E />}>
  <Suspense fallback={<S />}>
    <Reviews />  {/* throw 하면 ErrorBoundary 가 잡고, await 하면 Suspense 가 잡음 */}
  </Suspense>
</ErrorBoundary>

App Router 에서는 error.tsx 가 자동으로 ErrorBoundary 역할.

💻 🅰️ Suspense 없이 — 가장 느린 fetch 가 전체를 막음
// ❌ 전체 await — 느린 컴포넌트가 빠른 부분도 막음

// 📁 app/product/[id]/page.tsx
export default async function ProductPage({ params }: { params: { id: string } }) {
  // 셋이 순차 (또는 Promise.all 로 병렬이라도 셋 다 끝나야 렌더)
  const product = await fetch(`/api/products/${params.id}`).then(r => r.json());
  const reviews = await fetch(`/api/reviews?productId=${params.id}`).then(r => r.json());
  const recommend = await fetch(`/api/recommend/${params.id}`).then(r => r.json());

  // 셋 다 도착해야 이 return 이 실행됨
  // 가장 느린 게 3초라면, 3초 동안 사용자는 빈 화면
  return (
    <div>
      <h1>{product.name}</h1>
      <Reviews items={reviews} />
      <Recommend items={recommend} />
    </div>
  );
}
💻 🅱️ Suspense 경계로 분할 — 도착 즉시 노출
// ✅ Suspense 경계로 분할 — 각자 도착 즉시 스트리밍

import { Suspense } from 'react';

// 📁 app/product/[id]/page.tsx
export default async function ProductPage({ params }: { params: { id: string } }) {
  // 빠른 데이터만 await — 헤더 즉시 표시
  const product = await fetch(`/api/products/${params.id}`).then(r => r.json());

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.price}원</p>

      {/* 느린 부분은 Suspense 로 감싸 점진 노출 */}
      <Suspense fallback={<ReviewSkeleton />}>
        <Reviews productId={params.id} />
      </Suspense>

      <Suspense fallback={<RecommendSkeleton />}>
        <Recommend productId={params.id} />
      </Suspense>
    </div>
  );
}

// 자식 컴포넌트들 — 각자 async
async function Reviews({ productId }: { productId: string }) {
  const reviews = await fetch(`/api/reviews?productId=${productId}`).then(r => r.json());
  return (
    <ul>
      {reviews.map((r: { id: number; text: string }) => (
        <li key={r.id}>{r.text}</li>
      ))}
    </ul>
  );
}

async function Recommend({ productId }: { productId: string }) {
  const items = await fetch(`/api/recommend/${productId}`).then(r => r.json());
  return <ul>{items.map((i: { id: number; name: string }) => <li key={i.id}>{i.name}</li>)}</ul>;
}

function ReviewSkeleton() {
  return <div className="animate-pulse">리뷰 로딩 중...</div>;
}

function RecommendSkeleton() {
  return <div className="animate-pulse">추천 로딩 중...</div>;
}

// 📁 app/product/[id]/loading.tsx — 페이지 첫 진입 시 (헤더도 못 그릴 때)
export default function Loading() {
  return <div className="animate-pulse">상품 정보 로딩 중...</div>;
}

// 사용자 체감:
// 0.1초: 헤더 + 스켈레톤들 표시 (HTML 일부 도착)
// 0.5초: 리뷰가 채워짐 (HTML 추가 스트리밍)
// 2.0초: 추천이 채워짐 (HTML 추가 스트리밍)
// → 빈 화면 시간 0.1초 (vs 옛 방식 3초)

💡 💡 Streaming + Suspense 실전 5

1. loading.tsx 는 페이지 전체, 는 부분
페이지의 90% 가 데이터 대기면 loading.tsx 로 충분. 일부만 느리면 로 부분 분할.

2. async Server Component 만 Suspense 가 잡는다
Client Component 의 useState 데이터는 Suspense 가 안 잡음 (그건 useTransition / useDeferredValue 영역).

3. Suspense 안에서 await 은 자동 throw → React 가 잡음
명시적 throw·try/catch 불필요. async function 그대로 작성.

4. waterfall 회피 — 각 컴포넌트가 자체 fetch
부모에서 모든 데이터 모은 후 자식에 props 로 내리면 waterfall 발생. 각 컴포넌트가 자기 데이터 fetch 하면 React 가 병렬 처리.

5. Suspense 경계는 ErrorBoundary 와 짝

await 가 reject 되면 ErrorBoundary 가 잡음 — Suspense 안이 아니라 바깥. App Router 의 error.tsx 가 자동.

code
<ErrorBoundary fallback={<E />}>      ← error.tsx 가 자동
  <Suspense fallback={<S />}>           ← loading.tsx 또는 명시
    <AsyncComponent />
  </Suspense>
</ErrorBoundary>

⚡ 직접 실행해보기 — Suspense 점진 스트리밍 시뮬레이션

각 컴포넌트의 도착 시간에 따라 HTML 이 점진적으로 그려지는 모습을 시뮬레이션합니다.
✏️ JS 코드
📟 콘솔 출력
▶ 실행 버튼을 눌러보세요
⚠️ 브라우저 샌드박스에서 실행 — console.log()만 지원, alert/fetch 불가

확인 퀴즈

App Router 에서 페이지 전체가 데이터 로딩 중일 때 가장 간단한 방법은?
💡 `loading.tsx` 만 같은 폴더에 두면 Next.js 가 page.tsx 전체를 자동으로 `<Suspense fallback={<Loading />}>` 으로 감쌉니다 — import·등록 코드 0줄. 페이지 일부만 느릴 때는 명시적 `<Suspense>` 경계로 부분 분할. 둘 다 함께 쓸 수 있습니다.
Streaming + Suspense — 점진적 HTML 스트리밍 - Next.js