Node.js 배포 가이드 | PM2, Docker, AWS, Nginx
이 글의 핵심
PM2 클러스터 모드와 Docker 컨테이너는 같은 문제(재시작·다중 코어)를 서로 다른 계층에서 풀기 때문에 둘을 겹쳐 쓰면 오히려 운영이 꼬입니다. Nginx 뒤에서 trust proxy를 빠뜨렸을 때의 증상, 컨테이너에서 SIGTERM이 전달되지 않는 이유, npm ci --omit=dev처럼 바뀐 옵션까지 배포 단계별로 정리합니다.
들어가며
배포 체크리스트
로컬에서 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
방식을 고를 때 핵심 질문은 “재시작, 로그 수집, 다중 인스턴스, TLS를 누가 책임지는가”입니다. VPS에서는 이 네 가지를 PM2와 Nginx가 나눠 맡고, 컨테이너 환경에서는 Docker·오케스트레이터와 로드 밸런서가 맡으며, PaaS와 서버리스에서는 플랫폼이 대신합니다. 문제는 계층을 섞을 때 생깁니다. 예를 들어 Docker 안에서 PM2 클러스터 모드를 쓰고 그 컨테이너를 다시 여러 개 띄우면, 누가 프로세스를 재시작했는지, 로그가 어디로 가는지 추적하기 어려워집니다. 아래 절들은 각 도구를 따로 설명하지만, 실제로는 하나의 계층에 한 가지 책임만 두는 조합을 고르는 것이 운영을 단순하게 만듭니다.
PM2 (Process Manager)
설치
# 전역 설치
npm install -g pm2
기본 사용법
# 앱 시작
pm2 start app.js
# 이름 지정
pm2 start app.js --name "my-app"
# 환경별 설정 (--env는 ecosystem 파일의 env_<이름> 블록을 선택할 때만 의미 있음)
NODE_ENV=production pm2 start app.js --name "my-app"
# 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
클러스터 모드는 Node.js 내장 cluster 모듈로 워커 프로세스를 여러 개 띄우고, 마스터가 들어오는 연결을 워커에 나눠 줍니다. Node.js의 JavaScript 실행은 한 프로세스당 한 스레드라서, 코어가 여러 개인 서버에서 단일 프로세스로 돌리면 나머지 코어가 놀게 됩니다. 다만 워커끼리는 메모리를 공유하지 않으므로 메모리에 세션이나 캐시를 저장하는 코드는 클러스터 모드에서 깨집니다. 로그인한 사용자가 요청마다 다른 워커에 걸려 로그아웃된 것처럼 보이는 증상이 대표적이며, 세션은 Redis 같은 외부 저장소로 옮겨야 합니다. WebSocket(Socket.IO)도 연결이 워커에 고정되어야 하므로 sticky session과 어댑터 설정이 따로 필요합니다. pm2 reload는 워커를 하나씩 교체하므로 인스턴스가 2개 이상일 때만 무중단이 되며, 인스턴스가 1개라면 restart와 사실상 같습니다.
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
pm2 startup은 systemd 서비스 등록 명령을 출력만 합니다. 출력된 sudo env PATH=... pm2 startup systemd -u ubuntu --hp /home/ubuntu 줄을 복사해 실행해야 실제로 등록되고, 그 뒤 pm2 save로 현재 프로세스 목록을 덤프해 두어야 재부팅 후 복원됩니다. nvm으로 Node.js 버전을 바꾸면 이 서비스가 옛 경로의 node를 가리켜 재부팅 뒤 기동에 실패하는 일이 흔하므로, 버전을 올린 뒤에는 pm2 unstartup 후 다시 등록하는 것이 안전합니다. --watch는 개발용입니다. 운영 서버에서 켜 두면 로그 파일이나 업로드 디렉터리 변경에도 재시작이 걸릴 수 있습니다.
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
max_memory_restart는 메모리 누수를 고치는 기능이 아니라 증상을 가리는 안전장치입니다. 주기적으로 재시작이 걸린다면 pm2 info의 restart 횟수를 모니터링하고 원인을 따로 찾아야 합니다. max_restarts와 min_uptime 조합은 기동 직후 죽는 앱이 무한 재시작 루프에 빠지지 않게 막아 줍니다. min_uptime보다 짧게 살다 죽는 경우가 max_restarts번 반복되면 PM2가 재시작을 멈추고 errored 상태로 둡니다. 환경 변수를 바꿨다면 pm2 restart ecosystem.config.js --update-env처럼 --update-env를 붙여야 새 값이 반영됩니다.
Docker
Dockerfile
# Dockerfile
FROM node:20-alpine
# 작업 디렉토리
WORKDIR /app
# 의존성 파일 복사
COPY package*.json ./
# 의존성 설치 (npm 7+에서는 --only=production 대신 --omit=dev)
RUN npm ci --omit=dev
# 소스 코드 복사
COPY . .
# 포트 노출
EXPOSE 3000
# 헬스체크
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node healthcheck.js
# 앱 시작
CMD ["node", "app.js"]
이 Dockerfile에서 순서가 중요한 부분은 package*.json을 먼저 복사하고 npm ci를 실행한 뒤 소스를 복사하는 것입니다. Docker는 레이어 단위로 캐시하므로, 소스만 바뀌고 의존성이 그대로면 설치 단계를 건너뛰어 빌드 시간이 크게 줄어듭니다. CMD를 exec 형식(["node", "app.js"])으로 쓴 것도 의도적입니다. 셸 형식(CMD node app.js)이나 CMD ["npm", "start"]로 실행하면 PID 1이 셸이나 npm이 되어 docker stop의 SIGTERM이 Node 프로세스까지 제대로 전달되지 않을 수 있고, 그러면 Docker는 기본 10초를 기다린 뒤 SIGKILL로 강제 종료합니다. 아래 Graceful Shutdown 코드가 컨테이너에서 전혀 동작하지 않는 것처럼 보인다면 이 부분을 먼저 확인하세요. 필요하면 docker run --init이나 tini로 시그널 전달과 좀비 프로세스 정리를 맡길 수 있습니다.
보안 측면에서는 공식 node 이미지에 이미 만들어져 있는 node 사용자로 전환하는 USER node 한 줄을 CMD 앞에 추가하는 것을 권합니다. 기본값인 root로 실행하면 애플리케이션 취약점이 곧 컨테이너 root 권한이 됩니다. 또 Alpine은 glibc 대신 musl을 쓰므로 bcrypt, sharp 같은 네이티브 모듈이 빌드되지 않거나 미리 빌드된 바이너리를 찾지 못하는 경우가 있으며, 이때는 node:20-slim(Debian 기반)으로 바꾸는 것이 가장 빠른 해결책입니다.
.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
.dockerignore에 node_modules를 넣는 이유는 빌드 속도뿐이 아닙니다. 개발 머신(macOS, Windows)에서 설치한 node_modules에는 그 OS용 네이티브 바이너리가 들어 있어서, 이미지 안으로 복사되면 리눅스에서 invalid ELF header 같은 오류로 실패합니다. .env를 제외하는 것도 중요합니다. 이미지에 비밀 값이 들어가면 레지스트리에 푸시된 뒤에는 되돌리기 어렵습니다.
Docker Compose
# docker-compose.yml (Compose V2에서는 version 키가 무시되므로 생략 가능)
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
이 구성에는 운영 전에 고칠 점이 두 가지 있습니다. 첫째, mongo의 27017:27017 포트 매핑은 개발 편의용입니다. 앱은 Compose 내부 네트워크에서 mongo:27017로 접근하므로 호스트에 포트를 열 필요가 없고, 인증 없이 인터넷에 노출된 MongoDB는 실제로 자주 공격 대상이 됩니다. 둘째, depends_on은 컨테이너 시작 순서만 보장할 뿐 MongoDB가 연결을 받을 준비가 됐는지는 기다리지 않습니다. 앱이 기동 직후 ECONNREFUSED로 죽는다면 healthcheck와 depends_on: condition: service_healthy를 함께 쓰거나, 앱 쪽 DB 연결에 재시도를 넣어야 합니다. 최신 Docker에서는 하이픈 없는 docker compose 명령이 표준입니다.
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;
}
}
Upgrade와 Connection 헤더 설정은 WebSocket 연결을 위한 것입니다. 다만 Connection 'upgrade'를 모든 요청에 고정하면 일반 HTTP 요청에도 upgrade 헤더가 붙으므로, Nginx 공식 문서처럼 map $http_upgrade $connection_upgrade { default upgrade; '' close; }를 정의해 조건부로 넣는 방식이 더 정확합니다. X-Forwarded-* 헤더는 Express가 읽어야 의미가 있습니다. Express는 기본적으로 이 헤더를 무시하므로 app.set('trust proxy', 1)을 설정하지 않으면 req.ip가 항상 127.0.0.1로 찍히고, express-rate-limit은 모든 사용자를 한 IP로 보고 한꺼번에 차단하며, secure: true 쿠키는 HTTPS인데도 HTTP로 판단되어 설정되지 않습니다. 저는 Nginx를 앞에 둔 뒤 “로그인 세션이 유지되지 않는다”는 문제가 생기면 가장 먼저 이 설정을 확인합니다.
활성화:
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;
}
Nginx 1.25.1부터는 listen 443 ssl http2;의 http2 매개변수가 deprecated되어 경고가 나오며, 대신 server 블록 안에 http2 on;을 따로 씁니다. 배포판 기본 패키지 버전에 따라 둘 중 하나만 동작하므로 nginx -v로 확인하세요. certbot --nginx는 인증서 발급과 함께 이 설정 파일을 직접 수정하므로, 설정을 Git으로 관리한다면 발급 후 변경분을 반영해 두어야 다음 배포에서 덮어쓰지 않습니다. 갱신은 패키지가 설치한 systemd 타이머나 cron이 처리하며, renew --dry-run이 성공하면 자동 갱신 경로가 정상이라는 뜻입니다.
로드 밸런싱
# 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;
# ... 프록시 설정
}
}
같은 서버의 포트 3000~3002에 프로세스를 여러 개 띄우고 Nginx로 분산하는 방식은 PM2 클러스터 모드와 목적이 겹칩니다. 둘 중 하나만 쓰는 것이 보통이며, Nginx upstream 방식은 프로세스마다 다른 버전을 올려 두는 블루-그린이나 카나리 배포에 유리하고, PM2 클러스터는 설정이 단순합니다. least_conn은 요청 처리 시간이 들쭉날쭉한 API에 유리하며, 오픈소스 Nginx의 upstream 헬스체크는 실제 요청이 실패했을 때만 서버를 잠시 제외하는 수동 방식(max_fails, fail_timeout)이라는 점도 알아 두세요.
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 --omit=dev
# 환경 변수 설정
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
EC2 보안 그룹에서 SSH(22)는 0.0.0.0/0이 아니라 본인 IP나 배스천 호스트로 제한하는 것이 기본입니다. 앱 포트 3000은 보안 그룹에서 열지 않습니다. Nginx가 같은 서버에서 localhost:3000으로 접근하므로 외부에 열 이유가 없고, 열어 두면 Nginx의 TLS와 헤더 설정을 우회하는 경로가 생깁니다. 서버에서 git pull 후 npm ci를 실행하는 방식은 단순하지만, 배포 중 의존성 설치가 실패하면 서버가 절반쯤 바뀐 상태로 남습니다. 규모가 커지면 CI에서 빌드한 산출물이나 Docker 이미지를 배포하는 방식으로 옮기는 이유가 이것입니다.
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
serverless-http로 기존 Express 앱을 감싸면 코드 변경 없이 Lambda에 올릴 수 있지만, 실행 모델이 완전히 다르다는 점을 이해해야 합니다. Lambda는 요청이 없으면 인스턴스를 얼리고 새 요청이 오면 새 인스턴스를 만들므로, 콜드 스타트 때마다 app.js의 초기화 코드(DB 연결, 설정 로드)가 다시 실행됩니다. DB 연결을 핸들러 밖 모듈 스코프에 두어 재사용하고, 동시 실행이 늘어날 때 인스턴스마다 DB 연결을 여는 문제는 RDS Proxy 같은 연결 풀러로 막는 것이 일반적입니다. setInterval, 메모리 캐시, 로컬 파일 저장처럼 “프로세스가 계속 살아 있다”고 가정하는 코드도 Lambda에서는 기대대로 동작하지 않습니다.
환경 변수 관리
.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;
기동 시점에 필수 변수를 검증하는 이 패턴은 단순하지만 효과가 큽니다. 검증이 없으면 JWT_SECRET이 빠진 채로 서버가 떠서 첫 로그인 요청에서야 secretOrPrivateKey must have a value 같은 오류가 나고, 이미 트래픽을 받은 뒤라 원인 파악이 늦어집니다. 한편 process.env의 값은 항상 문자열이라는 점을 잊기 쉽습니다. PORT는 '8080'이고, DEBUG=false는 문자열 'false'라서 if (process.env.DEBUG)는 참이 됩니다. 규모가 있는 프로젝트라면 zod나 envalid 같은 라이브러리로 타입 변환과 검증을 한 곳에서 처리하는 편이 낫습니다. .env.production 파일에 실제 비밀 값을 적어 저장소에 커밋하는 것은 피해야 하며, Node.js 20.6 이상에서는 dotenv 없이 node --env-file=.env app.js로 파일을 읽을 수도 있습니다.
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 };
위 코드는 AWS SDK v2(aws-sdk)를 사용합니다. v2는 유지보수 모드를 거쳐 지원이 종료되었고, Lambda의 Node.js 18 이상 런타임에는 기본 포함되지 않으므로 새 코드라면 v3의 @aws-sdk/client-ssm에서 SSMClient와 GetParametersCommand를 쓰는 편이 맞습니다. 또 GetParameters는 한 번에 가져올 수 있는 이름 수에 제한이 있고, 존재하지 않는 이름은 오류 대신 InvalidParameters 배열로 돌려주므로 이 배열이 비어 있는지 확인해야 누락을 놓치지 않습니다. 매 요청마다 SSM을 호출하면 지연과 API 제한 문제가 생기므로 기동 시 한 번 읽어 캐시하는 것이 일반적입니다.
로깅
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() });
컨테이너나 PM2 환경에서는 파일 transport보다 표준 출력(stdout)으로 JSON을 쓰는 방식이 더 흔합니다. Docker와 Kubernetes는 stdout을 수집하도록 설계되어 있고, 컨테이너 안의 logs/ 파일은 컨테이너가 재생성되면 사라지거나 볼륨을 채울 수 있기 때문입니다. 파일 로그를 계속 쓴다면 logrotate나 pm2-logrotate로 순환시키지 않으면 디스크가 가득 차는 날이 옵니다. 위 예제처럼 운영 환경에서 Console transport를 빼면 docker logs에 아무것도 나오지 않는다는 점도 주의하세요.
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'));
}
모니터링
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());
});
라벨 값에 req.path를 쓰는 부분은 주의가 필요합니다. 라우트에 매칭되지 않은 요청(404, 스캐너가 보내는 임의 경로)이나 /users/123 같은 실제 경로가 라벨로 들어가면 시계열 개수가 끝없이 늘어나 Prometheus 메모리를 잡아먹습니다. 매칭된 라우트 패턴(req.route.path)만 쓰고, 없으면 'unmatched' 같은 고정 값으로 묶는 것이 안전합니다. 또 /metrics와 /health는 외부에 공개할 이유가 없으므로 Nginx에서 내부 IP만 허용하는 것이 좋습니다. 헬스체크 엔드포인트에서 DB까지 확인할지는 신중히 정해야 합니다. DB가 잠깐 느려질 때 모든 인스턴스가 동시에 unhealthy로 판정되어 재시작되면 장애가 더 커질 수 있습니다.
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 --omit=dev
pm2 reload ecosystem.config.js --env production
이 워크플로는 동작하지만 운영 전에 손볼 곳이 있습니다. actions/checkout@v3, setup-node@v3는 구버전이라 현재는 v4를 쓰는 것이 권장되고, appleboy/ssh-action@master처럼 서드파티 액션을 브랜치로 참조하면 그 저장소에 변경이 생길 때 검증 없이 비밀 키가 담긴 워크플로에서 실행됩니다. 릴리스 태그나 커밋 SHA로 고정하는 것이 안전합니다. deploy 잡도 테스트 잡과 마찬가지로 main 푸시에만 실행되지만, environment: 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 # 롤백을 위해 커밋 SHA 태그도 함께 푸시 권장
- 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
무중단 배포
PM2 무중단 재시작
# reload: 무중단 재시작 (클러스터 모드)
pm2 reload my-app
# 참고: 예전의 gracefulReload는 PM2 3.x에서 제거되었고, reload가 그 역할을 함
pm2 reload가 진짜 무중단이 되려면 새 워커가 요청을 받을 준비가 된 뒤에 옛 워커를 내려야 합니다. 기본값으로는 PM2가 프로세스가 뜨자마자 준비된 것으로 보므로, DB 연결 같은 초기화가 오래 걸리는 앱은 ecosystem에 wait_ready: true를 설정하고 준비가 끝난 뒤 process.send('ready')를 호출하게 해야 합니다. 옛 워커 쪽에서는 PM2가 보내는 SIGINT를 받아 아래처럼 정리한 뒤 종료해야 하며, kill_timeout(기본 1.6초)이 지나면 강제로 종료되므로 요청 처리 시간이 긴 서비스라면 이 값을 늘려야 합니다.
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'));
이 코드의 핵심은 server.close()가 새 연결만 거부하고 이미 열린 keep-alive 연결은 닫지 않는다는 점입니다. 그래서 브라우저나 로드 밸런서가 연결을 계속 잡고 있으면 콜백이 호출되지 않아 30초 타이머까지 기다리게 됩니다. 이 예제처럼 소켓을 직접 추적해 닫을 수도 있지만, conn.end()는 응답을 보내는 중인 연결까지 끊을 수 있다는 점이 문제입니다. Node.js 18.2 이상에서는 server.closeIdleConnections()로 유휴 연결만 닫고, 19 이상에서는 server.close()가 유휴 연결을 자동으로 닫아 주므로 직접 소켓을 관리할 필요가 줄었습니다. 예제의 mongoose는 이미 연결되어 있다고 가정한 것이며, 30초 강제 종료 타이머는 .unref()를 붙여야 정상 종료 경로를 막지 않습니다. 로드 밸런서 뒤에 있다면 종료 신호를 받은 즉시 헬스체크를 실패로 바꾸고 몇 초 기다린 뒤 server.close()를 호출해야, 로드 밸런서가 트래픽을 빼기 전에 들어온 요청이 거부되지 않습니다.
자주 발생하는 문제
문제 1: 포트 충돌
에러:
Error: listen EADDRINUSE: address already in use :::3000
해결:
# 프로세스 찾기
lsof -i :3000
netstat -ano | findstr :3000
# 프로세스 종료 (먼저 kill <PID>로 정상 종료를 시도하고, 응답이 없을 때만 -9)
kill <PID>
kill -9 <PID>
taskkill /PID <PID> /F
# PM2로 관리
pm2 delete all
pm2 start app.js
PM2를 쓰는 서버에서 이 오류가 반복된다면 대개 PM2가 관리하는 프로세스가 이미 포트를 잡고 있는데 node app.js를 한 번 더 실행한 경우입니다. pm2 list로 먼저 확인하고, 모르는 프로세스를 -9로 죽이기 전에 lsof 출력의 명령 이름을 확인하세요. -9(SIGKILL)는 앞에서 만든 Graceful Shutdown을 건너뛰므로 진행 중인 요청과 트랜잭션이 그대로 끊깁니다.
문제 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);
}
}
롤백할 수 있게 배포하기
배포 자동화보다 먼저 정해 둘 것은 “문제가 생겼을 때 몇 분 안에 직전 버전으로 돌아갈 수 있는가”입니다. 가장 단순한 방법은 배포 단위를 Git 태그로 고정하는 것입니다.
# Git 태그로 버전 관리
git tag v1.0.0
git push origin v1.0.0
# 배포
git checkout v1.0.0
npm ci --omit=dev
pm2 reload my-app
# 롤백
git checkout v0.9.9
npm ci --omit=dev
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 환경 종료
nginx -s reload(또는 systemctl reload nginx)는 새 워커를 띄우고 옛 워커가 처리 중인 연결을 마친 뒤 종료하므로, upstream을 바꾸는 순간 요청이 끊기지 않습니다. 블루-그린의 장점은 Blue를 바로 끄지 않고 두면 롤백이 “Nginx 설정을 되돌리고 reload”라는 몇 초짜리 작업이 된다는 점입니다. 대신 두 버전이 동시에 떠 있는 동안 같은 DB를 공유하므로, 앞서 말한 스키마 호환성 조건을 여기서도 지켜야 합니다.
어떤 배포 방식을 고를지
이 글에서 다룬 방식들은 서로 대체재라기보다 운영 부담을 어디에 둘지의 선택입니다. 서버 한두 대에 PM2와 Nginx를 올리는 방식은 모든 것을 직접 볼 수 있는 대신 OS 업데이트와 인증서 갱신까지 직접 챙겨야 하고, Docker는 “내 컴퓨터에서는 되는데” 문제를 없애는 대신 이미지 빌드와 레지스트리 관리가 따라옵니다. PaaS와 서버리스는 운영을 넘기는 대신 실행 시간·연결 수·콜드 스타트 같은 플랫폼 제약을 받아들이는 것입니다. 트래픽이 작고 팀이 작다면 PM2 + Nginx나 PaaS로 시작하고, 환경 차이로 사고가 나기 시작할 때 Docker로 옮기는 순서가 무리가 적습니다.
| 방식 | 장점 | 단점 | 어울리는 경우 |
|---|---|---|---|
| VPS + PM2 | 완전한 제어 | OS·인증서·보안 패치 직접 관리 | 작은 서비스, 서버 한두 대 |
| Docker | 환경 일관성 | 이미지 빌드·레지스트리 관리 | 여러 서비스, 환경 차이로 사고가 나는 팀 |
| PaaS | 간편함 | 설정 자유도·비용 구조 제한 | 빠른 출시, 운영 인력 없음 |
| 서버리스 | 요청량에 따른 자동 확장 | 콜드 스타트, 장시간 연결 불가 | 이벤트 기반 처리, 불규칙한 트래픽 |
다음으로 읽을 글: Node.js 성능 최적화, Node.js 인증과 보안
참고 자료
도구:
자주 묻는 질문 (FAQ)
Q. 배포할 때마다 진행 중인 요청이 끊기지 않게 하려면 어떻게 하나요?
A. 프로세스가 SIGTERM이나 SIGINT를 받으면 server.close()로 새 연결을 거부하고, 진행 중인 요청이 끝난 뒤 DB 연결을 닫고 종료하는 Graceful Shutdown을 구현합니다. 요청이 끝나지 않고 매달리는 경우에 대비해 일정 시간 뒤에는 강제 종료하는 타이머도 함께 둡니다. PM2의 무중단 재시작이나 블루-그린 배포도 이 종료 처리가 되어 있어야 제대로 효과를 냅니다.