React 서버 컴포넌트의 스트리밍과 점진적 하이드레이션 분석

reactserver-componentssuspensestreamingssrperformance

1. 전통적 SSR의 렌더링 병목 현상

// 전통적인 SSR (e.g., Next.js Pages Router)
export async function getServerSideProps() {
  // 각 API 호출은 순차적으로 또는 병렬로 실행될 수 있으나,
  // 최종 HTML은 모든 Promise가 resolve된 후에야 생성된다.
  const [user, posts, recommendations] = await Promise.all([
    fetchUser(),          // 500ms
    fetchPosts(),         // 1200ms
    fetchRecommendations()  // 800ms
  ]);
  
  // 가장 느린 fetchPosts(1.2초)가 전체 응답 시간을 결정한다.
  return { props: { user, posts, recommendations } };
}

전통적인 서버 사이드 렌더링(SSR)은 명확한 한계를 가진다. 서버에서 페이지에 필요한 모든 데이터를 동기적으로 조회하고, 전체 페이지에 대한 HTML을 완전히 생성한 후에야 클라이언트에 응답을 보낼 수 있다. 이 "All-or-Nothing" 방식은 다음과 같은 렌더링 병목을 유발한다.

  • 데이터 페칭 병목: 가장 느린 데이터 소스가 전체 Time to First Byte(TTFB)를 결정한다.
  • 서버 렌더링 병목: 모든 데이터를 기반으로 전체 HTML을 문자열로 변환하는 과정이 완료될 때까지 응답이 지연된다.
  • 클라이언트 하이드레이션 병목: 클라이언트는 전체 JavaScript 번들을 다운로드하고 실행하여 페이지 전체를 한 번에 상호작용 가능하게 만들어야 한다(Hydration).

React 18과 서버 컴포넌트(RSC)는 이 문제를 해결하기 위해 렌더링과 데이터 조회를 분리하고, 이를 스트리밍과 점진적 하이드레이션으로 처리하는 새로운 방법을 제시한다.

2. Streaming SSR: renderToPipeableStream의 역할

React 18은 renderToPipeableStream이라는 저수준 API를 통해 스트리밍을 구현한다. (실제 개발에서는 Next.js와 같은 프레임워크가 이를 추상화하여 제공한다.)

// Streaming SSR의 개념적 구현
app.get('/', (req, res) => {
  const stream = renderToPipeableStream(<App />, {
    bootstrapScripts: ['/main.js'],
    // 1. 초기 셸(Shell)이 준비되면 즉시 호출됨
    onShellReady() {
      res.statusCode = 200;
      res.setHeader('Content-type', 'text/html');
      stream.pipe(res); // 스트림을 클라이언트로 전송 시작
    },
    // 2. 모든 비동기 작업이 완료되면 호출됨 (검색 엔진 크롤러 등에 유용)
    onAllReady() {
      // ... 로깅 또는 추가 처리
    }
  });
});

onShellReady 콜백 덕분에, 서버는 비동기 작업이 모두 끝날 때까지 기다리지 않고 즉시 렌더링 가능한 HTML(셸)부터 클라이언트로 보낼 수 있다. 브라우저는 이 초기 HTML을 받는 즉시 파싱하고 렌더링을 시작하여 주요 성능 지표인 FCP(First Contentful Paint)를 크게 개선한다.

3. Suspense와 스트리밍의 통합

서버에서 어떤 부분을 '초기 셸'로 간주하고, 어떤 부분을 나중에 스트리밍할지 결정하는 기준이 바로 <Suspense>이다.

// app/page.js (Server Component)
export default async function HomePage() {
  return (
    <div>
      {/* 이 부분은 await이 없으므로 초기 셸에 포함됨 */}
      <Header /> 
      
      {/* Posts 컴포넌트는 비동기이므로 Suspense 경계가 설정됨 */}
      <Suspense fallback={<PostsSkeleton />}>
        <Posts />
      </Suspense>
    </div>
  );
}
 
// Posts는 비동기 Server Component
async function Posts() {
  // await 키워드를 만나면 React 렌더러는 이 컴포넌트의 렌더링을 일시 중단한다.
  const posts = await db.posts.findMany(); 
  return <PostList posts={posts} />;
}

동작 순서:

  1. React 렌더러가 HomePage를 렌더링하다가 <Posts />의 await을 만난다.
  2. Posts의 Promise가 아직 pending 상태이므로, 렌더러는 Posts의 실행을 멈추고 트리를 거슬러 올라가 가장 가까운 <Suspense>를 찾는다.
  3. 찾아낸 <Suspense>의 fallback인 <PostsSkeleton />을 초기 셸에 포함시켜 먼저 클라이언트로 스트리밍한다.
  4. 서버에서 db.posts.findMany() Promise가 resolved되면, 렌더러는 <PostList />의 렌더링을 완료하고, 그 결과 HTML을 별도의 청크로 클라이언트에 스트리밍한다.
  5. 클라이언트는 이 청크를 받아 스켈레톤 UI를 실제 콘텐츠로 교체한다.

4. 점진적 하이드레이션과 선택적 하이드레이션

스트리밍의 또 다른 핵심은 점진적 하이드레이션(Progressive Hydration)이다.

  • 전통적 하이드레이션: 전체 페이지의 HTML과 JavaScript가 로드된 후, 앱 전체를 한 번에 상호작용 가능하게 만든다.
  • 점진적 하이드레이션: HTML 청크가 스트리밍되는 대로, 해당 부분의 코드만으로 하이드레이션을 점진적으로 수행할 수 있다. 즉, 전체 JS 번들을 기다릴 필요가 없다.

