node server.js로 프로덕션 서버를 운영하면 안 되는 이유가 뭘까요?

개발 환경에서는 node server.js면 충분하지만, 프로덕션에서는 프로세스가 죽으면 자동 재시작되어야 하고, CPU 코어를 모두 활용해야 하며, 환경 설정이 코드와 분리되어야 합니다. 이 세 가지를 어떻게 해결하는지 살펴보겠습니다.

PM2 — Node.js 프로세스 매니저

왜 PM2인가

node server.js의 문제점은 명확합니다.

  1. 에러로 프로세스가 죽으면 **아무도 재시작해주지 않습니다 **.
  2. Node.js는 싱글 스레드이므로 **CPU 코어 하나만 사용합니다 **.
  3. 로그가 콘솔에만 찍히고 ** 파일로 보관되지 않습니다 **.

PM2가 이 세 가지를 모두 해결합니다.

클러스터 모드

PM2의 클러스터 모드는 Node.js의 cluster 모듈을 활용하여 여러 프로세스를 생성합니다.

PLAINTEXT
              ┌── Worker 1 (CPU 코어 1)

PM2 Master ──├── Worker 2 (CPU 코어 2)

              ├── Worker 3 (CPU 코어 3)

              └── Worker 4 (CPU 코어 4)

Master 프로세스가 요청을 Worker에 분배합니다. Worker가 죽으면 Master가 자동으로 새 Worker를 생성합니다.

BASH
# 클러스터 모드로 시작 (CPU 코어 수만큼 프로세스)
pm2 start server.js -i max

# 상태 확인
pm2 status

# 무중단 재시작
pm2 reload server.js

-i max는 사용 가능한 모든 CPU 코어 수만큼 워커를 생성합니다. pm2 reload는 워커를 하나씩 순차적으로 재시작하여 다운타임 없이 배포합니다.

ecosystem.config.js

PM2 설정을 파일로 관리할 수 있습니다.

JS
// ecosystem.config.js
module.exports = {
  apps: [{
    name: 'express-api',
    script: './server.js',
    instances: 'max',         // CPU 코어 수만큼
    exec_mode: 'cluster',     // 클러스터 모드
    env: {
      NODE_ENV: 'production',
      PORT: 3000,
    },
    max_memory_restart: '500M', // 메모리 500MB 초과 시 재시작
    log_date_format: 'YYYY-MM-DD HH:mm:ss',
  }],
};
BASH
pm2 start ecosystem.config.js

max_memory_restart는 메모리 누수가 발생해도 서버가 다운되기 전에 프로세스를 재시작합니다.

Docker — 컨테이너화

왜 Docker인가

"내 로컬에서는 되는데요" 문제를 해결합니다. Node.js 버전, OS 패키지, 환경 변수가 모두 Dockerfile에 선언되므로 어디서든 동일한 환경으로 실행됩니다.

멀티스테이지 빌드

빌드 의존성과 런타임 의존성을 분리하여 이미지 크기를 줄입니다.

DOCKERFILE
# Stage 1: 빌드
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci                    # ← devDependencies 포함 설치
COPY . .
RUN npm run build             # TypeScript 컴파일 등

# Stage 2: 런타임
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev         # ← devDependencies 제외
COPY --from=builder /app/dist ./dist
EXPOSE 3000
USER node                     # ← root가 아닌 node 유저로 실행
CMD ["node", "dist/server.js"]

위 Dockerfile에서 핵심은 세 가지입니다.

  • npm ci: package-lock.json 기반으로 정확한 버전을 설치합니다. npm install보다 빠르고 재현 가능합니다.
  • --omit=dev: 런타임에 필요 없는 jest, nodemon 등을 제외하여 이미지 크기를 줄입니다.
  • USER node: 컨테이너를 root가 아닌 일반 사용자로 실행하여 보안을 강화합니다.

.dockerignore

빌드 컨텍스트에서 불필요한 파일을 제외합니다.

PLAINTEXT
node_modules
.git
.env
npm-debug.log
dist

