배포 — PM2, Docker, 환경 변수 관리
node server.js로 프로덕션 서버를 운영하면 안 되는 이유가 뭘까요?
개발 환경에서는 node server.js면 충분하지만, 프로덕션에서는 프로세스가 죽으면 자동 재시작되어야 하고, CPU 코어를 모두 활용해야 하며, 환경 설정이 코드와 분리되어야 합니다. 이 세 가지를 어떻게 해결하는지 살펴보겠습니다.
PM2 — Node.js 프로세스 매니저
왜 PM2인가
node server.js의 문제점은 명확합니다.
- 에러로 프로세스가 죽으면 **아무도 재시작해주지 않습니다 **.
- Node.js는 싱글 스레드이므로 **CPU 코어 하나만 사용합니다 **.
- 로그가 콘솔에만 찍히고 ** 파일로 보관되지 않습니다 **.
PM2가 이 세 가지를 모두 해결합니다.
클러스터 모드
PM2의 클러스터 모드는 Node.js의 cluster 모듈을 활용하여 여러 프로세스를 생성합니다.
┌── Worker 1 (CPU 코어 1)
│
PM2 Master ──├── Worker 2 (CPU 코어 2)
│
├── Worker 3 (CPU 코어 3)
│
└── Worker 4 (CPU 코어 4)
Master 프로세스가 요청을 Worker에 분배합니다. Worker가 죽으면 Master가 자동으로 새 Worker를 생성합니다.
# 클러스터 모드로 시작 (CPU 코어 수만큼 프로세스)
pm2 start server.js -i max
# 상태 확인
pm2 status
# 무중단 재시작
pm2 reload server.js
-i max는 사용 가능한 모든 CPU 코어 수만큼 워커를 생성합니다. pm2 reload는 워커를 하나씩 순차적으로 재시작하여 다운타임 없이 배포합니다.
ecosystem.config.js
PM2 설정을 파일로 관리할 수 있습니다.
// 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',
}],
};
pm2 start ecosystem.config.js
max_memory_restart는 메모리 누수가 발생해도 서버가 다운되기 전에 프로세스를 재시작합니다.
Docker — 컨테이너화
왜 Docker인가
"내 로컬에서는 되는데요" 문제를 해결합니다. Node.js 버전, OS 패키지, 환경 변수가 모두 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
빌드 컨텍스트에서 불필요한 파일을 제외합니다.
node_modules
.git
.env
npm-debug.log
dist
node_modules를 제외하지 않으면 로컬의 OS별 바이너리가 컨테이너에 복사되어 문제가 발생합니다.
환경 변수 관리
12-Factor App 원칙
12-Factor App의 3번째 원칙: ** 설정을 환경 변수에 저장한다.**
코드에 하드코딩 ❌ 보안 취약, 환경별 분기 불가능
설정 파일(.env) △ gitignore 필수, 로컬 개발용
환경 변수 ✅ 환경별로 다른 값, 코드 변경 불필요
dotenv 패턴
로컬 개발에서는 .env 파일, 프로덕션에서는 실제 환경 변수를 사용합니다.
# .env (gitignore에 추가 필수!)
DATABASE_URL=postgresql://localhost:5432/mydb
JWT_SECRET=dev-secret-key
PORT=3000
// 앱 시작 시점에 한 번만 로드
require('dotenv').config();
const port = process.env.PORT || 3000;
const dbUrl = process.env.DATABASE_URL;
dotenv는 .env 파일을 읽어서 process.env에 주입합니다. 프로덕션에서는 Docker의 -e 플래그나 Kubernetes의 ConfigMap으로 환경 변수를 주입하므로 dotenv가 필요 없습니다.
환경 변수 검증
서버가 시작될 때 필수 환경 변수가 있는지 확인합니다.
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를 사용하면 두 가지 장점을 모두 얻을 수 있습니다.
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에 커밋하지 않기
# .gitignore에 반드시 추가
.env
.env.local
.env.production
비밀키, DB 비밀번호 등이 git 히스토리에 남으면 제거가 매우 어렵습니다.
Docker에서 npm install 대신 npm ci 사용
npm install은 package-lock.json을 수정할 수 있습니다. Docker 빌드의 재현성이 깨집니다. npm ci는 lock 파일과 정확히 일치하는 버전만 설치합니다.
PM2 클러스터 모드에서 상태 공유 불가
클러스터 모드의 각 워커는 독립된 프로세스입니다. 메모리에 저장한 세션이나 캐시는 워커 간에 공유되지 않습니다.
// 잘못된 패턴 — 워커 간 공유 안 됨
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 ci | lock 파일 기반 재현 가능한 설치 |
| 환경 변수 | 12-Factor 원칙, .env는 로컬만, 프로덕션은 실제 환경 변수 |
| 클러스터 주의 | 워커 간 메모리 비공유, 상태는 외부 저장소(Redis) |