React Server Components의 스트리밍과 Suspense: 진짜 점진적 렌더링의 시대

2024년 11월 15일
reactserver-componentssuspensestreamingssrperformance

React Server Components의 스트리밍과 Suspense 통합: 진짜 점진적 렌더링의 시대

1. 서론: 흰 화면의 공포

// 전통적인 SSR
export async function getServerSideProps() {
  const user = await fetchUser();
  const posts = await fetchPosts();
  const recommendations = await fetchRecommendations();
  const analytics = await fetchAnalytics();
  
  // 모든 데이터가 준비될 때까지 사용자는 흰 화면을 본다
  return { props: { user, posts, recommendations, analytics } };
}

사용자는 얼마나 기다려야 할까? 가장 느린 API가 페이지 전체의 로딩 시간을 결정한다.

이것이 전통적인 SSR의 "폭포수(Waterfall)" 문제다:

  1. 서버에서 모든 데이터를 가져온다
  2. 서버에서 모든 HTML을 생성한다
  3. 클라이언트가 모든 HTML을 다운로드한다
  4. 클라이언트가 모든 JavaScript를 로드한다
  5. 클라이언트가 모든 컴포넌트를 hydrate한다

"모든"이라는 단어가 문제다. 하나라도 느리면 전체가 느려진다.

React 18과 Server Components는 이 문제를 근본적으로 다시 생각했다: "왜 모든 걸 기다려야 하지?"

2. Streaming SSR: HTML을 조각으로 보내기

전통적 SSR vs Streaming SSR

// 전통적 SSR: 한 번에 모든 것을
app.get('/', async (req, res) => {
  const html = await renderToString(<App />);
  res.send(`
    <!DOCTYPE html>
    <html>
      <body>
        <div id="root">${html}</div>
      </body>
    </html>
  `);
});

// Streaming SSR: 준비되는 대로
app.get('/', (req, res) => {
  const stream = renderToPipeableStream(<App />, {
    bootstrapScripts: ['/main.js'],
    onShellReady() {
      res.setHeader('Content-Type', 'text/html');
      stream.pipe(res);
    }
  });
});

스트리밍의 마법: Transfer-Encoding: chunked

HTTP/1.1 200 OK
Transfer-Encoding: chunked

1a\r\n
<html><body><div id="root">
\r\n
2f\r\n
<nav>...</nav><main><div class="spinner">Loading...</div>
\r\n
45\r\n
<!--$?--><template id="B:0"></template><div hidden id="S:0">
\r\n

브라우저는 청크를 받는 즉시 렌더링을 시작한다!

3. Suspense와의 완벽한 결합

Server Components에서 Suspense 사용하기

// app/page.js (Server Component)
export default async function HomePage() {
  return (
    <div>
      <Header /> {/* 즉시 렌더링 */}
      
      <Suspense fallback={<PostsSkeleton />}>
        <Posts /> {/* 데이터를 기다리며 스트리밍 */}
      </Suspense>
      
      <Suspense fallback={<SidebarSkeleton />}>
        <Sidebar /> {/* 독립적으로 스트리밍 */}
      </Suspense>
    </div>
  );
}

// Posts는 비동기 Server Component
async function Posts() {
  const posts = await fetch('/api/posts');
  return <PostList posts={posts} />;
}

실제로 무슨 일이 일어나는가?

  1. 초기 Shell 전송
<div>
  <header>...</header>
  <div class="posts-skeleton">Loading...</div>
  <div class="sidebar-skeleton">Loading...</div>
</div>
  1. Posts 데이터 준비 완료 시
<script>
// React가 이해하는 특별한 스크립트
$RC("B:0", "S:0", <PostList posts={...} />)
</script>
  1. 브라우저에서 자동 교체
  • Skeleton → 실제 컨텐츠로 부드럽게 전환
  • 다른 부분은 계속 로딩 중

4. RSC의 스트리밍 프로토콜 분석

RSC Wire Format 들여다보기

