Next.js 하이드레이션(Hydration) 원리: renderToString부터 hydrateRoot까지

하이드레이션은 단순한 이벤트 연결 작업이 아니라, SSR되어서 이미 보이는 화면을 브라우저에서 React가 상태와 동작을 관리하는 화면으로 이어받는 과정이다.

Next.js

⭐️ 하이드레이션을 공부하기 전 알아야 할 배경지식

클라이언트에서 React 앱은 어떻게 시작될까?

  1. 본격적으로 하이드레이션을 배우기 전에, 우선 Vite로 만든 클라이언트 렌더링 React 프로젝트의 진입 코드를 먼저 살펴보자. 보통 main.jsx에는 다음과 같은 코드가 있다.

    // main.jsx
    import { createRoot } from 'react-dom/client';
    import App from './App.jsx';
    
    const domNode = document.getElementById('root');
    const root = createRoot(domNode);
    
    root.render(<App />);
  2. 이 코드가 찾는 root는 HTML에 미리 작성해 둔 요소이다.

    <!-- index.html의 body 내부 -->
    
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  3. 화면에 우리 앱을 보여주기 위해서는 DOM 에 그려야 한다.

  4. 앱의 최상위 컴포넌트인 <App /> 를 저기 보이는 <div id="root"></div> 안에 넣어 렌더링 시켜야 한다.

  5. document.getElementById('root'); 기본 브라우저 API로 루트 태그를 가져와서

  6. createRoot(domNode, options?) 함수에 넘겨준다.

  7. createRoot 함수는 두 가지 파라미터를 받는다.

    • domNode는 React가 관리할 영역이 될 DOM 요소이다. 여기에 넘긴 요소 하나(id=”root”)를 기준으로 루트가 만들어지고, 그 안쪽은 React가 관리한다.
    • options는 다양한 옵션을 가진 객체이다. (지금은 패스한다.)
  8. 이렇게 반환된 root는 render와 unmount 두 개의 메서드를 가진 객체이다.

    • root.render(...)
      • React 루트가 담당하는 DOM 노드 안에 React 노드를 그려 넣는 메서드이다.
    • root.unmount()
      • render의 반대 동작이다. 루트 안에 그려진 트리를 파괴하고 React를 그 DOM 노드에서 떼어낸다.
      • 직접적으로 다룰 일이 별로 없으며, 이 글에서도 다루지 않는다.

root.render(<App />); 역할

  1. 우리 앱을 화면에 보여주기 위해서는 DOM에다 렌더링 해야 하는데, <App /> 자체는 DOM 요소가 아니다.

  2. 화면에 무엇을 그리고 싶은지 설명해 놓은 JavaScript 객체, 즉 React 엘리먼트이다.

  3. 브라우저는 React 엘리먼트 객체를 직접 화면에 그리지 못한다. React가 이 객체를 바탕으로 UI를 계산하고 실제 DOM에 반영하면, 브라우저가 DOM과 스타일 등을 바탕으로 화면을 그린다.

  4. root.render(<App />)는 이 렌더링을 요청하는 메서드이다. 다만 이 함수 호출이 끝났다고 해서 DOM 반영과 화면 표시까지 즉시 완료되었다는 뜻은 아니다.

  5. 다시 돌아가서 보면

    // main.jsx
    import { createRoot } from 'react-dom/client';
    import App from './App.jsx';
    
    const domNode = document.getElementById('root'); // 이건 root id가 붙어있는 태그
    const root = createRoot(domNode); // 이건 .render(), .unmount() 메서드를 가진 객체
    
    root.render(<App />); // 반환값은 undefined이며, App의 렌더링을 요청한다.

예시와 함께 살펴보자

  1. 간단한 카운터 앱이다.

    // App.jsx
    import { useState } from 'react';
    
    export default function App() {
      const [count, setCount] = useState(0);
    
      return (
        <div>
          <p>카운터: <span>{count}</span></p>
          <button onClick={() => setCount(c => c + 1)}>+1</button>
        </div>
      );
    }
  2. count는 React가 렌더링 사이에 기억하는 상태이고, setCount는 다음 상태를 요청하는 함수이다.

  3. 버튼을 누르면 React는 변경된 상태를 반영하기 위해 다시 렌더링한다

