Step 01: SSR의 본질 — Express 수동 배관 vs Next.js 자동 배관
Step 01: SSR의 본질 — Express 수동 배관 vs Next.js 자동 배관
PM 요청 (김도연)
그래요 나에요
김도연 PM: 안녕하세요, 준혁님! A003 Express SSR 프로젝트를 만들어서 잘 돌아가고 있는데요, 문득 궁금한 게 생겼어요. Next.js도 SSR을 한다고 하잖아요? 우리가 만든 것과 Next.js는 어떻게 다른 건가요? 둘을 나란히 비교해서 볼 수 있을까요?
이준혁 시니어: 오, 좋은 질문이야! A003 프로젝트를 제대로 이해했다면 Next.js가 왜 필요한지도 자연스럽게 보일 거야. 지금부터 둘을 한번 해부해볼까?
시니어 멘토링 (이준혁)
질문 1: SSR이 뭐라고 생각해?
이준혁: 먼저 물어볼게. 도연 PM은 SSR이 정확히 뭐라고 생각해?
김도연: 음... 서버에서 HTML을 만들어서 보내주는 거 아닌가요?
이준혁: 반만 맞았어. SSR은 사실 3단계 프로세스야:
SSR의 3단계 본질
┌─────────────────────────────────────────────────────────────┐
│ 1단계: Server — Component → HTML string │
│ • React/Svelte 컴포넌트를 HTML 문자열로 변환 │
│ • renderToString() / Component.render() │
│ │
│ 2단계: Transfer — HTML + JS bundle 전송 │
│ • 브라우저에 HTML과 JS 번들을 함께 전송 │
│ • 사용자는 HTML을 먼저 보고, JS는 비동기로 로드 │
│ │
│ 3단계: Client — Hydration (HTML + JS 결합) │
│ • 정적 HTML에 이벤트 핸들러와 상태를 "주입" │
│ • hydrateRoot() / new Component() │
└─────────────────────────────────────────────────────────────┘이준혁: 이 3단계가 모두 정상 작동해야 비로소 "SSR이 성공했다"고 말할 수 있어. 그럼 A003에서 이 3단계가 어떻게 구현되어 있는지 볼까?
질문 2: A003에서 이 3단계를 어떻게 구현했지?
김도연: 음... server.js에서 renderToString을 쓰고, script 태그로 번들을 넣고, 그다음에... 뭐가 있었더라?
이준혁: 정확해! 하나씩 코드로 확인해보자.
Express SSR 배관 분석 (A003 기반)
1단계: Server — Component → HTML string
이준혁: 먼저 server.js를 봐봐. 여기가 1단계야.
// server.js
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) => {
// ✅ 1단계: 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) => {
// ✅ 1단계: Svelte 컴포넌트를 HTML 문자열로 변환
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}`);
});이준혁: 보이지? 여기서 우리가 직접 한 일들:
- 수동으로 HTML 템플릿 작성 (
<!DOCTYPE html>...) - 수동으로
${html}위치 지정 (어디에 컴포넌트를 삽입할지) - 수동으로
<script>태그 삽입 (어떤 JS 번들을 로드할지) - 수동으로 라우트 등록 (
app.get('/', ...),app.get('/about', ...))
이게 바로 **"수동 배관"**이야. 모든 파이프를 개발자가 직접 연결해야 해.
2단계: Transfer — HTML + JS bundle 전송
김도연: 그럼 2단계는요?
이준혁: 2단계는 res.send()가 하는 거야. HTML 문자열과 함께 <script src="/public/react-bundle.js"> 태그가 포함되어 있지? 브라우저가 이 HTML을 받으면:
- 즉시 HTML을 렌더링 (사용자가 화면을 본다)
- 비동기로 JS 번들 다운로드 (
/public/react-bundle.js) - JS 번들 실행 → 3단계 시작
근데 이 JS 번들은 어디서 온 거지?
빌드 파이프라인 — 4단계 수동 설정
김도연: 아, build.js에서 만든 거죠?
이준혁: 맞아! build.js를 보면 4개의 빌드 단계가 있어.
// build.js
import esbuild from 'esbuild';
import sveltePlugin from 'esbuild-svelte';
import { mkdirSync } from 'fs';
// 디렉토리 생성
mkdirSync('./svelte-pages/build', { recursive: true });
mkdirSync('./public', { recursive: true });
mkdirSync('./dist', { recursive: true });
console.log('Building...\n');
// ✅ 1/4: Svelte SSR 컴포넌트 빌드
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',
platform: 'node', // ← 서버용
plugins: [
sveltePlugin({
compilerOptions: { generate: 'ssr', hydratable: true },
}),
],
});
// ✅ 2/4: 서버 번들 빌드
console.log('2/4 Server bundle');
await esbuild.build({
entryPoints: ['./server.js'],
outfile: './dist/server.js',
bundle: true,
format: 'esm',
platform: 'node', // ← 서버용
packages: 'external',
jsx: 'automatic',
jsxImportSource: 'react',
});
// ✅ 3/4: 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,
});
// ✅ 4/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', hydratable: true },
}),
],
minify: true,
});
console.log('\nBuild complete! Run: npm start');이준혁: 보이지? 우리가 직접:
- SSR용 Svelte 컴포넌트 빌드 (서버에서 실행 가능하도록)
- 서버 코드 번들링 (Express 서버 전체를 하나의 파일로)
- React 클라이언트 번들 (브라우저에서 Hydration용)
- Svelte 클라이언트 번들 (브라우저에서 Hydration용)
이렇게 4개의 빌드 설정을 수동으로 작성했어. 총 69줄이야.
3단계: Client — Hydration (HTML + JS 결합)
김도연: 그럼 마지막 3단계는요?
이준혁: 3단계는 client/react-entry.jsx에서 일어나.
// client/react-entry.jsx
import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import HomePage from '../react-pages/HomePage.jsx';
// ✅ 3단계: 서버가 보낸 HTML에 React를 "주입"
hydrateRoot(document.getElementById('root'), <HomePage />);이준혁: 이 코드가 하는 일:
- 서버가 만든 HTML을 찾는다 (
document.getElementById('root')) - 같은 컴포넌트(
HomePage)를 다시 렌더링한다 (가상 DOM 생성) - 서버 HTML과 클라이언트 가상 DOM을 비교한다
- 이벤트 핸들러만 추가한다 (HTML은 그대로 유지)
이게 바로 Hydration이야. 정적 HTML에 생명을 불어넣는 거지.
질문 3: 이게 다 수동이라고?
김도연: 와... 생각보다 복잡하네요. 이걸 매번 다 작성해야 하나요?
이준혁: 그렇지! 새 페이지를 추가하려면:
server.js에app.get('/new-page', ...)라우트 추가- HTML 템플릿 수동 작성
<script>태그 수동 삽입client/new-page-entry.jsx생성build.js에 새 빌드 설정 추가
이게 **"Express SSR 수동 배관"**의 현실이야. 모든 것을 직접 제어할 수 있지만, 반복 작업이 많아.
Next.js 자동 배관 확인
김도연: 그럼 Next.js는 이게 어떻게 다른 건가요?
이준혁: Next.js는 이 모든 걸 자동화해. 코드를 보면 바로 알 거야.
Next.js에서 같은 페이지 만들기
// app/page.tsx - 그냥 컴포넌트만 작성하면 끝
'use client';
import { useState } from 'react';
export default function HomePage() {
const [count, setCount] = useState(0);
return (
<div style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}>
<h1 style={{ color: '#61dafb' }}>Hello from Next.js SSR</h1>
<p>This page was server-side rendered and hydrated on the client.</p>
<div style={{ marginTop: '2rem' }}>
<button
onClick={() => setCount(count + 1)}
style={{
padding: '0.6rem 1.2rem',
fontSize: '1rem',
cursor: 'pointer',
background: '#61dafb',
border: 'none',
borderRadius: '6px',
color: '#222',
fontWeight: 'bold',
}}
>
Count: {count}
</button>
</div>
</div>
);
}
// 끝! HTML 생성, Hydration, 빌드 설정 - 전부 자동김도연: 어? 이게 전부인가요? HTML 템플릿은요? script 태그는요? hydrateRoot는요?
이준혁: 다 자동이야! Next.js가 내부적으로:
- HTML 템플릿 자동 생성 (
<!DOCTYPE html>...) - script 태그 자동 삽입 (
<script src="/_next/static/...">) - Hydration 코드 자동 실행 (내부 런타임이 처리)
- 빌드 설정 자동 구성 (Webpack/Turbopack)
- 라우팅 자동 등록 (파일 시스템 기반)
- 개발 서버 자동 실행 (
next dev- HMR 포함)
너는 그냥 컴포넌트만 작성하면 돼.
배관 비교표
이준혁: 정리하면 이렇게 돼:
| 작업 | Express (A003) | Next.js |
|---|---|---|
| HTML 템플릿 | 수동 작성 (template string) | 자동 생성 |
| Script 삽입 | 수동 (<script src="...">) | 자동 삽입 |
| Hydration 코드 | 직접 작성 (hydrateRoot) | 자동 실행 |
| 빌드 설정 | build.js (69줄) | next.config (0줄 가능) |
| 라우팅 | Express 라우트 수동 등록 (app.get) | 파일 시스템 기반 (app/page.tsx) |
| 개발 서버 | 직접 구현 + rebuild | next dev (HMR 자동) |
| 페이지 추가 | 5단계 수동 작업 | 파일 생성만 |
| 코드 수정 후 | npm run build && npm start | 자동 새로고침 (Fast Refresh) |
| 최적화 | 직접 구현 (minify, code split) | 자동 (Route 기반 code split) |
| TypeScript | esbuild 설정 수동 | 기본 지원 |
| CSS 처리 | 수동 설정 필요 | CSS Modules / Tailwind 자동 |
| API 라우트 | Express 미들웨어 작성 | app/api/route.ts 파일 생성 |
코드 양 비교
김도연: 그럼 실제로 코드 양이 얼마나 차이나나요?
이준혁: A003 프로젝트와 비교해볼까:
Express SSR (A003)
server.js 54줄 (라우트 + HTML 템플릿)
build.js 69줄 (4단계 빌드 설정)
react-entry.jsx 5줄 (Hydration 코드)
svelte-entry.js 5줄 (Hydration 코드)
package.json 20줄 (scripts + dependencies)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
총합: 153줄 (실제 비즈니스 로직 제외)Next.js
app/page.tsx 25줄 (컴포넌트만)
app/about/page.tsx 25줄 (컴포넌트만)
next.config.js 0줄 (기본 설정으로 충분)
package.json 8줄 (scripts만)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
총합: 58줄 (배관 코드 0줄!)이준혁: 배관 코드가 95줄 → 0줄로 줄었어. 그리고 새 페이지를 추가할 때:
- Express: 5단계 (라우트 등록, HTML 템플릿, entry 파일, 빌드 설정, 재빌드)
- Next.js: 1단계 (파일 생성, 자동 새로고침)
깨달음 포인트
이준혁: 자, 이제 정리해볼까?
Express SSR은 '배관공'이 직접 모든 파이프를 연결하는 것이고, Next.js는 '조립식 주택'처럼 배관이 이미 설치되어 있는 것.
둘 다 SSR을 하지만 투명성 vs 편의성의 트레이드오프가 있다.
Express SSR의 장점 (투명성)
- 모든 단계가 명시적이고 투명함
- 원하는 대로 커스터마이징 가능
- SSR 동작 원리를 깊이 이해할 수 있음
- 프레임워크 의존성 없음 (순수 Node.js + React/Svelte)
Express SSR의 단점 (반복 작업)
- 배관 코드를 매번 작성해야 함
- 새 페이지 추가가 복잡함 (5단계)
- 빌드 설정을 직접 관리해야 함
- HMR(Hot Module Replacement) 직접 구현 필요
- 최적화(Code Splitting, Lazy Loading)를 직접 구현해야 함
Next.js의 장점 (편의성)
- 배관 코드가 거의 없음 (컴포넌트만 작성)
- 새 페이지 추가가 간단함 (파일 생성만)
- 빌드 설정 자동화
- HMR 기본 제공 (Fast Refresh)
- 최적화 자동 (Route 기반 Code Splitting)
Next.js의 단점 (불투명성)
- 내부 동작이 추상화되어 있음 (블랙박스)
- 프레임워크에 종속됨 (Next.js 없이는 동작 안 함)
- 커스터마이징이 제한적 (Next.js의 규칙을 따라야 함)
- SSR 원리를 이해하지 못하고 사용할 수 있음
실습: A003 프로젝트에서 확인하기
김도연: 그럼 직접 A003 프로젝트에서 확인해볼 수 있을까요?
이준혁: 물론이지! 한번 실험해보자.
실험 1: 새 페이지 추가하기
Express (A003)에서 /contact 페이지 추가
- 컴포넌트 생성:
react-pages/ContactPage.jsx
export default function ContactPage() {
return <h1>Contact Us</h1>;
}- 서버 라우트 추가:
server.js
import ContactPage from './react-pages/ContactPage.jsx';
app.get('/contact', (req, res) => {
const html = renderToString(React.createElement(ContactPage));
res.send(`<!DOCTYPE html>
<html><head><title>Contact</title></head>
<body>
<div id="root">${html}</div>
<script src="/public/contact-bundle.js"></script>
</body></html>`);
});- Hydration 파일 생성:
client/contact-entry.jsx
import { hydrateRoot } from 'react-dom/client';
import ContactPage from '../react-pages/ContactPage.jsx';
hydrateRoot(document.getElementById('root'), <ContactPage />);- 빌드 설정 추가:
build.js
await esbuild.build({
entryPoints: ['./client/contact-entry.jsx'],
outfile: './public/contact-bundle.js',
// ... 나머지 설정
});- 재빌드 후 서버 재시작
npm run build
npm start총 5단계, 약 10분 소요
Next.js에서 /contact 페이지 추가
- 파일 생성:
app/contact/page.tsx
export default function ContactPage() {
return <h1>Contact Us</h1>;
}끝! 자동 새로고침, 1분 소요
실험 2: HTML 소스 비교
이준혁: 브라우저에서 "소스 보기"를 해봐. A003과 Next.js의 HTML을 비교해보자.
Express (A003) - http://localhost:4001/
<!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"><div style="padding:2rem;font-family:system-ui, sans-serif">
<h1 style="color:#61dafb">Hello from React SSR</h1>
<p>This page was server-side rendered and hydrated on the client.</p>
<!-- ... -->
</div></div>
<script src="/public/react-bundle.js"></script>
</body>
</html>특징:
- 우리가 직접 작성한 HTML 템플릿
<script>태그 1개 (우리가 직접 삽입)- 단순하고 명확함
Next.js - http://localhost:3000/
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="preload" href="/_next/static/css/app/layout.css" as="style"/>
<link rel="stylesheet" href="/_next/static/css/app/layout.css"/>
</head>
<body>
<div id="__next"><div style="padding:2rem;font-family:system-ui, sans-serif">
<h1 style="color:#61dafb">Hello from Next.js SSR</h1>
<!-- ... -->
</div></div>
<script src="/_next/static/chunks/webpack-abc123.js" async=""></script>
<script src="/_next/static/chunks/main-app-xyz789.js" async=""></script>
<script src="/_next/static/chunks/app/page-def456.js" async=""></script>
<!-- ... -->
</body>
</html>특징:
- Next.js가 자동 생성한 HTML
<script>태그 여러 개 (자동 Code Splitting)<link rel="preload">(자동 최적화)- Webpack chunk 자동 관리
이준혁: 보이지? Next.js는 우리가 신경 쓰지 않아도 자동으로 최적화까지 해줘.
비유로 이해하기
김도연: 그럼 언제 Express를, 언제 Next.js를 써야 할까요?
이준혁: 좋은 질문이야! 비유로 설명할게.
Express SSR = 수동 기어 자동차
- 장점: 엔진과 기어를 직접 제어 → 동작 원리 이해, 세밀한 제어
- 단점: 기어를 매번 바꿔야 함 → 피곤함
- 적합한 경우:
- 자동차 동작 원리를 배우고 싶을 때
- 트랙에서 레이싱할 때 (극한의 제어 필요)
- 특수한 커스터마이징이 필요할 때
Next.js = 자동 기어 자동차
- 장점: 기어를 자동으로 변속 → 편함, 빠름
- 단점: 내부 동작이 추상화됨 → 블랙박스
- 적합한 경우:
- 일상적인 운전 (대부분의 웹 서비스)
- 빠른 개발이 중요할 때
- 최적화를 자동으로 원할 때
실무 관점
이준혁: 실무에서는 대부분 Next.js를 써. 왜냐하면:
- 생산성: 배관 코드 없이 비즈니스 로직에 집중
- 유지보수성: 파일 구조가 명확 (파일 시스템 = 라우팅)
- 최적화: 자동 Code Splitting, Image Optimization, Font Optimization
- 팀 협업: 규칙이 명확해서 협업하기 쉬움
하지만 A003 같은 Express SSR 프로젝트도 중요해. 왜냐하면:
- 학습: SSR의 본질을 이해할 수 있음
- 디버깅: Next.js에서 문제가 생겼을 때 원인을 파악할 수 있음
- 특수 케이스: Next.js로 불가능한 커스터마이징이 필요할 때
핵심 정리
이준혁: 오늘 배운 걸 정리하면:
SSR의 3단계
- Server: Component → HTML string (
renderToString) - Transfer: HTML + JS bundle 전송 (
res.send) - Client: Hydration (
hydrateRoot)
Express SSR (수동 배관)
- 모든 단계를 직접 구현
- 투명하지만 반복 작업이 많음
- 배관 코드 95줄 (A003 기준)
Next.js (자동 배관)
- 프레임워크가 자동 처리
- 편리하지만 내부는 블랙박스
- 배관 코드 0줄
트레이드오프
투명성 ←――――――――――――→ 편의성
Express Next.js
제어권 많음 제어권 적음
학습 곡선 높음 학습 곡선 낮음
커스텀 쉬움 커스텀 어려움숙제
김도연: 오늘 진짜 많이 배웠어요!
이준혁: 그렇다면 숙제를 하나 줄게:
직접 비교해보기
- A003 프로젝트에서 새 페이지를 추가해봐 (
/services) - Next.js 프로젝트를 만들고 같은 페이지를 추가해봐
- 두 과정의 차이를 노트에 정리해봐
생각해보기
- Express SSR에서 HMR(Hot Module Replacement)을 구현하려면 어떻게 해야 할까?
- Next.js 없이 파일 시스템 기반 라우팅을 구현하려면 어떻게 해야 할까?
- 왜 Next.js는
_next/static/chunks/...같은 복잡한 경로를 쓸까?
다음 단계
이준혁: 다음 시간에는 더 재밌어질 거야.
Step 02: Zustand 상태관리에서 상태관리 라이브러리를 추가할 때 배관 작업이 어떻게 달라지는지 알아봅니다.
김도연: 상태관리를 추가하면 또 배관이 복잡해지나요?
이준혁: 정답! Express에서는 클라이언트와 서버 상태를 동기화하는 배관을 직접 작성해야 해. Next.js는? 당연히 자동이지. 다음 시간에 봐!
참고 자료
A003 프로젝트 주요 파일
/Users/yeongbeen/Desktop/yb-skills/A003-Express-based-React-Svelte/server.js/Users/yeongbeen/Desktop/yb-skills/A003-Express-based-React-Svelte/build.js/Users/yeongbeen/Desktop/yb-skills/A003-Express-based-React-Svelte/client/react-entry.jsx/Users/yeongbeen/Desktop/yb-skills/A003-Express-based-React-Svelte/react-pages/HomePage.jsx
공식 문서
작성: 2026-02-09 버전: 1.0 예상 독서 시간: 15분