이번주 학습 자료:
Portal을 import할 때 react
가 아닌 react-dom
에서 가져와야 합니다. 왜 그럴까요?
// packages/react-dom/src/shared/ReactDOM.js
import {createPortal as createPortalImpl} from 'react-reconciler/src/ReactPortal';
// packages/react-dom/index.js
export { createPortal } from './src/shared/ReactDOM';
Portal은 사실 reconciler 기능이지만, 특정 renderer에 종속적이기 때문입니다. DOM이 아닌 환경(React Native 등)에서는 Portal의 개념이 달라지기 때문에 DOM 전용 패키지에서 export하는 것이 맞습니다.
더 알아보기: React 공식 문서 - createPortal
jser.dev 글에서 인상깊었던 문장이 있습니다:
“Because of the nature of reconciling, the DOM structure is opaque to React runtime, thus for Portal it could only focus on how to manage the container in the commit phase”
이는 React reconciler가 DOM 구조를 직접 알지 못한다는 뜻입니다. reconciler는 Fiber 트리만 관리하고, 실제 DOM 조작은 renderer(react-dom)가 담당합니다.
Portal이 동작하는 방식을 보면:
흥미로운 점은 Portal이 다른 컴포넌트와 다르게 동작한다는 것입니다:
“Different from createFiberFromElement(), where stateNode is not set, since we need the DOM to be created inside hierarchy. So it won’t be set until commit phase. But for Portal, it already knows where the root is.”
일반 컴포넌트는 DOM 계층 구조 안에서 생성되어야 하므로 stateNode가 commit phase까지 설정되지 않지만, Portal은 이미 어디에 렌더될지(container) 알고 있기 때문에 다르게 처리됩니다.
결국 React의 깔끔한 아키텍처 덕분에 Portal 같은 기능을 쉽게 구현할 수 있습니다.
더 알아보기: React Fiber 아키텍처
Portal의 동작 방식을 이해했으니, 이제 실제로 Portal과 createRoot의 차이를 확인해봅시다.
위 예제에서 테마 버튼을 눌러보고, 각 팝업을 클릭해서 이벤트 버블링도 확인해보세요:
Portal은 React 트리 구조를 유지하면서 DOM 위치만 다른 곳에 렌더하는 반면, createRoot는 완전히 별도의 React 앱을 만듭니다.
더 알아보기: React 공식 문서 - createRoot
그렇다면 createRoot를 여러 개 사용하는 것은 어떨까요? React 공식 문서에 따르면 이는 완전히 지원되는 패턴입니다. “페이지가 완전히 React로 만들어지지 않은 경우” 여러 createRoot를 호출할 수 있다고 명시되어 있습니다:
// React 공식 문서 예제
const navDomNode = document.getElementById('navigation');
const navRoot = createRoot(navDomNode);
navRoot.render(<Navigation />);
const commentDomNode = document.getElementById('comments');
const commentRoot = createRoot(commentDomNode);
commentRoot.render(<Comments />);
하지만 여러 createRoot를 사용할 때는 몇 가지 주의점이 있습니다:
unmount()
해야 합니다// 메모리 누수를 피하려면 반드시 cleanup
useEffect(() => {
const root = createRoot(container);
root.render(<Component />);
return () => {
root.unmount(); // 필수!
};
}, []);
Astro는 “island architecture”라는 개념을 사용합니다. 기본적으로 모든 페이지는 정적 HTML이고, client:*
디렉티브가 붙은 컴포넌트만 “island”가 되어 JavaScript가 로드됩니다:
---
// .astro 파일
---
<h1>이 부분은 정적 HTML</h1>
<!-- 이 부분만 JavaScript가 로드되는 "island" -->
<ReactCounter client:load />
<p>다시 정적 HTML</p>
<!-- 또 다른 독립적인 "island" -->
<ReactModal client:idle />
Astro가 island를 어떻게 관리하는지 알아보기 위해 client.ts 소스코드를 확인해봤습니다.
이 파일은 브라우저에서 실행되는 클라이언트 사이드 코드입니다. Astro가 빌드할 때 각 React island에 대해 다음과 같은 과정이 일어납니다:
.astro
파일을 파싱하고 client:*
디렉티브를 발견<astro-island>
태그와 함께 정적 HTML 생성<astro-island>
에 대해 client.ts의 코드가 실행되어 React root 생성즉, 이 코드는 각 React island가 브라우저에서 hydrate되거나 렌더될 때마다 실행되는 코드입니다:
// astro/packages/integrations/react/src/client.ts
let rootMap = new WeakMap<HTMLElement, Root>();
const getOrCreateRoot = (element: HTMLElement, creator: () => Root) => {
let root = rootMap.get(element);
if (!root) {
root = creator(); // 각 island마다 새로운 root 생성!
rootMap.set(element, root);
}
return root;
};
// hydration 시 (서버에서 렌더된 HTML을 React와 연결)
const root = getOrCreateRoot(element, () => {
const r = hydrateRoot(element, componentEl, renderOptions);
element.addEventListener('astro:unmount', () => r.unmount(), { once: true });
return r;
});
// 클라이언트 전용 렌더링 시 (처음부터 React로 렌더링)
const root = getOrCreateRoot(element, () => {
const r = createRoot(element, renderOptions);
element.addEventListener('astro:unmount', () => r.unmount(), { once: true });
return r;
});
이 코드가 하는 일:
client:*
컴포넌트마다 별도의 createRoot/hydrateRoot 호출이것이 바로 “island architecture”의 핵심입니다. 페이지의 대부분은 빠른 정적 HTML이고, 필요한 부분만 독립적인 React island로 만들어서 성능과 개발 경험을 모두 잡았습니다.
더 알아보기: React 공식 문서 - Multiple roots
Hydration은 서버에서 렌더링된 HTML을 클라이언트에서 React 컴포넌트와 연결하는 과정입니다. 이 과정에서 서버와 클라이언트의 HTML이 다르면 경고가 발생합니다.
먼저 react-dom-bindings
패키지에 대해 알아보겠습니다. 이 패키지는 React의 monorepo 구조에서 DOM 관련 바인딩을 담당합니다.
브릿지 역할이란?
react-reconciler
: 플랫폼에 관계없이 Virtual DOM 트리를 관리하는 핵심 로직react-dom-bindings
: reconciler가 “DOM 노드를 만들어”라고 하면 실제 document.createElement()
호출react-dom
: 사용자가 쓰는 public API (createRoot
, hydrateRoot
등)// 개념적인 흐름
reconciler: "div 태그를 만들어줘"
react-dom-bindings: document.createElement('div') // 실제 DOM 호출
react-dom: 사용자에게 createRoot() API 제공
즉, reconciler는 “무엇을 해야 하는지”만 알고, react-dom-bindings가 “실제로 어떻게 DOM에서 하는지”를 담당합니다. 이런 분리 덕분에 React Native에서는 react-dom-bindings 대신 Native 바인딩을 사용할 수 있습니다.
React는 hydration 중에 모든 속성을 비교합니다:
// react-dom-bindings/src/client/ReactDOMComponent.js
function diffHydratedProperties(...) {
for (const propKey in props) {
if (props.suppressHydrationWarning === true) {
// Don't bother comparing. We're ignoring all these warnings.
continue;
}
// Validate that the properties correspond to their expected values.
switch (propKey) {
// ... 각 속성별 비교 로직
}
}
}
해당 속성 비교를 완전히 건너뜁니다:
if (props.suppressHydrationWarning === true) {
// Don't bother comparing. We're ignoring all these warnings.
continue;
}
텍스트 노드의 경우도 마찬가지입니다:
// react-dom-bindings/src/client/ReactDOMComponent.js
function diffHydratedText(textNode, text, parentProps) {
const isDifferent = textNode.nodeValue !== text;
if (
isDifferent &&
(parentProps === null || parentProps.suppressHydrationWarning !== true) &&
!checkForUnmatchedText(textNode.nodeValue, text)
) {
return false; // 경고 발생
}
return true;
}
결론적으로, suppressHydrationWarning은 경고만 억제할 뿐 실제 동작은 동일합니다:
즉, 경고가 없을 뿐이지 실제 DOM에 반영되는 결과는 완전히 동일합니다. suppressHydrationWarning이 있든 없든 결국 클라이언트 값이 DOM에 반영됩니다.
Portal과 Hydration을 살펴보면서 React의 설계 철학을 엿볼 수 있었습니다.
겉보기에 단순한 기능들도 내부적으로는 확장성과 유연성을 위한 깊은 설계가 담겨있다는 것이 인상적입니다.
더 깊이 배우고 싶다면:
댓글을 남기려면 로그인이 필요합니다.