2 / 3

Step 06: 프로덕션 배포 — esbuild 수동 파이프라인 vs Next.js Build System

예상 시간: 6분

Step 06: 프로덕션 배포 — esbuild 수동 파이프라인 vs Next.js Build System

PM 요청 (김도연)

"준혁님, 개발 환경에서는 둘 다 잘 돌아가는데요, 실제 프로덕션 배포는 어떻게 하나요? 빌드 과정이랑 최적화, 배포 프로세스가 궁금해요. 우리 서비스 런칭할 때 뭘 준비해야 할지 미리 알고 싶어서요."

시니어 멘토링 (이준혁)

Express SSR 빌드 파이프라인 — 직접 만드는 4단계 빌드

도연씨, 개발 환경에서는 차이가 별로 안 느껴지는데, 프로덕션 배포 단계에서 차이가 가장 극명하게 드러나. Express SSR은 빌드부터 배포까지 모든 걸 직접 설정해야 하거든.

A003 프로젝트의 실제 build.js 파일부터 보자:

// build.js — Express SSR 프로덕션 빌드 스크립트
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 });
 
// 1/4 단계: Svelte SSR 컴포넌트 빌드
console.log('Building Svelte SSR components...');
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      // 클라이언트 hydration 가능하게
      }
    })
  ],
});
 
// 2/4 단계: 서버 코드 번들링
console.log('Building server bundle...');
await esbuild.build({
  entryPoints: ['./server.js'],
  outfile: './dist/server.js',
  bundle: true,
  format: 'esm',
  platform: 'node',
  packages: 'external',     // node_modules는 번들에 포함 안 함
  jsx: 'automatic',
  jsxImportSource: 'react',
});
 
// 3/4 단계: React 클라이언트 번들 (브라우저용)
console.log('Building 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('Building 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
      }
    })
  ],
  minify: true,
});
 
console.log('Build complete!');

이게 끝이 아니야. 이 빌드 스크립트는 기본만 처리하고, 프로덕션에서 필요한 많은 기능이 빠져 있어.

프로덕션에 필요한데 빠진 것들

1. Code Splitting (코드 분할)

지금 보면 format: 'iife'로 되어 있지? 이건 모든 코드를 하나의 파일로 만드는 거야.

// 현재 상태
react-bundle.js  // React + 모든 페이지 코드 → 500KB
svelte-bundle.js // Svelte + 모든 페이지 코드 → 300KB
 
// 문제점
// 사용자가 홈페이지만 봐도 전체 500KB를 다운로드해야 함
// About 페이지 코드도, 아직 안 본 Product 페이지 코드도 전부

Code splitting을 하려면 format을 바꿔야 하는데:

// Code splitting 하려면
await esbuild.build({
  entryPoints: ['./client/react-entry.jsx'],
  outdir: './public',      // outfile → outdir로 변경
  bundle: true,
  format: 'esm',           // iife → esm으로 변경
  splitting: true,         // 코드 분할 활성화
  platform: 'browser',
  jsx: 'automatic',
  jsxImportSource: 'react',
  minify: true,
});
 
// 결과
// public/
//   react-entry.js       // 100KB (기본 React만)
//   chunk-HASH1.js       // 50KB (공통 코드)
//   Home-HASH2.js        // 30KB (홈페이지 전용)
//   About-HASH3.js       // 20KB (About 페이지 전용)

하지만 ESM은 구형 브라우저(IE11)에서 안 돌아가. 브라우저 지원 범위를 고려해야 해.

2. Asset Hashing (캐시 무효화)

지금은 번들 파일명이 고정이야:

<!-- 현재 -->
<script src="/react-bundle.js"></script>
<!-- 문제: 코드 업데이트해도 브라우저가 캐시된 옛날 파일 사용 -->
 
<!-- 필요한 것 -->
<script src="/react-bundle.a3f2c1b9.js"></script>
<!-- 해시가 변경되면 새 파일로 인식 → 캐시 우회 -->

esbuild로 이걸 구현하려면:

// build.js에 추가
await esbuild.build({
  entryPoints: ['./client/react-entry.jsx'],
  outdir: './public',
  bundle: true,
  format: 'esm',
  splitting: true,
  entryNames: '[dir]/[name]-[hash]',  // 해시 추가
  chunkNames: 'chunks/[name]-[hash]',
  metafile: true,                      // 메타 정보 생성
  minify: true,
});
 
// metafile을 파싱해서 manifest.json 생성
import { writeFileSync } from 'fs';
 
const result = await esbuild.build({ /* ... */ });
const manifest = {};
for (const [output, info] of Object.entries(result.metafile.outputs)) {
  if (info.entryPoint) {
    manifest[info.entryPoint] = output;
  }
}
writeFileSync('./public/manifest.json', JSON stringify(manifest, null, 2));
 
// manifest.json
// {
//   "client/react-entry.jsx": "public/react-entry-a3f2c1b9.js"
// }

그리고 서버에서 이 manifest를 읽어서 HTML에 넣어야 해:

// server.js
import manifest from './public/manifest.json' assert { type: 'json' };
 
app.get('/home', (req, res) => {
  const reactScript = manifest['client/react-entry.jsx'];
  res.send(`
    <!DOCTYPE html>
    <html>
      <head><title>Home</title></head>
      <body>
        <div id="root">${reactHtml}</div>
        <script type="module" src="/${reactScript}"></script>
      </body>
    </html>
  `);
});

보이지? 간단한 캐시 무효화 하나 추가하는 데도 이렇게 많은 작업이 필요해.

3. CSS 처리

CSS를 JavaScript 번들에 포함시키면 FOUC (Flash of Unstyled Content) 문제가 생겨:

// React 컴포넌트
import './styles.css';  // JS 번들에 포함됨
 
// 문제
// 1. JS 로드 → 2. JS 실행 → 3. CSS 주입
// → 사용자는 먼저 스타일 없는 화면을 보게 됨

CSS를 별도 파일로 추출하려면:

npm install esbuild-css-modules-plugin
import { cssModulesPlugin } from 'esbuild-css-modules-plugin';
 
await esbuild.build({
  entryPoints: ['./client/react-entry.jsx'],
  outdir: './public',
  bundle: true,
  plugins: [cssModulesPlugin()],
  loader: { '.css': 'css' },  // CSS를 별도 파일로
  minify: true,
});

그리고 생성된 CSS 파일을 HTML <head>에 수동으로 링크해야 해.

4. 이미지 최적화

// 현재 상태
<img src="/images/hero.png" />  // 원본 PNG 2MB 그대로 전송
 
// 필요한 것
// - WebP/AVIF 변환 (2MB → 200KB)
// - Responsive images (디바이스별 크기)
// - Lazy loading
// - Blur placeholder

esbuild는 이미지 최적화 기능이 없어서 별도 도구가 필요해:

npm install sharp
// build-images.js
import sharp from 'sharp';
import { readdirSync } from 'fs';
 
const images = readdirSync('./images');
for (const img of images) {
  // WebP 변환
  await sharp(`./images/${img}`)
    .resize(1200, 800, { fit: 'cover' })
    .webp({ quality: 80 })
    .toFile(`./public/images/${img.replace('.png', '.webp')}`);
 
  // Thumbnail
  await sharp(`./images/${img}`)
    .resize(400, 300)
    .webp({ quality: 60 })
    .toFile(`./public/images/${img.replace('.png', '-thumb.webp')}`);
}

빌드 스크립트에 추가:

// package.json
{
  "scripts": {
    "build": "node build-images.js && node build.js"
  }
}

5. 압축 (Gzip/Brotli)

정적 파일 압축은 서버에서 처리:

npm install compression
// server.js
import compression from 'compression';
 
app.use(compression({
  level: 6,
  threshold: 10 * 1024,  // 10KB 이상만 압축
  filter: (req, res) => {
    if (req.headers['x-no-compression']) return false;
    return compression.filter(req, res);
  }
}));
 
// 500KB JS 파일 → 100KB로 압축 전송

또는 nginx에서 처리:

# nginx.conf
http {
  gzip on;
  gzip_types text/css application/javascript application/json;
  gzip_min_length 1000;
  gzip_comp_level 6;
}

6. SSR 페이지 캐싱

매 요청마다 React/Svelte를 서버에서 렌더링하면 느려:

// 문제: 매번 렌더링
app.get('/products', async (req, res) => {
  const products = await db.query('SELECT * FROM products');
  const html = renderToString(<ProductList products={products} />);
  res.send(html);  // 요청마다 CPU 소모
});

캐싱 구현:

// 간단한 메모리 캐싱
const cache = new Map();
 
app.get('/products', async (req, res) => {
  const cacheKey = 'products-page';
 
  if (cache.has(cacheKey)) {
    const cached = cache.get(cacheKey);
    if (Date.now() - cached.timestamp < 60000) {  // 1분 캐시
      return res.send(cached.html);
    }
  }
 
  const products = await db.query('SELECT * FROM products');
  const html = renderToString(<ProductList products={products} />);
 
  cache.set(cacheKey, { html, timestamp: Date.now() });
  res.send(html);
});

프로덕션에서는 Redis 사용:

import Redis from 'ioredis';
const redis = new Redis();
 
app.get('/products', async (req, res) => {
  const cached = await redis.get('products-page');
  if (cached) return res.send(cached);
 
  const products = await db.query('SELECT * FROM products');
  const html = renderToString(<ProductList products={products} />);
 
  await redis.setex('products-page', 60, html);  // 60초 TTL
  res.send(html);
});

7. 환경 변수 관리

// 빌드 시점에 주입
await esbuild.build({
  entryPoints: ['./client/react-entry.jsx'],
  outfile: './public/react-bundle.js',
  bundle: true,
  define: {
    'process.env.NODE_ENV': '"production"',
    'process.env.API_URL': '"https://api.example.com"',
  },
  minify: true,
});
 
