2 / 2

07. A003 프로젝트 코드 분석

예상 시간: 5분

07. A003 프로젝트 코드 분석

이 문서에서 배우는 것

  • A003 프로젝트에 2개의 SSR 시스템이 공존하는 구조
  • 시스템 A (esbuild 기반, 완성): build.js → server.js, React + Svelte SSR
  • 시스템 B (Vite 기반, 미완성): vite.config.js → test.js, React SSR만
  • build.js의 4단계 빌드 분석
  • 파일별 역할과 실행 방법
  • "같은 컴포넌트가 서버용과 브라우저용으로 2번 빌드됨"의 실제 코드

PM 요청 (김도연)

김도연 PM: 준혁님, A003 프로젝트 폴더를 열어보니까 파일이 꽤 많더라고요. server.js도 있고 test.js도 있고, build.js도 있고 vite.config.js도 있고... 어떤 게 뭘 하는 건지 정리해주실 수 있나요?

이준혁 시니어: 맞아, 이 프로젝트가 좀 특이해. 사실 2개의 독립된 SSR 시스템이 한 폴더에 공존하고 있거든. 하나씩 분리해서 설명해줄게.


시니어 멘토링 (이준혁)

프로젝트 전체 구조

이준혁: 먼저 파일 전체를 보자.

A003-Express-based-React-Svelte/
├── package.json            ← 프로젝트 설정, 두 시스템의 스크립트 모두 포함

├── ─── 시스템 A (esbuild 기반, 완성) ───
├── build.js                ← esbuild 빌드 스크립트 (4단계)
├── server.js               ← Express 서버 (React + Svelte SSR)
├── react-pages/
│   └── HomePage.jsx        ← React 페이지 컴포넌트
├── svelte-pages/
│   ├── About.svelte        ← Svelte 페이지 컴포넌트
│   └── build/
│       └── About.js        ← Svelte SSR 빌드 결과물
├── client/
│   ├── react-entry.jsx     ← React 클라이언트 진입점
│   └── svelte-entry.js     ← Svelte 클라이언트 진입점
├── public/                 ← esbuild 클라이언트 빌드 결과물
│   ├── react-bundle.js
│   └── svelte-bundle.js
├── dist/
│   └── server.js           ← esbuild 서버 빌드 결과물

├── ─── 시스템 B (Vite 기반, 미완성) ───
├── vite.config.js          ← Vite 빌드 설정
├── test.js                 ← Vite용 Express 서버
├── entry-server.jsx        ← Vite용 서버 진입점
├── entry-client.jsx        ← Vite용 클라이언트 진입점
├── HomePage.jsx            ← Vite용 React 컴포넌트 (간소화 버전)
├── HomePage.js             ← Vite용 React 컴포넌트 (동일)
└── dist/
    ├── client/             ← Vite 클라이언트 빌드 결과물
    │   ├── assets/
    │   │   └── entry-client-D6usOwnO.js
    │   └── .vite/
    │       └── manifest.json
    └── server/             ← Vite 서버 빌드 결과물
        └── entry-server.js

시스템 A vs 시스템 B 요약

비교 항목시스템 A (esbuild)시스템 B (Vite)
빌드 도구esbuild (직접 API 호출)Vite (CLI)
빌드 파일build.js (68줄)vite.config.js (12줄)
서버 파일server.jstest.js
지원 프레임워크React + SvelteReact만
완성도완성 (실행 가능)미완성 (실험적)
manifest없음 (파일명 고정)있음 (해시 파일명)
캐시 무효화안 됨
실행 명령npm run devnpm run build && node test.js

시스템 A: esbuild 기반 (완성)

이준혁: 시스템 A가 프로젝트의 메인이야. esbuild로 4단계 빌드를 하고, Express로 React + Svelte SSR을 제공해.

build.js 4단계 분석

이준혁: build.js를 단계별로 뜯어볼게.

1단계: Svelte SSR 컴포넌트 빌드

// build.js — 1단계
// Svelte 컴포넌트를 서버용으로 빌드
console.log('1/4  Svelte SSR component');
await esbuild.build({
  entryPoints: ['./svelte-pages/About.svelte'],
  outfile: './svelte-pages/build/About.js',
  bundle: true,
  format: 'esm',       // ES 모듈 형식
  platform: 'node',     // Node.js에서 실행
  plugins: [
    sveltePlugin({
      compilerOptions: {
        generate: 'ssr',        // ← 서버 렌더링용 코드 생성
        hydratable: true,       // ← 클라이언트에서 hydration 가능하게
      },
    }),
  ],
});
1단계가 하는 일:
 
  입력:  svelte-pages/About.svelte (Svelte 컴포넌트)
  처리:  Svelte 컴파일러가 generate: 'ssr' 모드로 변환
  출력:  svelte-pages/build/About.js (Node.js에서 import 가능)
 
  변환 결과:
    About.svelte의 <script>, <main>, <style>이
    → .render() 메서드를 가진 JavaScript 모듈로 변환
    → server.js에서 AboutComponent.render()로 호출 가능

