이성열
A

Portal과 Hydration의 실제 구현

이번주 학습 자료:

Portal은 왜 react-dom에서 나올까?

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

”DOM structure is opaque to React runtime” 이게 무슨 뜻일까?

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 vs createRoot - 실제 차이점

Portal의 동작 방식을 이해했으니, 이제 실제로 Portal과 createRoot의 차이를 확인해봅시다.

Portal은 React 트리 구조를 유지하지만, createRoot는 완전히 별도의 React 앱입니다.

위 예제에서 테마 버튼을 눌러보고, 각 팝업을 클릭해서 이벤트 버블링도 확인해보세요:

Portal은 React 트리 구조를 유지하면서 DOM 위치만 다른 곳에 렌더하는 반면, createRoot는 완전히 별도의 React 앱을 만듭니다.

더 알아보기: React 공식 문서 - createRoot

Multiple 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를 사용할 때는 몇 가지 주의점이 있습니다:

  1. 메모리 누수: 각 root는 명시적으로 unmount()해야 합니다
  2. Context 단절: 각 root는 독립적인 Context를 가집니다
  3. 성능 오버헤드: 각각 별도의 reconciler 인스턴스를 생성합니다
  4. 상태 공유 어려움: root 간 상태 공유가 복잡합니다
// 메모리 누수를 피하려면 반드시 cleanup
useEffect(() => {
  const root = createRoot(container);
  root.render(<Component />);
  
  return () => {
    root.unmount(); // 필수!
  };
}, []);

Astro Island Architecture

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에 대해 다음과 같은 과정이 일어납니다:

  1. 빌드 타임: Astro가 .astro 파일을 파싱하고 client:* 디렉티브를 발견
  2. HTML 생성: 해당 위치에 <astro-island> 태그와 함께 정적 HTML 생성
  3. JavaScript 번들링: client.ts의 코드가 브라우저용 JavaScript로 번들링됨
  4. 브라우저 실행: 페이지 로드 시 각 <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;
});

이 코드가 하는 일:

  1. WeakMap으로 root 관리: HTML element당 하나의 React root만 생성
  2. island별 독립적인 root: 각 client:* 컴포넌트마다 별도의 createRoot/hydrateRoot 호출
  3. 자동 cleanup: 컴포넌트가 언마운트될 때 root도 자동으로 정리

이것이 바로 “island architecture”의 핵심입니다. 페이지의 대부분은 빠른 정적 HTML이고, 필요한 부분만 독립적인 React island로 만들어서 성능과 개발 경험을 모두 잡았습니다.

더 알아보기: React 공식 문서 - Multiple roots

Hydration과 suppressHydrationWarning 동작 원리

Hydration은 서버에서 렌더링된 HTML을 클라이언트에서 React 컴포넌트와 연결하는 과정입니다. 이 과정에서 서버와 클라이언트의 HTML이 다르면 경고가 발생합니다.

react-dom-bindings 패키지란?

먼저 react-dom-bindings 패키지에 대해 알아보겠습니다. 이 패키지는 React의 monorepo 구조에서 DOM 관련 바인딩을 담당합니다.

브릿지 역할이란?

// 개념적인 흐름
reconciler: "div 태그를 만들어줘"
react-dom-bindings: document.createElement('div') // 실제 DOM 호출
react-dom: 사용자에게 createRoot() API 제공

즉, reconciler는 “무엇을 해야 하는지”만 알고, react-dom-bindings가 “실제로 어떻게 DOM에서 하는지”를 담당합니다. 이런 분리 덕분에 React Native에서는 react-dom-bindings 대신 Native 바인딩을 사용할 수 있습니다.

suppressHydrationWarning이 없을 때

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) {
      // ... 각 속성별 비교 로직
    }
  }
}

suppressHydrationWarning이 있을 때

해당 속성 비교를 완전히 건너뜁니다:

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은 경고만 억제할 뿐 실제 동작은 동일합니다:

  1. 비교는 건너뛰지만: hydration mismatch가 있어도 경고를 출력하지 않습니다
  2. 렌더링은 동일하게: 클라이언트의 새로운 값으로 DOM을 업데이트합니다
  3. 성능상 이점: 비교 로직을 건너뛰므로 약간의 성능 향상이 있습니다

즉, 경고가 없을 뿐이지 실제 DOM에 반영되는 결과는 완전히 동일합니다. suppressHydrationWarning이 있든 없든 결국 클라이언트 값이 DOM에 반영됩니다.

suppressHydrationWarning 없음:
현재 시간: 로딩 중...
suppressHydrationWarning 있음:
현재 시간: 로딩 중...
서버 렌더링된 시간과 클라이언트 hydration 시간이 다를 때 경고가 발생합니다. suppressHydrationWarning은 이 경고를 억제할 뿐입니다.

더 알아보기: React 공식 문서 - suppressHydrationWarning

마무리

Portal과 Hydration을 살펴보면서 React의 설계 철학을 엿볼 수 있었습니다.

겉보기에 단순한 기능들도 내부적으로는 확장성과 유연성을 위한 깊은 설계가 담겨있다는 것이 인상적입니다.

더 깊이 배우고 싶다면:

댓글을 남기려면 로그인이 필요합니다.