
서버 사이드 렌더링(SSR)을 사용하는 React 애플리케이션을 개발해 본 경험이 있다면, "hydration"이라는 용어를 들어봤을 것입니다. 이 글에서는 React의 hydration이 무엇인지, 어떻게 작동하는지, 그리고 내부적으로 어떤 과정을 통해 처리되는지 심층적으로 알아보겠습니다.
위 그림은 React의 Fiber 트리와 DOM 트리 간의 관계를 보여줍니다. Hydration 과정에서 React는 이 두 트리를 매칭시켜 기존 DOM 노드를 재사용합니다. 이 과정이 어떻게 이루어지는지 자세히 살펴보겠습니다.
"Hydration"은 '수분 공급'이라는 의미를 가진 단어로, React에서는 이 용어가 매우 적절하게 사용됩니다. 서버에서 렌더링된 HTML은 일종의 "탈수된(dehydrated)" 상태로 볼 수 있습니다. 이 HTML은 구조는 갖추고 있지만 상호작용이 불가능한 상태입니다. React의 hydration은 이 정적인 HTML에 "수분을 공급하여" 상호작용이 가능한 애플리케이션으로 변환하는 과정을 말합니다.
공식적으로 ReactDOM.hydrateRoot() 함수는 "서버에서 렌더링된 HTML에 이벤트 리스너를 부착"한다고 설명되어 있지만, 실제로는 훨씬 더 많은 일을 합니다.
일반적인 클라이언트 사이드 렌더링과 hydration의 주요 차이점을 이해하기 위해 간단한 예제를 살펴보겠습니다
<div id="container"><button>0</button></div>
<script type="text/babel">
const useState = React.useState;
function App() {
const [state, setState] = useState(0);
return (
<button onClick={() => setState((state) => state + 1)}>{state}</button>
);
}
const rootElement = document.getElementById("container");
const originalButton = rootElement.firstChild;
ReactDOM.createRoot(rootElement).render(<App />);
// DOM 재사용 여부 확인
setTimeout(
() => console.assert(
originalButton === rootElement.firstChild,
"DOM is reused?"
),
0
);
</script>
이 코드를 실행하면 콘솔에 오류가 표시됩니다. 이는 React가 기존 DOM 노드(<button>)를 버리고 새로운 노드를 생성했음을 의미합니다.
<div id="container"><button>0</button></div>
<script type="text/babel">
const useState = React.useState;
function App() {
const [state, setState] = useState(0);
return (
<button onClick={() => setState((state) => state + 1)}>{state}</button>
);
}
const rootElement = document.getElementById("container");
const originalButton = rootElement.firstChild;
ReactDOM.hydrateRoot(rootElement, <App />);
// DOM 재사용 여부 확인
setTimeout(
() => console.assert(
originalButton === rootElement.firstChild,
"DOM is reused"
),
0
);
</script>
이번에는 콘솔에 오류가 표시되지 않습니다. 이는 React가 기존 DOM 노드를 재사용했음을 의미합니다.
이것이 바로 hydration의 핵심입니다: 기존 DOM 구조를 파괴하고 다시 만드는 대신, 가능한 한 재사용합니다.
Hydration의 동작 원리를 이해하기 위해 먼저 React의 일반적인 렌더링 과정을 간략하게 복습해 보겠습니다
beginWork()와 completeWork()라는 두 단계로 처리됩니다:beginWork(): 현재 Fiber에서 어떤 자식을 생성할지 결정completeWork(): 현재 Fiber의 작업 완료 및 DOM 노드 생성completeWork() 단계에서 DOM 노드가 생성되고 stateNode 속성에 설정됩니다.일반 렌더링에서는 이 과정을 통해 새로운 DOM 트리가 생성됩니다.
hydration에서는 위 과정이 약간 수정됩니다. 핵심 아이디어는 다음과 같습니다
위 그림에서 볼 수 있듯이, Fiber 트리와 DOM 트리는 구조적으로 유사하지만 완전히 동일하지는 않습니다. Fiber 트리에는 Context 같은 추가 노드가 있을 수 있으며, 이들은 실제 DOM 노드로 변환되지 않습니다. hydration 과정에서 React는 이러한 차이를 처리하면서 두 트리를 매칭시킵니다.
React 내부 코드를 살펴보면 hydration이 어떻게 구현되어 있는지 더 명확하게 이해할 수 있습니다.
beginWork() 단계에서 React는 tryToClaimNextHydratableInstance() 함수를 호출하여 현재 Fiber 노드에 맞는 DOM 노드를 찾으려고 시도합니다:
function updateHostComponent(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes
) {
pushHostContext(workInProgress);
if (current === null) {
tryToClaimNextHydratableInstance(workInProgress);
}
// ...
return workInProgress.child;
}
tryToClaimNextHydratableInstance() 함수는 다음 DOM 노드가 현재 Fiber 노드와 일치하는지 확인합니다:
function tryHydrateInstance(fiber: Fiber, nextInstance: any) {
// fiber는 HostComponent Fiber입니다
const instance = canHydrateInstance(
nextInstance,
fiber.type,
fiber.pendingProps
);
if (instance !== null) {
fiber.stateNode = (instance: Instance);
hydrationParentFiber = fiber;
nextHydratableInstance = getFirstHydratableChild(instance);
rootOrSingletonContext = false;
return true;
}
return false;
}
여기서 중요한 부분은 다음과 같습니다:
canHydrateInstance()를 통해 DOM 노드 타입 및 속성이 일치하는지 확인합니다.fiber.stateNode에 기존 DOM 노드를 설정합니다.completeWork() 단계에서는 hydration이 성공적으로 수행되었는지 확인하고, 필요한 경우 DOM 노드 속성을 업데이트합니다
function completeWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
switch (workInProgress.tag) {
case HostComponent: {
// ...
const wasHydrated = popHydrationState(workInProgress);
if (wasHydrated) {
if (
prepareToHydrateHostInstance(workInProgress, currentHostContext)
) {
// 변경 사항이 있으면 업데이트 표시
markUpdate(workInProgress);
}
} else {
// hydration이 실패한 경우 새 DOM 노드 생성
const rootContainerInstance = getRootHostContainer();
const instance = createInstance(
type,
newProps,
rootContainerInstance,
currentHostContext,
workInProgress,
);
appendAllChildren(instance, workInProgress, false, false);
workInProgress.stateNode = instance;
}
return null;
}
// ...
}
}
prepareToHydrateHostInstance() 함수는 기존 DOM 노드의 속성을 현재 Fiber 노드의 props와 비교하고 필요한 업데이트를 계산합니다:
function prepareToHydrateHostInstance(
fiber: Fiber,
hostContext: HostContext
): boolean {
const instance: Instance = fiber.stateNode;
const updatePayload = hydrateInstance(
instance,
fiber.type,
fiber.memoizedProps,
hostContext,
fiber
);
fiber.updateQueue = (updatePayload: any);
// 업데이트가 필요한 경우 true 반환
if (updatePayload !== null) {
return true;
}
return false;
}
효율적인 hydration을 위해 React는 기존 DOM 트리의 현재 위치를 추적하는 "커서"를 유지합니다. 이 커서는 Fiber 트리를 순회할 때마다 적절히 업데이트됩니다
beginWork() 단계에서 Fiber 노드가 자식을 가질 때 커서는 기존 DOM 노드의 첫 번째 자식으로 이동합니다.completeWork() 단계에서 커서는 현재 DOM 노드의 다음 형제로 이동합니다.위에서 본 그림에서 Fiber 트리와 DOM 트리가 어떻게 병렬로 구성되어 있는지 볼 수 있습니다. React는 Fiber 트리를 순회하면서 각 노드에 대한 DOM 노드를 찾아 매칭합니다. 예를 들어, Fiber 트리의 'p' 노드는 DOM 트리의 'p' 노드와 매칭되고, 'button' 노드는 DOM 트리의 'button' 노드와 매칭됩니다.
이를 통해 React는 Fiber 트리와 기존 DOM 트리를 동시에 효율적으로 순회할 수 있습니다.
hydration 과정에서 Fiber 트리와 기존 DOM 트리 사이에 불일치가 발생할 수 있습니다. 이런 경우 React는 다음과 같은 전략을 사용합니다
React 18부터는 hydrateRoot() API가 개선되어, 서버와 클라이언트 간의 불일치를 더 효율적으로 복구할 수 있게 되었습니다.
React 18에서는 Suspense와 hydration의 통합이 크게 개선되었습니다
이러한 개선 사항은 대규모 애플리케이션의 초기 로딩 성능을 크게 향상시킵니다.
Hydration을 최적화하기 위한 몇 가지 팁은 다음과 같습니다
// 서버 컴포넌트
import { Suspense } from 'react';
import Comments from './Comments';
import Loading from './Loading';
export default function Post() {
return (
<article>
<h1>포스트 제목</h1>
<p>포스트 내용...</p>
<Suspense fallback={<Loading />}>
<Comments />
</Suspense>
</article>
);
}
이렇게 하면 Comments 컴포넌트를 hydrate하는 동안 사용자는 나머지 페이지와 상호작용할 수 있습니다.
서버와 클라이언트에서 동일한 콘텐츠가 렌더링되도록 주의해야 합니다. 예를 들어
// 피해야 할 예
function Component() {
// 서버와 클라이언트에서 다른 결과를 생성할 수 있음
return <div>{new Date().toLocaleTimeString()}</div>;
}
// 대신 이렇게 사용
function Component() {
const [time, setTime] = useState(() => new Date().toLocaleTimeString());
useEffect(() => {
// 클라이언트에서만 시간 업데이트
const timer = setInterval(() => {
setTime(new Date().toLocaleTimeString());
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{time}</div>;
}
일부 컴포넌트가 클라이언트에서만 실행되어야 하는 경우, 명시적으로 표시하는 것이 좋습니다
'use client';
import { useState, useEffect } from 'react';
export default function ClientOnlyComponent() {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null; // 또는 로딩 상태
}
// 클라이언트에서만 실행되는 코드
return <div>...</div>;
}
React의 hydration은 서버 사이드 렌더링의 핵심 부분으로, 초기 로딩 성능과 SEO를 향상시키면서도 풍부한 상호작용이 가능한 애플리케이션을 구현할 수 있게 해줍니다. 내부적으로는 Fiber 트리와 기존 DOM 트리를 동시에 순회하며, 가능한 한 많은 DOM 노드를 재사용하는 방식으로 작동합니다.
React 18의 동시성 기능과 함께 hydration은 더욱 강력해졌으며, 선택적 hydration과 스트리밍 SSR을 통해 대규모 애플리케이션에서도 부드러운 사용자 경험을 제공할 수 있게 되었습니다.
Hydration의 작동 방식을 이해하면 서버 사이드 렌더링 애플리케이션을 더 효율적으로 개발하고 최적화할 수 있습니다.