서버 사이드 렌더링(SSR)과 클라이언트 사이드 렌더링(CSR)의 장점을 결합한 렌더링 기술이다.
이 방식은 전체 어플리케이션을 한 번에 Hydrtation(수화)하는 대신 DOM 노드를 점진적으로 Hydration을 진행하여 어플리케이션의 성능을 최적화하고 사용자 경험을 향상시키는 데 중점을 둔다.
🚨 Progressive Hydration은 코드를 분할할 수 있어 번들 크기 감소에 도움을 주지만 어플리케이션 로드 시 모두 대화형(상호작용이 가능한)으로 만들어야 하는 어플리케이션에는 적합하지 않다.
문제: 기존 서버 사이드 렌더링(SSR) 방식에서는 모든 데이터가 준비될 때 까지 HTML 전송을 시작할 수 없었다.
해결: Streaming SSR과 Suspense를 활용하여 일부 컴포넌트의 데이터가 준비되면 즉시 HTML 스트리밍을 시작할 수 있다. Progressive Hydration은 이 과정과 연계되어 클라이언트 측 Hydration을 최적화한다.
➔ 이로 인해 사용자는 더 빠르게 초기 콘텐츠를 볼 수 있어 사용자 경험이 향상된다.
💡 Streaming SSR
React 18에서 도입된 새로운 서버 사이드 렌더링(SSR) 기술이다.
기존 SSR의 성능 문제를 해결하고 사용자 경험을 개선하는 데 중점을 둔다.
- 점진적 렌더링: 서버에서 전체 페이지를 한번에 렌더링하지 않고, 준비된 부분부터 점진적으로 클라이언트에 전송한다.
- 선택적 Hydration: 페이지의 일부분만 먼저 Hydrate되어 상호작용이 가능하다.
💡
renderToPipeableStream
API
React 18에서 도입된 새로운 서버 렌더링 함수이다. 이 함수는 Streaming SSR를 구현하는 데 핵심적인 역할을 한다.
기존 API들(renderToString
,renderToNodeStream
)과는 다르게 데이터를 기다리지 않고 즉시 스트리밍을 시작할 수 있다는 장점이 있다.
- Node.js의 스트림을 반환하여 HTML을 점진적으로 클라이언트에 전송한다.
- Suspense 경계를 인식하고, 준비된 컨텐츠부터 스트리밍한다.
문제: 기존 방식에서는 모든 JavaScript가 로드되어야 Hydration이 시작될 수 있었다.
해결: 필요한 코드만 먼저 로드하여 부분적으로 Hydration을 시작할 수 있다.
➔ 코드 스플리팅을 효과적으로 활용하여 초기 로드 시간이 단축된다.
문제: 모든 컴포넌트가 Hydration될 때까지 어떤 부분도 상호작용이 불가능했다.
해결: 컴포넌트 별로 개별적인 Hydration이 가능해져, 중요한 부분부터 상호작용이 가능해졌다.
➔ 사용자는 전체 페이지가 완전히 로드되기 전에 일부 기능을 사용할 수 있어 사용자 경험을 향상시킬 수 있다.
Progressive Hydration과 Streaming SSR은 서로 다른 개념이지만, 종종 함께 사용되어 상호 보완적인 이점을 제공한다.
React 18에서는 이 두기술을 결합하여 사용할 수 있다.
1. Streaming SSR을 통해 서버에서 HTML을 빠르게 스트리밍한다.
2. 클라이언트에서는 Progressive Hydration을 통해 받은 HTML을 점진적으로 hydrate한다.
이 두 기술의 조합은 초기 로딩 속도와 사용자 상호작용을 크게 개선할 수 있지만, 항상 함께 사용해야 하는 것 같은 아니다.
Suspense로 감싸진 컴포넌트는 다른 부분과 독립적으로 hydration될 수 있다.
이는 전체 애플리케이션이 아닌 특정 부분만 먼저 상호작용 가능한 상태가 될 수 있다는 것을 의미한다.
<!--$?-->
주석 노드 처리// 대기 중인 Suspense 경계의 시작 부분이다.
// <template> 태그의 사작과 함께 사용되며 아직 렌더링되지 않은 컨텐츠를 위한 것이다.
// Progressive(점진적) Hydration과 관련이 있다.
const startPendingSuspenseBoundary1 = stringToPrecomputedChunk(
'<!--$?--><template id="',
);
이 주석 노드는 Suspense 컴포넌트가 아직 서버로부터 모든 데이터를 받지 못했음을 나타낸다.
const SUSPENSE_PENDING_START_DATA = "$?";
export function isSuspenseInstancePending(instance: SuspenseInstance): boolean {
return instance.data === SUSPENSE_PENDING_START_DATA;
}
// updateDehydratedSuspenseComponent 함수의 일부분
else if (isSuspenseInstancePending(suspenseInstance)) {
// 이 컴포넌트는 서버에서 더 많은 데이터를 기다리고 있으므로 콘텐츠를 수화할 수 없다.
// 이 컴포넌트가 자체적으로 중단된 것처럼 처리한다.
workInProgress.flags |= DidCapture; // Suspense 경계의 포착을 알림
workInProgress.child = current.child; // 서버에서 렌더링된 내용을 유지
// 현재 파이버에 바인딩하여 새로운 retry 함수 생성, 이 함수는 나중에 서버 데이터가 도착했을 때 호출
const retry = retryDehydratedSuspenseBoundary.bind(null, current);
registerSuspenseInstanceRetry(suspenseInstance, retry);
return null; // Suspense 처리를 중단하고 서버에서 렌더링된 내용을 유지
}
// 나중에 컴포넌트를 다시 렌더링하는 데 사용
export function registerSuspenseInstanceRetry(
instance: SuspenseInstance,
callback: () => void,
) {
instance._reactRetry = callback; // DOM 요소에 특별한 메서드 추가
}
null
을 반환하는 것은 더 이상 하위 요소를 처리하지 않겠다는 의미이다.<!--$!-->
주석으로 표시되는 Full(완전한) Hydration과는 다르다.mountSuspensePrimaryChildren
함수를 호출하여 실제 내용(primary children)을 렌더링하려고 시도한다.➔ 이 코드는 Suspense 컴포넌트가 아직 데이터를 기다리고 있을 때의 동작을 설명한다.
React는 이 상태에서 더 이상 렌더링 작업을 진행하지 않고, 현재의 폴백(Fallback) 상태를 유지한다.
이는 서버에서 렌더링 된 HTML을 유지하면서 클라이언트에서 필요한 데이터가 준비될 때까지 기다리는 전략이다.
export function completeBoundary(
suspenseBoundaryID, // Suspense 경계를 식별하는 ID
contentID, // 새로 도착한 컨텐츠의 ID
errorDigest, // 에러가 발생했을 경우의 에러 정보
) {
const contentNode = document.getElementById(contentID); // 새로 도착한 콘텐츠를 담고 있는 노드 찾기
contentNode.parentNode.removeChild(contentNode); // 이 노드를 DOM 트리에서 일시적으로 제거
const suspenseIdNode = document.getElementById(suspenseBoundaryID); // Suspense 경계 노드를 찾기
if (!suspenseIdNode) {
// 이 노드가 없다면 사용자가 이미 다른 페이지로 이동했다고 판단하고 함수를 종료
return;
}
const suspenseNode = suspenseIdNode.previousSibling; // Suspense 경계를 나타내는 주석 노드
if (!errorDigest) {
// 에러가 없는 경우 기존 Fallback 컨텐츠를 제거한다.
// 이 과정은 Fallback 내부에 중첩된 Suspense가 있을 수 있기 때문에 이 과정이 복잡하게 구현된다.
const parentInstance = suspenseNode.parentNode;
let node = suspenseNode.nextSibling;
let depth = 0; // 중첩된 Suspense 경계 추적
do {
if (node && node.nodeType === COMMENT_NODE) {
// 노드가 주석이라면
const data = node.data;
if (data === SUSPENSE_END_DATA) {
// Suspense 경계의 끝이라면 루프 중지 또는 depth 감소
if (depth === 0) {
break;
} else {
depth--;
}
} else if (
data === SUSPENSE_START_DATA ||
data === SUSPENSE_PENDING_START_DATA ||
data === SUSPENSE_FALLBACK_START_DATA
) {
// Suspense 경계의 시작이라면 depth 증가
depth++;
}
}
const nextNode = node.nextSibling;
parentInstance.removeChild(node); // 노드 제거
node = nextNode; // 다음 노드로 이동
} while (node);
const endOfBoundary = node; // Suspense 경계의 끝을 나타내는 노드
// 제거된 Fallback 콘텐츠 자리에 contentNode의 자식들을 삽입
while (contentNode.firstChild) {
parentInstance.insertBefore(contentNode.firstChild, endOfBoundary);
}
suspenseNode.data = SUSPENSE_START_DATA; // 이는 콘텐츠가 성공적으로 로드되었음을 나타낸다.
} else {
// 에러가 있는 경우
suspenseNode.data = SUSPENSE_FALLBACK_START_DATA; // 에러가 발생하여 Fallback UI를 렌더링해야 하는 것을 알림
suspenseIdNode.setAttribute('data-dgst', errorDigest); // 에러 정보를 DOM 속성에 추가
}
// 이전에서 설정한 _reactRetry가 있다면 실행
if (suspenseNode['_reactRetry']) {
suspenseNode['_reactRetry'](); // Suspense 경계 내의 콘텐츠를 다시 로드하고 렌더링을 시도
}
}
contentNode
)를 찾고 DOM 트리에서 임시로 제거에러가 없는 경우
시작
상태 업데이트에러가 있는 경우
Fallback 시작
상태 업데이트재시도 함수 실행
_reactRetry
)가 있다면 실행💡 처음에
contentNode
를 제거 하는 이유
1. 성능 최적화: DOM 조작은 비용이 많이 드는 작업으로 새로운 콘텐츠를 사입하는 동안 불필요한 리플로우(reflow)와 리페인트(repaint)를 방지하기 위해 일시적으로 노드를 제거한다.
2. 메모리 관리: 기존 DOM 트리에서 노드를 제거함으로써, 브라우저가 해당 노드와 관련된 리소스를 효율적으로 관리할 수 있게 한다.
3. 트랜잭션적 업데이트: 모든 변경사항을 준비한 후 한 번에 DOM을 적용할 수 있어, 중간 상태를 방지하고 일관된 사용자 경험을 제공한다.
➔ 이 함수는 서버에서 전송된 새로운 컨텐츠로 기존의 Fallback 컨텐츠를 교체하고 React에게 변경사항을 알려 리렌더링을 트리거하는 것이다.
이 과정을 통해 초기에는 서버에서 렌더링된 HTML을 빠르게 볼 수 있고, 이후 JavaScript가 로드되고 실행되면서 각 부분이 상호작용 가능한 상태로 전환된다.
이는 초기 로딩 시간을 줄이고 사용자 경험을 향상시키는 Progressive(점진적) Hydration의 핵심 매커니즘이다.
// dehydrated 상태의 Suspense 경계를 재시도
export function retryDehydratedSuspenseBoundary(boundaryFiber: Fiber) {
const suspenseState: null | SuspenseState = boundaryFiber.memoizedState;
let retryLane: Lane = NoLane; // retryLane 초기화
if (suspenseState !== null) {
// suspenseState가 존재하는 경우
retryLane = suspenseState.retryLane; // 그 안의 retryLane을 사용
}
retryTimedOutBoundary(boundaryFiber, retryLane); // 실제 재시도 로직 수행
}
// timeout된 Suspense 경계를 재시도
function retryTimedOutBoundary(boundaryFiber: Fiber, retryLane: Lane) {
if (retryLane === NoLane) {
// retryLane가 할당되지 않은 경우, 새로운 레인(우선순위)을 요청
retryLane = requestRetryLane(boundaryFiber);
}
// concurrent 렌더링을 큐에 추가
const root = enqueueConcurrentRenderForLane(boundaryFiber, retryLane);
if (root !== null) {
markRootUpdated(root, retryLane); // 루트가 업데이트되었음을 표시
ensureRootIsScheduled(root); // 루트가 스케줄링되도록 설정
}
}
리렌더링 스케줄링
retryDehydratedSuspenseBoundary
함수는 Suspense 경계의 재시도를 처리한다.suspenseState.retryLane
을 사용하여 리렌더링의 우선순위를 설정한다.retryTimedOutBoundary
함수를 호출하여 실제 재시도 로직을 수행한다.리렌더링 처리
retryTimedOutBoundary
함수는 concurrent 렌더링을 큐에 추가한다.Suspense in Hydration
suspenseState.retryLane
은 일반적으로 낮은 우선순위(offscreenLane
)로 설정된다.(상황에 따라 다를 수 있지만 대체로 낮은 우선순위가 부여된다)➔ 이 함수들은 Suspense 컴포넌트가 이전에 Fallback 상태로 렌더링되었다가 중단되었던 데이터 로딩이 완료되었을 때 트리의 해당 부분을 리렌더링하는 과정을 관리한다.
리렌더링은 예약되지만 낮은 우선 순위로 처리되어 사용자 인터랙션과 같은 더 중요한 작업들이 먼저 실행될 수 있도록 한다.
Suspense 컴포넌트의 초기 처리
<!--$?-->
주석으로 표시되면 나중에 콘텐츠를 채울 수 있는 Placeholder 역할을 한다.새로운 콘텐츠 도착 시 처리
Progressive(점진적) Hydration
사용자 경험
Progressive Hydration은 React의 Concurrent Mode와 밀접하게 연관되어 있으며, 이를 통해 더 세밀한 렌더링 제어와 우선순위 관리가 가능해진다. 이는 대규모 애플리케이션에서 특히 유용하며, 사용자 경험을 크게 향상시킬 수 있다.
<template>
태그const placeholder1 = stringToPrecomputedChunk('<template id="');
const placeholder2 = stringToPrecomputedChunk('"></template>');
export function writePlaceholder(
destination: Destination, // 결과를 쓸 대상(응답 스트림)
responseState: ResponseState, // 응답 상태 정보
id: number, // placeholder 고유 식별자
): boolean {
writeChunk(destination, placeholder1);
writeChunk(destination, responseState.placeholderPrefix);
const formattedID = stringToChunk(id.toString(16)); // id를 16진수로 변환
writeChunk(destination, formattedID);
return writeChunkAndReturn(destination, placeholder2);
}
palceholder의 구조
<template id="[prefix][id]">
></template>
palceholder의 정의
<template>
태그를 사용하는 이유
<template>
태그는 모든 종류의 부모 요소 내에서 사용할 수 있는 유연성을 가지고 있다.<template>
태그의 대안 - <script>
태그
<script>
태그도 <colgroup>
태그를 제외한 모든 다른 태그 내에서 사용할 수 있다.<template>
가 더 범용적이기 때문에 선택되었다.➔ 이 함수는 HTML 스트리밍에 Placeholder를 작성한다. Placeholder는 나중에 실제 콘텐츠로 채워질 수 있는 임시 위치 표시자이다.
💡 HTML 스트리밍
웹 페이지의 HTML 콘텐츠를 점진적으로 전송하고 렌더링하는 기술이다.
- 서버가 전체 HTML을 한 번에 보내는 대신, 작은 조각으로 나누어 순차적으로 전송한다.
- 브라우저는 받은 조각을 즉시 처리하고 렌더링할 수 있다.
// DOM 이벤트에 대한 리스너 래퍼(wrapper) 생성
export function createEventListenerWrapperWithPriority(
targetContainer: EventTarget, // 이벤트가 발생한 DOM 요소
domEventName: DOMEventName, // 처리할 DOM 이벤트의 이름
eventSystemFlags: EventSystemFlags, // 이벤트 시스템에 대한 추가 정보를 제공하는 플래그
): Function {
const eventPriority = getEventPriority(domEventName);
let listenerWrapper;
switch (eventPriority) {
case DiscreteEventPriority:
listenerWrapper = dispatchDiscreteEvent; // 개별적이고 독립적인 이벤트(클릭, 키 등)
break;
case ContinuousEventPriority:
listenerWrapper = dispatchContinuousEvent; // 연속적인 이벤트(스크롤, 마우스 이동 등)
break;
case DefaultEventPriority:
default:
listenerWrapper = dispatchEvent; // 기본 이벤트
break;
}
return listenerWrapper.bind(
null,
domEventName,
eventSystemFlags,
targetContainer,
);
}
DiscreteEventPriority: 개별적이고 독립적인 이벤트에 사용된다.(클릭, 키 입력 등)
ContinuousEventPriority: 연속적인 이벤트에 사용된다.(스크롤, 마우스 이동 등)
DefaultEventPriority: 기본 우선순위의 이벤트에 사용된다.
우선순위(빠르게 처리되는 순): DiscreteEventPriority ➔ ContinuousEventPriority ➔ DefaultEventPriority
export const DiscreteEventPriority: EventPriority = SyncLane; // 2
export const ContinuousEventPriority: EventPriority = InputContinuousLane; // 8
export const DefaultEventPriority: EventPriority = DefaultLane; // 32
더 많은 DOM 이벤트 우선순위 보기(내부 소스 코드 링크, getEventPriority
함수)
export default function App() {
const [allow, setAllow] = useState(false);
let load = () => import("./stream"); // client
let Hydrator = ClientHydrator;
if (typeof window === "undefined") { // server
Hydrator = ServerHydrator;
load = () => require("./stream");
}
return (
<div id="app">
<Header
onClick={() => {
setAllow(true); // 클릭 시 Hydration 허용
}}
/>
<Intro />
{items.map((profile) => (
<Hydrator allowHydration={allow} load={load} profile={profile} />
))}
</div>
);
}
export class Hydrator extends React.Component {
shouldComponentUpdate() {
return false;
}
componentDidMount() {
new IntersectionObserver(
async ([entry], obs) => {
if (!entry.isIntersecting) return; // 뷰포트에 진입하지 않으면 early return
obs.unobserve(this.root); // 관찰 중지
const { load, ...props } = this.props;
const Child = interopDefault(await load()); // 컴포넌트 로드
if (props.allowHydration) { // Hydration 허용 상태라면
ReactDOM.hydrate(<Child {...props} />, this.root); // 컴포넌트 hydrate
}
},
{
rootMargin: "-200px 0px",
threshold: 0,
}
).observe(this.root);
}
render() {
return <section ref={(c) => (this.root = c)} dangerouslySetInnerHTML={{ __html: "" }} />;
}
}
allow hydration 버튼을 누르지 않았기 때문에 hydrate가 진행되지 않아 리스트를 클릭해도 아무런 반응이 없는 것을 볼 수 있다.
이번에는 allow hydration 버튼을 클릭해서 hydration을 허용하고, 뷰포트에 200px만큼 들어온 리스트들만 hydrate가 진행되어 클릭하면 콘솔이 찍히는 것을 볼 수 있다.
hydrate가 진행된 리스트들은 배경색이 보라색으로 flush 효과가 발생하고 클릭 이벤트와 같은 상호작용이 가능하다.
(뷰포트에 200px만큼 들어오지 않은 리스트들은 hydrate가 진행되지 않아 상호작용이 불가능하다)
What is Progressive Hydration and how does it work internally in React?
New Suspense SSR Architecture in React 18 #37
개발자들의 SSR 최적화 여정
Progressive Hydration - Rendering Pattern
Airbnb Case Study
<template>
: 콘텐츠 템플릿 요소
ProgressiveHydration - Ahmad Ilawa