2단계: Express 서버 번들

// build.js — 2단계
// server.js와 의존성을 하나의 번들로
console.log('2/4  Server bundle');
await esbuild.build({
  entryPoints: ['./server.js'],
  outfile: './dist/server.js',
  bundle: true,
  format: 'esm',
  platform: 'node',
  packages: 'external',    // ← node_modules는 번들에 포함하지 않음
  jsx: 'automatic',        // ← JSX 자동 변환
  jsxImportSource: 'react',
});
2단계가 하는 일:
 
  입력:  server.js (Express 서버 + React SSR 코드)
  처리:  server.js와 내부 import들(react-pages/HomePage.jsx 등)을 번들링
         node_modules(express, react 등)는 external로 제외
  출력:  dist/server.js (실행 가능한 서버 번들)
 
  packages: 'external'의 의미:
    express, react, react-dom 등은 node_modules에 있으니까
    번들에 넣지 말고 런타임에 require/import하라는 뜻
    → 번들 크기 최소화

3단계: React 클라이언트 번들

// build.js — 3단계
// 브라우저에서 실행할 React 번들
console.log('3/4  React client bundle');
await esbuild.build({
  entryPoints: ['./client/react-entry.jsx'],
  outfile: './public/react-bundle.js',
  bundle: true,
  format: 'iife',          // ← 브라우저용 즉시 실행 함수
  platform: 'browser',      // ← 브라우저 환경
  jsx: 'automatic',
  jsxImportSource: 'react',
  minify: true,             // ← 코드 압축
});
3단계가 하는 일:
 
  입력:  client/react-entry.jsx
         → import HomePage from '../react-pages/HomePage.jsx'
         → hydrateRoot(document.getElementById('root'), <HomePage />)
 
  처리:  react-entry.jsx + HomePage.jsx + react + react-dom을 하나로 번들링
         JSX → React.createElement 변환
         코드 압축(minify)
 
  출력:  public/react-bundle.js (브라우저가 다운로드하는 단일 파일)
 
  format: 'iife'의 의미:
    (function() { /* 모든 코드 */ })();
    즉시 실행 함수로 감싸서 전역 스코프 오염 방지
    <script src="..."> 태그로 로드하면 자동 실행

4단계: Svelte 클라이언트 번들

// build.js — 4단계
// 브라우저에서 실행할 Svelte 번들
console.log('4/4  Svelte client bundle');
await esbuild.build({
  entryPoints: ['./client/svelte-entry.js'],
  outfile: './public/svelte-bundle.js',
  bundle: true,
  format: 'iife',
  platform: 'browser',
  plugins: [
    sveltePlugin({
      compilerOptions: {
        generate: 'dom',         // ← 브라우저 DOM 조작용 코드 생성
        hydratable: true,        // ← 서버 HTML과 hydration 가능
      },
    }),
  ],
  minify: true,
});
4단계가 하는 일:
 
  입력:  client/svelte-entry.js
         → import About from '../svelte-pages/About.svelte'
         → new About({ target: document.getElementById('root'), hydrate: true })
 
  처리:  Svelte 컴파일러가 generate: 'dom' 모드로 변환
         → 브라우저에서 DOM 조작 가능한 JavaScript 생성
 
  출력:  public/svelte-bundle.js (브라우저가 다운로드)
 
  1단계와 4단계 비교:
    1단계: generate: 'ssr'  → 서버용 (HTML 문자열 생성)
    4단계: generate: 'dom'  → 브라우저용 (DOM 조작 + hydration)
    같은 About.svelte가 2번 빌드됨!

4단계 빌드 전체 흐름 다이어그램

