2 / 2

05. entry-server.jsx와 entry-client.jsx

예상 시간: 5분

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.jsxentry-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.jsxentry-client.jsx
실행 환경Node.js (서버)브라우저
React 패키지react-dom/serverreact-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 호환도 신경 쓸게"라고 판단해.


핵심 정리

  1. SSR에서 같은 컴포넌트가 2번 실행된다. 서버에서 한 번(HTML 생성), 브라우저에서 한 번(Hydration).

  2. entry-server.jsx는 Node.js에서 실행되며, renderToString()으로 HTML 문자열을 만든다. react-dom/server를 사용한다.

  3. entry-client.jsx는 브라우저에서 실행되며, hydrateRoot()로 이벤트 핸들러를 연결한다. react-dom/client를 사용한다.

  4. JSX는 JavaScript가 아니다. 브라우저가 직접 이해할 수 없다. 빌드 도구가 React.createElement()로 변환해야 한다.

  5. JSX는 syntactic sugar다. <h1>안녕</h1>React.createElement('h1', null, '안녕')의 간편 표기법이다.

  6. 두 진입점은 합칠 수 없다. 서로 다른 환경(Node.js vs 브라우저)에서 실행되므로, 번들 크기와 최적화를 위해 반드시 분리해야 한다.