// 클라이언트 코드에서 사용
const apiUrl = process.env.API_URL;  // 빌드 시 치환됨

보안을 위해 클라이언트/서버 환경 변수 분리:

// .env.production
DATABASE_URL=postgres://...       # 서버 전용
API_SECRET=xyz123                 # 서버 전용
NEXT_PUBLIC_API_URL=https://...  # 클라이언트에 노출 가능

완전한 프로덕션 빌드 스크립트

지금까지 설명한 걸 다 합치면:

// build-production.js
import esbuild from 'esbuild';
import sveltePlugin from 'esbuild-svelte';
import { cssModulesPlugin } from 'esbuild-css-modules-plugin';
import sharp from 'sharp';
import { mkdirSync, readdirSync, writeFileSync } from 'fs';
 
console.log('🚀 Starting production build...\n');
 
// 1. 디렉토리 준비
mkdirSync('./svelte-pages/build', { recursive: true });
mkdirSync('./public', { recursive: true });
mkdirSync('./dist', { recursive: true });
 
// 2. 이미지 최적화
console.log('📸 Optimizing images...');
const images = readdirSync('./images').filter(f => /\.(png|jpg|jpeg)$/.test(f));
for (const img of images) {
  await sharp(`./images/${img}`)
    .resize(1200, null, { withoutEnlargement: true })
    .webp({ quality: 85 })
    .toFile(`./public/images/${img.replace(/\.(png|jpg|jpeg)$/, '.webp')}`);
}
console.log(`✓ Optimized ${images.length} images\n`);
 
// 3. Svelte SSR 컴포넌트
console.log('⚙️  Building Svelte SSR components...');
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 }
    })
  ],
  minify: true,
});
console.log('✓ Svelte SSR built\n');
 
// 4. 서버 번들
console.log('🖥️  Building server bundle...');
await esbuild.build({
  entryPoints: ['./server.js'],
  outfile: './dist/server.js',
  bundle: true,
  format: 'esm',
  platform: 'node',
  packages: 'external',
  jsx: 'automatic',
  jsxImportSource: 'react',
  minify: true,
  sourcemap: true,  // 프로덕션 디버깅용
});
console.log('✓ Server bundle built\n');
 
// 5. React 클라이언트 번들 (코드 분할)
console.log('⚛️  Building React client bundle...');
const reactBuild = await esbuild.build({
  entryPoints: ['./client/react-entry.jsx'],
  outdir: './public',
  bundle: true,
  format: 'esm',
  splitting: true,
  platform: 'browser',
  entryNames: '[dir]/[name]-[hash]',
  chunkNames: 'chunks/[name]-[hash]',
  jsx: 'automatic',
  jsxImportSource: 'react',
  plugins: [cssModulesPlugin()],
  minify: true,
  sourcemap: true,
  metafile: true,
  define: {
    'process.env.NODE_ENV': '"production"',
  },
});
console.log('✓ React bundle built\n');
 
// 6. Svelte 클라이언트 번들
console.log('🔶 Building Svelte client bundle...');
const svelteBuild = await esbuild.build({
  entryPoints: ['./client/svelte-entry.js'],
  outdir: './public',
  bundle: true,
  format: 'esm',
  splitting: true,
  platform: 'browser',
  entryNames: '[dir]/[name]-[hash]',
  chunkNames: 'chunks/[name]-[hash]',
  plugins: [
    sveltePlugin({
      compilerOptions: { generate: 'dom', hydratable: true }
    })
  ],
  minify: true,
  sourcemap: true,
  metafile: true,
});
console.log('✓ Svelte bundle built\n');
 
// 7. Manifest 파일 생성 (asset hashing)
console.log('📋 Generating asset manifest...');
const manifest = {};
for (const build of [reactBuild, svelteBuild]) {
  for (const [output, info] of Object.entries(build.metafile.outputs)) {
    if (info.entryPoint) {
      manifest[info.entryPoint] = output.replace('public/', '');
    }
  }
}
writeFileSync('./public/manifest.json', JSON.stringify(manifest, null, 2));
console.log('✓ Manifest generated\n');
 
// 8. 번들 분석
console.log('📊 Bundle analysis:');
for (const [file, info] of Object.entries(reactBuild.metafile.outputs)) {
  const size = (info.bytes / 1024).toFixed(2);
  console.log(`  ${file.replace('public/', '')}: ${size} KB`);
}
 
console.log('\n✅ Production build complete!');

실행하면:

$ node build-production.js
 
🚀 Starting production build...
 
📸 Optimizing images...
 Optimized 12 images
 
⚙️  Building Svelte SSR components...
 Svelte SSR built
 
🖥️  Building server bundle...
 Server bundle built
 
⚛️  Building React client bundle...
 React bundle built
 
🔶 Building Svelte client bundle...
 Svelte bundle built
 
📋 Generating asset manifest...
 Manifest generated
 
