Node.js 배포 가이드 | PM2, Docker, AWS, Nginx
이 글의 핵심
Node.js 배포 가이드에 대한 실전 가이드입니다. PM2, Docker, AWS, Nginx 등을 예제와 함께 상세히 설명합니다.
들어가며
배포 체크리스트
로컬에서 node app.js로 돌리던 것을 프로세스 관리(PM2)·리버스 프록시(Nginx)·컨테이너(Docker)까지 올리면, 재시작·로그·환경 분리·무중단 배포 같은 운영 요구를 맞출 수 있습니다. 아래 체크리스트는 “코드만 올리는 것”이 아니라 실행 환경까지 포함했을 때의 최소 점검 항목입니다.
배포 전에는 Node.js 테스트(Jest 등)로 회귀를 막고, GitHub Actions CI/CD로 빌드·테스트를 자동화하는 흐름이 흔합니다. 로컬·스테이징은 Docker Compose, 오케스트레이션 입문은 minikube, C++·네이티브 쪽은 C++ Docker·배포 이미지·C++ GitHub Actions와 같은 언어 무관 패턴을 비교해 보세요.
배포 전 확인사항:
- 환경 변수 설정
- 프로덕션 의존성만 설치
- 에러 로깅 설정
- 보안 설정 (Helmet, CORS)
- 데이터베이스 마이그레이션
- 정적 파일 빌드
- 테스트 통과
- 성능 테스트
배포 방식:
- ✅ 전통적 방식: VPS, PM2, Nginx
- ✅ 컨테이너: Docker, Kubernetes
- ✅ 서버리스: AWS Lambda, Vercel
- ✅ PaaS: Heroku, Railway, Render
1. PM2 (Process Manager)
설치
# 전역 설치
npm install -g pm2
기본 사용법
# 앱 시작
pm2 start app.js
# 이름 지정
pm2 start app.js --name "my-app"
# 환경 변수 설정
pm2 start app.js --name "my-app" --env production
# Watch 모드 (파일 변경 시 재시작)
pm2 start app.js --watch
# 인터프리터 지정
pm2 start app.js --interpreter node
클러스터 모드
# 클러스터 모드 (멀티 코어 활용)
pm2 start app.js -i max # CPU 코어 수만큼
# 특정 개수
pm2 start app.js -i 4
# 무중단 재시작
pm2 reload my-app
PM2 명령어
# 상태 확인
pm2 status
pm2 list
# 로그 확인
pm2 logs
pm2 logs my-app
pm2 logs --lines 100
# 모니터링
pm2 monit
# 재시작
pm2 restart my-app
pm2 restart all
# 중지
pm2 stop my-app
pm2 stop all
# 삭제
pm2 delete my-app
pm2 delete all
# 정보
pm2 info my-app
# 저장 (현재 프로세스 목록)
pm2 save
# 부팅 시 자동 시작
pm2 startup
pm2 save
ecosystem.config.js
// ecosystem.config.js
module.exports = {
apps: [{
name: 'my-app',
script: './app.js',
instances: 'max',
exec_mode: 'cluster',
env: {
NODE_ENV: 'development',
PORT: 3000
},
env_production: {
NODE_ENV: 'production',
PORT: 8080
},
error_file: './logs/err.log',
out_file: './logs/out.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss',
merge_logs: true,
max_memory_restart: '500M',
watch: false,
ignore_watch: ['node_modules', 'logs'],
max_restarts: 10,
min_uptime: '10s'
}]
};
사용:
# 시작
pm2 start ecosystem.config.js
# 프로덕션 환경
pm2 start ecosystem.config.js --env production
# 재시작
pm2 restart ecosystem.config.js
2. Docker
Dockerfile
# Dockerfile
FROM node:20-alpine
# 작업 디렉토리
WORKDIR /app
# 의존성 파일 복사
COPY package*.json ./
# 의존성 설치
RUN npm ci --only=production
# 소스 코드 복사
COPY . .
# 포트 노출
EXPOSE 3000
# 헬스체크
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node healthcheck.js
# 앱 시작
CMD ["node", "app.js"]
.dockerignore
node_modules
npm-debug.log
.env
.git
.gitignore
README.md
.vscode
coverage
.DS_Store
Docker 명령어
# 이미지 빌드
docker build -t my-app:1.0.0 .
# 컨테이너 실행
docker run -d \
--name my-app \
-p 3000:3000 \
-e NODE_ENV=production \
-e PORT=3000 \
my-app:1.0.0
# 로그 확인
docker logs my-app
docker logs -f my-app # 실시간
# 컨테이너 중지/시작
docker stop my-app
docker start my-app
# 컨테이너 재시작
docker restart my-app
# 컨테이너 삭제
docker rm my-app
# 이미지 삭제
docker rmi my-app:1.0.0
Docker Compose
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
- MONGODB_URI=mongodb://mongo:27017/mydb
depends_on:
- mongo
restart: unless-stopped
volumes:
- ./logs:/app/logs
mongo:
image: mongo:7
ports:
- "27017:27017"
volumes:
- mongo-data:/data/db
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./ssl:/etc/nginx/ssl
depends_on:
- app
restart: unless-stopped
volumes:
mongo-data:
실행:
# 시작
docker-compose up -d
# 로그
docker-compose logs -f
# 중지
docker-compose down
# 재시작
docker-compose restart
# 빌드 후 시작
docker-compose up -d --build
3. Nginx 리버스 프록시
설치 (Ubuntu)
sudo apt update
sudo apt install nginx
기본 설정
# /etc/nginx/sites-available/myapp
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
location / {
proxy_pass http://localhost:3000;
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;
}
}
활성화:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
SSL (Let’s Encrypt)
# Certbot 설치
sudo apt install certbot python3-certbot-nginx
# SSL 인증서 발급
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
# 자동 갱신 테스트
sudo certbot renew --dry-run
SSL 설정:
server {
listen 443 ssl http2;
server_name yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
location / {
proxy_pass http://localhost:3000;
# ... 프록시 설정
}
}
# HTTP → HTTPS 리다이렉트
server {
listen 80;
server_name yourdomain.com;
return 301 https://$server_name$request_uri;
}
로드 밸런싱
# upstream 정의
upstream backend {
least_conn; # 연결 수가 적은 서버로
server localhost:3000;
server localhost:3001;
server localhost:3002;
}
server {
listen 80;
server_name yourdomain.com;
location / {
proxy_pass http://backend;
# ... 프록시 설정
}
}
4. AWS 배포
EC2 배포
1. EC2 인스턴스 생성:
- Ubuntu Server 선택
- 보안 그룹: HTTP(80), HTTPS(443), SSH(22) 포트 열기
2. 서버 설정:
# SSH 접속
ssh -i your-key.pem ubuntu@your-ec2-ip
# Node.js 설치
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
# Git 설치
sudo apt-get install git
# 프로젝트 클론
git clone https://github.com/yourusername/your-repo.git
cd your-repo
# 의존성 설치
npm ci --only=production
# 환경 변수 설정
nano .env
# PM2로 실행
npm install -g pm2
pm2 start app.js --name "my-app" -i max
pm2 startup
pm2 save
# Nginx 설정
sudo apt install nginx
sudo nano /etc/nginx/sites-available/myapp
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Elastic Beanstalk
설치:
pip install awsebcli
초기화:
eb init
# 환경 생성 및 배포
eb create production
eb deploy
# 로그 확인
eb logs
# 환경 변수 설정
eb setenv NODE_ENV=production PORT=8080
# 상태 확인
eb status
# 종료
eb terminate production
Lambda (서버리스)
// lambda.js
const serverless = require('serverless-http');
const app = require('./app');
module.exports.handler = serverless(app);
serverless.yml:
service: my-app
provider:
name: aws
runtime: nodejs20.x
region: ap-northeast-2
functions:
app:
handler: lambda.handler
events:
- http:
path: /{proxy+}
method: ANY
cors: true
배포:
npm install -g serverless
serverless deploy
5. 환경 변수 관리
.env 파일
# .env.development
NODE_ENV=development
PORT=3000
MONGODB_URI=mongodb://localhost:27017/mydb-dev
JWT_SECRET=dev-secret
# .env.production
NODE_ENV=production
PORT=8080
MONGODB_URI=mongodb://prod-server:27017/mydb
JWT_SECRET=super-secret-production-key
dotenv 사용
// config.js
require('dotenv').config({
path: `.env.${process.env.NODE_ENV || 'development'}`
});
const config = {
nodeEnv: process.env.NODE_ENV || 'development',
port: process.env.PORT || 3000,
mongodbUri: process.env.MONGODB_URI,
jwtSecret: process.env.JWT_SECRET,
isDevelopment: process.env.NODE_ENV === 'development',
isProduction: process.env.NODE_ENV === 'production',
isTest: process.env.NODE_ENV === 'test'
};
// 필수 환경 변수 검증
const required = ['MONGODB_URI', 'JWT_SECRET'];
for (const key of required) {
if (!process.env[key]) {
throw new Error(`환경 변수 ${key}가 필요합니다`);
}
}
module.exports = config;
AWS Systems Manager Parameter Store
# AWS CLI로 환경 변수 저장
aws ssm put-parameter \
--name "/myapp/production/JWT_SECRET" \
--value "your-secret-key" \
--type "SecureString"
// config/aws.js
const AWS = require('aws-sdk');
const ssm = new AWS.SSM({ region: 'ap-northeast-2' });
async function loadConfig() {
const params = {
Names: [
'/myapp/production/JWT_SECRET',
'/myapp/production/MONGODB_URI'
],
WithDecryption: true
};
const result = await ssm.getParameters(params).promise();
const config = {};
result.Parameters.forEach(param => {
const key = param.Name.split('/').pop();
config[key] = param.Value;
});
return config;
}
module.exports = { loadConfig };
6. 로깅
Winston
npm install winston
// logger.js
const winston = require('winston');
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: { service: 'my-app' },
transports: [
// 파일 로그
new winston.transports.File({
filename: 'logs/error.log',
level: 'error'
}),
new winston.transports.File({
filename: 'logs/combined.log'
})
]
});
// 개발 환경에서는 콘솔 출력
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}));
}
module.exports = logger;
// app.js
const logger = require('./logger');
logger.info('서버 시작', { port: 3000 });
logger.error('에러 발생', { error: err.message, stack: err.stack });
logger.warn('경고', { memory: process.memoryUsage() });
Morgan (HTTP 로깅)
const morgan = require('morgan');
const logger = require('./logger');
// 커스텀 스트림
const stream = {
write: (message) => {
logger.info(message.trim());
}
};
// 프로덕션
if (process.env.NODE_ENV === 'production') {
app.use(morgan('combined', { stream }));
} else {
app.use(morgan('dev'));
}
7. 모니터링
PM2 모니터링
# 실시간 모니터링
pm2 monit
# 웹 대시보드
pm2 plus
헬스체크
// healthcheck.js
const http = require('http');
const options = {
host: 'localhost',
port: 3000,
path: '/health',
timeout: 2000
};
const request = http.request(options, (res) => {
if (res.statusCode === 200) {
process.exit(0);
} else {
process.exit(1);
}
});
request.on('error', () => {
process.exit(1);
});
request.end();
// app.js
app.get('/health', (req, res) => {
res.status(200).json({
status: 'ok',
uptime: process.uptime(),
timestamp: Date.now()
});
});
메트릭 수집
npm install prom-client
const promClient = require('prom-client');
// 기본 메트릭 수집
const register = new promClient.Registry();
promClient.collectDefaultMetrics({ register });
// 커스텀 메트릭
const httpRequestDuration = new promClient.Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP 요청 처리 시간',
labelNames: ['method', 'route', 'status_code'],
registers: [register]
});
// 미들웨어
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = (Date.now() - start) / 1000;
httpRequestDuration
.labels(req.method, req.route?.path || req.path, res.statusCode)
.observe(duration);
});
next();
});
// 메트릭 엔드포인트
app.get('/metrics', async (req, res) => {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
});
8. CI/CD
GitHub Actions
# .github/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Run linter
run: npm run lint
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Deploy to EC2
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.EC2_HOST }}
username: ubuntu
key: ${{ secrets.EC2_SSH_KEY }}
script: |
cd /home/ubuntu/my-app
git pull origin main
npm ci --only=production
pm2 reload ecosystem.config.js --env production
Docker 이미지 빌드 및 배포
# .github/workflows/docker.yml
name: Build and Deploy Docker
on:
push:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: yourusername/my-app:latest
- name: Deploy to server
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_KEY }}
script: |
docker pull yourusername/my-app:latest
docker stop my-app || true
docker rm my-app || true
docker run -d \
--name my-app \
-p 3000:3000 \
-e NODE_ENV=production \
yourusername/my-app:latest
9. 무중단 배포
PM2 무중단 재시작
# reload: 무중단 재시작 (클러스터 모드)
pm2 reload my-app
# gracefulReload: 더 안전한 재시작
pm2 gracefulReload my-app
Graceful Shutdown
// app.js
const express = require('express');
const app = express();
const server = app.listen(3000);
// 진행 중인 요청 추적
let connections = new Set();
server.on('connection', (conn) => {
connections.add(conn);
conn.on('close', () => {
connections.delete(conn);
});
});
// Graceful Shutdown
function gracefulShutdown(signal) {
console.log(`${signal} 신호 받음. 서버 종료 중...`);
// 새 연결 거부
server.close(async () => {
console.log('서버 종료됨');
// 데이터베이스 연결 종료
await mongoose.connection.close();
process.exit(0);
});
// 30초 후 강제 종료
setTimeout(() => {
console.error('강제 종료');
process.exit(1);
}, 30000);
// 기존 연결 종료
connections.forEach((conn) => {
conn.end();
setTimeout(() => {
conn.destroy();
}, 5000);
});
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
10. 자주 발생하는 문제
문제 1: 포트 충돌
에러:
Error: listen EADDRINUSE: address already in use :::3000
해결:
# 프로세스 찾기
lsof -i :3000
netstat -ano | findstr :3000
# 프로세스 종료
kill -9 <PID>
taskkill /PID <PID> /F
# PM2로 관리
pm2 delete all
pm2 start app.js
문제 2: 메모리 누수
증상: 메모리 사용량이 계속 증가
해결:
// PM2 설정
module.exports = {
apps: [{
name: 'my-app',
script: './app.js',
max_memory_restart: '500M' // 500MB 초과 시 재시작
}]
};
// 메모리 모니터링
setInterval(() => {
const used = process.memoryUsage();
console.log({
rss: `${Math.round(used.rss / 1024 / 1024)} MB`,
heapTotal: `${Math.round(used.heapTotal / 1024 / 1024)} MB`,
heapUsed: `${Math.round(used.heapUsed / 1024 / 1024)} MB`
});
}, 60000);
문제 3: 환경 변수 누락
// 시작 시 검증
const required = [
'NODE_ENV',
'PORT',
'MONGODB_URI',
'JWT_SECRET'
];
for (const key of required) {
if (!process.env[key]) {
console.error(`환경 변수 ${key}가 설정되지 않았습니다`);
process.exit(1);
}
}
11. 실전 팁
배포 스크립트
{
"scripts": {
"start": "node app.js",
"dev": "nodemon app.js",
"test": "jest",
"lint": "eslint .",
"build": "npm ci --only=production",
"deploy": "npm run test && npm run build && pm2 reload ecosystem.config.js"
}
}
롤백 전략
# Git 태그로 버전 관리
git tag v1.0.0
git push origin v1.0.0
# 배포
git checkout v1.0.0
npm ci --only=production
pm2 reload my-app
# 롤백
git checkout v0.9.9
npm ci --only=production
pm2 reload my-app
블루-그린 배포
# Nginx 설정
upstream backend {
server localhost:3000; # Blue (현재)
}
# 배포 시:
# 1. Green 환경에 새 버전 배포 (포트 3001)
# 2. 테스트
# 3. Nginx 설정 변경
upstream backend {
server localhost:3001; # Green (새 버전)
}
# 4. Nginx 리로드
# 5. Blue 환경 종료
정리
핵심 요약
- PM2: 프로세스 관리, 클러스터 모드, 자동 재시작
- Docker: 컨테이너화, 환경 일관성
- Nginx: 리버스 프록시, SSL, 로드 밸런싱
- AWS: EC2, Elastic Beanstalk, Lambda
- CI/CD: GitHub Actions, 자동 배포
- 모니터링: 로그, 메트릭, 헬스체크
배포 방식 비교
| 방식 | 장점 | 단점 | 사용 사례 |
|---|---|---|---|
| VPS + PM2 | 완전한 제어 | 관리 부담 | 중소규모 |
| Docker | 환경 일관성 | 학습 곡선 | 마이크로서비스 |
| PaaS | 간편함 | 제한적 | 빠른 프로토타입 |
| 서버리스 | 자동 확장 | Cold Start | 이벤트 기반 |
배포 체크리스트
코드:
- 테스트 통과
- Linter 통과
- 프로덕션 빌드
- 의존성 최신화
설정:
- 환경 변수 설정
- 데이터베이스 마이그레이션
- SSL 인증서
- 방화벽 규칙
모니터링:
- 로깅 설정
- 에러 추적
- 성능 모니터링
- 알림 설정
보안:
- 비밀 키 관리
- HTTPS 적용
- Rate Limiting
- 보안 헤더
다음 단계
- Node.js 성능 최적화
- Node.js 보안 심화
- Node.js 마이크로서비스
추천 학습 자료
도구:
플랫폼:
관련 글
- Node.js 시작하기 | 설치, 설정, Hello World
- Node.js 모듈 시스템 | CommonJS와 ES Modules 완벽 가이드