Next.js Suspense와 스트리밍 렌더링 완벽 가이드

조주영·2025년 7월 14일

Next.js Suspense와 스트리밍 렌더링 완벽 가이드

React의 Suspense가 Next.js에서 어떻게 활용되는지, 그리고 서버 컴포넌트에서 스트리밍 렌더링이 어떻게 동작하는지 깊이 있게 알아보겠습니다.

Suspense가 Next.js에서 다른 점

Next.js에서 Suspense를 사용할 때는 일반적인 React 앱과 몇 가지 중요한 차이점이 있습니다.

App Router vs Pages Router

App Router (권장)

  • Suspense가 더 자연스럽게 통합되어 있음
  • 서버 컴포넌트와 클라이언트 컴포넌트 모두에서 활용 가능
  • 스트리밍 렌더링 지원

Pages Router

  • Suspense 사용에 제한이 있음
  • 주로 클라이언트 사이드에서만 활용

서버 컴포넌트에서의 Suspense

// app/page.tsx
import { Suspense } from 'react'
import UserProfile from './UserProfile'

export default function Page() {
  return (
    <div>
      <h1>대시보드</h1>
      <Suspense fallback={<div>사용자 정보 로딩 중...</div>}>
        <UserProfile />
      </Suspense>
    </div>
  )
}

// UserProfile.tsx (서버 컴포넌트)
async function UserProfile() {
  const user = await fetch('https://api.example.com/user').then(res => res.json())
  return <div>{user.name}</div>
}

서버 컴포넌트에서 Suspense의 역할

처음에는 의문이 들 수 있습니다. "서버에서 이미 데이터를 가져와서 내려주는 컴포넌트인데 로딩이 필요한가?"

하지만 스트리밍 렌더링과 함께 사용될 때 진짜 효과를 발휘합니다.

스트리밍 없이 (기존 방식)

export default async function Page() {
  const user = await fetchUser()        // 3초 대기
  const posts = await fetchPosts()      // 2초 대기
  const comments = await fetchComments() // 1초 대기
  
  return (
    <div>
      <UserProfile user={user} />
      <PostList posts={posts} />
      <CommentList comments={comments} />
    </div>
  )
}
// 총 6초 후에 완성된 페이지가 한 번에 전송

스트리밍 + Suspense 사용

export default function Page() {
  return (
    <div>
      <h1>대시보드</h1>  {/* 즉시 보임 */}
      
      <Suspense fallback={<UserSkeleton />}>
        <UserProfile />  {/* 3초 후 교체 */}
      </Suspense>
      
      <Suspense fallback={<PostSkeleton />}>
        <PostList />     {/* 2초 후 교체 */}
      </Suspense>
      
      <Suspense fallback={<CommentSkeleton />}>
        <CommentList />  {/* 1초 후 교체 */}
      </Suspense>
    </div>
  )
}

실제 사용자 경험:

  • 0초: 제목 + 3개 스켈레톤 보임
  • 1초: 제목 + 2개 스켈레톤 + 실제 댓글
  • 2초: 제목 + 1개 스켈레톤 + 실제 댓글 + 실제 게시물
  • 3초: 모든 것이 완성됨

스트리밍 vs PPR (Partial Prerendering)

스트리밍과 PPR은 다른 개념입니다.

스트리밍 (현재 App Router 기본 기능)

  • 런타임에 서버에서 HTML 청크를 점진적으로 전송
  • 요청 시마다 서버에서 실시간 렌더링
  • Suspense 경계를 기준으로 준비된 부분부터 스트리밍

PPR (Partial Prerendering - 실험적 기능)

  • 빌드 타임에 정적 부분은 미리 렌더링
  • 동적 부분만 런타임에 처리
  • 정적 셸(shell) + 동적 홀(holes) 구조

차이점 요약: 동적/정적 미리 빌드된 걸 보여주냐 안 보여주냐의 차이입니다.

스트리밍: 모든 걸 런타임에 서버에서 만들어서 조각조각 보내줌
PPR: 미리 만들어놓은 정적 부분은 바로 보여주고, 동적 부분만 나중에 채워넣음