📊 Bundle analysis:
  react-entry-a3f2c1b9.js: 145.32 KB
  chunks/react-vendor-8d4e5f67.js: 132.41 KB
  chunks/Home-b2c3a456.js: 12.45 KB
  chunks/About-c9d8e123.js: 8.76 KB
  svelte-entry-d4e5f678.js: 89.23 KB
  chunks/svelte-vendor-e5f6a789.js: 45.12 KB
 
 Production build complete!

Docker 컨테이너화

빌드가 끝났으니 배포할 차례야. Docker로 패키징:

# Dockerfile
FROM node:20-alpine AS builder
 
WORKDIR /app
 
# 의존성 설치
COPY package*.json ./
RUN npm ci
 
# 소스 코드 복사 및 빌드
COPY . .
RUN npm run build
 
# --- 프로덕션 이미지 ---
FROM node:20-alpine
 
WORKDIR /app
 
# 프로덕션 의존성만 설치
COPY package*.json ./
RUN npm ci --omit=dev
 
# 빌드 결과물만 복사
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/public ./public
COPY --from=builder /app/svelte-pages/build ./svelte-pages/build
 
# 포트 노출
EXPOSE 4001
 
# 실행
CMD ["node", "dist/server.js"]

빌드 및 실행:

# Docker 이미지 빌드
docker build -t express-ssr:latest .
 
# 컨테이너 실행
docker run -p 4001:4001 \
  -e NODE_ENV=production \
  -e DATABASE_URL=postgres://... \
  express-ssr:latest

PM2로 프로세스 관리 (Cluster Mode)

Docker 없이 직접 서버에 배포한다면 PM2 사용:

// ecosystem.config.cjs
module.exports = {
  apps: [{
    name: 'express-ssr',
    script: './dist/server.js',
    instances: 'max',        // CPU 코어 수만큼 프로세스 생성
    exec_mode: 'cluster',    // 클러스터 모드
    env_production: {
      NODE_ENV: 'production',
      PORT: 4001,
    },
    error_file: './logs/err.log',
    out_file: './logs/out.log',
    log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
    merge_logs: true,
    autorestart: true,
    max_memory_restart: '500M',  // 메모리 500MB 초과 시 재시작
    watch: false,
  }],
};

배포:

# PM2로 시작
pm2 start ecosystem.config.cjs --env production
 
# 상태 확인
pm2 status
 
# 로그 보기
pm2 logs express-ssr
 
# 재시작 (무중단)
pm2 reload express-ssr

Nginx 리버스 프록시

서버 앞단에 Nginx 배치:

# /etc/nginx/sites-available/express-ssr
upstream express_backend {
  server 127.0.0.1:4001;
  server 127.0.0.1:4002;  # PM2 cluster의 다른 인스턴스
  keepalive 64;
}
 
server {
  listen 80;
  server_name example.com;
 
  # SSL 리다이렉트
  return 301 https://$server_name$request_uri;
}
 
server {
  listen 443 ssl http2;
  server_name example.com;
 
  ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
 
  # 정적 파일 직접 서빙 (Express 거치지 않음)
  location /public/ {
    alias /var/www/express-ssr/public/;
    expires 1y;
    add_header Cache-Control "public, immutable";
  }
 
  # API 요청은 Express로
  location / {
    proxy_pass http://express_backend;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection 'upgrade';
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_cache_bypass $http_upgrade;
  }
 
  # Gzip 압축
  gzip on;
  gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
  gzip_min_length 1000;
  gzip_comp_level 6;
}

프로덕션 체크리스트

Express SSR을 프로덕션에 올리기 전에 확인해야 할 것들:

빌드 최적화
□ Code splitting 설정 (ESM + splitting: true)
□ Asset hashing (entryNames: '[name]-[hash]')
□ CSS 분리 및 최소화
□ 이미지 최적화 (WebP/AVIF 변환)
□ Source maps 생성 (디버깅용)
□ Tree shaking 확인
□ Bundle 크기 분석 (--metafile)
 
서버 설정
□ Gzip/Brotli 압축 미들웨어
□ Static 파일 캐시 헤더 (Cache-Control)
□ SSR 페이지 캐싱 (메모리 또는 Redis)
□ Rate limiting (DDoS 방어)
□ Helmet (보안 헤더)
□ CORS 설정
□ Error 핸들링 미들웨어
□ Access 로깅 (morgan)
□ Health check 엔드포인트 (/health)
□ Graceful shutdown 처리
 
