1 / 2

Step 01: SSR의 본질 — Express 수동 배관 vs Next.js 자동 배관

예상 시간: 8분

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}`);
});

이준혁: 보이지? 여기서 우리가 직접 한 일들:

  1. 수동으로 HTML 템플릿 작성 (<!DOCTYPE html>...)
  2. 수동으로 ${html} 위치 지정 (어디에 컴포넌트를 삽입할지)
  3. 수동으로 <script> 태그 삽입 (어떤 JS 번들을 로드할지)
  4. 수동으로 라우트 등록 (app.get('/', ...), app.get('/about', ...))

이게 바로 **"수동 배관"**이야. 모든 파이프를 개발자가 직접 연결해야 해.


2단계: Transfer — HTML + JS bundle 전송

김도연: 그럼 2단계는요?

이준혁: 2단계는 res.send()가 하는 거야. HTML 문자열과 함께 <script src="/public/react-bundle.js"> 태그가 포함되어 있지? 브라우저가 이 HTML을 받으면:

  1. 즉시 HTML을 렌더링 (사용자가 화면을 본다)
  2. 비동기로 JS 번들 다운로드 (/public/react-bundle.js)
  3. 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');

이준혁: 보이지? 우리가 직접:

  1. SSR용 Svelte 컴포넌트 빌드 (서버에서 실행 가능하도록)
  2. 서버 코드 번들링 (Express 서버 전체를 하나의 파일로)
  3. React 클라이언트 번들 (브라우저에서 Hydration용)
  4. 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 />);

이준혁: 이 코드가 하는 일:

  1. 서버가 만든 HTML을 찾는다 (document.getElementById('root'))
  2. 같은 컴포넌트(HomePage)를 다시 렌더링한다 (가상 DOM 생성)
  3. 서버 HTML과 클라이언트 가상 DOM을 비교한다
  4. 이벤트 핸들러만 추가한다 (HTML은 그대로 유지)

이게 바로 Hydration이야. 정적 HTML에 생명을 불어넣는 거지.


질문 3: 이게 다 수동이라고?

김도연: 와... 생각보다 복잡하네요. 이걸 매번 다 작성해야 하나요?

이준혁: 그렇지! 새 페이지를 추가하려면:

  1. server.jsapp.get('/new-page', ...) 라우트 추가
  2. HTML 템플릿 수동 작성
  3. <script> 태그 수동 삽입
  4. client/new-page-entry.jsx 생성
  5. 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가 내부적으로:

  1. HTML 템플릿 자동 생성 (<!DOCTYPE html>...)
  2. script 태그 자동 삽입 (<script src="/_next/static/...">)
  3. Hydration 코드 자동 실행 (내부 런타임이 처리)
  4. 빌드 설정 자동 구성 (Webpack/Turbopack)
  5. 라우팅 자동 등록 (파일 시스템 기반)
  6. 개발 서버 자동 실행 (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)
개발 서버직접 구현 + rebuildnext dev (HMR 자동)
페이지 추가5단계 수동 작업파일 생성만
코드 수정 후npm run build && npm start자동 새로고침 (Fast Refresh)
최적화직접 구현 (minify, code split)자동 (Route 기반 code split)
TypeScriptesbuild 설정 수동기본 지원
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 페이지 추가

  1. 컴포넌트 생성: react-pages/ContactPage.jsx
export default function ContactPage() {
  return <h1>Contact Us</h1>;
}
  1. 서버 라우트 추가: 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>`);
});
  1. Hydration 파일 생성: client/contact-entry.jsx
import { hydrateRoot } from 'react-dom/client';
import ContactPage from '../react-pages/ContactPage.jsx';
hydrateRoot(document.getElementById('root'), <ContactPage />);
  1. 빌드 설정 추가: build.js
await esbuild.build({
  entryPoints: ['./client/contact-entry.jsx'],
  outfile: './public/contact-bundle.js',
  // ... 나머지 설정
});
  1. 재빌드 후 서버 재시작
npm run build
npm start

총 5단계, 약 10분 소요

Next.js에서 /contact 페이지 추가

  1. 파일 생성: 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를 써. 왜냐하면:

  1. 생산성: 배관 코드 없이 비즈니스 로직에 집중
  2. 유지보수성: 파일 구조가 명확 (파일 시스템 = 라우팅)
  3. 최적화: 자동 Code Splitting, Image Optimization, Font Optimization
  4. 팀 협업: 규칙이 명확해서 협업하기 쉬움

하지만 A003 같은 Express SSR 프로젝트도 중요해. 왜냐하면:

  1. 학습: SSR의 본질을 이해할 수 있음
  2. 디버깅: Next.js에서 문제가 생겼을 때 원인을 파악할 수 있음
  3. 특수 케이스: Next.js로 불가능한 커스터마이징이 필요할 때

핵심 정리

이준혁: 오늘 배운 걸 정리하면:

SSR의 3단계

  1. Server: Component → HTML string (renderToString)
  2. Transfer: HTML + JS bundle 전송 (res.send)
  3. Client: Hydration (hydrateRoot)

Express SSR (수동 배관)

  • 모든 단계를 직접 구현
  • 투명하지만 반복 작업이 많음
  • 배관 코드 95줄 (A003 기준)

Next.js (자동 배관)

  • 프레임워크가 자동 처리
  • 편리하지만 내부는 블랙박스
  • 배관 코드 0줄

트레이드오프

투명성 ←――――――――――――→ 편의성
Express              Next.js
제어권 많음           제어권 적음
학습 곡선 높음        학습 곡선 낮음
커스텀 쉬움          커스텀 어려움

숙제

김도연: 오늘 진짜 많이 배웠어요!

이준혁: 그렇다면 숙제를 하나 줄게:

직접 비교해보기

  1. A003 프로젝트에서 새 페이지를 추가해봐 (/services)
  2. Next.js 프로젝트를 만들고 같은 페이지를 추가해봐
  3. 두 과정의 차이를 노트에 정리해봐

생각해보기

  • 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분