← posts

Suspense fallback이 실제 콘텐츠로 바뀌기까지

2026-08-07 · 지식 정리

1. 왜 이 글을 쓰게 되었는가?

Next.js App Router로 상품 목록 페이지를 구현하면서 서버에서 상품 데이터를 불러오는 동안 보여줄 로딩 UI가 필요했다. 데이터가 준비될 때까지 목록 영역을 비워두는 대신, 사용자가 목록을 불러오고 있다는 사실을 알 수 있도록 Suspense로 감싸고 fallback으로 Skeleton을 지정했다.

<Suspense fallback={<ProductListPending />}>
  <ProductListContent />
</Suspense>

화면에서는 의도한 대로 Skeleton이 먼저 보이고, 데이터가 준비되면 실제 상품 목록으로 바뀌었다. 사용법 자체는 어렵지 않았지만 이 변화를 브라우저와 서버의 통신 관점에서 설명하려니 막히는 부분이 있었다.

데이터가 준비됐다는 것을 누가 알고 화면을 교체할까?
Skeleton을 받은 뒤 브라우저가 서버에 다시 요청하는 걸까?

당시 나는 HTML을 서버가 완성해서 한 번에 보내는 문서라고 생각했다. 이 관점에서는 이미 fallback이 담긴 HTML 응답을 받은 뒤, 실제 상품 HTML이 어떻게 같은 자리에 들어오는지 설명할 수 없었다.

fallback 응답이 끝난 뒤 다시 요청한다고 생각했던 흐름과 하나의 HTTP 응답이 열린 채 이어지는 실제 흐름 비교

2. 답을 찾기 위해 무엇을 확인했는가?

매주 진행하는 멘토링에서 이 흐름에 관해 질문을 드렸다. 답변 중 다음 내용에서 힌트를 얻었다.

HTML은 완성된 문서를 한 번에 보내는 것만 가능한 게 아니라, 준비된 부분을 조각으로 나눠서 보낼 수 있다. Streaming SSR에서는 보류된 Suspense 경계를 기다리는 동안에도 HTML 응답을 열어두고 준비되는 조각을 같은 응답에 이어서 보낸 뒤, 모든 렌더링이 끝나면 응답을 닫는다.

멘토님께서는 이 답변과 함께 React의 renderToPipeableStream 문서도 살펴보라고 알려주셨다. 문서를 읽으며 답변에서 들은 “열려 있는 응답”을 React의 동작과 연결할 수 있었다. renderToPipeableStream은 React 트리를 스트림으로 출력하고, pipe(response)는 그 출력을 Node.js의 HTTP 응답에 연결한다.

함께 전달받은 RSC Streaming Sample도 직접 실행해봤다. 샘플의 핵심 구조를 단순화하면, 기다리는 작업을 Suspense 내부의 비동기 자식에서 실행하는 형태였다.

export default function ProductsPage() {
  return (
    <Suspense fallback={<ProductSkeleton />}>
      <ProductList />
    </Suspense>
  );
}
 
async function ProductList() {
  const products = await getProducts(); // 응답까지 약 10초
 
  return products.map((product) => (
    <ProductCard key={product.id} product={product} />
  ));
}

이 샘플을 실행하자 콘솔에는 Shell과 실제 콘텐츠가 서로 다른 시점에 도착한 것이 기록됐다.