배포 인프라
□ Docker 이미지 빌드
□ PM2 cluster mode 설정
□ Nginx 리버스 프록시
□ SSL/TLS 인증서 (Let's Encrypt)
□ 환경 변수 관리 (.env.production)
□ 데이터베이스 마이그레이션
□ 로그 수집 (CloudWatch, Datadog 등)
□ 모니터링 (APM, Sentry 등)
□ CI/CD 파이프라인
□ Blue-Green 또는 Rolling 배포 전략

이 모든 걸 직접 설정하고 관리해야 해. 자유도는 높지만 책임도 크지.


Next.js 빌드 시스템 — 한 줄로 끝나는 마법

도연씨, 위에서 Express SSR로 프로덕션 배포하려면 뭘 해야 하는지 봤지? 이제 Next.js를 보자. 차이가 충격적일 거야.

빌드: 정말로 한 줄

next build

끝. 이게 전부야.

실행하면:

$ next build
 
 Next.js 14.2.0
 
 Creating an optimized production build
 Compiled successfully
 Linting and checking validity of types
 Collecting page data
 Generating static pages (8/8)
 Collecting build traces
 Finalizing page optimization
 
Route (app)                              Size     First Load JS
 /                                    5.2 kB         92.1 kB
 /about                               3.8 kB         90.7 kB
 λ /api/products                        0 kB           0 kB
 ƒ /products                            8.4 kB         95.3 kB
 ƒ /products/[id]                       6.1 kB         93.0 kB
 
  (Static)  automatically rendered as static HTML (uses no initial props)
λ  (Server)  server-side renders at runtime (uses getInitialProps or getServerSideProps)
ƒ  (Dynamic) server-side renders at runtime (uses fetch with ISR)
 
 Build complete!

이 한 줄 명령어가 자동으로 처리하는 것들:

✓ TypeScript 컴파일
✓ ESLint 검사
✓ Code splitting (페이지별 자동 분할)
✓ Tree shaking (안 쓰는 코드 제거)
✓ CSS 최적화 (PostCSS, CSS Modules)
✓ Image 최적화 준비 (next/image)
✓ Asset hashing (파일명에 해시 자동 추가)
✓ 정적 생성 가능한 페이지 자동 감지
✓ Server/Client 컴포넌트 분리
✓ Minification
✓ Gzip 압축
✓ Source maps
✓ 환경 변수 처리
✓ Bundle 분석 리포트

Express에서 수백 줄 걸려 구현했던 게 자동이야.

빌드 결과물

.next/
├── cache/                    # 빌드 캐시 (재빌드 시 빠름)
├── server/
   ├── app/
   ├── page.js          # 홈페이지 서버 번들
   ├── about/page.js    # About 페이지 서버 번들
   └── products/
       ├── page.js
       └── [id]/page.js
   └── chunks/              # 공통 서버 코드
├── static/
   ├── chunks/
   ├── main-a3f2c1b9.js        # 메인 런타임
   ├── webpack-8d4e5f67.js     # Webpack 런타임
   └── pages/
       ├── index-b2c3a456.js   # 홈페이지 클라이언트 코드
       ├── about-c9d8e123.js   # About 페이지 코드
       └── ...
   ├── css/
   ├── main-d4e5f678.css       # 전역 스타일
   └── about-e5f6a789.css      # 페이지별 CSS
   └── media/                       # 이미지 등
└── standalone/              # Docker용 독립 실행 파일 (선택적)

해시가 자동으로 붙어 있고, 페이지별로 분할되어 있어. manifest.json 같은 거 만들 필요 없이 Next.js가 알아서 관리해.

Next.js Config — 대부분은 기본값으로 충분

// next.config.ts
import type { NextConfig } from 'next';
 
const nextConfig: NextConfig = {
  // 대부분 프로젝트는 이것만으로 충분
  reactStrictMode: true,
 
  // 이미지 최적화 설정 (외부 도메인 허용)
  images: {
    domains: ['cdn.example.com', 'images.unsplash.com'],
    formats: ['image/avif', 'image/webp'],  // 자동 포맷 변환
  },
 
  // Docker 배포 시 (선택적)
  output: 'standalone',  // node_modules 없이 실행 가능한 번들
};
 
export default nextConfig;

그게 다야. Express에서 수백 줄 설정했던 거랑 비교해봐.

이미지 최적화 — 자동

Express에서는 sharp로 수동 변환했지? Next.js는 next/image 컴포넌트만 쓰면 끝:

// Express SSR 방식
<img src="/images/hero.webp" alt="Hero" />
// 문제:
// - 수동 WebP 변환
// - Responsive 없음
// - Lazy loading 수동 구현
// - Blur placeholder 없음
 
// Next.js 방식
import Image from 'next/image';
 
<Image
  src="/images/hero.png"      // 원본 PNG 그대로 써도 됨
  alt="Hero"
  width={1200}
  height={800}
  priority                     // LCP 최적화
  placeholder="blur"           // 로딩 중 blur 효과
  blurDataURL="data:image/..."
/>
 
// 자동 처리:
// ✓ 브라우저에 따라 AVIF/WebP/PNG 선택
// ✓ srcset 자동 생성 (1x, 2x, 3x)
// ✓ Lazy loading
// ✓ 크기 최적화 (1200x800 요청하면 그 크기로만 전송)
// ✓ CDN 캐싱

실제 HTML 출력:

<img
  srcset="
    /_next/image?url=/images/hero.png&w=640&q=75 640w,
    /_next/image?url=/images/hero.png&w=1200&q=75 1200w,
    /_next/image?url=/images/hero.png&w=2048&q=75 2048w
  "
  sizes="100vw"
  src="/_next/image?url=/images/hero.png&w=2048&q=75"
  alt="Hero"
  loading="lazy"
  style="color:transparent;background-image:url(data:image/svg+xml...)"
/>

sharp 없이도 자동으로 최적화돼.

ISR (Incremental Static Regeneration) — 캐싱이 이렇게 쉬워?

Express에서 SSR 캐싱하려면 Redis 설정하고 TTL 관리하고 복잡했지? Next.js는:

// app/products/page.tsx
export const revalidate = 60;  // 이 한 줄이 전부
 
export default async function ProductsPage() {
  const products = await fetch('https://api.example.com/products');
  return <ProductList products={products} />;
}

이게 끝이야. 이 한 줄로:

1. 첫 요청 → 서버에서 렌더링
2. 결과를 CDN에 캐싱
3. 60초 동안 모든 사용자에게 캐시된 HTML 제공
4. 60초 후 첫 요청 → 백그라운드에서 재생성
5. 재생성 완료 전까지는 기존 캐시 제공 (stale-while-revalidate)
6. 재생성 완료 → 새 버전으로 교체

Express에서는 이걸 직접 구현해야 했어:

// Express에서 같은 동작 구현하려면
const cache = new Map();
 
app.get('/products', async (req, res) => {
  const cacheKey = 'products-page';
  const cached = cache.get(cacheKey);
 
  if (cached) {
    const age = Date.now() - cached.timestamp;
 
    if (age < 60000) {
      // 60초 이내면 캐시 반환
      return res.send(cached.html);
    } else {
      // 60초 지났으면 stale 버전 먼저 보내고
      res.send(cached.html);
 
      // 백그라운드에서 재생성
      (async () => {
        const products = await fetch('https://api.example.com/products');
        const html = renderToString(<ProductList products={products} />);
        cache.set(cacheKey, { html, timestamp: Date.now() });
      })();
      return;
    }
  }
 
  // 캐시 없으면 생성
  const products = await fetch('https://api.example.com/products');
  const html = renderToString(<ProductList products={products} />);
  cache.set(cacheKey, { html, timestamp: Date.now() });
  res.send(html);
});

50줄 vs 1줄.

On-Demand Revalidation — API로 캐시 무효화

상품 정보가 업데이트되면 즉시 캐시를 갱신하고 싶을 때:

// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
 
export async function POST(request: Request) {
  const { path } = await request.json();
 
  // 인증 확인
  const token = request.headers.get('Authorization');
  if (token !== process.env.REVALIDATE_TOKEN) {
    return Response.json({ message: 'Unauthorized' }, { status: 401 });
  }
 
  // 캐시 무효화
  revalidatePath(path);
 
  return Response.json({ revalidated: true, now: Date.now() });
}

사용:

# 상품 정보 업데이트 후
curl -X POST https://example.com/api/revalidate \
  -H "Authorization: Bearer secret-token" \
  -d '{"path": "/products"}'
 
# → /products 페이지 즉시 재생성

Express에서는 이것도 직접 구현해야 해.

배포 — Vercel이라면 git push 끝

Vercel에 배포하는 게 가장 쉬워:

# 1. Vercel 계정 연결 (최초 1회)
npx vercel login
 
# 2. 프로젝트 초기화 (최초 1회)
npx vercel
 
# 3. 배포 (이후 매번)
git push origin main

그게 다야. git push 하면:

1. GitHub webhook → Vercel
2. Vercel이 자동으로 빌드 (next build)
3. CDN에 배포
4. SSL 인증서 자동 생성
5. Preview URL 생성 (예: my-app-git-main-username.vercel.app)
6. 커스텀 도메인 연결 (선택적)

실시간 로그도 볼 수 있어:

Running "npm run build"
...
✓ Creating an optimized production build
✓ Compiled successfully
...
Deployment complete!
https://my-app-a3f2c1b9.vercel.app

PR별로 자동 Preview 배포도 돼:

PR #42 opened → https://my-app-git-feature-username.vercel.app
PR merged → https://my-app.vercel.app (프로덕션)

Express에서는 이걸 Jenkins/GitHub Actions로 직접 구축해야 해.

Docker 배포 (자체 호스팅)

Vercel 안 쓰고 자체 서버에 배포하려면:

# Dockerfile
FROM node:20-alpine AS builder
 
WORKDIR /app
 
# 의존성 설치
COPY package*.json ./
RUN npm ci
 
# 빌드
COPY . .
RUN npm run build
 
# --- 프로덕션 이미지 ---
FROM node:20-alpine AS runner
 
WORKDIR /app
 
ENV NODE_ENV=production
 
# 시스템 사용자 추가 (보안)
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
 
# Standalone 모드 파일만 복사
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
 
USER nextjs
 
EXPOSE 3000
 
ENV PORT=3000
 
CMD ["node", "server.js"]

next.config.tsoutput: 'standalone' 추가하면 node_modules 없이 실행 가능한 독립 번들이 생성돼:

# 일반 빌드
.next/ + node_modules/ 300MB
 
# Standalone 빌드
.next/standalone/ 30MB (10분의 1!)

빌드 및 실행:

docker build -t my-nextjs-app .
docker run -p 3000:3000 my-nextjs-app

Express 배포랑 비교하면 Dockerfile은 비슷한데, 빌드 설정이 필요 없어. Next.js가 알아서 최적화해줘.

환경 변수 — 자동 분리

# .env.production
DATABASE_URL=postgres://...           # 서버 전용 (노출 안 됨)
API_SECRET_KEY=xyz123                 # 서버 전용
 
NEXT_PUBLIC_API_URL=https://...      # 클라이언트에도 노출됨
NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX       # 클라이언트에도 노출됨

NEXT_PUBLIC_ 접두사 유무로 자동 분리:

// 서버 컴포넌트 (app/page.tsx)
const dbUrl = process.env.DATABASE_URL;        // OK
const apiKey = process.env.API_SECRET_KEY;     // OK
const publicApi = process.env.NEXT_PUBLIC_API_URL;  // OK
 
// 클라이언트 컴포넌트
'use client';
 
const dbUrl = process.env.DATABASE_URL;        // ❌ undefined (보안상 차단)
const publicApi = process.env.NEXT_PUBLIC_API_URL;  // ✓ OK

Express에서는 이걸 esbuild define으로 수동 설정했지.

Bundle 분석 — 플러그인 하나

npm install @next/bundle-analyzer
// next.config.ts
import withBundleAnalyzer from '@next/bundle-analyzer';
 
const nextConfig = { /* ... */ };
 
export default withBundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
})(nextConfig);