리액트가 화면을 업데이트하는 과정

  1. root.render를 호출하면 실제로 무슨 일이 일어날까?
  2. 3가지 단계가 실행된다. Commit은 나중에 다시 언급하니 잘 봐두자.
    • https://react.dev/learn/render-and-commit

    • Trigger → 렌더링을 요청하는 단계

      • 지금 예제에서는 root.render(<App />)가 첫 렌더링을 요청한다.

        createRoot(...).render(<App />)
      • 이후에는 setCount 같은 상태 업데이트가 새로운 렌더링을 요청할 수 있다.

    • Render → 컴포넌트를 호출해 UI를 계산하는 단계

      • React는 App 함수를 호출하고, 반환된 JSX를 통해 어떤 요소와 내용이 필요한지 알아낸다.

      • 첫 렌더에서는 count가 0이므로 숫자 0이 들어간 카운터를 계산한다.

        const [count, setCount] = useState(0);
    • Commit → 계산 결과를 실제 DOM에 반영하는 단계

      • 처음에는 비어 있던 root 안에 카운터에 필요한 DOM이 추가된다.
      • 그다음 브라우저가 DOM과 스타일 등을 바탕으로 화면을 그린다.
  3. 자, 여기서 버튼을 눌러 count가 1이 된다면
    • React는 새로운 UI를 계산하고,
    • Commit에서 필요한 DOM 변경을 반영한다.
  4. 숫자가 바뀐다고 화면 전체를 없애고 다시 만들 필요가 없다. 컴포넌트가 다시 렌더링된다는 말은 DOM 전체가 다시 만들어진다는 뜻이 아니다.
  5. 그럼 뭘까? 컴포넌트 함수가 다시 호출된다는 뜻이다. (Counter가 리렌더링됐다 = Counter()가 한 번 더 실행됐다.)
  6. Render 단계에서 함수를 다시 실행해 새 결과를 얻고, Commit 단계에서 이전 결과와 달라진 부분만 실제 DOM에 반영한다.
  7. 위 예제라면 h1.textContent = '1' 한 줄이 실행될 뿐이다. 화면의 <h1> 요소는 처음 만들어진 그 객체 그대로고, 글자만 바뀐다.
  8. 그래서 Render가 돌아도 결과가 이전과 같으면 Commit에서 아무 DOM도 건드리지 않는다.
  9. 여기까지 공부하고 조금 있다가 다시 확인하자.

⭐️ 본격적인 Next.js 파트

Next.js에서는 첫 화면을 어떻게 보여줄까?

  1. 앞의 예제(순수 React)에서는 브라우저가 처음 받은 root 안이 비어 있다
  2. 카운터를 보려면 화면을 열었을 때, 앱의 JavaScript를 내려받고 실행해서 React가 DOM을 채워야 한다.
  3. 여기서 SSR(Server Side Rendering)은 JavaScript를 내려받기 전에 서버에서 HTML을 미리 만들어 내용을 빠르게 볼 수 있게 해준다.
  4. Next.js는 이런 서버 렌더링을 제공하는 React 프레임워크이다.

SSR을 이해할 때 중요한 renderToString API