build.js 실행 흐름:
 
  ┌──────────────────────────────────────────────────────┐
  │ 1단계: Svelte SSR                                     │
  │   About.svelte ──[generate:'ssr']──→ build/About.js  │
  │   (서버에서 .render() 호출용)                         │
  └──────────────────────┬───────────────────────────────┘

  ┌──────────────────────────────────────────────────────┐
  │ 2단계: Server Bundle                                  │
  │   server.js ──[esbuild]──→ dist/server.js            │
  │   (1단계의 About.js를 import하는 서버)                │
  └──────────────────────┬───────────────────────────────┘

  ┌──────────────────────────────────────────────────────┐
  │ 3단계: React Client                                   │
  │   react-entry.jsx ──[esbuild]──→ public/react-bundle │
  │   (hydrateRoot로 React hydration)                     │
  └──────────────────────┬───────────────────────────────┘

  ┌──────────────────────────────────────────────────────┐
  │ 4단계: Svelte Client                                  │
  │   svelte-entry.js ──[generate:'dom']──→ public/svelte│
  │   (hydrate: true로 Svelte hydration)                  │
  └──────────────────────────────────────────────────────┘

server.js 코드 분석

이준혁: 빌드가 끝나면 dist/server.js가 실행돼. 원본 server.js를 보자.

// server.js (A003 프로젝트)
import express from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server';
import HomePage from './react-pages/HomePage.jsx';
import AboutComponent from './svelte-pages/build/About.js';
 
const app = express();
const PORT = 4001;
 
// 정적 파일 서빙 (클라이언트 번들)
app.use('/public', express.static('public'));
 
// ── React SSR ──────────────────────────
app.get('/', (req, res) => {
  // React 컴포넌트 → HTML 문자열
  const html = renderToString(React.createElement(HomePage));
 
  res.send(`<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>React SSR - Home</title>
</head>
<body>
  <div id="root">${html}</div>
  <script src="/public/react-bundle.js"></script>
</body>
</html>`);
});
 
// ── Svelte SSR ─────────────────────────
app.get('/about', (req, res) => {
  // Svelte 컴포넌트 → HTML + CSS
  const { html, css } = AboutComponent.render();
 
  res.send(`<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Svelte SSR - About</title>
  <style>${css.code}</style>
</head>
<body>
  <div id="root">${html}</div>
  <script src="/public/svelte-bundle.js"></script>
</body>
</html>`);
});
 
app.listen(PORT, () => {
  console.log(`Server running at http://localhost:${PORT}`);
  console.log(`  React:  http://localhost:${PORT}/`);
  console.log(`  Svelte: http://localhost:${PORT}/about`);
});
server.js가 하는 일:
 
  GET /       → React SSR
                renderToString(HomePage)
                + <script src="/public/react-bundle.js">
 
  GET /about  → Svelte SSR
                AboutComponent.render()
                + <style> (Svelte CSS 주입)
                + <script src="/public/svelte-bundle.js">
 
  React와 Svelte의 SSR 차이:
    React:   renderToString(React.createElement(HomePage))
             → HTML 문자열만 반환
 
    Svelte:  AboutComponent.render()
             → { html, css } 객체 반환 (CSS도 함께!)

클라이언트 진입점 코드 분석

이준혁: 서버가 HTML을 보내면, 브라우저에서 실행되는 코드를 보자.

// client/react-entry.jsx — React Hydration
import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import HomePage from '../react-pages/HomePage.jsx';
 
hydrateRoot(document.getElementById('root'), <HomePage />);
// 서버가 보낸 HTML의 <div id="root">에
// HomePage의 이벤트 핸들러를 연결
// client/svelte-entry.js — Svelte Hydration
import About from '../svelte-pages/About.svelte';
 
new About({
  target: document.getElementById('root'),
  hydrate: true,   // ← "기존 HTML을 재사용해" (hydrateRoot와 같은 역할)
});
React vs Svelte의 Hydration 코드 비교:
 
  React:
    hydrateRoot(element, <Component />)
    → react-dom/client의 함수 호출
 
  Svelte:
    new Component({ target: element, hydrate: true })
    → 컴포넌트 인스턴스 생성 시 hydrate 옵션
 
  같은 목적(서버 HTML에 이벤트 연결), 다른 API

시스템 B: Vite 기반 (미완성)

이준혁: 시스템 B는 "같은 결과를 Vite로 달성하면 얼마나 간단해지는지" 실험한 거야. React SSR만 포함되어 있고, Svelte는 아직 안 했어.

Vite 빌드 설정

// vite.config.js — Vite 설정 (12줄)
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
 
export default defineConfig({
  plugins: [react()],
  build: {
    manifest: true,
    rollupOptions: {
      input: 'entry-client.jsx',
    }
  }
})

Vite용 진입점 파일

// entry-server.jsx — 서버 진입점 (Vite)
import React from 'react'
import { renderToString } from 'react-dom/server'
import HomePage from './HomePage.jsx'
 