실행:

ANALYZE=true npm run build

브라우저에 자동으로 인터랙티브 treemap 열려:

┌────────────────────────────────────────┐
│ Client Bundle                          │
├────────────────────────────────────────┤
│ ████████████ react-dom (130KB)        │
│ ██████ lodash (60KB)  ← 이거 줄여야 함 │
│ ████ next/router (40KB)               │
│ ██ home page (20KB)                   │
└────────────────────────────────────────┘

Express에서는 esbuild metafile 파싱해서 직접 시각화해야 해.

프로덕션 체크리스트 — 거의 안 해도 됨

빌드 최적화
☑ Code splitting (자동)
☑ Asset hashing (자동)
☑ CSS 최적화 (자동)
☑ Image 최적화 (next/image 사용만 하면 됨)
☑ Source maps (자동)
☑ Tree shaking (자동)
□ Bundle 분석 (선택적: @next/bundle-analyzer)
 
서버 설정
☑ Gzip 압축 (자동)
☑ Static 파일 캐시 (자동)
□ SSR 캐싱 (revalidate = 60 한 줄)
☑ 보안 헤더 (기본 적용됨)
□ Rate limiting (선택적: Vercel은 자동)
□ Error 핸들링 (선택적: Sentry 연동)
☑ Health check (자동: /_next/health)
 
