이성열
A

Suspense와 함께하는 Hydration

들어가며

서버에서 렌더링된 React 애플리케이션을 브라우저에서 다시 살아나게 하는 과정, 바로 hydration입니다.

여기에 Suspense가 더해지면 어떻게 될까요?

예를 들어, 사용자 프로필을 보여주는 컴포넌트가 있다고 해봅시다. 일반 SSR에서는 서버와 클라이언트 모두 동일한 API를 호출합니다:

타이밍 차이로 복잡한 상황이 생깁니다: 서버에서는 API 타임아웃으로 fallback(로딩 화면)을 보냈는데, 그 사이 클라이언트에서는 이미 데이터를 받아온 경우도 있을 수 있습니다. React는 이런 서버와 클라이언트의 상태 불일치를 어떻게 처리할까요?

이 글에서는 React가 Suspense 컴포넌트를 어떻게 직렬화하고, hydration 과정에서 발생할 수 있는 다양한 시나리오를 어떻게 처리하는지 React 소스코드와 함께 자세히 살펴보겠습니다.

왜 클라이언트에서 또 데이터를 가져오나요?

“서버에서 이미 API로 데이터를 가져와서 HTML을 만들었는데, 왜 클라이언트에서 또 같은 API를 호출하죠?”

이것은 많은 개발자들이 헷갈려하는 부분입니다! 차근차근 설명해드릴게요.

🎭 문제의 핵심: HTML vs JavaScript 상태

서버가 보내는 것:

<!-- 서버가 보낸 HTML (데이터가 이미 렌더링됨) -->
<div class="profile">
  <h2>김철수</h2>
  <p>kim@example.com</p>
</div>

하지만 React 컴포넌트는 이렇게 생겼죠:

function UserProfile({ userId }) {
  // React는 이 함수를 실행해야 합니다!
  const user = fetchUser(userId);  // 이 데이터가 필요해요
  
  return (
    <div className="profile">
      <h2>{user.name}</h2>      {/* user 객체가 필요 */}
      <p>{user.email}</p>       {/* user 객체가 필요 */}
    </div>
  );
}

🤯 Hydration의 딜레마

Hydration 과정에서 일어나는 일:

  1. HTML 도착: 서버가 렌더링한 HTML이 브라우저에 도착합니다
  2. React 시작: React가 클라이언트에서 실행되기 시작합니다
  3. 컴포넌트 실행: React가 UserProfile 컴포넌트를 실행합니다
  4. 문제 발생!: fetchUser()가 실행되는데… user 데이터가 없습니다!

왜냐하면:

💡 해결 방법들

방법 1: 데이터를 HTML에 포함시키기

// 서버에서 데이터를 script 태그로 포함
<script>
  window.__INITIAL_DATA__ = {
    user: { name: "김철수", email: "kim@example.com" }
  };
</script>

방법 2: 같은 데이터 다시 가져오기 (일반적인 Suspense SSR)

function fetchUser(userId) {
  // 클라이언트에서 같은 데이터를 다시 가져옴
  return fetch(`/api/users/${userId}`);
}

🎯 그래서 Suspense가 필요한 이유

Suspense는 이런 “데이터 불일치” 상황을 우아하게 처리합니다:

  1. 서버가 빠른 경우:

    • 서버가 데이터를 가져와 HTML 렌더링 → <!--$-->
    • 클라이언트는 아직 데이터 없음 → 콘텐츠 유지! (깜빡임 방지)
  2. 서버가 느린 경우:

    • 서버가 타임아웃으로 fallback 렌더링 → <!--$!-->
    • 클라이언트가 나중에 데이터 도착 → fallback을 콘텐츠로 교체
  3. 둘 다 준비된 경우:

    • 완벽! DOM 재사용하며 이벤트 리스너만 연결

📝 요약: 왜 두 번 가져오나요?

답: HTML과 JavaScript 상태는 다르기 때문입니다!

이것이 바로 Suspense가 해결하려는 문제이고, React가 4가지 시나리오를 준비한 이유입니다!