스트리밍 렌더링의 내부 동작

HTTP 스트리밍 기본 원리

HTTP/1.1의 Transfer-Encoding: chunked를 사용합니다:

HTTP/1.1 200 OK
Content-Type: text/html
Transfer-Encoding: chunked

4
<htm
6
l><hea
3
d>
...
0

실제 스트리밍 과정

// 이 컴포넌트가 있다고 가정
export default function Page() {
  return (
    <div>
      <Header />
      <Suspense fallback={<Loading />}>
        <SlowComponent />
      </Suspense>
      <Footer />
    </div>
  )
}

실제 전송되는 청크들:

// 청크 1: 초기 HTML 청크 (즉시 전송)
`<!DOCTYPE html>
<html>
<head>...</head>
<body>
  <div>
    <header>헤더 내용</header>
    <div>로딩 중...</div>  <!-- fallback -->
    <footer>푸터 내용</footer>
  </div>
</body>
</html>`

// 청크 2: React 하이드레이션 스크립트
`<script>/* React 초기화 코드 */</script>`

// 청크 3: SlowComponent 준비 완료 후 교체 청크
`<script>
  $RC = function(id, html) {
    const element = document.getElementById(id);
    element.innerHTML = html;
  };
  $RC("suspense-boundary-1", "<div>실제 컴포넌트 내용</div>");
</script>`

TTFB (Time To First Byte)와 성능

TTFB는 사용자가 요청을 보낸 후 서버로부터 첫 번째 바이트를 받을 때까지 걸리는 시간입니다.

renderToString vs renderToStream에서 TTFB 차이

renderToString:

function handleRequest(req, res) {
  const html = renderToString(<App />)  // 5초 소요
  res.send(html)  // 5초 후에 첫 바이트 전송
}
// TTFB: 5초 + 네트워크 지연

renderToStream:

function handleRequest(req, res) {
  const stream = renderToPipeableStream(<App />, {
    onShellReady() {
      stream.pipe(res)  // 0.1초 후에 첫 바이트 전송
    }
  })
}
// TTFB: 0.1초 + 네트워크 지연

renderToString vs renderToStream

renderToString (기존 방식)

import { renderToString } from 'react-dom/server'

function handleRequest(req, res) {
  const App = <MyApp />
  const html = renderToString(App)  // 동기적 처리
  
  res.send(`<!DOCTYPE html><html><body><div id="root">${html}</div></body></html>`)
}

특징:

  • 동기적 - 모든 컴포넌트 렌더링 완료까지 기다림
  • 문자열 반환 - 완성된 HTML 문자열 한 번에 반환
  • Suspense 지원 안함 - async 컴포넌트 처리 불가
  • 블로킹 - 느린 컴포넌트 하나가 전체를 지연

renderToStream (React 18+)

import { renderToPipeableStream } from 'react-dom/server'

function handleRequest(req, res) {
  const stream = renderToPipeableStream(<App />, {
    onShellReady() {
      res.setHeader('Content-Type', 'text/html')
      stream.pipe(res)
    }
  })
}

특징:

  • 비동기적 - 준비된 부분부터 점진적 전송
  • 스트림 반환 - ReadableStream 객체 반환
  • Suspense 지원 - async 컴포넌트 자연스럽게 처리
  • 논블로킹 - 느린 컴포넌트가 다른 부분을 지연시키지 않음

Suspense 내부 동작 원리

기본 Suspense 구현

// React 내부 Suspense 구현 (단순화)
function Suspense({ children, fallback }) {
  try {
    return children;
  } catch (promise) {
    if (isPromise(promise)) {
      // Promise를 throw하면 Suspense가 catch
      return fallback;
    }
    throw promise;
  }
}

// async 컴포넌트에서 사용
function AsyncComponent() {
  const data = use(fetchData()); // use hook이 Promise를 throw
  return <div>{data}</div>;
}

Promise를 throw하는 방식

// React의 use hook 내부
function use(promise) {
  if (promise.status === 'pending') {
    throw promise; // 핵심: Promise 자체를 throw!
  } else if (promise.status === 'fulfilled') {
    return promise.value;
  } else {
    throw promise.reason;
  }
}