export function render() {
  return renderToString(<HomePage />);
}
// entry-client.jsx — 클라이언트 진입점 (Vite)
import React from 'react'
import { hydrateRoot } from 'react-dom/client'
import HomePage from './HomePage.jsx'
 
hydrateRoot(document.getElementById('root'), <HomePage />);

Vite용 서버

// test.js — Vite용 Express 서버
import express from 'express';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
 
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
 
// Vite 빌드 결과물 서빙
app.use(express.static(path.join(__dirname, 'dist/client')));
 
// 서버 빌드 결과물 로드
const { render } = await import('./dist/server/entry-server.js');
 
// manifest.json으로 클라이언트 번들 경로 조회
const manifest = JSON.parse(
  fs.readFileSync(
    path.join(__dirname, 'dist/client/.vite/manifest.json'),
    'utf-8'
  )
);
const clientBundle = manifest['entry-client.jsx'].file;
 
app.get('/', (req, res) => {
  const appHtml = render();
 
  res.send(`<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>React SSR - Home</title>
</head>
<body>
  <div id="root">${appHtml}</div>
  <script type="module" src="/${clientBundle}"></script>
</body>
</html>`);
});
 
const PORT = 3600;
app.listen(PORT, () => {
  console.log(`Server running at http://localhost:${PORT}`);
});
시스템 A(server.js) vs 시스템 B(test.js) 차이:
 
  server.js (esbuild):
    - renderToString을 직접 호출
    - <script src="/public/react-bundle.js"> 하드코딩
    - React + Svelte 모두 지원
 
  test.js (Vite):
    - render() 함수를 import (entry-server.js에서)
    - manifest.json에서 clientBundle 경로 조회
    - <script type="module" src="/${clientBundle}"> 동적
    - React만 지원
 
  핵심 차이: manifest를 통한 파일명 조회

파일별 역할 표

파일시스템역할실행 환경
build.jsAesbuild 빌드 스크립트 (4단계)Node.js (빌드 시)
server.jsAExpress 서버 (React + Svelte SSR)Node.js (런타임)
react-pages/HomePage.jsxAReact 페이지 컴포넌트 (풀 버전)서버 + 브라우저
svelte-pages/About.svelteASvelte 페이지 컴포넌트서버 + 브라우저
client/react-entry.jsxAReact Hydration 진입점브라우저
client/svelte-entry.jsASvelte Hydration 진입점브라우저
vite.config.jsBVite 빌드 설정Node.js (빌드 시)
test.jsBVite용 Express 서버Node.js (런타임)
entry-server.jsxBVite용 서버 진입점Node.js (런타임)
entry-client.jsxBVite용 클라이언트 진입점브라우저
HomePage.jsx (루트)BReact 컴포넌트 (간소화 버전)서버 + 브라우저
package.json공통의존성 + 빌드 스크립트-

실행 방법

이준혁: 실제로 실행하는 방법을 알려줄게.

시스템 A 실행 (esbuild)

# 1. 의존성 설치
npm install
 
# 2. 빌드 + 서버 시작 (한 번에)
npm run dev
# → node build.js && node dist/server.js
 
# 3. 브라우저에서 확인
#   React:  http://localhost:4001/
#   Svelte: http://localhost:4001/about
npm run dev 실행 시 출력:
 
  Building...
 
  1/4  Svelte SSR component
  2/4  Server bundle
  3/4  React client bundle
  4/4  Svelte client bundle
 
  Build complete! Run: npm start
  Server running at http://localhost:4001
    React:  http://localhost:4001/
    Svelte: http://localhost:4001/about

시스템 B 실행 (Vite)

# 1. 클라이언트 빌드
npm run build:client
# → vite build --outDir dist/client
 
# 2. 서버 빌드
npm run build:server
# → vite build --outDir dist/server --ssr entry-server.jsx
 
# 3. 서버 시작
node test.js
 
# 4. 브라우저에서 확인
#   React: http://localhost:3600/

핵심: 같은 컴포넌트가 2번 빌드되는 것

이준혁: 이 프로젝트에서 가장 중요한 패턴을 정리할게. 같은 컴포넌트가 서버용과 브라우저용으로 2번 빌드된다는 거야.

HomePage.jsx가 빌드되는 경로:
 
[시스템 A — esbuild]
 
  react-pages/HomePage.jsx

       ├──[2단계: 서버 빌드]──→ dist/server.js에 포함
       │    server.js가 import하고
       │    renderToString(React.createElement(HomePage)) 호출

       └──[3단계: 클라이언트 빌드]──→ public/react-bundle.js에 포함
            react-entry.jsx가 import하고
            hydrateRoot(element, <HomePage />) 호출
 
 