node_modules를 제외하지 않으면 로컬의 OS별 바이너리가 컨테이너에 복사되어 문제가 발생합니다.

환경 변수 관리

12-Factor App 원칙

12-Factor App의 3번째 원칙: ** 설정을 환경 변수에 저장한다.**

PLAINTEXT
코드에 하드코딩     ❌  보안 취약, 환경별 분기 불가능
설정 파일(.env)    △  gitignore 필수, 로컬 개발용
환경 변수          ✅  환경별로 다른 값, 코드 변경 불필요

dotenv 패턴

로컬 개발에서는 .env 파일, 프로덕션에서는 실제 환경 변수를 사용합니다.

BASH
# .env (gitignore에 추가 필수!)
DATABASE_URL=postgresql://localhost:5432/mydb
JWT_SECRET=dev-secret-key
PORT=3000
JS
// 앱 시작 시점에 한 번만 로드
require('dotenv').config();

const port = process.env.PORT || 3000;
const dbUrl = process.env.DATABASE_URL;

dotenv.env 파일을 읽어서 process.env에 주입합니다. 프로덕션에서는 Docker의 -e 플래그나 Kubernetes의 ConfigMap으로 환경 변수를 주입하므로 dotenv가 필요 없습니다.

환경 변수 검증

서버가 시작될 때 필수 환경 변수가 있는지 확인합니다.

JS
const required = ['DATABASE_URL', 'JWT_SECRET'];
for (const key of required) {
  if (!process.env[key]) {
    console.error(`Missing env: ${key}`);
    process.exit(1); // ← 환경 변수 없으면 시작하지 않음
  }
}

이 검증이 없으면 서버가 시작된 후 첫 DB 연결 시점에서야 에러가 발생합니다. 빠르게 실패하는 것이 디버깅에 유리합니다.

Docker + PM2 조합

Docker 컨테이너 안에서 PM2를 사용하면 두 가지 장점을 모두 얻을 수 있습니다.

DOCKERFILE
FROM node:20-alpine
RUN npm install -g pm2
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
USER node
CMD ["pm2-runtime", "ecosystem.config.js"]

pm2-runtime은 Docker 컨테이너 전용 명령어입니다. 일반 pm2 start는 백그라운드로 실행되어 컨테이너가 즉시 종료되지만, pm2-runtime은 포그라운드에서 실행됩니다.

주의할 점

.env 파일을 git에 커밋하지 않기

BASH
# .gitignore에 반드시 추가
.env
.env.local
.env.production

비밀키, DB 비밀번호 등이 git 히스토리에 남으면 제거가 매우 어렵습니다.

Docker에서 npm install 대신 npm ci 사용

npm installpackage-lock.json을 수정할 수 있습니다. Docker 빌드의 재현성이 깨집니다. npm ci는 lock 파일과 정확히 일치하는 버전만 설치합니다.

PM2 클러스터 모드에서 상태 공유 불가

클러스터 모드의 각 워커는 독립된 프로세스입니다. 메모리에 저장한 세션이나 캐시는 워커 간에 공유되지 않습니다.

JS
// 잘못된 패턴 — 워커 간 공유 안 됨
const sessions = new Map();

// 올바른 패턴 — Redis로 외부 저장소 사용
const session = require('express-session');
const RedisStore = require('connect-redis').default;

클러스터 모드에서 상태를 공유하려면 Redis 같은 외부 저장소를 사용해야 합니다. 이 원칙은 12-Factor App의 6번째 원칙(Stateless Processes)과 같습니다.

정리

항목설명
PM2프로세스 관리, 클러스터 모드로 CPU 코어 활용, 자동 재시작
pm2 reload무중단 재배포 (워커 순차 재시작)
Docker 멀티스테이지빌드/런타임 분리로 이미지 크기 최소화
npm cilock 파일 기반 재현 가능한 설치
환경 변수12-Factor 원칙, .env는 로컬만, 프로덕션은 실제 환경 변수
클러스터 주의워커 간 메모리 비공유, 상태는 외부 저장소(Redis)
댓글 로딩 중...