배포 인프라
□ Git push → Vercel (자동 배포)
  또는
□ Docker 이미지 빌드 (standalone 모드)
☑ SSL/TLS (Vercel 자동, 자체 호스팅 시 nginx)
☑ 환경 변수 (NEXT_PUBLIC_ 접두사로 자동 분리)
□ 모니터링 (선택적: Vercel Analytics 내장)
☑ CDN (Vercel Edge Network 자동)
☑ Preview 배포 (PR별 자동)

Express에서 20개 체크했던 거의 절반이 자동이야.


빌드/배포 최종 비교

항목Express (A003)Next.js
빌드 명령어node build.js (수동 스크립트)next build
빌드 스크립트 길이~200줄0줄 (설정 파일만)
Code splitting수동 (format 제약)자동 (페이지별)
Asset hashing수동 (metafile → manifest.json)자동
CSS 최적화플러그인 수동 설정자동 (PostCSS 내장)
Image 최적화sharp로 수동 변환next/image 자동
캐싱Redis 수동 구현 (50줄+)revalidate = 60 (1줄)
압축compression 미들웨어자동
환경 변수esbuild define 수동NEXT_PUBLIC_ 자동 분리
Bundle 분석metafile 파싱 수동@next/bundle-analyzer
Docker 이미지 크기300MB (node_modules 포함)30MB (standalone)
배포 (Vercel)불가 (Express 전용)git push
배포 (자체 호스팅)Docker + PM2 + Nginx 수동Docker (간단)
배포 시간10분+ (수동 단계 많음)1분 (Vercel), 5분 (Docker)
CDNCloudflare 수동 설정Vercel Edge (자동)
Preview 배포수동 구현 (CI/CD)PR별 자동 URL
SSLLet's Encrypt + nginxVercel 자동, Docker는 수동
모니터링Sentry/Datadog 수동 연동Vercel Analytics 내장
Health check직접 구현 (/health)/_next/health 자동
보안 헤더Helmet 미들웨어기본 적용
Rate limitingexpress-rate-limitVercel 자동