실제 코드 예시: 서버는 거의 항상 Fallback

// 일반적인 SSR with Suspense 패턴
function createUserResource(userId) {
  let status = 'loading';
  let result;
  
  const promise = fetch(`/api/users/${userId}`)
    .then(res => res.json())
    .then(data => {
      status = 'success';
      result = data;
    });
  
  return {
    read() {
      if (status === 'loading') throw promise; // 👈 서버에서도 Promise throw!
      if (status === 'error') throw result;
      return result;
    }
  };
}

// Suspense를 사용하는 컴포넌트
function UserProfile({ userId }) {
  const userResource = createUserResource(userId);
  const user = userResource.read(); // 서버: Promise throw → fallback 렌더링
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

// 서버에서 일어나는 일:
// 1. UserProfile 컴포넌트 실행
// 2. userResource.read()가 Promise를 throw
// 3. React가 Suspense fallback을 렌더링
// 4. HTML에 <!--$!--> (fallback 마커) 포함
// 5. 클라이언트로 전송

renderToPipeableStream(<App userId="123" />, {
  onShellReady() {
    // fallback이 포함된 HTML 전송
    // <div><!--$!--><div>로딩 중...</div><!--/$--></div>
  }
});

💡 실제로 서버가 Content를 렌더링하는 경우

// 동기적으로 사용 가능한 데이터만 content로 렌더링됩니다
function StaticProfile() {
  // Suspense를 트리거하지 않는 동기 데이터
  const user = { name: "김철수", email: "kim@example.com" };
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

// 이 경우 서버가 content를 렌더링:
// <div><!--$--><div><h1>김철수</h1>...</div><!--/$--></div>

그럼 왜 4가지 시나리오를 준비했나요?

React 팀은 미래의 가능성엣지 케이스를 모두 고려했습니다:

  1. 미래의 데이터 페칭 전략: 서버에서 동기적으로 데이터를 가져오는 패턴이 나올 수 있음
  2. 서버 컴포넌트와의 통합: 나중에 서버 컴포넌트와 함께 사용될 때를 대비
  3. 커스텀 구현: 개발자가 독특한 방식으로 Suspense를 활용할 가능성
  4. 완벽한 호환성: 어떤 상황에서도 깨지지 않는 견고한 시스템

실제로 대부분의 경우:

하지만 React는 모든 경우를 우아하게 처리합니다!

🌊 스트리밍 SSR: 진짜 마법이 일어나는 곳

renderToPipeableStream이란?

renderToPipeableStream은 React 18에서 도입된 스트리밍 SSR API입니다. 이전의 renderToString과 달리, HTML을 조각조각 나누어서 점진적으로 전송할 수 있습니다!

// 기존 방식: 모든 데이터를 기다렸다가 한 번에 전송
renderToString(<App />)  // 전체 HTML을 한 번에 생성

// 새로운 방식: 준비된 부분부터 스트리밍
renderToPipeableStream(<App />, {
  onShellReady() {
    // 1️⃣ 초기 HTML 구조 (shell) 준비 완료!
    // fallback이 포함된 HTML을 즉시 전송
    response.pipe(res);
  },
  onAllReady() {
    // 2️⃣ 모든 Suspense가 해결되고 전체 콘텐츠 준비 완료
    // SEO나 정적 생성에 사용
  }
})

📡 스트리밍의 실제 동작

<Suspense fallback={<div>로딩 중...</div>}>
  <UserProfile />  {/* 데이터 fetching 중 */}
</Suspense>

스트리밍 타임라인:

시간 ─────────────────────────────────────────────►

서버:   [초기 HTML + Fallback 전송] ──── [데이터 도착] ──── [콘텐츠 스트리밍]
         <!--$!-->로딩 중...<!--/$-->                    <script>완성된 콘텐츠 삽입</script>

브라우저: [Fallback 표시] ─────────────── [JavaScript 실행] ─── [콘텐츠 교체]
           로딩 중...                                         실제 프로필 표시!

🤯 그래서 왜 4가지 시나리오가 가능한가?

스트리밍 덕분입니다!

  1. 서버가 Content를 보낼 수 있는 이유:

    • 서버는 초기에 fallback을 보내지만
    • 나중에 데이터가 준비되면 콘텐츠를 스트리밍합니다
    • 클라이언트가 hydration할 때 이미 콘텐츠가 DOM에 있을 수 있음!
  2. 타이밍의 차이:

    서버:    Fallback 전송 ──── 데이터 도착 ──── Content 스트리밍
    클라이언트:     ──────── Hydration 시작 ─────► (이 시점이 중요!)

    Hydration 시점에 따라:

    • 너무 빠르면: 서버도 fallback, 클라이언트도 fallback
    • 조금 늦으면: 서버는 content 스트리밍 완료, 클라이언트는 아직
    • 더 늦으면: 둘 다 content 준비 완료

💡 핵심 통찰: Progressive Enhancement

React의 스트리밍 SSR은 점진적 향상을 구현합니다:

  1. 사용자는 즉시 페이지 구조를 봅니다 (shell)
  2. Suspense 영역은 로딩 상태를 표시합니다
  3. 데이터가 준비되면 자동으로 콘텐츠가 채워집니다
  4. JavaScript가 로드되면 인터랙티브하게 만듭니다

이 모든 것이 끊김 없이 일어납니다!

🔬 스트리밍 동작 확인하기

직접 스트리밍과 fetch 타이밍을 확인해보세요:

1. Fetch 타이밍 데모 (간단!)

# 서버와 클라이언트의 fetch 타이밍 확인
node fetch-timing-demo.mjs both-loading   # 둘 다 fetch
node fetch-timing-demo.mjs client-ready   # 클라가 먼저 완료
node fetch-timing-demo.mjs server-ready   # 서버는 동기 데이터
node fetch-timing-demo.mjs both-ready     # 둘 다 동기 데이터

예시 출력 (both-loading):

[   0ms] 🖥️ SERVER: fetch 시작
[   2ms] 🌐 CLIENT: fetch 시작
[1503ms] ✅ SERVER: fetch 완료
[1503ms] ✅ CLIENT: fetch 완료
[1805ms] 💧 HYDRATION 시작

핵심 발견: 서버와 클라이언트가 각자 독립적으로 같은 데이터를 fetch합니다!

2. 스트리밍 시나리오 데모 (상세)

# HTML 스트리밍과 마커 확인
node streaming-scenarios-demo.mjs server-ready

server-ready 시나리오가 특히 중요:

이것이 React가 사용자 경험을 우선시하는 증거입니다!

Suspense의 직렬화: HTML로 변환하기

React가 서버에서 Suspense 컴포넌트를 HTML로 변환할 때, 특별한 주석 노드를 사용합니다. 왜 주석을 사용할까요? 주석은 브라우저에 표시되지 않으면서도 DOM 트리에 남아있어, React가 나중에 이 위치를 찾을 수 있기 때문입니다.

// packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js
// React 소스코드에서 사용하는 Suspense 마커들
export const SUSPENSE_START_DATA = '$';           // 콘텐츠 시작
export const SUSPENSE_END_DATA = '/$';            // 경계 끝
export const SUSPENSE_PENDING_START_DATA = '$?';  // 대기 중
export const SUSPENSE_FALLBACK_START_DATA = '$!'; // fallback 표시 중

실제 예시로 이해하기

예를 들어 다음과 같은 React 컴포넌트가 있다고 해보겠습니다:

<Suspense fallback={<div>로딩 중...</div>}>
  <UserProfile />
</Suspense>

서버에서 데이터가 아직 준비되지 않았다면:

<!--$!-->  <!-- "fallback을 보여주고 있어요!" -->
<div>로딩 중...</div>
<!--/$-->  <!-- "Suspense 경계 끝" -->

서버에서 데이터가 준비되었다면:

<!--$-->   <!-- "실제 콘텐츠를 보여주고 있어요!" -->
<div class="profile">
  <h1>사용자 프로필</h1>
  <p>환영합니다!</p>
</div>
<!--/$-->  <!-- "Suspense 경계 끝" -->

Two-Pass Hydration: 두 번에 나눠서 처리하기

Suspense가 포함된 hydration은 특별합니다. 일반적인 hydration과 달리 두 번에 나눠서 처리합니다. 왜 그럴까요?

👀 첫 번째 패스: “여기 Suspense가 있네?”

  1. React가 DOM을 살펴보다가 <!--$-->같은 Suspense 주석을 발견합니다
  2. “아, 여기는 나중에 처리해야겠다” 하고 자식 컴포넌트로 들어가지 않습니다
  3. 대신 다른 우선순위로 “나중에 다시 처리하기” 예약을 겁니다

🏃 두 번째 패스: “이제 정말로 처리해보자”

  1. 예약했던 Suspense 컴포넌트로 다시 돌아옵니다
  2. 서버와 클라이언트의 상태를 비교하고
  3. 상황에 따라 적절한 처리를 합니다 (아래 4가지 시나리오 참고)

이렇게 두 번에 나눠 처리하면, 전체 페이지가 빠르게 hydration되고, Suspense 경계는 필요한 시점에 처리됩니다.

4가지 Hydration 시나리오: 이론적 가능성 vs 실제

🤔 잠깐, 서버에서 Content가 가능한가요?

중요한 사실: 일반적인 SSR에서 Suspense를 사용하면, 서버에서는 거의 항상 fallback만 렌더링됩니다!

왜냐하면:

그럼 언제 서버가 content를 렌더링할까요?

// 1. 동기적으로 사용 가능한 데이터 (Suspense를 트리거하지 않음)
function Profile() {
  const user = { name: "김철수" }; // 동기 데이터
  return <div>{user.name}</div>;
}

// 2. 이미 resolve된 Promise (실제로는 드문 경우)
function CachedProfile() {
  const user = getCachedUserSync(); // 동기적으로 캐시에서 가져옴
  return <div>{user.name}</div>;
}

하지만 React는 이론적으로 가능한 모든 경우를 대비해 4가지 시나리오를 준비했습니다:

🖥️서버 상태
Fallback
SSR에서 생성된 HTML
<!--$!-->
<div>로딩 중...</div>
<!--/$-->
🌐클라이언트 상태
Fallback
브라우저에서의 상태

로딩 중...

⬇️

React의 대응

🗑️➡️🆕

기존 fallback 버리고 새로 생성

🔍 상황: 서버와 클라이언트 모두 데이터를 기다리는 중

💡 React의 선택: 기존 fallback DOM을 버리고 새로 생성합니다.

📌 이유: Fallback은 보통 간단해서 재생성 비용이 적습니다.

💭 가장 흔한 시나리오:

일반적인 SSR with Suspense에서 가장 자주 보는 패턴입니다. 서버에서 API 호출이 Promise를 throw하므로 fallback을 렌더링하고, 클라이언트도 아직 데이터를 받지 못한 상태입니다. 서버는 `<!--$!-->{`로 마킹된 fallback HTML을 보내고, 클라이언트는 이를 버리고 새로운 fallback을 생성합니다. 대부분의 실제 앱에서 이 케이스가 발생합니다.

기술적 구현 세부사항

핵심 함수들: 실제 코드로 이해하기

1️⃣ Suspense 노드를 만났을 때: tryHydrateSuspense

// packages/react-reconciler/src/ReactFiberHydrationContext.js
function tryHydrateSuspense(fiber, nextInstance) {
  // Suspense 주석 노드인지 확인합니다
  const suspenseInstance = canHydrateSuspenseInstance(
    nextInstance,
    rootOrSingletonContext,
  );
  
  if (suspenseInstance !== null) {
    // Suspense를 찾았네요! 상태를 저장해둡시다
    const suspenseState = {
      dehydrated: suspenseInstance,       // 서버에서 온 Suspense 인스턴스
      treeContext: getSuspendedTreeContext(),
      retryLane: OffscreenLane,          // 나중에 다시 시도할 우선순위
      hydrationErrors: null,
    };
    fiber.memoizedState = suspenseState;
    
    // dehydrated fragment를 자식으로 만들어 저장합니다
    // (이렇게 하면 나중에 처리하기 편해요)
    const dehydratedFragment = 
      createFiberFromDehydratedFragment(suspenseInstance);
    dehydratedFragment.return = fiber;
    fiber.child = dehydratedFragment;
    
    hydrationParentFiber = fiber;
    
    // 👇 핵심! 첫 번째 패스에서는 자식으로 들어가지 않습니다
    // 두 번째 패스에서 다시 돌아올 거예요
    nextHydratableInstance = null;
    return true;
  }
  return false;
}

2️⃣ 첫 번째 패스에서 Suspense 처리: mountDehydratedSuspenseComponent

// packages/react-reconciler/src/ReactFiberBeginWork.js
function mountDehydratedSuspenseComponent(
  workInProgress,
  suspenseInstance,
  renderLanes,
) {
  // 첫 번째 패스에서는 자식을 탐색하지 않고
  // 콘텐츠를 그대로 두고 나중에 hydration을 시도합니다
  
  if (isSuspenseInstanceFallback(suspenseInstance)) {
    // 🚨 클라이언트 전용 경계입니다!
    // 서버에서 콘텐츠를 받지 못할 거니까
    // 높은 우선순위로 스케줄링합니다
    workInProgress.lanes = laneToLanes(
      enableHydrationLaneScheduling ? DefaultLane : DefaultHydrationLane,
    );
  } else {
    // 😌 서버에서 올바른 콘텐츠를 보내준 경우
    // 이미 화면에 보여줄 콘텐츠가 있으니
    // 낮은 우선순위(offscreen)로 천천히 hydration합니다
    workInProgress.lanes = laneToLanes(OffscreenLane);
  }
  
  return null; // 자식을 처리하지 않고 null 반환
}

3️⃣ 두 번째 패스에서 Suspense 업데이트: updateDehydratedSuspenseComponent

// packages/react-reconciler/src/ReactFiberBeginWork.js 
function updateDehydratedSuspenseComponent(
  current,
  workInProgress,
  didSuspend,           // 현재 suspend 상태인지
  didPrimaryChildrenDefer,
  nextProps,
  suspenseInstance,     // 서버에서 온 Suspense 인스턴스
  suspenseState,
  renderLanes,
) {
  if (!didSuspend) {
    // 🏃 첫 번째 렌더 패스입니다. hydration을 시도해봅시다!
    pushPrimaryTreeSuspenseHandler(workInProgress);
    
    if (isSuspenseInstanceFallback(suspenseInstance)) {
      // 🚫 이 경계는 영구적으로 fallback 상태입니다
      // hydration을 포기하고 클라이언트에서 다시 렌더링합니다
      return retrySuspenseComponentWithoutHydrating(
        current,
        workInProgress,
        renderLanes,
      );
    }
    
    // 여기서 계속 hydration 로직이 진행됩니다...
    // 서버와 클라이언트 상태를 비교하고
    // 위에서 설명한 4가지 시나리오에 따라 처리합니다
  }
  // ... 나머지 로직
}

Fiber Tree와 Reconciliation

Suspense hydration은 React의 Fiber 아키텍처를 활용합니다. Fiber는 React의 각 컴포넌트를 나타내는 자료구조입니다.

Hydration 상태를 추적하는 방법

// packages/react-reconciler/src/ReactFiberHydrationContext.js
function popHydrationState(fiber) {
  if (!supportsHydration) {
    return false;
  }
  
  if (fiber !== hydrationParentFiber) {
    // 🏯 현재 hydration 컨텍스트보다 깊은 곳에 있습니다
    // (삽입된 트리 내부에 있음)
    return false;
  }
  
  if (!isHydrating) {
    // 🔄 hydration 중이 아니지만 hydration 컨텍스트에 있다면
    // 삽입이었고, 이제 형제 노드들의 hydration을 다시 시작해야 합니다
    popToNextHostParent(fiber);
    isHydrating = true;
    return false;
  }

  const tag = fiber.tag;
  
  if (tag === SuspenseComponent) {
    // 💧 Suspense 컴포넌트를 처리했으니 건너뜹니다
    nextHydratableInstance = skipPastDehydratedSuspenseInstance(fiber);
  } else if (supportsSingletons && tag === HostSingleton) {
    // 🏯 싱글턴 컴포넌트 처리
    nextHydratableInstance = getNextHydratableSiblingAfterSingleton(
      fiber.type,
      nextHydratableInstance,
    );
  } else {
    // 🌳 일반 컴포넌트: 다음 형제 노드로 이동
    nextHydratableInstance = hydrationParentFiber
      ? getNextHydratableSibling(fiber.stateNode)
      : null;
  }
  return true;
}

Hydration 실패 시 클라이언트 렌더링

hydration이 실패하면 클라이언트에서 처음부터 다시 렌더링합니다:

// packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js
export function clientRenderBoundary(
  suspenseBoundaryID,
  errorDigest,
  errorMsg,
  errorStack,
  errorComponentStack,
) {
  // 1️⃣ fallback의 첫 번째 요소를 찾습니다
  const suspenseIdNode = document.getElementById(suspenseBoundaryID);
  if (!suspenseIdNode) {
    // 사용자가 이미 다른 페이지로 이동했나 봐요
    return;
  }
  
  // 2️⃣ fallback 주변의 경계를 찾습니다 (항상 이전 노드)
  const suspenseNode = suspenseIdNode.previousSibling;
  
  // 3️⃣ "클라이언트에서 렌더링해야 함"으로 표시합니다
  suspenseNode.data = SUSPENSE_FALLBACK_START_DATA; // <!--$!-->
  
  // 4️⃣ 에러 정보를 첫 번째 형제 노드에 저장합니다
  const dataset = suspenseIdNode.dataset;
  if (errorDigest) dataset['dgst'] = errorDigest;    // 에러 다이제스트
  if (errorMsg) dataset['msg'] = errorMsg;           // 에러 메시지
  if (errorStack) dataset['stck'] = errorStack;      // 에러 스택
  
  // 5️⃣ 부모가 이미 hydration 되었다면 React에게 재시도를 요청합니다
  if (suspenseNode['_reactRetry']) {
    suspenseNode['_reactRetry']();
  }
}

Suspense 콘텐츠가 준비되었을 때: Fallback을 실제 콘텐츠로 교체

드디어 데이터가 도착했습니다! 로딩 화면을 실제 콘텐츠로 바꾸는 과정입니다:

// packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js
export function completeBoundary(suspenseBoundaryID, contentID, errorDigest) {
  // 1️⃣ 먼저 새로운 콘텐츠 노드를 찾아서 떼어둡니다
  const contentNode = document.getElementById(contentID);
  contentNode.parentNode.removeChild(contentNode);
  // 왜 떼어낼까요? 작업 중에 에러가 나더라도
  // DOM에 중간 상태로 남지 않도록 하기 위해서입니다

  // 2️⃣ Suspense 경계를 찾습니다
  const suspenseIdNode = document.getElementById(suspenseBoundaryID);
  if (!suspenseIdNode) {
    // 사용자가 다른 페이지로 갔나 봐요 🚀
    return;
  }
  const suspenseNode = suspenseIdNode.previousSibling;

  if (!errorDigest) {
    // 3️⃣ 기존 fallback을 모두 제거합니다
    // 🎆 복잡한 부분: 중첩된 Suspense가 있을 수 있어요!
    const parentInstance = suspenseNode.parentNode;
    let node = suspenseNode.nextSibling;
    let depth = 0;  // 중첩 깊이를 추적합니다
    
    do {
      if (node && node.nodeType === COMMENT_NODE) {
        const data = node.data;
        
        if (data === SUSPENSE_END_DATA) {  // <!--/$-->
          if (depth === 0) {
            // 🎉 최상위 Suspense의 끝을 찾았습니다!
            break;
          } else {
            depth--;  // 중첩에서 하나 빠져나왔어요
          }
        } else if (
          data === SUSPENSE_START_DATA ||          // <!--$-->
          data === SUSPENSE_PENDING_START_DATA ||  // <!--$?-->
          data === SUSPENSE_FALLBACK_START_DATA    // <!--$!-->
        ) {
          depth++;  // 중첩으로 더 들어갔어요
        }
      }
      
      // 현재 노드를 제거하고 다음으로 이동
      const nextNode = node.nextSibling;
      parentInstance.removeChild(node);
      node = nextNode;
    } while (node);
    
    // 4️⃣ 드디어! 새로운 콘텐츠를 삽입합니다 🎆
    while (contentNode.firstChild) {
      parentInstance.insertBefore(contentNode.firstChild, node);
    }
  }
}

이 함수의 핵심은 depth 추적입니다. Suspense 안에 또 다른 Suspense가 있을 수 있기 때문에, 누가 누구의 경계인지 헷갈리지 않도록 depth를 세면서 처리합니다.

성능 최적화의 비밀

Suspense hydration은 다음과 같은 성능 최적화 기법을 사용합니다:

1. 🎨 부드러운 사용자 경험

2. ⚡ 최소한의 DOM 조작

3. 🤖 지능적인 우선순위 관리

깊이 있는 분석: stringToPrecomputedChunk와 플랫폼별 구현

stringToPrecomputedChunk란?

stringToPrecomputedChunk는 문자열을 미리 바이트 배열로 변환해 캐싱하는 함수입니다. “미리 계산된(precomputed)” 청크라고 부르는 이유는, 나중에 여러 번 사용할 때마다 다시 인코딩하지 않고 캐싱된 값을 재사용하기 때문입니다.

플랫폼별로 구현이 다른 이유

React는 각 환경에 맞게 최적화된 구현을 제공합니다:

🌐 브라우저 버전

// packages/react-server/src/ReactServerStreamConfigBrowser.js
const textEncoder = new TextEncoder();

export function stringToPrecomputedChunk(content) {
  const precomputedChunk = textEncoder.encode(content);
  
  if (__DEV__) {
    // 캐싱할 청크는 반드시 VIEW_SIZE(2048 바이트)보다 작아야 합니다
    if (precomputedChunk.byteLength > VIEW_SIZE) {
      console.error(
        '사전 계산된 청크는 VIEW_SIZE보다 작아야 합니다. ' +
        '이것은 React의 버그입니다.'
      );
    }
  }
  
  return precomputedChunk; // Uint8Array 반환
}

📦 Node.js 버전

// packages/react-server/src/ReactServerStreamConfigNode.js  
const textEncoder = new TextEncoder();

export function stringToPrecomputedChunk(content) {
  const precomputedChunk = textEncoder.encode(content);
  
  // Node.js도 브라우저와 비슷하게 TextEncoder 사용
  // 다만 Chunk 타입은 string으로 유지할 수 있음
  return precomputedChunk;
}

// Node.js에서는 chunk가 string일 수도 있음
export function byteLengthOfChunk(chunk) {
  return typeof chunk === 'string'
    ? Buffer.byteLength(chunk, 'utf8')  // 문자열일 때
    : chunk.byteLength;                  // Uint8Array일 때
}

🌶️ Bun 버전

// packages/react-server/src/ReactServerStreamConfigBun.js
export function createFastHash(input) {
  return Bun.hash(input);  // Bun의 내장 해시 함수 사용 (더 빠름!)
}

왜 플랫폼별로 다를까?

  1. 성능 최적화: 각 환경의 특징을 최대한 활용

    • Bun: 내장 해시 함수 사용
    • Node.js: Buffer API 활용, 문자열 직접 전달 가능
    • Browser: TextEncoder만 사용 가능
  2. 스트리밍 방식의 차이:

    • Browser: ReadableStreamController 사용
    • Node.js: Writable 스트림 사용
    • 각각의 backpressure 처리 방식이 다름
  3. 메모리 관리:

    • 모든 환경에서 VIEW_SIZE (2048 바이트) 단위로 버퍼링
    • 큰 청크는 직접 전달, 작은 청크는 버퍼에 모았다가 전송

🤔 사용자 주석과의 충돌 문제

문제 제기: 사용자가 똑같이 <!--$-->를 쓴다면?

사용자가 HTML에 직접 <!--$--> 같은 주석을 쓴 가능성이 있을까요? React는 이 문제를 어떻게 처리할까요?

React의 해결책

// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
export function canHydrateSuspenseInstance(
  instance,
  inRootOrSingleton,
) {
  // 주석 노드가 아니면 건너뜁니다
  while (instance.nodeType !== COMMENT_NODE) {
    if (!inRootOrSingleton) {
      return null;
    }
    const nextInstance = getNextHydratableSibling(instance);
    if (nextInstance === null) {
      return null;
    }
    instance = nextInstance;
  }
  // 이제 주석 노드를 찾았지만, data 값을 확인해야 합니다
  return instance;
}

// 실제로 Suspense 인스턴스인지 확인
export function isSuspenseInstanceFallback(instance) {
  return (
    instance.data === SUSPENSE_FALLBACK_START_DATA ||  // <!--$!-->
    instance.data === SUSPENSE_PENDING_START_DATA      // <!--$?-->
  );
}

충돌이 일어나지 않는 진짜 이유

React 공식 문서에 따르면, renderToPipeableStreamrenderToString전체 문서를 렌더링하도록 설계되었습니다:

“A React node you want to render to HTML. For example, a JSX element like <App />. It is expected to represent the entire document, so the App component should render the <html> tag.”

이것이 핵심입니다! 🎯

  1. React가 전체 HTML을 제어:

    • React SSR은 <html> 태그부터 시작해 전체 문서를 생성합니다
    • 사용자가 작성한 HTML 주석도 React 컴포넌트를 통해서만 들어갑니다
    • 따라서 React가 모든 주석을 알고 있고 제어할 수 있습니다
  2. 사용자 주석도 React를 통해 렌더링:

    // 사용자가 주석을 넣고 싶다면 이렇게 해야 함
    <div dangerouslySetInnerHTML={{ __html: '<!-- 내 주석 -->' }} />
    • React는 이 주석이 어디에 있는지 정확히 알고 있습니다
    • Suspense 마커와 구분이 가능합니다
  3. 부분 hydration이 아닌 전체 hydration:

    • React는 전체 문서 구조를 알고 있으므로
    • 어떤 주석이 Suspense 마커이고 어떤 것이 사용자 주석인지 구분 가능합니다
  4. 만약 충돌이 발생한다면?:

    • 사실 충돌 자체가 불가능합니다
    • React가 생성하지 않은 <!--$-->가 있다면, 그것은 React 앱 밖의 영역입니다
    • React는 자신이 렌더링한 영역만 hydration하므로 문제없습니다

한계와 트레이드오프

주석을 사용한 마커 방식은:

하지만 실용성과 성능을 고려할 때, 현재 방식이 최선의 선택입니다.

마치며

Suspense hydration은 복잡한 주제이지만, React 팀이 얼마나 세심하게 고민했는지 잘 보여줍니다.

💡 기억해야 할 핵심 포인트:

특히 “서버에서 콘텐츠를 보냈는데 클라이언트가 아직 준비 안 됨” 시나리오에서 콘텐츠를 유지하는 결정은 정말 인상적입니다. 기술적 일관성보다 사용자 경험을 선택한 React 팀에게 박수를 보냅니다! 👏

이런 세심한 구현 덕분에 우리는 Suspense의 복잡성을 신경 쓰지 않고, SSR과 streaming의 혜택을 누릴 수 있습니다.

댓글을 남기려면 로그인이 필요합니다.