// 실제 RSC 페이로드 예시
0:["$","div",null,{"children":[
  ["$","header",null,{"children":"My App"}],
  ["$","$Suspense",null,{
    "fallback":["$","div",null,{"children":"Loading..."}],
    "children":"$L1"
  }]
]}]
1:["$","div",null,{"children":["Post 1","Post 2"]}]

RSC 프로토콜의 핵심:

  • 0: - 청크 ID
  • $ - React 엘리먼트 마커
  • $Suspense - 특별한 Suspense 마커
  • $L1 - 나중에 스트리밍될 참조

점진적 하이드레이션

// React가 생성하는 인라인 스크립트
<script>
function $RC(a, b, c) {
  // a: 대체할 template ID
  // b: Suspense boundary ID
  // c: 새로운 컨텐츠
  
  const template = document.getElementById(a);
  const suspenseBoundary = document.getElementById(b);
  
  // 실제 DOM으로 변환
  const newContent = hydrate(c);
  
  // Suspense boundary 교체
  suspenseBoundary.replaceWith(newContent);
}
</script>

5. 실전 패턴: 우선순위 기반 스트리밍

중요한 것 먼저, 덜 중요한 것은 나중에

export default async function ProductPage({ params }) {
  return (
    <>
      {/* 핵심 정보는 즉시 */}
      <ProductHeader productId={params.id} />
      
      {/* 구매 결정에 중요한 정보는 빠르게 */}
      <Suspense fallback={<PriceSkeleton />}>
        <ProductPrice productId={params.id} />
      </Suspense>
      
      {/* 부가 정보는 천천히 */}
      <Suspense fallback={<DetailsSkeleton />}>
        <ProductDetails productId={params.id} />
      </Suspense>
      
      {/* 추천 상품은 가장 나중에 */}
      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations productId={params.id} />
      </Suspense>
    </>
  );
}

병렬 데이터 페칭

// ❌ 순차적 - Waterfall
async function BadProduct({ id }) {
  const product = await fetchProduct(id);
  const reviews = await fetchReviews(id);
  const related = await fetchRelated(id);
  
  return <ProductView {...{product, reviews, related}} />;
}

// ✅ 병렬 - 각자 독립적으로
function GoodProduct({ id }) {
  return (
    <>
      <Suspense fallback={<ProductInfoSkeleton />}>
        <ProductInfo id={id} />
      </Suspense>
      
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews id={id} />
      </Suspense>
      
      <Suspense fallback={<RelatedSkeleton />}>
        <RelatedProducts id={id} />
      </Suspense>
    </>
  );
}

6. 고급 패턴: 스트리밍 최적화

1. 중첩된 Suspense로 점진적 개선

function ArticlePage() {
  return (
    <Suspense fallback={<ArticleSkeleton />}>
      <Article>
        {/* 텍스트는 빠르게 로드 */}
        <ArticleContent />
        
        {/* 이미지는 별도로 스트리밍 */}
        <Suspense fallback={<ImagePlaceholder />}>
          <ArticleImages />
        </Suspense>
        
        {/* 댓글은 가장 나중에 */}
        <Suspense fallback={<CommentsLoading />}>
          <Comments />
        </Suspense>
      </Article>
    </Suspense>
  );
}

2. 조건부 스트리밍

async function ConditionalContent({ user }) {
  return (
    <>
      <WelcomeMessage user={user} />
      
      {user.isPremium ? (
        <Suspense fallback={<PremiumContentLoading />}>
          <PremiumContent userId={user.id} />
        </Suspense>
      ) : (
        <Suspense fallback={<StandardContentLoading />}>
          <StandardContent />
        </Suspense>
      )}
    </>
  );
}

3. 에러와 함께 스트리밍

// error.js
'use client';