https://react.dev/reference/react-dom/server/renderToString

  1. Next.js는 서버 렌더링을 제공한다. 여기서는 그 원리를 쉽게 이해하기 위해 React의 renderToString API를 예시로 살펴보자.

  2. 결론부터 말하면, 이 API는 “React tree(객체)를 렌더링하기 위해서 끝까지 전부 호출해가며 평평하게 펼친 뒤 그 결과를 문자열로 이어붙여서 돌려준다.”

    function Greeting({ name }) {
      return <h1 className="title">안녕, {name}</h1>;
    }
    
    const html = renderToString(<Greeting name="세계" />);
    // html === '<h1 class="title">안녕, <!-- -->세계</h1>'
  3. React tree란 무엇인가 간단히 언급하고 넘어가겠다.

  4. 우리가 JSX로 <App />이라고 쓰면 그건 실제 DOM 요소가 아니라 JavaScript 객체이다.

  5. App 안에 Layout이 있고 그 안에 Header, Article이 있고 각각이 또 자식을 갖는 구조로, 메모리 위에 존재하는 중첩된 객체 덩어리이다.

  6. 컴포넌트 함수를 호출하면 또 다른 객체를 반환하고, 그걸 또 호출하면서 뿌리에서 잎까지 내려가는 이 계층 구조를 React tree라고 부른다.

  7. 즉, React tree → JavaScript 객체이다.

  8. JavaScript 객체를 화면에 그리려면 DOM 노드로 변환해야 한다. (브라우저에서는 이 일을 react-dom이 한다.)

  9. 그런데 서버에는 DOM이 없다. Node.js에는 document가 없어서 DOM 노드를 만들 수가 없다.

  10. 그리고 이걸 브라우저로 보내야 한다. 어떻게 보낼까? HTTP 응답으로 보낸다.

  11. 근데 HTTP 응답으로 보낼 수 있는 건 결국 문자열이다.

  12. 그래서 “문자열 형태”로 바꾸어서 브라우저로 보내야 한다.

  13. 이러한 이유들 때문에 서버에서는 DOM을 거치지 않고, 펼친 트리를 곧바로 HTML 문자열로 이어붙여서 브라우저로 보낸다.

  14. 브라우저는 이 HTML을 파싱해 DOM을 만들고 내용을 표시할 수 있다. 이 시점에 JavaScript 코드는 없다.

    <div id="root">
      <div>
        <p>카운터: <span>0</span></p>
        <button>+1</button>
      </div>
    </div>
  15. 이런 원리로 앱의 JavaScript가 실행되기 전에도 카운터의 숫자와 버튼을 먼저 보여줄 수 있게 된다.

SSR로 만든 화면을 받은 이후에

  1. 위 예제에서 서버가 보낸 HTML에는 숫자 0과 +1 버튼이 있다.

  2. 하지만 우리가 작성했던 자바스크립트 코드가 남아있다.

    const [count, setCount] = useState(0);
    
    // 버튼에 전달했던 이벤트 핸들러
    () => setCount(c => c + 1)
  3. SSR로 받은 화면에는 이런 코드가 없기 때문에 React의 이벤트 핸들러와 상태 업데이트에 의존하는 동작은 할 수 없다.

  4. 카운터를 동작시키려면 브라우저에서도 컴포넌트 로직을 실행하고, 이미 표시된 DOM을 React가 관리하도록 연결해야 한다.

  5. 이 화면을 이어받는 과정이 하이드레이션이다.

hydrateRoot API

https://react.dev/reference/react-dom/client/hydrateRoot

  1. Next.js에서는 아까와 다른 코드 스니펫을 사용한다.

    import { hydrateRoot } from 'react-dom/client';
    
    const domNode = document.getElementById('root');
    const root = hydrateRoot(domNode, reactNode);
  2. createRoot 가 아닌 hydrateRoot 를 사용한다.

  3. 이 코드는 서버가 미리 만들어 보낸 HTML에 React를 붙여서, 화면을 새로 그리지 않고 클릭, 입력이 되는 앱으로 바꿔 주는 작업이다.

  4. 사용법은 다음과 같다. (프레임워크 차원에서 해주기 때문에 사용할 일은 없다)

    // 서버에서 만든 HTML을 받는 앱의 클라이언트 진입 코드
    import { hydrateRoot } from 'react-dom/client';
    import App from './App.jsx';
    
    const domNode = document.getElementById('root');
    
    hydrateRoot(domNode, <App />);
  5. domNode 는 아까 React 앱에서 봤던 것과 동일하다.

  6. 다만 차이점은 이 요소 안에는 이미 내용이 채워져 있다는 것이다. (서버가 만들어줘서)

    <div id="root">
      <h1>About</h1>   <!-- 서버가 미리 그린 내용 -->
    </div>
  7. 두 번째 인자인 <App />는 앱의 최상위 컴포넌트이다.

  8. hydrateRoot는 domNode 안에 서버가 미리 그려 놓은 HTML을 찾아서, <App />을 실행해 나온 이벤트 핸들러와 상태를 거기에 붙이는 역할을 한다. → 하이드레이션

