Next.js 하이드레이션(Hydration) 원리: renderToString부터 hydrateRoot까지
하이드레이션은 단순한 이벤트 연결 작업이 아니라, SSR되어서 이미 보이는 화면을 브라우저에서 React가 상태와 동작을 관리하는 화면으로 이어받는 과정이다.
⭐️ 하이드레이션을 공부하기 전 알아야 할 배경지식
클라이언트에서 React 앱은 어떻게 시작될까?
-
본격적으로 하이드레이션을 배우기 전에, 우선 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 />); -
이 코드가 찾는
root는 HTML에 미리 작성해 둔 요소이다.<!-- index.html의 body 내부 --> <div id="root"></div> <script type="module" src="/src/main.jsx"></script> -
화면에 우리 앱을 보여주기 위해서는 DOM 에 그려야 한다.
-
앱의 최상위 컴포넌트인
<App />를 저기 보이는<div id="root"></div>안에 넣어 렌더링 시켜야 한다. -
document.getElementById('root');기본 브라우저 API로 루트 태그를 가져와서 -
createRoot(domNode, options?)함수에 넘겨준다. -
createRoot함수는 두 가지 파라미터를 받는다.domNode는 React가 관리할 영역이 될 DOM 요소이다. 여기에 넘긴 요소 하나(id=”root”)를 기준으로 루트가 만들어지고, 그 안쪽은 React가 관리한다.options는 다양한 옵션을 가진 객체이다. (지금은 패스한다.)
-
이렇게 반환된 root는
render와unmount두 개의 메서드를 가진 객체이다.root.render(...)- React 루트가 담당하는 DOM 노드 안에 React 노드를 그려 넣는 메서드이다.
root.unmount()render의 반대 동작이다. 루트 안에 그려진 트리를 파괴하고 React를 그 DOM 노드에서 떼어낸다.- 직접적으로 다룰 일이 별로 없으며, 이 글에서도 다루지 않는다.
root.render(<App />); 역할
-
우리 앱을 화면에 보여주기 위해서는 DOM에다 렌더링 해야 하는데,
<App />자체는 DOM 요소가 아니다. -
화면에 무엇을 그리고 싶은지 설명해 놓은 JavaScript 객체, 즉 React 엘리먼트이다.
-
브라우저는 React 엘리먼트 객체를 직접 화면에 그리지 못한다. React가 이 객체를 바탕으로 UI를 계산하고 실제 DOM에 반영하면, 브라우저가 DOM과 스타일 등을 바탕으로 화면을 그린다.
-
root.render(<App />)는 이 렌더링을 요청하는 메서드이다. 다만 이 함수 호출이 끝났다고 해서 DOM 반영과 화면 표시까지 즉시 완료되었다는 뜻은 아니다. -
다시 돌아가서 보면
// 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의 렌더링을 요청한다.
예시와 함께 살펴보자
-
간단한 카운터 앱이다.
// 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> ); } -
count는 React가 렌더링 사이에 기억하는 상태이고,setCount는 다음 상태를 요청하는 함수이다. -
버튼을 누르면 React는 변경된 상태를 반영하기 위해 다시 렌더링한다
리액트가 화면을 업데이트하는 과정
root.render를 호출하면 실제로 무슨 일이 일어날까?- 3가지 단계가 실행된다. 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과 스타일 등을 바탕으로 화면을 그린다.
- 처음에는 비어 있던
- 자, 여기서 버튼을 눌러
count가1이 된다면- React는 새로운 UI를 계산하고,
- Commit에서 필요한 DOM 변경을 반영한다.
- 숫자가 바뀐다고 화면 전체를 없애고 다시 만들 필요가 없다. 컴포넌트가 다시 렌더링된다는 말은 DOM 전체가 다시 만들어진다는 뜻이 아니다.
- 그럼 뭘까? 컴포넌트 함수가 다시 호출된다는 뜻이다. (
Counter가 리렌더링됐다 =Counter()가 한 번 더 실행됐다.) - Render 단계에서 함수를 다시 실행해 새 결과를 얻고, Commit 단계에서 이전 결과와 달라진 부분만 실제 DOM에 반영한다.
- 위 예제라면
h1.textContent = '1'한 줄이 실행될 뿐이다. 화면의<h1>요소는 처음 만들어진 그 객체 그대로고, 글자만 바뀐다. - 그래서 Render가 돌아도 결과가 이전과 같으면 Commit에서 아무 DOM도 건드리지 않는다.
- 여기까지 공부하고 조금 있다가 다시 확인하자.
⭐️ 본격적인 Next.js 파트
Next.js에서는 첫 화면을 어떻게 보여줄까?
- 앞의 예제(순수 React)에서는 브라우저가 처음 받은
root안이 비어 있다 - 카운터를 보려면 화면을 열었을 때, 앱의 JavaScript를 내려받고 실행해서 React가 DOM을 채워야 한다.
- 여기서 SSR(Server Side Rendering)은 JavaScript를 내려받기 전에 서버에서 HTML을 미리 만들어 내용을 빠르게 볼 수 있게 해준다.
- Next.js는 이런 서버 렌더링을 제공하는 React 프레임워크이다.
SSR을 이해할 때 중요한 renderToString API
https://react.dev/reference/react-dom/server/renderToString
-
Next.js는 서버 렌더링을 제공한다. 여기서는 그 원리를 쉽게 이해하기 위해 React의
renderToStringAPI를 예시로 살펴보자. -
결론부터 말하면, 이 API는 “React tree(객체)를 렌더링하기 위해서 끝까지 전부 호출해가며 평평하게 펼친 뒤 그 결과를 문자열로 이어붙여서 돌려준다.”
function Greeting({ name }) { return <h1 className="title">안녕, {name}</h1>; } const html = renderToString(<Greeting name="세계" />); // html === '<h1 class="title">안녕, <!-- -->세계</h1>' -
React tree란 무엇인가 간단히 언급하고 넘어가겠다.
-
우리가 JSX로
<App />이라고 쓰면 그건 실제 DOM 요소가 아니라 JavaScript 객체이다. -
App안에Layout이 있고 그 안에Header,Article이 있고 각각이 또 자식을 갖는 구조로, 메모리 위에 존재하는 중첩된 객체 덩어리이다. -
컴포넌트 함수를 호출하면 또 다른 객체를 반환하고, 그걸 또 호출하면서 뿌리에서 잎까지 내려가는 이 계층 구조를 React tree라고 부른다.
-
즉, React tree → JavaScript 객체이다.
-
JavaScript 객체를 화면에 그리려면 DOM 노드로 변환해야 한다. (브라우저에서는 이 일을 react-dom이 한다.)
-
그런데 서버에는 DOM이 없다. Node.js에는
document가 없어서 DOM 노드를 만들 수가 없다. -
그리고 이걸 브라우저로 보내야 한다. 어떻게 보낼까? HTTP 응답으로 보낸다.
-
근데 HTTP 응답으로 보낼 수 있는 건 결국 문자열이다.
-
그래서 “문자열 형태”로 바꾸어서 브라우저로 보내야 한다.
-
이러한 이유들 때문에 서버에서는 DOM을 거치지 않고, 펼친 트리를 곧바로 HTML 문자열로 이어붙여서 브라우저로 보낸다.
-
브라우저는 이 HTML을 파싱해 DOM을 만들고 내용을 표시할 수 있다. 이 시점에 JavaScript 코드는 없다.
<div id="root"> <div> <p>카운터: <span>0</span></p> <button>+1</button> </div> </div> -
이런 원리로 앱의 JavaScript가 실행되기 전에도 카운터의 숫자와 버튼을 먼저 보여줄 수 있게 된다.
SSR로 만든 화면을 받은 이후에
-
위 예제에서 서버가 보낸 HTML에는 숫자
0과+1버튼이 있다. -
하지만 우리가 작성했던 자바스크립트 코드가 남아있다.
const [count, setCount] = useState(0); // 버튼에 전달했던 이벤트 핸들러 () => setCount(c => c + 1) -
SSR로 받은 화면에는 이런 코드가 없기 때문에 React의 이벤트 핸들러와 상태 업데이트에 의존하는 동작은 할 수 없다.
-
카운터를 동작시키려면 브라우저에서도 컴포넌트 로직을 실행하고, 이미 표시된 DOM을 React가 관리하도록 연결해야 한다.
-
이 화면을 이어받는 과정이 하이드레이션이다.
hydrateRoot API
https://react.dev/reference/react-dom/client/hydrateRoot
-
Next.js에서는 아까와 다른 코드 스니펫을 사용한다.
import { hydrateRoot } from 'react-dom/client'; const domNode = document.getElementById('root'); const root = hydrateRoot(domNode, reactNode); -
createRoot가 아닌hydrateRoot를 사용한다. -
이 코드는 서버가 미리 만들어 보낸 HTML에 React를 붙여서, 화면을 새로 그리지 않고 클릭, 입력이 되는 앱으로 바꿔 주는 작업이다.
-
사용법은 다음과 같다. (프레임워크 차원에서 해주기 때문에 사용할 일은 없다)
// 서버에서 만든 HTML을 받는 앱의 클라이언트 진입 코드 import { hydrateRoot } from 'react-dom/client'; import App from './App.jsx'; const domNode = document.getElementById('root'); hydrateRoot(domNode, <App />); -
domNode는 아까 React 앱에서 봤던 것과 동일하다. -
다만 차이점은 이 요소 안에는 이미 내용이 채워져 있다는 것이다. (서버가 만들어줘서)
<div id="root"> <h1>About</h1> <!-- 서버가 미리 그린 내용 --> </div> -
두 번째 인자인
<App />는 앱의 최상위 컴포넌트이다. -
hydrateRoot는domNode안에 서버가 미리 그려 놓은 HTML을 찾아서,<App />을 실행해 나온 이벤트 핸들러와 상태를 거기에 붙이는 역할을 한다. → 하이드레이션
Commit 단계 차이점
-
하이드레이션은 클라이언트에서 Render는 똑같이 하되, Commit 단계에서 DOM을 새로 만들지 않고 서버가 만든 DOM을 재사용하는 첫 렌더한다.
단계 클라이언트 렌더링 (이 페이지 기준) SSR + 하이드레이션 Trigger createRoot().render()hydrateRoot()Render 브라우저에서 컴포넌트 호출 서버에서 한 번, 브라우저에서 한 번 더 호출 Commit DOM 노드를 새로 만들어 appendChild()서버가 만든 DOM을 재사용하고 이벤트만 연결 Paint Commit이 끝난 뒤 JS를 받기 전에 서버 HTML이 먼저 그려짐 -
하이드레이션에서도 Render와 Commit 단계는 진행된다. 다만 클라이언트 렌더링과 달리, 이미 존재하는 DOM을 재사용하면서 React가 관리하도록 연결하는 작업만 한다.
-
이미 만든 DOM이 있기 때문에 재사용하고 이벤트만 연결하면 된다.
-
하이드레이션은 결국 "Commit 단계가 이미 서버에서 만들어진 DOM을 대상으로 일어나는 특수한 경우”라고 볼 수 있다.
그렇다면 하이드레이션은 한 번만 일어날까?
-
Next.js는 페이지마다 html파일을 따로 만든다.
/ → index.html /about → about.html /blog/123 → blog/123.html (빌드 때 굽거나(SSG), 요청 때마다 만들거나(SSR)) -
그럼 이 HTML파일에 들어갈 때마다 하이드레이션이 발생하는가? 아니다.
-
페이지에 들어가는 방식에 따라서 다르다.
-
주소창 입력, 새로고침, 외부 링크로 진입 → 하이드레이션 실행 ✅
1. 브라우저 → 서버: "/about 주세요" 2. 서버 → 브라우저: about.html (완성된 HTML) + JS 3. hydrateRoot 실행 ✅ -
앱 안에서
<Link>로 이동 → 하이드레이션 실행 X1. 브라우저 → 서버: "/about 데이터만 주세요" (HTML을 주는게 아니다.) 2. 서버 → 브라우저: RSC payload (App Router) 또는 JSON + JS 조각 (Pages Router) 3. 이미 붙어 있는 React가 화면만 교체 (이미 이전에 하이드레이션이 발생한 이후이다.) 4. hydrateRoot 실행 ❌- RSC payload에 대해서는 다음에 자세히 다뤄보자.
-
-
왜 b방식에서는 하이드레이션이 발생하지 않을까? Link를 누르기 전에 이미 앱 안에 들어온 상태이고, 그때 하이드레이션이 발생했기 때문이다.
-
이미 React가 페이지를 관리하고 있으니(하이드레이션 효과) HTML을 다시 받아 붙일 필요가 없다. 필요한 데이터만 받아서 일반적인 React 렌더링으로 화면을 바꾼다. (이건 기존 React 방식이다.)
Next.js는 SPA일까? <Link>와 <a>로 보는 SPA·MPA 차이 이 글에서 해당 내용을 다룬다.
-
흐름을 정리하면 다음과 같다.
1. 주소창에 /blog 입력 → blog.html 받음 → 하이드레이션 1번 2. <Link>로 /about 이동 → 데이터만 받음 → 클라이언트 렌더링 3. <Link>로 /blog/123 이동 → 데이터만 받음 → 클라이언트 렌더링 4. F5 새로고침 → blog/123.html 받음 → 하이드레이션 다시 1번 -
하이드레이션은 해당 사이트에 들어가는 최소 1회에 발생하고, 이후 사이트 안에서
<Link>이동은 클라이언트 사이드 내비게이션이 발생한다.
마치며
하이드레이션에 대해서 공부하다보면 hydration mismatch 용어가 자주 보인다. 분량 때문에 이 글에서 다루지 않았지만 나중에 다뤄보겠다.