일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 |
- CSR
- key
- ErrorBoundary
- NextJs
- react
- lazy()
- 리액트훅
- Database
- react-hook
- msw
- reactquery
- Firebase
- next-cookies
- docker
- 리액트 훅
- SSR
- useEffect
- 클래스
- react-hook-form
- 초기마운트
- 모던자바스크립트
- express
- useLayoutEffect
- 리액트
- Today
- Total
한우의 개발일기
hydration알아보기 본문
React의 Hydration 동작 원리 분석
서버 사이드 렌더링(SSR)을 사용하는 React 애플리케이션을 개발해 본 경험이 있다면, "hydration"이라는 용어를 들어봤을 것입니다. 이 글에서는 React의 hydration이 무엇인지, 어떻게 작동하는지, 그리고 내부적으로 어떤 과정을 통해 처리되는지 심층적으로 알아보겠습니다.
위 그림은 React의 Fiber 트리와 DOM 트리 간의 관계를 보여줍니다. Hydration 과정에서 React는 이 두 트리를 매칭시켜 기존 DOM 노드를 재사용합니다. 이 과정이 어떻게 이루어지는지 자세히 살펴보겠습니다.
Hydration이란 무엇인가?
"Hydration"은 '수분 공급'이라는 의미를 가진 단어로, React에서는 이 용어가 매우 적절하게 사용됩니다. 서버에서 렌더링된 HTML은 일종의 "탈수된(dehydrated)" 상태로 볼 수 있습니다. 이 HTML은 구조는 갖추고 있지만 상호작용이 불가능한 상태입니다. React의 hydration은 이 정적인 HTML에 "수분을 공급하여" 상호작용이 가능한 애플리케이션으로 변환하는 과정을 말합니다.
공식적으로 ReactDOM.hydrateRoot()
함수는 "서버에서 렌더링된 HTML에 이벤트 리스너를 부착"한다고 설명되어 있지만, 실제로는 훨씬 더 많은 일을 합니다.
Hydration vs 일반 렌더링
일반적인 클라이언트 사이드 렌더링과 hydration의 주요 차이점을 이해하기 위해 간단한 예제를 살펴보겠습니다
일반 렌더링 (createRoot)
<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>
)를 버리고 새로운 노드를 생성했음을 의미합니다.
Hydration (hydrateRoot)
<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 구조를 파괴하고 다시 만드는 대신, 가능한 한 재사용합니다.
React의 렌더링 과정 복습
Hydration의 동작 원리를 이해하기 위해 먼저 React의 일반적인 렌더링 과정을 간략하게 복습해 보겠습니다
- Fiber 트리 구성: React는 컴포넌트를 렌더링할 때 Fiber 노드로 구성된 트리를 생성합니다.
- Fiber 트리 순회: React는 DFS(깊이 우선 탐색) 방식으로 Fiber 트리를 순회합니다.
- 두 단계 처리: 각 Fiber 노드는
beginWork()
와completeWork()
라는 두 단계로 처리됩니다:beginWork()
: 현재 Fiber에서 어떤 자식을 생성할지 결정completeWork()
: 현재 Fiber의 작업 완료 및 DOM 노드 생성
- DOM 트리 구성:
completeWork()
단계에서 DOM 노드가 생성되고stateNode
속성에 설정됩니다. - 자식 노드 연결: 생성된 DOM 노드에 자식 노드가 연결됩니다.
일반 렌더링에서는 이 과정을 통해 새로운 DOM 트리가 생성됩니다.
Hydration의 동작 원리
hydration에서는 위 과정이 약간 수정됩니다. 핵심 아이디어는 다음과 같습니다
- 기존 DOM 트리에 커서를 유지합니다.
- Fiber 트리를 순회하면서 DOM 노드가 필요할 때마다 기존 DOM 트리에서 일치하는 노드를 찾습니다.
- 일치하는 노드가 있으면 새로 생성하지 않고 그대로 재사용합니다.
- 필요한 경우 속성 및 이벤트 리스너만 업데이트합니다.
내부 구현 살펴보기
위 그림에서 볼 수 있듯이, Fiber 트리와 DOM 트리는 구조적으로 유사하지만 완전히 동일하지는 않습니다. Fiber 트리에는 Context 같은 추가 노드가 있을 수 있으며, 이들은 실제 DOM 노드로 변환되지 않습니다. hydration 과정에서 React는 이러한 차이를 처리하면서 두 트리를 매칭시킵니다.
React 내부 코드를 살펴보면 hydration이 어떻게 구현되어 있는지 더 명확하게 이해할 수 있습니다.
1. beginWork() 단계에서의 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 노드를 설정합니다. - 다음으로 처리할 DOM 노드를 현재 노드의 첫 번째 자식으로 업데이트합니다.
2. completeWork() 단계에서의 hydration
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;
}
DOM 커서 관리
효율적인 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는 해당 컴포넌트를 클라이언트에서 다시 렌더링합니다.
- 성능 영향: 이런 폴백은 성능에 부정적인 영향을 미치므로, 서버와 클라이언트 렌더링이 일치하도록 주의해야 합니다.
React 18부터는 hydrateRoot()
API가 개선되어, 서버와 클라이언트 간의 불일치를 더 효율적으로 복구할 수 있게 되었습니다.
Hydration과 Suspense
React 18에서는 Suspense와 hydration의 통합이 크게 개선되었습니다
- 선택적 hydration: 모든 컴포넌트를 한번에 hydrate하는 대신, 사용자 상호작용이 있는 부분부터 우선적으로 hydrate할 수 있습니다.
- 스트리밍 SSR: 서버에서 HTML을 점진적으로 스트리밍하고, 클라이언트에서는 도착하는 순서대로 hydrate할 수 있습니다.
- 중단 가능한 hydration: 사용자 상호작용이 발생하면 현재 진행 중인 hydration을 중단하고 상호작용이 발생한 컴포넌트를 먼저 처리할 수 있습니다.
이러한 개선 사항은 대규모 애플리케이션의 초기 로딩 성능을 크게 향상시킵니다.
실전에서의 Hydration 최적화
Hydration을 최적화하기 위한 몇 가지 팁은 다음과 같습니다
1. Suspense를 사용한 로딩 상태 처리
// 서버 컴포넌트
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하는 동안 사용자는 나머지 페이지와 상호작용할 수 있습니다.
2. 콘텐츠 일치 확인
서버와 클라이언트에서 동일한 콘텐츠가 렌더링되도록 주의해야 합니다. 예를 들어
// 피해야 할 예
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>;
}
3. 클라이언트 전용 컴포넌트 분리
일부 컴포넌트가 클라이언트에서만 실행되어야 하는 경우, 명시적으로 표시하는 것이 좋습니다
'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의 작동 방식을 이해하면 서버 사이드 렌더링 애플리케이션을 더 효율적으로 개발하고 최적화할 수 있습니다.
'React' 카테고리의 다른 글
lazy() 알아보기 (0) | 2025.03.28 |
---|---|
Suspense 알아보기 (1) | 2025.03.24 |
Errorboundary 내부동작 이해하기 (1) | 2025.03.23 |
useEffect vs useLayoutEffect (0) | 2025.03.21 |
useAsync 파해치기 (0) | 2025.03.18 |