Commit 단계 차이점

  1. 하이드레이션은 클라이언트에서 Render는 똑같이 하되, Commit 단계에서 DOM을 새로 만들지 않고 서버가 만든 DOM을 재사용하는 첫 렌더한다.

    단계클라이언트 렌더링 (이 페이지 기준)SSR + 하이드레이션
    TriggercreateRoot().render()hydrateRoot()
    Render브라우저에서 컴포넌트 호출서버에서 한 번, 브라우저에서 한 번 더 호출
    CommitDOM 노드를 새로 만들어 appendChild()서버가 만든 DOM을 재사용하고 이벤트만 연결
    PaintCommit이 끝난 뒤JS를 받기 전에 서버 HTML이 먼저 그려짐
  2. 하이드레이션에서도 Render와 Commit 단계는 진행된다. 다만 클라이언트 렌더링과 달리, 이미 존재하는 DOM을 재사용하면서 React가 관리하도록 연결하는 작업만 한다.

  3. 이미 만든 DOM이 있기 때문에 재사용하고 이벤트만 연결하면 된다.

  4. 하이드레이션은 결국 "Commit 단계가 이미 서버에서 만들어진 DOM을 대상으로 일어나는 특수한 경우”라고 볼 수 있다.

그렇다면 하이드레이션은 한 번만 일어날까?

  1. Next.js는 페이지마다 html파일을 따로 만든다.

    /           → index.html
    /about      → about.html
    /blog/123   → blog/123.html   (빌드 때 굽거나(SSG), 요청 때마다 만들거나(SSR))
  2. 그럼 이 HTML파일에 들어갈 때마다 하이드레이션이 발생하는가? 아니다.

  3. 페이지에 들어가는 방식에 따라서 다르다.

    1. 주소창 입력, 새로고침, 외부 링크로 진입 → 하이드레이션 실행 ✅

      1. 브라우저 → 서버: "/about 주세요"
      2. 서버 → 브라우저: about.html (완성된 HTML) + JS
      3. hydrateRoot 실행 ✅
    2. 앱 안에서 <Link>로 이동 → 하이드레이션 실행 X

      1. 브라우저 → 서버: "/about 데이터만 주세요" (HTML을 주는게 아니다.)
      2. 서버 → 브라우저: RSC payload (App Router) 또는 JSON + JS 조각 (Pages Router)
      3. 이미 붙어 있는 React가 화면만 교체 (이미 이전에 하이드레이션이 발생한 이후이다.)
      4. hydrateRoot 실행 ❌
      • RSC payload에 대해서는 다음에 자세히 다뤄보자.
  4. 왜 b방식에서는 하이드레이션이 발생하지 않을까? Link를 누르기 전에 이미 앱 안에 들어온 상태이고, 그때 하이드레이션이 발생했기 때문이다.

  5. 이미 React가 페이지를 관리하고 있으니(하이드레이션 효과) HTML을 다시 받아 붙일 필요가 없다. 필요한 데이터만 받아서 일반적인 React 렌더링으로 화면을 바꾼다. (이건 기존 React 방식이다.)

  6. 흐름을 정리하면 다음과 같다.

    1. 주소창에 /blog 입력    → blog.html 받음 → 하이드레이션 1번
    2. <Link>로 /about 이동   → 데이터만 받음 → 클라이언트 렌더링
    3. <Link>로 /blog/123 이동 → 데이터만 받음 → 클라이언트 렌더링
    4. F5 새로고침            → blog/123.html 받음 → 하이드레이션 다시 1번
  7. 하이드레이션은 해당 사이트에 들어가는 최소 1회에 발생하고, 이후 사이트 안에서 <Link>이동은 클라이언트 사이드 내비게이션이 발생한다.

마치며

하이드레이션에 대해서 공부하다보면 hydration mismatch 용어가 자주 보인다. 분량 때문에 이 글에서 다루지 않았지만 나중에 다뤄보겠다.

댓글