개발자 시간 비교

Express SSR 프로덕션 준비

빌드 파이프라인 구축: 2일
이미지 최적화 구현: 1일
캐싱 시스템 (Redis): 1일
Docker/PM2 설정: 1일
Nginx 설정: 0.5일
CI/CD 파이프라인: 2일
모니터링/로깅 설정: 1일
---
총: 8.5일 (약 2주)

Next.js 프로덕션 준비

next.config.ts 설정: 0.5일
Vercel 연결: 0.5일
---
총: 1일

8배 차이.

실제 서비스 시나리오

시나리오 1: 긴급 버그 수정

Express SSR

1. 코드 수정
2. npm run build (빌드 4단계 실행)
3. Docker 이미지 빌드
4. 이미지 레지스트리 푸시
5. 서버에서 이미지 pull
6. PM2 reload
7. Nginx 재시작 (필요 시)
---
소요 시간: 15분

Next.js (Vercel)

1. 코드 수정
2. git push
---
소요 시간: 2분 (자동 빌드+배포)

시나리오 2: 트래픽 급증 (HackerNews 메인)

Express SSR

1. CPU 100% → SSR 병목
2. PM2 cluster 인스턴스 늘림 (수동)
3. Load balancer 설정 조정
4. Redis 캐시 TTL 늘림
5. Nginx worker 수 조정
6. 서버 추가 (필요 시)
---
소요 시간: 1시간+
스트레스: 높음

Next.js (Vercel)

1. Edge Network가 자동 스케일
2. ISR 캐시가 부하 흡수
---
소요 시간: 0분 (자동)
스트레스: 없음

시나리오 3: A/B 테스트

Express SSR

1. Feature flag 라이브러리 설치
2. SSR 로직에 분기 추가
3. 클라이언트 번들도 분기 추가
4. 빌드 → 배포
5. 분석 도구 수동 연동
---
소요 시간: 2일

Next.js + Vercel

1. Middleware에서 분기
2. git push
3. Vercel Analytics 자동 수집
---
소요 시간: 2시간
// middleware.ts
import { NextResponse } from 'next/server';
 
export function middleware(request: Request) {
  const variant = Math.random() > 0.5 ? 'A' : 'B';
  const response = NextResponse.next();
  response.cookies.set('ab-test-variant', variant);
  return response;
}
 
// app/page.tsx
import { cookies } from 'next/headers';
 
export default function HomePage() {
  const variant = cookies().get('ab-test-variant')?.value;
 
  return variant === 'A' ? <HomePageA /> : <HomePageB />;
}

깨달음 포인트

도연씨, 지금까지 본 대로 개발 환경에서는 차이가 작아도, 프로덕션 배포에서 차이가 엄청나.

Express SSR은:

  • 완전한 제어권: 빌드 도구, 캐싱 전략, 배포 방식 모두 선택 가능
  • 학습 가치: esbuild, Docker, PM2, Nginx 다 배우게 됨
  • 시간 소모: 프로덕션 준비에 2주+
  • 유지보수 부담: 빌드 파이프라인, 캐싱, 배포 모두 직접 관리
  • 운영 리스크: 설정 실수로 장애 가능성

Next.js는:

  • 빠른 출시: 프로덕션 준비 1일
  • 자동 최적화: Code splitting, ISR, Image 등 자동
  • 안정성: 검증된 빌드 시스템
  • 개발 집중: 비즈니스 로직에만 집중 가능
  • 제어권 제한: 내부 동작 커스터마이징 어려움
  • 벤더 락인: Vercel 의존도 (자체 호스팅은 가능하지만 기능 제한)

언제 Express SSR을 선택할까?

  • 빌드 파이프라인 학습이 목적
  • 기존 Express 서버가 있고 SSR만 추가
  • 매우 특수한 빌드 요구사항 (예: Rollup 필수)
  • 프로덕션 배포 시간 여유 충분

언제 Next.js를 선택할까?

  • 빠른 MVP 출시 (1주일 안에 프로덕션)
  • 작은 팀 (인프라 엔지니어 없음)
  • 운영 부담 최소화
  • Vercel의 Edge Network 활용
  • 99.9%의 일반적인 웹 서비스

현실적으로 스타트업이나 작은 팀이면 Next.js가 압도적으로 유리해. 개발자가 인프라 걱정 없이 제품 개발에만 집중할 수 있거든.


다음 단계

Step 07: 종합 비교 및 선택 가이드에서 지금까지의 모든 비교(라우팅, 데이터, 렌더링, 성능, 배포)를 종합하고, 실제 프로젝트에서 어떤 걸 선택해야 할지 의사결정 프레임워크를 제시합니다.


작성: 시니어 개발자 이준혁 (8년차) 검수: PM 김도연 마지막 업데이트: 2026-02-09