01. SSR 전체 구조와 필요한 6가지 요소
01. SSR 전체 구조와 필요한 6가지 요소
이 문서에서 배우는 것
- Express에서 SSR을 직접 구현하려면 무엇이 필요한지
- 6가지 필수 요소: 빌드 설정, Hydration, 코드 스플리팅, 라우팅, 데이터 페칭, HMR
- 각 요소의 코드 예시
- Next.js가 이 6가지를 어떻게 자동으로 해주는지
PM 요청 (김도연)
김도연 PM: 준혁님, 저번에 Express로 SSR 프로젝트 완성한 거 잘 봤어요! 근데 막상 실무에서 쓰려면 빠진 게 많다고 하셨잖아요. 정확히 뭐가 더 필요한 건가요? 한눈에 볼 수 있게 정리해주실 수 있나요?
이준혁 시니어: 좋은 질문이야. A003 프로젝트에서 renderToString + hydration은 구현했지만, 그건 SSR의 "최소 동작"만 된 거거든. 프로덕션 수준의 SSR을 하려면 6가지 요소가 전부 갖춰져야 해. 하나씩 뜯어볼게.
시니어 멘토링 (이준혁)
SSR의 6가지 필수 요소
이준혁: SSR을 직접 구현한다는 건, 결국 이 6가지를 전부 직접 만든다는 뜻이야.
┌─────────────────────────────────────────────────────────────┐
│ SSR 6가지 필수 요소 │
│ │
│ ① 빌드 설정 서버용 + 클라이언트용 각각 빌드 │
│ ② Hydration 서버 HTML ↔ 클라이언트 JS 연결 │
│ ③ 코드 스플리팅 페이지별 JS 분리 │
│ ④ 라우팅 서버 라우트 ↔ 클라이언트 라우트 동기화 │
│ ⑤ 데이터 페칭 서버 fetch → HTML 주입 → 클라이언트 재사용│
│ ⑥ HMR / 개발 서버 코드 수정 → 즉시 반영 │
└─────────────────────────────────────────────────────────────┘김도연: 6개나요? 저는 renderToString만 하면 끝인 줄 알았는데...
이준혁: renderToString은 빙산의 일각이야. 나머지 5개가 수면 아래에 있어. 하나씩 보자.
요소 1: 빌드 설정 (서버용 + 클라이언트용 이중 빌드)
이준혁: 가장 먼저, 같은 컴포넌트를 2번 빌드해야 해. 서버용 한 번, 브라우저용 한 번.
김도연: 왜 2번이나 빌드해야 하죠?
이준혁: 서버(Node.js)와 브라우저는 실행 환경이 다르거든.
| 구분 | 서버용 빌드 | 클라이언트용 빌드 |
|---|---|---|
| 실행 환경 | Node.js | 브라우저 |
| 모듈 형식 | ESM (import/export) | IIFE 또는 ESM |
| 번들링 대상 | renderToString 호출 코드 | hydrateRoot 호출 코드 |
| 외부 패키지 | external (node_modules에서 직접 로드) | 모두 번들에 포함 |
| 결과물 | dist/server.js | public/react-bundle.js |
A003 프로젝트의 build.js가 바로 이 이중 빌드를 하고 있어:
// build.js — esbuild 기반 이중 빌드
// 서버 번들 (Node.js에서 실행)
await esbuild.build({
entryPoints: ['./server.js'],
outfile: './dist/server.js',
bundle: true,
format: 'esm',
platform: 'node', // ← Node.js 환경
packages: 'external', // ← node_modules는 번들에 포함 안 함
jsx: 'automatic',
});
// 클라이언트 번들 (브라우저에서 실행)
await esbuild.build({
entryPoints: ['./client/react-entry.jsx'],
outfile: './public/react-bundle.js',
bundle: true,
format: 'iife', // ← 브라우저용 즉시 실행 함수
platform: 'browser', // ← 브라우저 환경
jsx: 'automatic',
minify: true, // ← 용량 줄이기
});이준혁: 같은 HomePage.jsx 컴포넌트가 서버 빌드에도, 클라이언트 빌드에도 포함돼. 이게 SSR의 핵심 구조야.
HomePage.jsx
┌─────────────┐
│ function │
│ HomePage() { │
│ ... │
│ } │
└──────┬──────┘
│
┌────────────┴────────────┐
▼ ▼
서버 빌드 (esbuild) 클라이언트 빌드 (esbuild)
platform: 'node' platform: 'browser'
│ │
▼ ▼
dist/server.js public/react-bundle.js
renderToString() 호출 hydrateRoot() 호출요소 2: Hydration 설정 (서버 HTML ↔ 클라이언트 JS 연결)
이준혁: 서버가 보내준 HTML은 "죽은 HTML"이야. 클릭해도 아무 반응 없어. 이걸 "살리는" 과정이 Hydration이지.
김도연: 죽은 HTML이요? 그냥 HTML 아닌가요?
이준혁: 코드로 보면 바로 이해돼.
// entry-server.jsx — 서버 진입점
import React from 'react'
import { renderToString } from 'react-dom/server'
import HomePage from './HomePage.jsx'
export function render() {
return renderToString(<HomePage />);
// 결과: "<div class="container"><h1>안녕하세요</h1>..."
// onClick? 없음. useState? 0으로 고정.
}// entry-client.jsx — 클라이언트 진입점
import React from 'react'
import { hydrateRoot } from 'react-dom/client'
import HomePage from './HomePage.jsx'
// 서버가 보내준 HTML 위에 이벤트 핸들러를 연결
hydrateRoot(document.getElementById('root'), <HomePage />);
// 이제 onClick이 동작하고, useState가 상태 변경 가능이준혁: renderToString은 HTML 문자열만 만들고, hydrateRoot는 그 HTML에 생명을 불어넣어. 이 두 파일이 짝을 이뤄야 SSR이 완성돼.
요소 3: 코드 스플리팅 (페이지별 JS 분리)
이준혁: 지금 A003 프로젝트는 모든 JS가 하나의 번들에 들어가 있어. 작은 프로젝트니까 괜찮지만, 페이지가 100개면?
김도연: 모든 페이지의 JS를 한꺼번에 다운로드하면 느려지겠네요.
이준혁: 맞아. 그래서 코드 스플리팅이 필요해. 페이지별로 JS를 나누는 거지.
// 코드 스플리팅 없이 (현재 A003)
// react-bundle.js 하나에 모든 페이지 포함 → 300KB
// 코드 스플리팅 적용 후
// home-a1b2c3.js → 30KB (홈페이지만)
// about-d4e5f6.js → 25KB (어바웃만)
// dashboard-g7h8.js → 80KB (대시보드만)// React에서 코드 스플리팅 구현
import { lazy, Suspense } from 'react';
// 동적 import → 별도 번들로 분리
const HomePage = lazy(() => import('./pages/HomePage'));
const AboutPage = lazy(() => import('./pages/AboutPage'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
</Routes>
</Suspense>
);
}이준혁: lazy()와 동적 import()를 사용하면 빌드 도구가 자동으로 코드를 나눠줘. 하지만 SSR에서 이걸 하려면 서버에서도 동일한 코드 스플리팅을 지원해야 하는데, 이게 꽤 복잡해.
요소 4: 라우팅 (서버 ↔ 클라이언트 동기화)
이준혁: A003에서 라우팅을 어떻게 했는지 기억나?
김도연: app.get('/'), app.get('/about') 이렇게요.
이준혁: 맞아. 근데 이건 서버 라우팅만 있는 거야. 페이지를 이동할 때마다 서버에 새 요청을 보내.
현재 (서버 라우팅만):
사용자가 /about 클릭
→ 브라우저가 서버에 GET /about 요청
→ 서버가 HTML 새로 렌더링
→ 전체 페이지 새로고침 (화면 깜빡)
SPA 라우팅 추가 시:
사용자가 /about 클릭
→ 클라이언트 JS가 URL만 변경
→ 해당 컴포넌트만 교체
→ 새로고침 없이 부드러운 전환// server.js — 서버 라우팅
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server';
import App from './App.jsx';
app.get('*', (req, res) => {
// 서버: 요청 URL을 StaticRouter에 전달
const html = renderToString(
<StaticRouter location={req.url}>
<App />
</StaticRouter>
);
res.send(`...${html}...`);
});// entry-client.jsx — 클라이언트 라우팅
import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx';
hydrateRoot(
document.getElementById('root'),
<BrowserRouter>
<App />
</BrowserRouter>
);이준혁: 핵심은 같은 <App /> 컴포넌트를 서버에서는 StaticRouter로, 클라이언트에서는 BrowserRouter로 감싸는 거야. 라우트 정의는 하나인데, 실행 환경에 따라 라우터만 바뀌지.
요소 5: 데이터 페칭 (서버 fetch → HTML 주입 → 클라이언트 재사용)
이준혁: 여기서부터 진짜 복잡해져. 서버에서 데이터를 가져와서 HTML에 포함시키고, 클라이언트에서 그 데이터를 재사용해야 해.
김도연: 재사용이요? 클라이언트에서 다시 API 호출하면 안 되나요?
이준혁: 할 수는 있지만, 그러면 두 번 호출하게 돼. 성능 낭비지. "서버에서 이미 가져온 데이터를 클라이언트에 전달"하는 게 핵심이야.
// server.js — 서버에서 데이터 페칭
app.get('/dashboard', async (req, res) => {
// 1. 서버에서 API 호출
const data = await fetch('https://api.example.com/stats').then(r => r.json());
// 2. 데이터를 포함하여 렌더링
const html = renderToString(<Dashboard initialData={data} />);
// 3. 데이터를 HTML에 직접 삽입 (클라이언트 재사용용)
res.send(`<!DOCTYPE html>
<html>
<body>
<div id="root">${html}</div>
<!-- 서버 데이터를 전역 변수로 전달 -->
<script>
window.__INITIAL_DATA__ = ${JSON.stringify(data)}
</script>
<script src="/bundle.js"></script>
</body>
</html>`);
});// entry-client.jsx — 클라이언트에서 데이터 재사용
const initialData = window.__INITIAL_DATA__; // 서버가 삽입한 데이터
hydrateRoot(
document.getElementById('root'),
<Dashboard initialData={initialData} />
// API를 다시 호출하지 않고 서버 데이터를 그대로 사용
);서버 데이터 페칭 흐름:
[서버] [브라우저]
│ │
│ 1. API 호출 │
│ fetch('/api/stats') │
│ data = { visits: 1234 } │
│ │
│ 2. HTML 렌더링 │
│ renderToString( │
│ <Dashboard data={data} /> │
│ ) │
│ │
│ 3. HTML + 데이터 전송 │
│ ─────────────────────────► │
│ <div>방문자: 1234</div> │
│ window.__INITIAL_DATA__ │
│ = { visits: 1234 } │
│ │
│ 4. hydration
│ initialData =
│ window.__INITIAL_DATA__
│ hydrateRoot(
│ <Dashboard data={initialData} />
│ )
│ → API 재호출 없음!요소 6: HMR / 개발 서버 설정
이준혁: 마지막, 개발 생산성. 코드를 수정할 때마다 npm run build → 서버 재시작하면 개발이 고통스러워.
김도연: A003 프로젝트에서 수정할 때마다 빌드하고 있었는데... 맞아요, 불편했어요.
이준혁: HMR(Hot Module Replacement)은 코드 수정 시 변경된 모듈만 브라우저에 보내서 전체 새로고침 없이 반영하는 기술이야.
// vite.config.js — Vite 개발 서버 설정 (자동 HMR)
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
// HMR은 기본 활성화. 코드 수정 → 즉시 반영
}
})// 직접 HMR을 구현하려면? (Webpack 예시)
if (module.hot) {
module.hot.accept('./App', () => {
// 변경된 App 모듈만 다시 렌더링
const NextApp = require('./App').default;
render(<NextApp />);
});
}이준혁: 근데 SSR에서 HMR이 진짜 어려운 이유가 있어. 서버 코드도 바뀌어야 하거든.
일반 CSR의 HMR:
코드 수정 → 클라이언트 모듈만 교체 → 끝
SSR의 HMR:
코드 수정 → 클라이언트 모듈 교체 + 서버 번들 다시 빌드
→ 서버 모듈 캐시 무효화
→ 다음 요청에서 새 서버 코드 반영6가지 요소 요약표
| 요소 | 하는 일 | Express 직접 구현 | Next.js |
|---|---|---|---|
| 빌드 설정 | 서버/클라이언트 이중 빌드 | build.js 68줄 직접 작성 | next build 한 줄 |
| Hydration | 서버 HTML에 이벤트 연결 | entry-server + entry-client 직접 작성 | 자동 (pages/ 규약) |
| 코드 스플리팅 | 페이지별 JS 분리 | lazy() + dynamic import 수동 설정 | 자동 (파일 기반 라우팅) |
| 라우팅 | 서버/클라이언트 라우트 동기화 | StaticRouter + BrowserRouter 수동 | 파일 시스템 라우팅 |
| 데이터 페칭 | 서버 fetch → 클라이언트 재사용 | window.INITIAL_DATA 수동 | getServerSideProps() |
| HMR | 코드 수정 → 즉시 반영 | Webpack/Vite 수동 설정 | next dev 자동 |
Next.js는 이 6가지를 어떻게 해결하는가
김도연: 와, 이렇게 많은 걸 직접 해야 하는 거였군요. Next.js는 이걸 다 해주는 건가요?
이준혁: 그렇지. Next.js가 인기 있는 이유가 바로 이거야. 비유하자면:
Express SSR = "집을 직접 짓기"
기초 공사, 배관, 전기, 단열, 지붕... 전부 직접
Next.js = "아파트 분양"
입주만 하면 됨. 배관, 전기, 단열은 이미 완료// Next.js에서 같은 작업을 하면:
// pages/index.js (빌드 + 라우팅 + 코드 스플리팅 자동)
export default function HomePage({ data }) {
const [count, setCount] = useState(0);
return (
<div>
<h1>방문자 수: {data.visits}</h1>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
// 데이터 페칭 (서버 → 클라이언트 전달 자동)
export async function getServerSideProps() {
const data = await fetch('https://api.example.com/stats').then(r => r.json());
return { props: { data } };
}
// Hydration? 자동.
// HMR? next dev로 자동.
// 빌드? next build로 자동.이준혁: Next.js에서는 pages/ 폴더에 파일을 넣으면 라우팅이 자동이고, getServerSideProps로 데이터 페칭을 선언하면 서버→클라이언트 전달이 자동이야. 6가지 요소를 프레임워크가 대신 처리해주는 거지.
왜 그래도 직접 만들어봐야 하는가
김도연: 그러면 처음부터 Next.js 쓰면 되지 않나요? 왜 굳이 직접 만드는 거예요?
이준혁: 세 가지 이유가 있어.
- 이해: 프레임워크가 뭘 해주는지 모르면, 문제가 생겼을 때 디버깅을 못 해.
- 판단: "이 프로젝트에 Next.js가 필요한가?"를 판단하려면 내부를 알아야 해.
- 커스터마이징: 프레임워크가 지원하지 않는 특수한 요구사항이 있을 때, 직접 구현할 수 있어야 해.
프레임워크를 모르고 쓰는 개발자:
"왜 안 되지?" → Stack Overflow 복붙 → "되긴 되는데 왜 되는지 모르겠다"
원리를 이해한 개발자:
"Hydration 불일치네" → "서버에서 Date.now() 때문이군" → 직접 수정이준혁: 그래서 이 튜토리얼에서는 6가지 요소를 하나씩 직접 만들어볼 거야. 다음 문서에서 첫 번째로 renderToString부터 깊이 파고들자.
핵심 정리
-
SSR에는 6가지 필수 요소가 있다: 빌드 설정, Hydration, 코드 스플리팅, 라우팅, 데이터 페칭, HMR.
-
Express에서 직접 구현하면 각 요소를 수동으로 만들어야 한다. build.js, entry-server.jsx, entry-client.jsx 등 파일이 늘어난다.
-
Next.js 같은 프레임워크는 이 6가지를 자동으로 해결한다.
pages/규약,getServerSideProps,next dev등. -
**"왜 프레임워크가 필요한지"**를 체감하려면, 먼저 직접 만들어봐야 한다. 이 튜토리얼의 목적이 바로 그것이다.
-
다음 문서에서는 6가지 요소 중 가장 핵심인 renderToString부터 시작한다.