서버에서 렌더링된 React 애플리케이션을 브라우저에서 다시 살아나게 하는 과정, 바로 hydration입니다.
여기에 Suspense가 더해지면 어떻게 될까요?
예를 들어, 사용자 프로필을 보여주는 컴포넌트가 있다고 해봅시다. 일반 SSR에서는 서버와 클라이언트 모두 동일한 API를 호출합니다:
타이밍 차이로 복잡한 상황이 생깁니다: 서버에서는 API 타임아웃으로 fallback(로딩 화면)을 보냈는데, 그 사이 클라이언트에서는 이미 데이터를 받아온 경우도 있을 수 있습니다. React는 이런 서버와 클라이언트의 상태 불일치를 어떻게 처리할까요?
이 글에서는 React가 Suspense 컴포넌트를 어떻게 직렬화하고, hydration 과정에서 발생할 수 있는 다양한 시나리오를 어떻게 처리하는지 React 소스코드와 함께 자세히 살펴보겠습니다.
“서버에서 이미 API로 데이터를 가져와서 HTML을 만들었는데, 왜 클라이언트에서 또 같은 API를 호출하죠?”
이것은 많은 개발자들이 헷갈려하는 부분입니다! 차근차근 설명해드릴게요.
서버가 보내는 것:
<!-- 서버가 보낸 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 과정에서 일어나는 일:
UserProfile
컴포넌트를 실행합니다fetchUser()
가 실행되는데… user 데이터가 없습니다!왜냐하면:
<h2>김철수</h2>
){ name: "김철수", email: "kim@example.com" }
)는 전달되지 않았습니다// 서버에서 데이터를 script 태그로 포함
<script>
window.__INITIAL_DATA__ = {
user: { name: "김철수", email: "kim@example.com" }
};
</script>
function fetchUser(userId) {
// 클라이언트에서 같은 데이터를 다시 가져옴
return fetch(`/api/users/${userId}`);
}
Suspense는 이런 “데이터 불일치” 상황을 우아하게 처리합니다:
서버가 빠른 경우:
<!--$-->
서버가 느린 경우:
<!--$!-->
둘 다 준비된 경우:
답: HTML과 JavaScript 상태는 다르기 때문입니다!
<h2>김철수</h2>
){ name: "김철수" }
)이것이 바로 Suspense가 해결하려는 문제이고, React가 4가지 시나리오를 준비한 이유입니다!
// 일반적인 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로 렌더링됩니다
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>
React 팀은 미래의 가능성과 엣지 케이스를 모두 고려했습니다:
실제로 대부분의 경우:
하지만 React는 모든 경우를 우아하게 처리합니다!
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 실행] ─── [콘텐츠 교체]
로딩 중... 실제 프로필 표시!
스트리밍 덕분입니다!
서버가 Content를 보낼 수 있는 이유:
타이밍의 차이:
서버: Fallback 전송 ──── 데이터 도착 ──── Content 스트리밍
클라이언트: ──────── Hydration 시작 ─────► (이 시점이 중요!)
Hydration 시점에 따라:
React의 스트리밍 SSR은 점진적 향상을 구현합니다:
이 모든 것이 끊김 없이 일어납니다!
직접 스트리밍과 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합니다!
# HTML 스트리밍과 마커 확인
node streaming-scenarios-demo.mjs server-ready
server-ready 시나리오가 특히 중요:
<!--$-->
(Content 마커) - 동기 데이터 사용이것이 React가 사용자 경험을 우선시하는 증거입니다!
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 경계 끝" -->
Suspense가 포함된 hydration은 특별합니다. 일반적인 hydration과 달리 두 번에 나눠서 처리합니다. 왜 그럴까요?
<!--$-->
같은 Suspense 주석을 발견합니다이렇게 두 번에 나눠 처리하면, 전체 페이지가 빠르게 hydration되고, Suspense 경계는 필요한 시점에 처리됩니다.
중요한 사실: 일반적인 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가지 시나리오를 준비했습니다:
<!--$!--><div>로딩 중...</div><!--/$-->
로딩 중...
React의 대응
기존 fallback 버리고 새로 생성
🔍 상황: 서버와 클라이언트 모두 데이터를 기다리는 중
💡 React의 선택: 기존 fallback DOM을 버리고 새로 생성합니다.
📌 이유: Fallback은 보통 간단해서 재생성 비용이 적습니다.
💭 가장 흔한 시나리오:
일반적인 SSR with Suspense에서 가장 자주 보는 패턴입니다. 서버에서 API 호출이 Promise를 throw하므로 fallback을 렌더링하고, 클라이언트도 아직 데이터를 받지 못한 상태입니다. 서버는 `<!--$!-->{`로 마킹된 fallback HTML을 보내고, 클라이언트는 이를 버리고 새로운 fallback을 생성합니다. 대부분의 실제 앱에서 이 케이스가 발생합니다.
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;
}
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 반환
}
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가지 시나리오에 따라 처리합니다
}
// ... 나머지 로직
}
Suspense hydration은 React의 Fiber 아키텍처를 활용합니다. Fiber는 React의 각 컴포넌트를 나타내는 자료구조입니다.
// 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이 실패하면 클라이언트에서 처음부터 다시 렌더링합니다:
// 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']();
}
}
드디어 데이터가 도착했습니다! 로딩 화면을 실제 콘텐츠로 바꾸는 과정입니다:
// 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은 다음과 같은 성능 최적화 기법을 사용합니다:
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 반환
}
// 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일 때
}
// packages/react-server/src/ReactServerStreamConfigBun.js
export function createFastHash(input) {
return Bun.hash(input); // Bun의 내장 해시 함수 사용 (더 빠름!)
}
성능 최적화: 각 환경의 특징을 최대한 활용
스트리밍 방식의 차이:
메모리 관리:
<!--$-->
를 쓴다면?사용자가 HTML에 직접 <!--$-->
같은 주석을 쓴 가능성이 있을까요? 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 공식 문서에 따르면, renderToPipeableStream
과 renderToString
은 전체 문서를 렌더링하도록 설계되었습니다:
“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.”
이것이 핵심입니다! 🎯
React가 전체 HTML을 제어:
<html>
태그부터 시작해 전체 문서를 생성합니다사용자 주석도 React를 통해 렌더링:
// 사용자가 주석을 넣고 싶다면 이렇게 해야 함
<div dangerouslySetInnerHTML={{ __html: '<!-- 내 주석 -->' }} />
부분 hydration이 아닌 전체 hydration:
만약 충돌이 발생한다면?:
<!--$-->
가 있다면, 그것은 React 앱 밖의 영역입니다주석을 사용한 마커 방식은:
하지만 실용성과 성능을 고려할 때, 현재 방식이 최선의 선택입니다.
Suspense hydration은 복잡한 주제이지만, React 팀이 얼마나 세심하게 고민했는지 잘 보여줍니다.
💡 기억해야 할 핵심 포인트:
특히 “서버에서 콘텐츠를 보냈는데 클라이언트가 아직 준비 안 됨” 시나리오에서 콘텐츠를 유지하는 결정은 정말 인상적입니다. 기술적 일관성보다 사용자 경험을 선택한 React 팀에게 박수를 보냅니다! 👏
이런 세심한 구현 덕분에 우리는 Suspense의 복잡성을 신경 쓰지 않고, SSR과 streaming의 혜택을 누릴 수 있습니다.
댓글을 남기려면 로그인이 필요합니다.