더 나아가 선택적 하이드레이션(Selective Hydration)이 가능해진다.
React는 사용자의 상호작용에 기반하여 하이드레이션의 우선순위를 정할 수 있다. 예를 들어, 댓글 컴포넌트의 코드가 아직 로드되지 않았더라도 사용자가 댓글 창을 클릭하면, React는 다른 부분보다 우선적으로 해당 컴포넌트의 코드와 하이드레이션을 처리하여 상호작용 지연을 최소화한다.

5. 실전 아키텍처 패턴

우선순위 기반 렌더링

사용자 경험에 중요한 컴포넌트와 그렇지 않은 컴포넌트를 Suspense 경계로 분리하여 렌더링 우선순위를 제어할 수 있다.

export default async function ProductPage({ params }) {
  return (
    <>
      {/* LCP(Largest Contentful Paint)에 해당할 수 있는 핵심 정보는 즉시 렌더링 */}
      <ProductHeader productId={params.id} />
      
      {/* 구매 전환율에 영향을 주는 가격 정보 */}
      <Suspense fallback={<PriceSkeleton />}>
        <ProductPrice productId={params.id} />
      </Suspense>
      
      {/* 스크롤해야 보이는 부가 정보 */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews productId={params.id} />
      </Suspense>
    </>
  );
}

데이터 페칭의 병렬화와 캐싱

각 Suspense 경계는 독립적인 데이터 요청을 병렬로 처리한다. 이때, 여러 컴포넌트가 동일한 데이터를 요청하는 경우를 처리하기 위해 캐싱이 필수적이다. React.cache 또는 Next.js의 확장된 fetch API는 렌더링 과정에서 발생하는 중복 데이터 호출을 자동으로 제거(dedupe)하여 네트워크 효율성을 보장한다.

6. 디버깅 및 성능 분석

React DevTools 활용

React의 내부 API를 직접 조작하는 것은 불안정하며 권장되지 않는다. 가장 효과적인 디버깅 방법은 공식 React DevTools를 사용하는 것이다.

  • 컴포넌트 트리: 서버 컴포넌트(Server)와 클라이언트 컴포넌트(Client)를 명확히 구분하여 보여준다.
  • Suspense 상태 시각화: 현재 '대기 중(pending)'인 Suspense 경계를 시각적으로 추적하고, 언제 콘텐츠가 로드되는지 확인할 수 있다.
  • Profiler: 어떤 컴포넌트가 언제, 얼마나 오래 렌더링되고 하이드레이션되는지 상세하게 분석하여 성능 병목을 찾을 수 있다.

웹 성능 메트릭스 분석

  • TTFB (Time to First Byte): 스트리밍을 통해 이 수치가 극적으로 감소하는 것을 확인할 수 있다.
  • FCP (First Contentful Paint): 초기 셸이 얼마나 빨리 렌더링되는지 보여주는 지표이다.
  • Waterfall 차트 (in Chrome DevTools): 초기 HTML 응답 이후, 추가적인 데이터와 HTML 청크들이 시간차를 두고 도착하는 스트리밍 동작을 시각적으로 확인할 수 있다.
async function ConditionalContent({ user }) {
  return (
    <>
      <WelcomeMessage user={user} />
      
      {user.isPremium ? (
        <Suspense fallback={<PremiumContentLoading />}>
          <PremiumContent userId={user.id} />
        </Suspense>
      ) : (
        <Suspense fallback={<StandardContentLoading />}>
          <StandardContent />
        </Suspense>
      )}
    </>
  );
}

7. Summary

  • 전통적 SSR의 한계 : "All-or-Nothing" 방식으로, 가장 느린 데이터 요청이 전체 페이지의 렌더링과 로딩 시간을 결정하여 TTFB, FCP가 지연된다.
  • 스트리밍 SSR : Suspense를 경계로, 서버는 렌더링 가능한 HTML(셸)을 즉시 전송하고, 데이터가 필요한 컴포넌트는 준비되는 대로 별도의 청크로 스트리밍하여 초기 로딩 성능을 극대화한다.
  • 점진적 & 선택적 하이드레이션 : 클라이언트는 전체 JS 번들을 기다리지 않고, 스트리밍된 청크 단위로 점진적인 하이드레이션을 수행한다. 또한, 사용자 상호작용에 따라 하이드레이션 순서의 우선순위를 동적으로 지정할 수 있다.
  • 개발자 경험 : 개발자는 서버 컴포넌트에서 async/await을 사용하여 데이터 의존성을 선언하고, 이를 Suspense로 감싸는 것만으로 복잡한 스트리밍 로직을 선언적으로 제어할 수 있다.
  • 궁극적 목표 : 렌더링 병목을 제거하여 사용자에게 더 빠른 초기 콘텐츠를 제공하고, 상호작용까지의 시간을 단축시켜 전반적인 웹 성능과 사용자 경험을 극대화하는 것이다.

7. 결론

React 서버 컴포넌트와 Suspense의 통합은 단순한 SSR 성능 개선을 넘어, 웹 애플리케이션의 렌더링과 데이터 조회 방식을 근본적으로 재설계한 새로운 아키텍처이다.

All-or-Nothing의 동기적 렌더링에서 벗어나, 준비된 콘텐츠부터 점진적으로 렌더링하고 하이드레이션하는 방식으로의 전환은 사용자 경험과 개발자 경험 모두를 한 단계 끌어올린다. 이 아키텍처를 깊이 이해하는 것은 React 애플리케이션을 효율적으로 구축하고 최적화하는 데 필요한 지식일 것이다.

📚 참고 링크