05. entry-server.jsx와 entry-client.jsx
05. entry-server.jsx와 entry-client.jsx
이 문서에서 배우는 것
- 같은 컴포넌트(
HomePage)가 서버와 클라이언트에서 2번 실행되는 구조 - entry-server.jsx의 역할: Node.js에서
renderToString()호출 - entry-client.jsx의 역할: 브라우저에서
hydrateRoot()호출 - JSX를 브라우저가 이해하지 못하는 이유
- 빌드가 필요한 이유: JSX →
React.createElement()변환 - JSX는 syntactic sugar라는 사실
PM 요청 (김도연)
김도연 PM: 준혁님, A003 프로젝트를 보니까 entry-server.jsx와 entry-client.jsx가 있더라고요. 둘 다 HomePage를 import하고 있는데, 왜 같은 컴포넌트를 두 곳에서 쓰는 건가요? 하나로 합칠 수 없나요?
이준혁 시니어: 합칠 수 없어. 이 두 파일은 완전히 다른 환경에서 실행되거든. 하나는 서버(Node.js), 하나는 브라우저. 왜 이런 구조인지 설명해줄게.
시니어 멘토링 (이준혁)
같은 컴포넌트가 2번 실행되는 구조
이준혁: SSR의 핵심 구조를 한 장으로 보여줄게.
┌─────────────────────────────────────────────────────┐
│ HomePage.jsx │
│ │
│ 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> │
│ ); │
│ } │
└──────────────────────┬──────────────────────────────┘
│
┌────────────┴────────────┐
▼ ▼
entry-server.jsx entry-client.jsx
(Node.js에서 실행) (브라우저에서 실행)
│ │
▼ ▼
renderToString() hydrateRoot()
HTML 문자열 생성 이벤트 핸들러 연결김도연: 두 파일이 같은 HomePage를 쓰지만 하는 일이 다른 거군요?
이준혁: 정확해. entry-server는 "스냅샷을 찍고", entry-client는 "생명을 불어넣어."
entry-server.jsx 분석
이준혁: 먼저 서버 쪽부터.
// entry-server.jsx (A003 프로젝트)
import React from 'react'
import { renderToString } from 'react-dom/server'
import HomePage from './HomePage.jsx'
export function render() {
return renderToString(<HomePage />);
}entry-server.jsx가 하는 일:
1. 'react-dom/server'에서 renderToString을 가져온다
(이 모듈은 서버 전용 — 브라우저에서는 사용 불가)
2. HomePage 컴포넌트를 import한다
3. render() 함수를 export한다
이 함수를 호출하면 HomePage의 HTML 문자열을 반환
4. 이 함수는 server.js(또는 test.js)에서 호출됨:
const { render } = await import('./dist/server/entry-server.js');
const appHtml = render();이준혁: 핵심 포인트는:
- 실행 환경: Node.js (서버)
- import 대상:
react-dom/server(서버 전용 패키지) - 하는 일: 컴포넌트 → HTML 문자열 (텍스트)
- 결과물:
"<div class=\"container\"><h1>안녕하세요</h1>..."
entry-client.jsx 분석
이준혁: 이번엔 클라이언트 쪽.
// entry-client.jsx (A003 프로젝트)
import React from 'react'
import { hydrateRoot } from 'react-dom/client'
import HomePage from './HomePage.jsx'
hydrateRoot(document.getElementById('root'), <HomePage />);entry-client.jsx가 하는 일:
1. 'react-dom/client'에서 hydrateRoot를 가져온다
(이 모듈은 브라우저 전용 — document.getElementById가 필요)
2. 같은 HomePage 컴포넌트를 import한다
3. document.getElementById('root')로 서버가 보낸 HTML 영역을 찾는다
4. hydrateRoot()로 그 HTML에 이벤트 핸들러를 연결한다이준혁: 핵심 포인트는:
- 실행 환경: 브라우저
- import 대상:
react-dom/client(브라우저 전용 패키지) - 하는 일: 기존 HTML DOM에 이벤트 핸들러 부착
- 결과물: "살아있는" 앱 (클릭 가능, 상태 변경 가능)
두 파일의 나란히 비교
| 속성 | entry-server.jsx | entry-client.jsx |
|---|---|---|
| 실행 환경 | Node.js (서버) | 브라우저 |
| React 패키지 | react-dom/server | react-dom/client |
| 핵심 함수 | renderToString() | hydrateRoot() |
| 입력 | <HomePage /> 컴포넌트 | <HomePage /> 컴포넌트 + DOM 참조 |
| 출력 | HTML 문자열 | 이벤트 핸들러가 연결된 DOM |
| 호출 시점 | 서버가 요청을 받을 때마다 | 브라우저가 JS를 로드한 후 1회 |
document 사용 | 불가 (서버에 DOM 없음) | 필수 (getElementById) |
onClick 처리 | 무시 (HTML에 포함 안 됨) | 연결 (DOM에 이벤트 부착) |
왜 합칠 수 없는가
김도연: 그래도... 하나의 파일에서 "서버면 renderToString, 브라우저면 hydrateRoot"처럼 분기하면 안 되나요?
이준혁: 이론적으로는 가능하지만, 실제로는 문제가 많아.
// 이렇게 합치면? (잘못된 접근)
import { renderToString } from 'react-dom/server'; // 서버 전용
import { hydrateRoot } from 'react-dom/client'; // 브라우저 전용
import HomePage from './HomePage.jsx';
if (typeof window === 'undefined') {
// 서버
export function render() {
return renderToString(<HomePage />);
}
} else {
// 브라우저
hydrateRoot(document.getElementById('root'), <HomePage />);
}왜 안 되는가:
1. 번들 크기 문제
브라우저 빌드에 react-dom/server가 포함됨 (불필요한 코드)
서버 빌드에 react-dom/client가 포함됨 (불필요한 코드)
2. Tree-shaking 실패
조건문 안의 import는 빌드 도구가 제거하기 어려움
→ 번들에 불필요한 코드가 남음
3. 정적 분석 불가
빌드 도구는 "이 파일은 서버용"/"이 파일은 클라이언트용"을
명확히 알아야 최적화 가능
→ 하나의 파일에 섞으면 최적화 불가
4. 가독성
서버 코드와 클라이언트 코드의 관심사가 다름
→ 분리하는 게 유지보수에 유리이준혁: 결론은, 진입점은 반드시 2개로 분리하는 게 표준이야. Next.js도 내부적으로는 서버용과 클라이언트용 진입점이 분리되어 있어.
JSX를 브라우저가 이해하지 못하는 이유
이준혁: 여기서 근본적인 질문 하나. <HomePage />라고 쓰잖아. 이걸 브라우저가 바로 이해할 수 있을까?
김도연: 당연히 되는 거 아닌가요? React가 해석하니까...
이준혁: 아니. 브라우저는 JSX를 전혀 이해하지 못해.
// 이 코드를 브라우저에서 바로 실행하면:
hydrateRoot(document.getElementById('root'), <HomePage />);
// 브라우저:
// SyntaxError: Unexpected token '<'
// "< 이게 뭔데? JavaScript에 < 다음에 태그명이 올 수 없어!"이준혁: 브라우저가 이해하는 건 순수 JavaScript야. <div>, <HomePage /> 같은 문법은 JavaScript 표준에 없어. HTML 태그처럼 보이지만, JavaScript 엔진은 이걸 파싱할 수 없어.
브라우저가 이해하는 것:
✅ const x = 1;
✅ function foo() {}
✅ document.getElementById('root')
✅ React.createElement('div', null, 'hello')
브라우저가 이해하지 못하는 것:
❌ <div>hello</div> ← JavaScript가 아님!
❌ <HomePage /> ← JavaScript가 아님!
❌ <button onClick={fn}> ← JavaScript가 아님!빌드가 필요한 이유: JSX → React.createElement 변환
이준혁: 그래서 빌드 과정이 필요한 거야. 빌드 도구(Vite, esbuild)가 JSX를 React.createElement로 변환해.
빌드 전 (개발자가 작성한 코드):
<div className="container">
<h1>안녕하세요</h1>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
빌드 후 (브라우저가 실행하는 코드):
React.createElement('div', { className: 'container' },
React.createElement('h1', null, '안녕하세요'),
React.createElement('button', { onClick: () => setCount(count + 1) }, '+1')
)JSX 변환 과정:
개발자 코드 (.jsx)
│
▼
[빌드 도구: Vite / esbuild / Babel]
│ JSX → React.createElement() 변환
│ import/export → 번들링
│ 최소화(minify)
▼
브라우저용 번들 (.js)
│
▼
브라우저에서 실행 가능!이준혁: A003 프로젝트에서 esbuild가 이 변환을 하는 부분을 봐봐.
// build.js에서 JSX 변환 설정
await esbuild.build({
entryPoints: ['./client/react-entry.jsx'],
outfile: './public/react-bundle.js',
bundle: true,
jsx: 'automatic', // ← JSX를 자동으로 React.createElement로 변환
jsxImportSource: 'react', // ← React의 jsx 함수를 사용
});jsx: 'automatic' 옵션의 의미:
'automatic' (React 17+):
import { jsx as _jsx } from 'react/jsx-runtime';
_jsx('div', { className: 'container', children: ... })
'transform' (React 16 이하):
React.createElement('div', { className: 'container' }, ...)
두 방식 모두 같은 결과. 'automatic'이 최신 방식.JSX는 Syntactic Sugar (문법적 설탕)
이준혁: JSX는 결국 React.createElement()를 편하게 쓰기 위한 **문법적 설탕(syntactic sugar)**이야. 완전히 동일한 코드야.
// JSX 버전 — 개발자가 작성하기 편한 형태
function HomePage() {
return (
<div className="container">
<h1>안녕하세요</h1>
<p>방문자 수: {count}</p>
</div>
);
}
// React.createElement 버전 — 100% 동일한 코드
function HomePage() {
return React.createElement('div', { className: 'container' },
React.createElement('h1', null, '안녕하세요'),
React.createElement('p', null, '방문자 수: ', count)
);
}김도연: 와, JSX 쪽이 훨씬 읽기 쉽네요.
이준혁: 그치. 그래서 JSX를 쓰는 거야. 하지만 브라우저가 실행하는 건 항상 React.createElement 버전이라는 걸 기억해.
syntactic sugar의 다른 예시:
JavaScript에서:
const { name, age } = person;
// sugar for:
const name = person.name;
const age = person.age;
JSX에서:
<h1>안녕</h1>
// sugar for:
React.createElement('h1', null, '안녕')
보기 좋게 쓰지만, 실제 동작은 변환된 코드가 한다.A003 프로젝트에서 entry 파일의 빌드 경로
이준혁: A003 프로젝트에서 entry 파일들이 어떤 경로로 빌드되는지 정리해볼게.
시스템 A (esbuild 기반):
client/react-entry.jsx ──[esbuild]──→ public/react-bundle.js
(브라우저 진입점) (브라우저가 다운로드)
server.js ──[esbuild]──→ dist/server.js
(서버 진입점) (Node.js가 실행)
(server.js 내부에서 직접 renderToString 호출)
시스템 B (Vite 기반):
entry-client.jsx ──[vite build]──→ dist/client/assets/entry-client-D6usOwnO.js
(브라우저 진입점) (브라우저가 다운로드)
entry-server.jsx ──[vite build --ssr]──→ dist/server/entry-server.js
(서버 진입점) (test.js에서 import하여 사용)시스템 A vs 시스템 B의 entry 구조 차이:
시스템 A (esbuild):
서버 진입점이 server.js 자체에 통합됨
server.js가 직접 renderToString 호출
→ entry-server가 별도 파일로 분리되지 않음
시스템 B (Vite):
entry-server.jsx가 명확히 분리됨
render() 함수를 export하고, test.js에서 import하여 호출
→ 관심사가 깔끔하게 분리됨빌드 명령어: 서버용과 클라이언트용
이준혁: package.json에 있는 Vite 빌드 명령어를 봐봐.
{
"scripts": {
"build:client": "vite build --outDir dist/client",
"build:server": "vite build --outDir dist/server --ssr entry-server.jsx"
}
}vite build --outDir dist/client
→ vite.config.js의 rollupOptions.input을 사용 (entry-client.jsx)
→ 브라우저용 번들 생성
→ manifest.json 생성
vite build --outDir dist/server --ssr entry-server.jsx
→ --ssr 플래그: "이 파일은 서버용으로 빌드해"
→ Node.js용 번들 생성 (external 처리 등)
→ entry-server.jsx의 export function render()를 보존이준혁: --ssr 플래그가 핵심이야. 이게 있으면 Vite가 "아, 서버용이구나. node_modules는 번들에 넣지 말고, CommonJS 호환도 신경 쓸게"라고 판단해.
핵심 정리
-
SSR에서 같은 컴포넌트가 2번 실행된다. 서버에서 한 번(HTML 생성), 브라우저에서 한 번(Hydration).
-
entry-server.jsx는 Node.js에서 실행되며,
renderToString()으로 HTML 문자열을 만든다.react-dom/server를 사용한다. -
entry-client.jsx는 브라우저에서 실행되며,
hydrateRoot()로 이벤트 핸들러를 연결한다.react-dom/client를 사용한다. -
JSX는 JavaScript가 아니다. 브라우저가 직접 이해할 수 없다. 빌드 도구가
React.createElement()로 변환해야 한다. -
JSX는 syntactic sugar다.
<h1>안녕</h1>은React.createElement('h1', null, '안녕')의 간편 표기법이다. -
두 진입점은 합칠 수 없다. 서로 다른 환경(Node.js vs 브라우저)에서 실행되므로, 번들 크기와 최적화를 위해 반드시 분리해야 한다.