07. A003 프로젝트 코드 분석
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.js | test.js |
| 지원 프레임워크 | React + Svelte | React만 |
| 완성도 | 완성 (실행 가능) | 미완성 (실험적) |
| manifest | 없음 (파일명 고정) | 있음 (해시 파일명) |
| 캐시 무효화 | 안 됨 | 됨 |
| 실행 명령 | npm run dev | npm 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.js | A | esbuild 빌드 스크립트 (4단계) | Node.js (빌드 시) |
server.js | A | Express 서버 (React + Svelte SSR) | Node.js (런타임) |
react-pages/HomePage.jsx | A | React 페이지 컴포넌트 (풀 버전) | 서버 + 브라우저 |
svelte-pages/About.svelte | A | Svelte 페이지 컴포넌트 | 서버 + 브라우저 |
client/react-entry.jsx | A | React Hydration 진입점 | 브라우저 |
client/svelte-entry.js | A | Svelte Hydration 진입점 | 브라우저 |
vite.config.js | B | Vite 빌드 설정 | Node.js (빌드 시) |
test.js | B | Vite용 Express 서버 | Node.js (런타임) |
entry-server.jsx | B | Vite용 서버 진입점 | Node.js (런타임) |
entry-client.jsx | B | Vite용 클라이언트 진입점 | 브라우저 |
HomePage.jsx (루트) | B | React 컴포넌트 (간소화 버전) | 서버 + 브라우저 |
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/aboutnpm 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)핵심 정리
-
A003 프로젝트에는 2개의 SSR 시스템이 공존한다. 시스템 A(esbuild, 완성)와 시스템 B(Vite, 미완성).
-
시스템 A의 build.js는 4단계로 빌드한다: (1) Svelte SSR, (2) 서버 번들, (3) React 클라이언트, (4) Svelte 클라이언트.
-
시스템 B의 Vite는 같은 작업을 vite.config.js 12줄 + CLI 명령어 2개로 수행한다. manifest.json으로 캐시 무효화도 자동.
-
같은 컴포넌트가 서버용과 브라우저용으로 2번 빌드된다. 서버에서는
renderToString, 브라우저에서는hydrateRoot를 호출한다. -
실행 방법: 시스템 A는
npm run dev(빌드+서버 한번에), 시스템 B는npm run build && node test.js. -
이 프로젝트는 esbuild로 원리를 이해하고, Vite로 생산성을 높이는 학습 경로를 보여준다. 원리를 알아야 도구를 제대로 쓸 수 있다.