=== http://localhost:3100/fetch ===
  [+  0.05s] 셸 + Skeleton(fallback) 도착      (chunk #1, 22930B)
  [+ 10.09s] 상품 목록(Suspense 콘텐츠) 도착   (chunk #2, 3928B)
  [+ 10.10s] 스트림 종료

같은 문서 응답에서 Shell은 약 0.05초, 실제 콘텐츠는 약 10초 후 도착한 타임라인

여기서 chunk #1, chunk #2는 이 실행에서 응답을 두 번에 걸쳐 읽었다는 표시다. Suspense 경계 하나가 언제나 네트워크 청크 하나로 전송된다는 뜻은 아니다.

중요한 점은 Shell과 실제 콘텐츠가 같은 문서 요청의 HTTP 응답에서 관찰됐다는 것이다. Skeleton을 받은 뒤 브라우저가 새로운 문서 요청을 보낸 것이 아니었다. 최초 응답은 열린 상태였고, 약 10초 뒤 실제 상품 HTML이 같은 응답에 이어졌다. 화면에서도 fallback이 먼저 보이고, 데이터가 준비된 뒤 실제 콘텐츠로 바뀌는 과정을 확인할 수 있었다.

Chrome Network Timing에서는 다음과 같이 보였다.

  • Waiting for server response: 69.38ms
  • Content Download: 10.07s

첫 바이트는 빠르게 도착했지만 문서 다운로드는 약 10초 동안 계속됐다. Network의 Response 탭은 도착한 내용을 시간순으로 나누지 않고 최종 응답 본문으로 합쳐서 보여줬다. 그래서 각 부분의 도착 시점은 콘솔 결과와 Timing을 함께 보며 확인했다.

짧은 Waiting 시간과 약 10초 동안 이어진 Content Download를 비교한 Network Timing

3. fallback에서 실제 콘텐츠까지 무슨 일이 일어나는가?

실험으로 Shell과 실제 콘텐츠가 다른 시점에 도착한다는 것을 확인했다. 이제 그 사이에 서버, React의 스트림, 브라우저가 각각 무엇을 하는지 나눠봤다.

서버에서 Shell을 만든다

서버가 React 트리를 렌더링하다가 아직 준비되지 않은 비동기 자식을 만나면, 가까운 Suspense 경계가 해당 영역의 렌더링을 보류한다. 실제 콘텐츠 대신 fallback을 포함해 먼저 만들 수 있는 UI를 완성한다.

이때 처음 전송할 수 있는 UI를 Shell이라고 부른다. Shell은 Suspense 바깥의 UI와 보류된 Suspense 경계의 fallback으로 구성된다.

<Header />
 
<Suspense fallback={<ProductSkeleton />}>
  <Products />
</Suspense>
 
<Footer />

위 예시의 첫 Shell은 Header + ProductSkeleton + Footer다. Products는 아직 포함되지 않지만, 그렇다고 HTTP 응답이 끝난 것은 아니다.

renderToPipeableStream이 전송을 관리한다

React의 renderToPipeableStream은 React 트리를 Node.js의 Writable Stream으로 점진적으로 출력할 수 있게 렌더링하는 서버 API다.

const { pipe } = renderToPipeableStream(<App />, {
  onShellReady() {
    response.setHeader('Content-Type', 'text/html');
    pipe(response);
  },
  onAllReady() {
    // 모든 Suspense 경계의 렌더링이 끝났다는 알림
  },
});

각 콜백과 pipe의 역할을 순서대로 나누면 다음과 같다.

  1. renderToPipeableStream()을 호출하면 서버 렌더링이 시작된다.
  2. 아직 준비되지 않은 Promise를 만나면 가까운 Suspense 경계를 보류하고 fallback을 Shell에 넣는다.
  3. Shell이 준비되면 onShellReady가 호출된다.
  4. 이때 pipe(response)를 호출하면 React의 출력 스트림이 HTTP 응답에 연결되고, Shell과 fallback의 전송이 시작된다.
  5. 기다리던 Promise가 완료되면 React 서버는 보류했던 Suspense 경계의 렌더링을 이어간다.
  6. 해당 경계가 완성되면 React가 실제 HTML과 fallback 교체 정보를 이미 연결된 스트림에 추가한다. 이 과정은 각 경계가 준비될 때마다 일어난다.
  7. 마지막 경계까지 렌더링이 끝나면 React가 onAllReady를 한 번 호출한다.
  8. 스트림에 남은 출력까지 전송되면 응답이 끝난다.

renderToPipeableStream 코드의 각 지점과 서버 렌더링, Shell 준비, 전송 시작, Suspense 경계 재개, 전체 렌더링 완료 동작을 좌우로 연결한 그림

여기서 pipe(response)는 React의 출력을 HTTP 응답으로 보내는 통로를 연결하고, onAllReady모든 렌더링이 끝났다는 사실을 알려준다. onAllReady 자체가 완성된 HTML을 보내는 것은 아니다.

위 코드처럼 onShellReady에서 이미 pipe(response)를 호출했다면 각 경계의 HTML은 준비되는 대로 전송된다. 반대로 onAllReady 안에서 처음 pipe(response)를 호출할 수도 있다. 이 경우에는 모든 렌더링이 끝날 때까지 전송을 시작하지 않으므로 완성된 결과를 한 번에 보내게 되고, 점진적인 로딩은 사라진다.

Next.js 애플리케이션에서 이 API를 직접 호출할 필요는 없다. Next.js가 실행 환경에 맞는 React 서버 렌더러와 스트림을 내부에서 관리한다. 여기서 renderToPipeableStream을 살펴본 이유는 React Streaming SSR의 원리를 가장 작은 단위로 이해하기 위해서였다.

브라우저가 도착한 HTML부터 처리한다

브라우저는 전체 응답이 끝날 때까지 기다리지 않는다. 먼저 도착한 Shell을 파싱해 DOM을 만들고 fallback을 화면에 표시한다. 이후 실제 HTML과 교체 정보가 도착하면 fallback DOM을 실제 콘텐츠 DOM으로 바꾼다.

최초 문서 요청부터 Shell과 fallback 표시, 실제 HTML과 교체 스크립트 실행, 응답 종료까지의 서버와 브라우저 시퀀스

React가 실제로 출력한 HTML을 단순화하면 다음과 비슷하다.

<!-- 먼저 도착하는 Shell의 Suspense 자리와 fallback -->
<!--$?--><template id="B:0"></template>
<p>Loading...</p>
<!--/$-->
 
<!-- Promise 완료 후 이어지는 실제 콘텐츠와 교체 지시 -->
<div hidden id="S:0"><p>실제 콘텐츠</p></div>
<script>$RC('B:0', 'S:0');</script>

마커 이름과 교체 함수는 React 버전에 따라 달라질 수 있다. 여기서 중요한 것은 브라우저가 Suspense라는 React 개념을 이해해 스스로 교체하는 것이 아니라는 점이다. React 서버 렌더러가 실제 HTML과 fallback을 교체할 <script>를 같은 응답에 이어서 보내고, 브라우저가 이 스크립트를 실행해 DOM을 변경한다. DOM이 바뀌면 브라우저는 영향을 받는 범위의 스타일을 다시 계산하고, 필요한 layout과 paint를 수행해 실제 콘텐츠를 화면에 표시한다.

이 DOM 교체와 hydration도 구분해야 했다. fallback 교체는 서버가 보낸 인라인 <script>가 수행하며 hydrateRoot가 하는 일이 아니다. hydration은 서버가 만든 DOM에 React의 상태와 이벤트 처리를 연결하는 별도 과정이다.

4. 이해하면서 정리된 것

await의 위치가 Shell을 결정한다

Suspense가 있다고 항상 fallback이 먼저 보이는 것은 아니다. React가 Suspense 경계를 만나기 전에 상위 컴포넌트에서 await으로 기다리면, 아직 경계 자체를 렌더링하지 못했으므로 fallback도 만들 수 없다.

async function ProductsPage() {
  const products = await getProducts();
 
  return (
    <Suspense fallback={<ProductListPending />}>
      <ProductList products={products} />
    </Suspense>
  );
}

이 코드에서는 getProducts()가 끝난 뒤에야 Suspense를 반환하므로 fallback을 먼저 만들 수 없다. 앞서 살펴본 샘플처럼 기다리는 작업을 Suspense 내부 자식으로 내리면 바깥 컴포넌트가 경계를 먼저 반환할 수 있고, React는 그 자식이 보류되는 동안 fallback을 포함한 Shell을 만든다.

여러 Suspense 경계는 준비된 순서대로 공개된다

한 페이지에 Suspense가 여러 개 있어도 문서 요청과 pipe(response) 호출은 한 번이다. 각 경계의 결과는 같은 스트림에 계속 추가된다.

<Suspense fallback={<ASkeleton />}>
  <A /> {/* 3초 */}
</Suspense>
 
<Suspense fallback={<BSkeleton />}>
  <B /> {/* 1초 */}
</Suspense>
 
<Suspense fallback={<CSkeleton />}>
  <C /> {/* 2초 */}
</Suspense>

세 경계가 서로 독립적인 Promise를 기다린다면 다른 경계를 기다리지 않고 B, C, A 순서로 준비될 수 있다. DOM에서의 위치가 바뀌는 것은 아니다. 각 경계가 원래 자리에서 fallback을 실제 콘텐츠로 교체한다.

A, B, C Suspense 경계가 각각 3초, 1초, 2초에 완료되어 B, C, A 순서로 교체되는 타임라인

Suspense 경계는 React가 UI를 보류하고 공개하는 단위이고, 네트워크 청크는 실제 바이트가 전송되는 단위다. 예를 들어 B와 C가 거의 동시에 준비되면 두 경계의 HTML이 전송 과정에서 하나로 묶여 브라우저에 도착할 수 있다. 따라서 경계가 세 개라고 해서 네트워크에서도 반드시 세 조각으로 도착하는 것은 아니다.

경계의 개수와 API 호출 횟수도 별개다. A, B, C가 각각 API 호출 함수를 실행하면 호출도 각각 발생하고, 한 번 만든 Promise를 공유하면 하나의 결과를 함께 기다린다. Suspense가 이 호출들을 자동으로 합치거나 나누지는 않는다.

따라서 서로 독립적으로 준비되고 다른 영역보다 먼저 보여줘도 의미 있는 UI인지를 기준으로 Suspense 경계를 나눠야 한다.

5. 결과와 깨달은 점

처음 질문에 대한 답

처음 궁금했던 것은 이것이었다.

Skeleton을 받은 뒤 브라우저가 실제 콘텐츠를 받기 위해 서버에 다시 요청하는 걸까?
데이터가 준비된 뒤에는 누가 fallback을 실제 콘텐츠로 교체할까?

브라우저가 실제 콘텐츠를 받기 위해 별도의 문서 요청을 보내는 것이 아니다. 하나의 HTTP 응답이 열린 상태로 유지되고, 서버가 Shell과 fallback을 먼저 보낸 뒤 실제 콘텐츠와 교체 정보를 같은 응답에 이어서 보낸다.

브라우저는 도착한 HTML부터 파싱해 fallback을 보여준다. 이후 서버가 실제 콘텐츠와 교체용 <script>를 보내면, 브라우저가 이 스크립트를 실행해 해당 DOM을 실제 콘텐츠로 교체한다.

새롭게 이해한 것

Streaming SSR에서 Suspense는 데이터 요청을 빠르게 하거나 자동으로 병렬화하는 기능이 아니라, 하나의 HTML 스트림 안에서 어떤 UI를 먼저 공개하고 어떤 UI를 함께 기다릴지 결정하는 경계라는 것을 이해했다.

회고

처음에는 서버, 네트워크, 브라우저의 역할을 한꺼번에 생각해서 헷갈렸다. 각 단계에서 누가 무엇을 만들고, 어떤 바이트를 보내며, 어떤 DOM을 바꾸는지 나눠보니 fallback에서 실제 콘텐츠까지의 흐름을 설명할 수 있었다.

내가 궁금했던 것은 Suspense가 브라우저와 통신하는 관점에서 어떻게 동작하는지였다. 문서와 샘플을 살펴보며 그 흐름을 이해할 수 있었고, Suspense도 많이 둘수록 좋은 것이 아니라 UI가 공개되는 단위를 생각해 적절한 경계를 선택해야 한다는 점을 깨달았다.

참고 자료