서버에서의 Suspense 처리

// React 서버 렌더러 내부 구현 (의사코드)
async function renderElement(element, controller, suspenseMap) {
  if (element.type === Suspense) {
    const boundaryId = generateId();
    
    try {
      const content = await renderElement(element.props.children);
      return content;
    } catch (promise) {
      if (isPromise(promise)) {
        // 1. 즉시 fallback을 스트림에 전송
        const fallbackHtml = renderSuspenseBoundary(boundaryId, element.props.fallback);
        controller.enqueue(fallbackHtml);
        
        // 2. Promise 완료를 기다린 후 교체 스크립트 전송
        promise.then(async () => {
          const resolvedContent = await renderElement(element.props.children);
          const replacementScript = createReplacementScript(boundaryId, resolvedContent);
          controller.enqueue(replacementScript);
        });
        
        return '';
      }
    }
  }
}

메모리 최적화 이슈

renderToStream은 React Fiber 노드를 생성해야 하므로 서버 메모리 부하가 클 수 있습니다.

React의 해결책들

1. Server Components의 경량화된 구조:

// 일반 클라이언트 컴포넌트 (무거운 Fiber)
function ClientComponent() {
  const [state, setState] = useState(0); // Fiber에 상태 저장
  useEffect(() => { /* 이펙트 저장 */ });
  return <div>{state}</div>
}

// 서버 컴포넌트 (경량화된 구조)
async function ServerComponent() {
  const data = await fetch('/api/data'); // 상태 없음
  return <div>{data}</div> // 단순 JSX 변환
}

2. 단계적 메모리 해제:

function renderToStream(element) {
  return new ReadableStream({
    async start(controller) {
      const workStack = [element];
      
      while (workStack.length > 0) {
        const currentWork = workStack.pop();
        const result = await processWork(currentWork);
        
        if (result.isComplete) {
          controller.enqueue(result.html);
          // 완료된 작업은 즉시 메모리에서 해제
          currentWork = null;
        }
      }
    }
  });
}

실제 사용 권장사항

1. 중첩된 Suspense 경계 활용

export default function Page() {
  return (
    <Suspense fallback={<div>전체 페이지 로딩...</div>}>
      <Header />
      <main>
        <Suspense fallback={<div>사이드바 로딩...</div>}>
          <Sidebar />
        </Suspense>
        <Suspense fallback={<div>콘텐츠 로딩...</div>}>
          <MainContent />
        </Suspense>
      </main>
    </Suspense>
  )
}

2. 스켈레톤 UI와 함께 사용

export default function Dashboard() {
  return (
    <div>
      <QuickStats /> {/* 빠른 데이터 */}
      
      <Suspense fallback={<ChartSkeleton />}>
        <ExpensiveChart /> {/* 느린 데이터 */}
      </Suspense>
      
      <Suspense fallback={<TableSkeleton />}>
        <HeavyDataTable /> {/* 또 다른 느린 데이터 */}
      </Suspense>
    </div>
  )
}

결론

Next.js App Router에서 Suspense와 스트리밍 렌더링은 다음과 같은 이점을 제공합니다:

  1. 향상된 사용자 경험: 빈 화면 시간 최소화
  2. 더 빠른 TTFB: 초기 콘텐츠 즉시 전송
  3. 점진적 로딩: 준비된 부분부터 차례대로 표시
  4. 서버 성능 최적화: 논블로킹 렌더링

서버 컴포넌트에서 Suspense는 스트리밍과 함께 사용될 때 진짜 효과를 발휘하며, 이는 현대 웹 개발에서 필수적인 성능 최적화 기법이 되었습니다.

메모리 사용량이나 복잡성 측면에서 고려사항이 있지만, React 팀의 지속적인 최적화를 통해 이런 문제들이 점차 해결되고 있습니다. Next.js 14+에서는 이러한 기능들이 기본적으로 제공되므로, 적극적으로 활용해보시기 바랍니다.

profile
꾸준히 성장하기

0개의 댓글