1 / 3

01. SSR 전체 구조와 필요한 6가지 요소

예상 시간: 5분

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.jspublic/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 쓰면 되지 않나요? 왜 굳이 직접 만드는 거예요?

이준혁: 세 가지 이유가 있어.

  1. 이해: 프레임워크가 뭘 해주는지 모르면, 문제가 생겼을 때 디버깅을 못 해.
  2. 판단: "이 프로젝트에 Next.js가 필요한가?"를 판단하려면 내부를 알아야 해.
  3. 커스터마이징: 프레임워크가 지원하지 않는 특수한 요구사항이 있을 때, 직접 구현할 수 있어야 해.
프레임워크를 모르고 쓰는 개발자:
  "왜 안 되지?" → Stack Overflow 복붙 → "되긴 되는데 왜 되는지 모르겠다"
 
원리를 이해한 개발자:
  "Hydration 불일치네" → "서버에서 Date.now() 때문이군" → 직접 수정

이준혁: 그래서 이 튜토리얼에서는 6가지 요소를 하나씩 직접 만들어볼 거야. 다음 문서에서 첫 번째로 renderToString부터 깊이 파고들자.


핵심 정리

  1. SSR에는 6가지 필수 요소가 있다: 빌드 설정, Hydration, 코드 스플리팅, 라우팅, 데이터 페칭, HMR.

  2. Express에서 직접 구현하면 각 요소를 수동으로 만들어야 한다. build.js, entry-server.jsx, entry-client.jsx 등 파일이 늘어난다.

  3. Next.js 같은 프레임워크는 이 6가지를 자동으로 해결한다. pages/ 규약, getServerSideProps, next dev 등.

  4. **"왜 프레임워크가 필요한지"**를 체감하려면, 먼저 직접 만들어봐야 한다. 이 튜토리얼의 목적이 바로 그것이다.

  5. 다음 문서에서는 6가지 요소 중 가장 핵심인 renderToString부터 시작한다.