2 / 3

02. renderToString의 실제 동작

예상 시간: 5분

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이 없어요! 그리고 classNameclass로 바뀌었고, <!-- -->라는 이상한 게 들어가 있어요!

이준혁: 아주 좋은 관찰이야. 세 가지 다 중요한 포인트야. 하나씩 설명할게.


왜 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)

핵심 정리

  1. renderToString은 React 컴포넌트를 HTML 문자열로 변환한다. 그 이상도 이하도 아니다.

  2. onClick 등 이벤트 핸들러는 출력에서 제거된다. JavaScript 함수(+클로저 컨텍스트)는 네트워크로 전송할 수 없기 때문이다.

  3. <!-- -->는 텍스트 노드 경계 표시다. Hydration 시 React가 "어디가 상태값이고 어디가 고정 텍스트인지" 구분하는 데 사용한다.

  4. useState의 초기값만 반영된다. 상태 변경, 이벤트 처리, 부수 효과는 모두 무시된다. renderToString은 "스냅샷"이다.

  5. JSX 없이 React.createElement로 직접 실행 가능하다. JSX는 빌드 도구가 변환해주는 문법적 설탕일 뿐이다.

  6. 서버에서 만든 이 "죽은 HTML"에 생명을 불어넣는 과정이 Hydration이다 (04-hydration.md에서 다룸).