[시스템 B — Vite]
 
  HomePage.jsx (루트)

       ├──[build:server]──→ dist/server/entry-server.js에 포함
       │    entry-server.jsx가 import하고
       │    renderToString(<HomePage />) 호출

       └──[build:client]──→ dist/client/assets/entry-client-D6usOwnO.js에 포함
            entry-client.jsx가 import하고
            hydrateRoot(element, <HomePage />) 호출
About.svelte가 빌드되는 경로 (시스템 A만):
 
  svelte-pages/About.svelte

       ├──[1단계: SSR 빌드]──→ svelte-pages/build/About.js
       │    generate: 'ssr'
       │    server.js가 AboutComponent.render() 호출

       └──[4단계: 클라이언트 빌드]──→ public/svelte-bundle.js에 포함
            generate: 'dom'
            svelte-entry.js가 new About({ hydrate: true }) 호출

이준혁: 이게 SSR의 본질이야. 하나의 컴포넌트, 두 개의 역할.

하나의 컴포넌트, 두 개의 역할:
 
  ┌─────────────────────────────────────────┐
  │           HomePage.jsx                   │
  │                                          │
  │  function HomePage() {                   │
  │    const [count, setCount] = useState(0);│
  │    return <button onClick={...}>+1</button>│
  │  }                                       │
  └──────────────┬──────────────────────────┘

     ┌───────────┴───────────┐
     ▼                       ▼
  서버 역할                 클라이언트 역할
  "HTML 스냅샷 생성"        "HTML에 생명 부여"
     │                       │
  renderToString()          hydrateRoot()
     │                       │
     ▼                       ▼
  HTML 문자열               살아있는 앱
  (onClick 없음)            (onClick 동작)
  (useState = 0 고정)       (useState 변경 가능)

package.json 분석

이준혁: 마지막으로, 두 시스템의 스크립트가 어떻게 공존하는지.

{
  "name": "express-react-svelte-ssr",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "dev": "node build.js && node dist/server.js",
    "build": "npm run build:client && npm run build:server",
    "build:client": "vite build --outDir dist/client",
    "build:server": "vite build --outDir dist/server --ssr entry-server.jsx"
  },
  "dependencies": {
    "express": "^4.21.0",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "svelte": "^4.2.18"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^5.1.3",
    "esbuild": "^0.24.0",
    "esbuild-svelte": "^0.8.1",
    "vite": "^7.3.1"
  }
}
scripts 분석:
 
  "start"        → 시스템 A의 원본 서버 직접 실행 (빌드 없이)
  "dev"          → 시스템 A: esbuild 빌드 후 서버 시작
  "build"        → 시스템 B: Vite로 클라이언트 + 서버 빌드
  "build:client" → 시스템 B: Vite 클라이언트 빌드
  "build:server" → 시스템 B: Vite 서버 빌드
 
dependencies:
  express     → HTTP 서버 프레임워크
  react       → UI 컴포넌트 라이브러리
  react-dom   → React의 DOM 렌더링 (server + client)
  svelte      → Svelte 컴파일러 + 런타임
 
devDependencies:
  @vitejs/plugin-react → Vite에서 React 지원 (JSX 변환, Fast Refresh)
  esbuild              → 저수준 번들러 (시스템 A)
  esbuild-svelte       → esbuild에서 Svelte 지원 플러그인
  vite                 → 고수준 빌드 도구 (시스템 B)
 
"type": "module" → ES 모듈 사용 (import/export)

핵심 정리

  1. A003 프로젝트에는 2개의 SSR 시스템이 공존한다. 시스템 A(esbuild, 완성)와 시스템 B(Vite, 미완성).

  2. 시스템 A의 build.js는 4단계로 빌드한다: (1) Svelte SSR, (2) 서버 번들, (3) React 클라이언트, (4) Svelte 클라이언트.

  3. 시스템 B의 Vite는 같은 작업을 vite.config.js 12줄 + CLI 명령어 2개로 수행한다. manifest.json으로 캐시 무효화도 자동.

  4. 같은 컴포넌트가 서버용과 브라우저용으로 2번 빌드된다. 서버에서는 renderToString, 브라우저에서는 hydrateRoot를 호출한다.

  5. 실행 방법: 시스템 A는 npm run dev (빌드+서버 한번에), 시스템 B는 npm run build && node test.js.

  6. 이 프로젝트는 esbuild로 원리를 이해하고, Vite로 생산성을 높이는 학습 경로를 보여준다. 원리를 알아야 도구를 제대로 쓸 수 있다.