02. renderToString의 실제 동작
02. renderToString의 실제 동작
이 문서에서 배우는 것
renderToString()이 정확히 무엇을 하는지- 실제 입력(React 컴포넌트)과 출력(HTML 문자열) 예시
onClick이 출력에서 사라지는 이유<!-- -->가 출력에 포함되는 이유- JSX 없이
React.createElement로 직접 실행해보는 코드
PM 요청 (김도연)
김도연 PM: 준혁님, 저번에 SSR의 6가지 요소를 배웠는데요, 첫 번째 단계인 "서버에서 HTML을 만든다"는 부분이 아직 잘 안 와닿아요. renderToString이 정확히 뭘 하는 건가요? 실제로 뭐가 들어가고 뭐가 나오는지 보여주실 수 있나요?
이준혁 시니어: 좋아. renderToString은 SSR의 심장이야. 이게 뭘 하는지 제대로 이해하면, SSR 전체가 보이기 시작해. 아주 단순한 것부터 시작하자.
시니어 멘토링 (이준혁)
renderToString이 하는 일: 딱 한 줄로 설명
이준혁: renderToString은 React 컴포넌트를 HTML 문자열로 변환하는 함수야. 그게 전부야.
입력: React 컴포넌트 (JavaScript 객체)
↓ renderToString()
출력: HTML 문자열 (순수 텍스트)김도연: 컴포넌트가 JavaScript 객체라고요?
이준혁: 맞아. JSX로 작성하지만, 실제로는 JavaScript 함수 호출이야. 이건 조금 뒤에 자세히 설명할게. 먼저 실제 예시를 보자.
실제 입력과 출력
이준혁: A003 프로젝트의 HomePage.jsx를 가져왔어. 이걸 renderToString에 넣으면 뭐가 나오는지 봐봐.
입력 컴포넌트:
// HomePage.jsx
import React, { useState } from 'react';
export default function HomePage() {
const [count, setCount] = useState(0);
return (
<div className="container">
<h1>안녕하세요</h1>
<p>방문자 수: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}renderToString 호출:
import { renderToString } from 'react-dom/server';
import HomePage from './HomePage.jsx';
const html = renderToString(<HomePage />);
console.log(html);출력 결과:
<div class="container"><h1>안녕하세요</h1><p>방문자 수: <!-- -->0</p><button>+1</button></div>김도연: 어? 뭔가 이상한 게 있는데요...
이준혁: 뭐가 이상해?
김도연: onClick이 없어요! 그리고 className이 class로 바뀌었고, <!-- -->라는 이상한 게 들어가 있어요!
이준혁: 아주 좋은 관찰이야. 세 가지 다 중요한 포인트야. 하나씩 설명할게.
왜 onClick이 사라지는가
이준혁: 이게 SSR에서 가장 핵심적인 개념이야. 이유는 간단해: JavaScript 함수를 네트워크로 전송할 수 없다.
서버 메모리에 있는 것:
onClick = () => setCount(count + 1)
이건 "서버 메모리의 특정 주소"에 존재하는 함수야.
이 함수가 참조하는 setCount도 서버 메모리에 있고,
count 변수도 서버 메모리에 있어.네트워크로 보낼 수 있는 것:
문자열: "안녕하세요" ✅
숫자: 0 ✅
HTML: "<button>+1</button>" ✅
함수: () => setCount(...) ❌ ← 불가능!이준혁: HTML은 "텍스트 형식"이야. 숫자, 문자열은 텍스트로 표현 가능하지만, JavaScript 함수는 텍스트가 아니거든.
김도연: 근데 함수도 문자열로 바꿀 수 있잖아요? toString()하면...
이준혁: 좋은 질문이야. 함수를 문자열로 바꿀 순 있어. 하지만:
const fn = () => setCount(count + 1);
console.log(fn.toString());
// "() => setCount(count + 1)"
// 이 문자열을 받은 브라우저가 실행한다고 해봐:
// "setCount가 뭔데?" → ReferenceError
// 함수가 참조하는 컨텍스트(클로저)까지는 전달 불가이준혁: 함수의 "코드 텍스트"만으로는 안 돼. 함수가 참조하는 변수, 클로저, 컨텍스트가 전부 서버 메모리에 있으니까. 그래서 renderToString은 이벤트 핸들러를 아예 출력에서 제거해.
renderToString이 보존하는 것:
✅ HTML 태그 구조 (<div>, <h1>, <button>)
✅ 텍스트 내용 ("안녕하세요", "방문자 수:", "0", "+1")
✅ CSS 클래스 (className → class)
✅ HTML 속성 (id, style, data-*)
renderToString이 제거하는 것:
❌ onClick, onChange 등 이벤트 핸들러
❌ useEffect (브라우저 전용)
❌ useRef (DOM 참조, 브라우저 전용)
❌ 상태 변경 로직 (setState, dispatch)className이 class로 바뀌는 이유
이준혁: 이건 간단해.
React에서: className="container" (JavaScript 예약어 class 회피)
HTML에서: class="container" (표준 HTML 속성)
renderToString은 HTML을 만드는 거니까,
React의 className을 HTML의 class로 변환해.<!-- -->이 출력에 포함되는 이유
이준혁: 출력에서 이 부분을 다시 봐봐.
<p>방문자 수: <!-- -->0</p>김도연: 이건 HTML 주석 아닌가요? 왜 들어간 거예요?
이준혁: 이건 React가 텍스트 노드의 경계를 표시하기 위해 넣는 거야. Hydration 때 사용돼.
// JSX에서 이렇게 작성하면:
<p>방문자 수: {count}</p>
// React 내부적으로 2개의 텍스트 노드가 됨:
// 텍스트 노드 1: "방문자 수: "
// 텍스트 노드 2: "0"
// 하지만 HTML에서는:
<p>방문자 수: 0</p>
// 이건 하나의 텍스트 노드 "방문자 수: 0"으로 해석됨
// 그래서 React는 구분자를 넣어:
<p>방문자 수: <!-- -->0</p>
// 이러면 "방문자 수: "와 "0"이 분리된 채로 유지됨이준혁: 왜 분리되어야 하느냐면, Hydration할 때 React가 "이 텍스트 노드는 고정 텍스트고, 저 텍스트 노드는 count 상태값이니까 나중에 업데이트해야 해"를 구분해야 하거든. <!-- -->가 없으면 브라우저가 하나의 텍스트 노드로 합쳐버려서, React가 어디가 count인지 찾을 수 없어.
<!-- -->이 하는 역할:
서버 출력: "방문자 수: " + <!-- --> + "0"
고정 텍스트 상태값
↑
hydration 시 이 부분만 업데이트 대상
만약 <!-- -->가 없으면:
서버 출력: "방문자 수: 0"
하나의 텍스트 노드
→ React: "어디가 count지? 찾을 수 없음"JSX 없이 직접 실행해보기
이준혁: 이제 renderToString을 직접 실행할 수 있는 코드를 보여줄게. JSX는 빌드 도구가 필요하니까, React.createElement를 직접 사용할 거야.
김도연: JSX 없이도 할 수 있어요?
이준혁: JSX는 React.createElement의 **문법적 설탕(syntactic sugar)**이야. 아래 두 줄은 100% 동일해:
// JSX 버전
<h1 className="title">안녕하세요</h1>
// React.createElement 버전
React.createElement('h1', { className: 'title' }, '안녕하세요')// 더 복잡한 예시
// JSX:
<div className="container">
<h1>안녕하세요</h1>
<p>방문자 수: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
// React.createElement:
React.createElement('div', { className: 'container' },
React.createElement('h1', null, '안녕하세요'),
React.createElement('p', null, '방문자 수: ', count),
React.createElement('button', { onClick: () => setCount(count + 1) }, '+1')
)이준혁: 이제 이걸로 Node.js에서 바로 실행할 수 있는 코드를 만들어보자.
직접 실행 가능한 코드:
// run-render.mjs — Node.js에서 바로 실행 가능
// 실행: node run-render.mjs
import React, { useState } from 'react';
import { renderToString } from 'react-dom/server';
// JSX 없이 컴포넌트 정의
function HomePage() {
const [count, setCount] = useState(0);
return React.createElement('div', { className: 'container' },
React.createElement('h1', null, '안녕하세요'),
React.createElement('p', null, '방문자 수: ', count),
React.createElement('button', { onClick: () => setCount(count + 1) }, '+1')
);
}
// renderToString 실행
const html = renderToString(React.createElement(HomePage));
console.log('=== renderToString 출력 ===');
console.log(html);
console.log('');
console.log('=== 포맷팅된 출력 ===');
console.log(html
.replace(/></g, '>\n<')
.replace(/(<!-- -->)/g, '\n$1\n')
);실행 결과:
=== renderToString 출력 ===
<div class="container"><h1>안녕하세요</h1><p>방문자 수: <!-- -->0</p><button>+1</button></div>
=== 포맷팅된 출력 ===
<div class="container">
<h1>안녕하세요</h1>
<p>방문자 수:
<!-- -->
0</p>
<button>+1</button>
</div>useState의 초기값만 사용된다
이준혁: 한 가지 더 짚을 게 있어. useState(0)에서 초기값 0만 출력에 반영돼.
const [count, setCount] = useState(0);
// ↑
// 이 값만 HTML에 나타남
// setCount는? → 서버에서 호출할 일이 없으므로 무의미
// 실제로 setCount를 서버에서 호출하면?
// → 아무 일도 안 일어남 (렌더링은 이미 끝났으니까)이준혁: renderToString은 컴포넌트를 딱 한 번만 실행해. 상태 변경? 이벤트? 부수 효과? 전부 무시. 오직 현재 상태의 스냅샷만 HTML로 찍어내는 거야.
renderToString은 "사진"이다
React 컴포넌트 = 움직이는 앱 (영상)
renderToString = 스크린샷 (정지 화면)
스크린샷에는:
✅ 현재 화면에 보이는 것
❌ 버튼 클릭 가능
❌ 애니메이션 재생
❌ 상태 변경server.js에서 renderToString이 사용되는 맥락
이준혁: 마지막으로, renderToString의 결과가 실제 서버에서 어떻게 사용되는지 전체 흐름을 보자.
// server.js (A003 프로젝트)
import express from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server';
import HomePage from './react-pages/HomePage.jsx';
const app = express();
app.get('/', (req, res) => {
// 1. 컴포넌트를 HTML 문자열로 변환
const html = renderToString(React.createElement(HomePage));
// 2. 완전한 HTML 문서에 삽입하여 응답
res.send(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>React SSR - Home</title>
</head>
<body>
<div id="root">${html}</div>
<script src="/public/react-bundle.js"></script>
</body>
</html>`);
});전체 흐름:
브라우저 → GET / → Express 서버
│
│ renderToString(HomePage)
│ → "<div class="container">..."
│
│ HTML 템플릿에 삽입
│ → "<!DOCTYPE html>..."
│
▼
브라우저 ← 완성된 HTML ← Express 서버
사용자가 화면을 보는 시점:
┌──────────────────────┐
│ 안녕하세요 │ ← 보인다!
│ 방문자 수: 0 │ ← 보인다!
│ [+1] │ ← 보이지만 클릭해도 반응 없음
└──────────────────────┘
아직 JS 다운로드 전이므로 "죽은 HTML" 상태
→ 다음 단계: Hydration으로 "살리기" (04-hydration.md)핵심 정리
-
renderToString은 React 컴포넌트를 HTML 문자열로 변환한다. 그 이상도 이하도 아니다.
-
onClick 등 이벤트 핸들러는 출력에서 제거된다. JavaScript 함수(+클로저 컨텍스트)는 네트워크로 전송할 수 없기 때문이다.
-
<!-- -->는 텍스트 노드 경계 표시다. Hydration 시 React가 "어디가 상태값이고 어디가 고정 텍스트인지" 구분하는 데 사용한다. -
useState의 초기값만 반영된다. 상태 변경, 이벤트 처리, 부수 효과는 모두 무시된다. renderToString은 "스냅샷"이다.
-
JSX 없이 React.createElement로 직접 실행 가능하다. JSX는 빌드 도구가 변환해주는 문법적 설탕일 뿐이다.
-
서버에서 만든 이 "죽은 HTML"에 생명을 불어넣는 과정이 Hydration이다 (04-hydration.md에서 다룸).