export default function Error({ error, reset }) {
  return (
    <div className="error-boundary">
      <h2>Something went wrong!</h2>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

// 컴포넌트에서 에러 발생 시
async function RiskyComponent() {
  const data = await fetchDataThatMightFail();
  
  if (!data) {
    throw new Error('Failed to load data');
  }
  
  return <DataDisplay data={data} />;
}

7. 성능 측정과 최적화

스트리밍 메트릭스

// 각 청크의 타이밍 측정
export function measureStreamingPerformance() {
  if (typeof window === 'undefined') return;
  
  // Navigation Timing API
  const navigation = performance.getEntriesByType('navigation')[0];
  
  // 첫 바이트까지의 시간 (TTFB)
  const ttfb = navigation.responseStart - navigation.requestStart;
  
  // 각 청크 도착 시간 추적
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.name.includes('chunk')) {
        console.log(`Chunk ${entry.name} arrived at ${entry.startTime}ms`);
      }
    }
  });
  
  observer.observe({ entryTypes: ['resource'] });
}

실제 성능 개선 사례

// Before: 모든 데이터 대기
// Time to First Byte: 2.3s
// First Contentful Paint: 2.5s
// Largest Contentful Paint: 2.8s

// After: 스트리밍 적용
// Time to First Byte: 0.3s
// First Contentful Paint: 0.5s
// Largest Contentful Paint: 1.2s

8. 주의사항과 한계

1. SEO 고려사항

// 중요한 SEO 컨텐츠는 Suspense 밖에
export default function ProductPage() {
  return (
    <>
      {/* SEO 크리티컬 컨텐츠 */}
      <h1>{product.title}</h1>
      <meta name="description" content={product.description} />
      
      {/* 나머지는 스트리밍 가능 */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews />
      </Suspense>
    </>
  );
}

2. 네트워크 오버헤드

// 너무 많은 작은 청크는 오히려 해로울 수 있다
// ❌ Bad: 너무 세분화
<Suspense><TinyComponent1 /></Suspense>
<Suspense><TinyComponent2 /></Suspense>
<Suspense><TinyComponent3 /></Suspense>

// ✅ Good: 의미있는 단위로 그룹화
<Suspense>
  <RelatedComponents>
    <TinyComponent1 />
    <TinyComponent2 />
    <TinyComponent3 />
  </RelatedComponents>
</Suspense>

9. 디버깅과 개발 도구

React DevTools에서 스트리밍 추적

// Chrome DevTools Console에서
__REACT_DEVTOOLS_GLOBAL_HOOK__.onCommitFiberRoot = (id, root) => {
  const suspenseBoundaries = [];
  
  function traverse(fiber) {
    if (fiber.tag === 19) { // SuspenseComponent
      suspenseBoundaries.push({
        name: fiber.elementType,
        state: fiber.memoizedState,
        fallback: fiber.memoizedProps.fallback
      });
    }
    if (fiber.child) traverse(fiber.child);
    if (fiber.sibling) traverse(fiber.sibling);
  }
  
  traverse(root.current);
  console.log('Active Suspense boundaries:', suspenseBoundaries);
};

10. 미래: React의 스트리밍 로드맵

곧 올 기능들

  1. Selective Hydration 개선

    • 사용자 상호작용 기반 우선순위
    • 뷰포트 기반 하이드레이션
  2. Server Components Streaming 최적화

    • HTTP/3 지원
    • 더 스마트한 청킹 알고리즘
  3. 개발자 경험 향상

    • 더 나은 에러 메시지
    • 스트리밍 성능 프로파일러

11. 결론: 진짜 점진적 웹의 시작

// 과거: All or Nothing
const html = await renderEverything();
res.send(html);

// 현재: 준비되는 대로
<Suspense>
  <ShowWhenReady />
</Suspense>

// 미래: 사용자가 필요한 것부터
<PrioritizedSuspense priority="high">
  <CriticalContent />
</PrioritizedSuspense>

React Server Components와 Suspense의 통합은 단순한 기능 추가가 아니다. 웹 애플리케이션이 콘텐츠를 전달하는 방식의 패러다임 전환이다.

더 이상 사용자를 기다리게 하지 마라. 준비된 것부터 보여주고, 나머지는 따라오게 하라.

이것이 진짜 "점진적 향상(Progressive Enhancement)"이다.


"The best time to show content is as soon as it's ready."