Nginx + NestJS: Node.js 멀티 코어 활용과 로드밸런싱 구성하기
들어가며
Node.js는 기본적으로 단일 스레드 이벤트 루프로 동작합니다. 이는 I/O 중심 작업에는 효율적이지만, CPU 코어를 하나만 활용한다는 한계가 있습니다. 서버에 8코어가 있어도 Node.js 프로세스 하나는 그 중 하나만 사용하게 됩니다.
프로덕션 환경에서 안정적이고 효율적으로 서비스를 운영하려면:
- 여러 CPU 코어를 활용해 처리량을 높이고
- Nginx를 통해 여러 Node.js 인스턴스를 로드밸런싱해 가용성을 확보해야 합니다.
이 글에서는 Node.js에서 멀티 코어를 활용하는 방법과 Nginx upstream을 통한 로드밸런싱 구성을 실제 경험을 바탕으로 정리했습니다.
참고: 이 글에서는 멀티 코어 활용과 로드밸런싱 구성에 집중합니다. Node.js 프로세스 장애 대처, 메트릭 수집 및 모니터링은 다른 포스팅에서 다룰 예정입니다.
Node.js에서 멀티 코어 활용하기
문제: 단일 프로세스의 한계
Node.js 애플리케이션을 node app.js로 실행하면 단일 프로세스가 하나의 CPU 코어만 사용합니다. 나머지 코어는 유휴 상태로 남게 되어 리소스 낭비가 발생합니다.
해결 방법 1: Node.js Cluster 모듈
Node.js는 내장 cluster 모듈을 제공합니다. Primary 프로세스(이전에는 마스터 프로세스라고 불림)가 여러 워커 프로세스를 생성하고, 모든 워커가 같은 포트를 공유합니다.
포트 공유 메커니즘:
- Primary 프로세스가 지정된 포트(예: 3000)를 리스닝
- 새로운 연결이 들어오면 Primary 프로세스가 이를 사용 가능한 워커 프로세스에 라운드로빈 방식으로 분배
- OS 레벨에서 소켓을 워커 프로세스 간에 공유하므로, 각 워커가 독립적인 포트를 사용할 필요가 없음
// cluster.ts 예시
import cluster from 'cluster';
import os from 'os';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
if (cluster.isPrimary) {
const numWorkers = os.cpus().length; // CPU 코어 수만큼 워커 생성
console.log(`Primary process ${process.pid} is running`);
console.log(`Starting ${numWorkers} workers...`);
for (let i = 0; i < numWorkers; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died. Restarting...`);
cluster.fork(); // 워커가 죽으면 재시작
});
} else {
// 워커 프로세스: NestJS 애플리케이션 실행
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 모든 워커가 같은 포트 3000을 공유
await app.listen(3000);
console.log(`Worker ${process.pid} started on port 3000`);
}
bootstrap();
}
장점:
- Node.js 내장 모듈로 추가 의존성 없음
- 프로세스 간 메모리 격리로 안정성 확보
- 워커가 죽으면 자동 재시작 가능
- 모든 워커가 같은 포트를 공유해 Nginx 설정이 단순함
단점:
- 애플리케이션 코드에 cluster 로직 포함 필요
포트 vs 유닉스 소켓
Node.js 인스턴스는 일반적으로 TCP 포트(예: 3000)를 사용하지만, 같은 서버 내에서만 통신한다면 유닉스 소켓도 고려할 수 있습니다.
TCP 포트 사용 (일반적):
- 장점: 표준 방식, 디버깅 용이, 외부에서도 접근 가능
- 단점: 네트워크 스택을 거쳐 약간의 오버헤드
유닉스 소켓 사용:
- 장점: 네트워크 스택을 거치지 않아 I/O 성능이 약간 더 좋음, 파일 시스템 권한으로 접근 제어 가능
- 단점: 같은 서버 내에서만 사용 가능, 디버깅이 다소 복잡
대부분의 경우 TCP 포트를 사용하는 것이 실용적입니다.
Nginx를 통한 로드밸런싱 구성
여러 Node.js 인스턴스가 실행되면, Nginx의 upstream을 사용해 요청을 분산시킬 수 있습니다.
기본 upstream 설정
upstream myapp {
least_conn; # 로드밸런싱 알고리즘: 활성 연결 수가 가장 적은 서버로 배분
server 127.0.0.1:3001 max_fails=3 fail_timeout=10s;
server 127.0.0.1:3002 max_fails=3 fail_timeout=10s;
server 127.0.0.1:3003 max_fails=3 fail_timeout=10s;
server 127.0.0.1:3004 max_fails=3 fail_timeout=10s;
keepalive 32; # nginx -> upstream 간 연결 재사용
}
# HTTP 요청을 HTTPS로 리다이렉트
server {
listen 80;
server_name example.com;
return 301 https://$server_name$request_uri;
}
# HTTPS 서버 설정
server {
listen 443 ssl http2;
server_name example.com;
# SSL/TLS 설정
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# 보안 헤더
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# 로깅 설정
access_log /var/log/nginx/example.com.access.log;
error_log /var/log/nginx/example.com.error.log warn;
# 정적 파일 직접 서빙 (선택사항)
location /static/ {
alias /var/www/static/;
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
# API 프록시
location / {
proxy_pass http://myapp;
proxy_http_version 1.1;
# 헤더 설정
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_connect_timeout 5s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
# 버퍼 설정 (큰 응답 처리)
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
client_max_body_size 10m;
}
# 에러 페이지 설정
error_page 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
internal;
}
}
로드밸런싱 알고리즘 선택
Nginx는 여러 로드밸런싱 알고리즘을 제공합니다:
round_robin(기본값): 가중치를 고려해 순차적으로 배분. 가장 단순하고 균등한 분산.least_conn: 활성 연결 수가 가장 적은 서버로 배분. 연결이 오래 지속되는 경우 유리.ip_hash: 클라이언트 IP를 해시해 동일 IP는 항상 같은 서버로 라우팅. 세션 유지가 필요한 경우 사용.hash: 사용자 지정 변수(예:$request_uri)로 해시 계산. 특정 리소스가 항상 같은 서버로 가도록 보장.
실무 권장:
- 일반적으로
least_conn이 가장 균형 잡힌 선택입니다. 연결 시간이 짧은 API 서버라면round_robin도 충분합니다. - 세션 스티키니스가 필요한 경우: 세션 기반 인증을 사용하는 경우
ip_hash를 사용해 동일 클라이언트가 항상 같은 서버로 라우팅되도록 할 수 있습니다. 다만 JWT나 Stateless 인증을 사용한다면 불필요합니다.
Keepalive 연결
keepalive 32; 설정은 Nginx와 Node.js 인스턴스 간 HTTP 연결을 재사용합니다.
동작 원리:
- Nginx가 upstream 서버로 요청을 보낼 때마다 새로운 TCP 연결을 맺는 대신, 기존 연결을 재사용
- 연결 수는 최대 32개까지 유지 (설정값에 따라 조정)
효과:
- TCP 핸드셰이크 오버헤드 감소 → 레이턴시 개선
- 연결 생성/해제 CPU 사용량 감소 → 처리량 향상
주의사항: Node.js 인스턴스도 keepAlive: true 옵션을 사용해야 효과적입니다 (NestJS는 기본적으로 활성화됨).
버퍼 설정 이해하기
Nginx는 upstream 서버의 응답을 버퍼에 저장한 후 클라이언트에 전송합니다.
버퍼의 역할:
- upstream 응답이 완료될 때까지 대기하지 않고, 버퍼에 데이터가 쌓이면 바로 클라이언트에 전송
- 큰 응답을 여러 청크로 나눠 전송해 메모리 사용량 최적화
주요 설정:
proxy_buffer_size: 첫 번째 응답 헤더를 저장할 버퍼 크기 (기본값: 4k 또는 8k, 여기서는 4k로 명시)proxy_buffers: 응답 본문을 저장할 버퍼 개수와 크기 (기본값:8 4k또는8 8k, 여기서는8 4k로 명시 = 8개 × 4KB = 32KB)proxy_busy_buffers_size: 클라이언트로 전송 중인 버퍼 크기 (기본값:proxy_buffer_size의 2배, 여기서는 8k로 명시)client_max_body_size: 클라이언트 요청 본문 최대 크기 (기본값: 1m, 여기서는 10m로 증가)
설정 이유:
- 기본값으로도 대부분의 API 응답에는 충분하지만, 큰 JSON 응답이나 파일 업로드를 고려해 명시적으로 설정
proxy_busy_buffers_size를 명시하면 버퍼링 동작을 더 예측 가능하게 만듦
튜닝 가이드:
- 작은 API 응답(수 KB): 기본값으로 충분
- 큰 파일 전송(수 MB):
proxy_buffers를 늘리거나proxy_buffering off고려 - 스트리밍 응답:
proxy_buffering off로 실시간 전송
헬스체크: Passive vs Active
Nginx는 기본적으로 passive health check를 제공합니다.
Passive Health Check:
- 실제 요청이 실패할 때만 감지
max_fails=3: 3번 연속 실패하면 해당 서버를 일시적으로 제외fail_timeout=10s: 10초 후 다시 트래픽을 보내 상태 확인- 장점: 추가 오버헤드 없음
- 단점: 실제 요청이 실패해야 감지 (사전 예방 불가)
Active Health Check (nginx-plus 유료 또는 외부 도구 필요):
- 주기적으로 헬스체크 엔드포인트에 요청을 보내 상태 확인
- 장점: 문제를 사전에 감지 가능
- 단점: 추가 리소스 사용, nginx-plus는 유료
실무 권장: 대부분의 경우 passive health check로 충분합니다. 더 정교한 모니터링이 필요하면 Prometheus + Grafana 같은 외부 모니터링 도구를 사용하는 것이 비용 대비 효율적입니다.
웹소켓 지원
웹소켓을 사용하는 경우 추가 설정이 필요합니다:
location / {
proxy_pass http://myapp;
proxy_http_version 1.1;
# 웹소켓 업그레이드 지원
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# 웹소켓 타임아웃 (연결이 오래 지속됨)
proxy_read_timeout 3600s;
}
설정 필요 이유:
- HTTP/1.1의
Upgrade헤더를 통해 웹소켓으로 프로토콜 전환 - Nginx가 이 헤더를 upstream으로 전달해야 웹소켓 연결이 성립
- 일반 HTTP 요청과 달리 연결이 오래 지속되므로 타임아웃을 길게 설정
정적 파일 서빙 전략
Nginx에서 정적 파일(이미지, CSS, JS 등)을 직접 서빙하면 NestJS 애플리케이션의 부하를 줄일 수 있습니다.
# 정적 파일 직접 서빙
location /static/ {
alias /var/www/static/;
expires 30d;
add_header Cache-Control "public, immutable";
access_log off; # 정적 파일 요청은 로그에서 제외
}
# NestJS는 API 요청만 처리
location /api/ {
proxy_pass http://myapp;
# ... 프록시 설정 ...
}
효과:
- NestJS 애플리케이션의 CPU/메모리 사용량 감소
- 정적 파일 응답 속도 향상 (Nginx가 직접 서빙)
- 캐싱 헤더로 클라이언트 측 캐싱 활용
CORS 설정 고려사항
프록시 환경에서 CORS를 처리하는 방법은 두 가지가 있습니다:
방법 1: NestJS에서 처리 (권장)
- NestJS의
@nestjs/platform-express에서app.enableCors()사용 - 프록시 환경에서도 정상 동작
- 애플리케이션 레벨에서 세밀한 제어 가능
// main.ts
app.enableCors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || ['https://example.com'],
credentials: true,
});
방법 2: Nginx에서 처리
- Nginx에서 CORS 헤더를 추가할 수도 있지만, 복잡도가 증가
- 대부분의 경우 NestJS에서 처리하는 것이 더 유연함
주의사항:
X-Forwarded-*헤더가 올바르게 설정되어야 CORS 검증이 정확함- 프록시를 거치면 원본 IP가 변경되므로,
X-Real-IP또는X-Forwarded-For헤더를 확인해야 함
로깅 설정
Nginx 로그를 적절히 설정하면 문제 진단과 모니터링이 쉬워집니다.
# 커스텀 로그 포맷 정의
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" '
'rt=$request_time uct="$upstream_connect_time" '
'uht="$upstream_header_time" urt="$upstream_response_time"';
# 서버 블록에서 사용
server {
access_log /var/log/nginx/example.com.access.log main;
error_log /var/log/nginx/example.com.error.log warn;
}
로그 로테이션:
logrotate를 사용해 로그 파일을 주기적으로 로테이션- 디스크 공간 관리 및 오래된 로그 보관
# /etc/logrotate.d/nginx 예시
/var/log/nginx/*.log {
daily
missingok
rotate 14
compress
delaycompress
notifempty
create 0640 www-data adm
sharedscripts
postrotate
[ -f /var/run/nginx.pid ] && kill -USR1 `cat /var/run/nginx.pid`
endscript
}
설정 리로드
Nginx 설정을 변경한 후 적용:
# 설정 파일 문법 검사
nginx -t
# 그레이스풀 리로드 (진행 중인 요청은 완료 후 새 설정 적용)
nginx -s reload
특정 upstream 서버를 일시적으로 제외하려면 설정 파일에서 down 옵션을 추가:
server 127.0.0.1:3001 down; # 이 서버는 트래픽을 받지 않음
실제 구성 예시
시나리오: 8코어 서버에서 NestJS 애플리케이션 운영
목표: CPU 코어를 최대한 활용하면서 안정적으로 서비스 운영
구성:
- Cluster 모듈로 8개의 Node.js 워커 프로세스 실행 (각각 포트 3001~3008)
- Nginx upstream으로 8개 인스턴스 로드밸런싱
Cluster 모듈을 사용한 멀티 포트 구성:
Cluster 모듈을 사용하되, 각 워커가 다른 포트를 사용하도록 구성하려면 다음과 같이 수정할 수 있습니다:
// cluster.ts
import cluster from 'cluster';
import os from 'os';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
if (cluster.isPrimary) {
const numWorkers = os.cpus().length;
const basePort = 3001;
console.log(`Primary process ${process.pid} is running`);
console.log(`Starting ${numWorkers} workers...`);
for (let i = 0; i < numWorkers; i++) {
const worker = cluster.fork({
WORKER_ID: i.toString(),
PORT: (basePort + i).toString()
});
console.log(`Worker ${worker.process.pid} started on port ${basePort + i}`);
}
cluster.on('exit', (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died. Restarting...`);
const workerId = parseInt(worker.env.WORKER_ID || '0', 10);
const newWorker = cluster.fork({
WORKER_ID: workerId.toString(),
PORT: (3001 + workerId).toString()
});
console.log(`Worker ${newWorker.process.pid} restarted on port ${3001 + workerId}`);
});
} else {
// 워커 프로세스: NestJS 애플리케이션 실행
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const port = parseInt(process.env.PORT || '3001', 10);
const workerId = process.env.WORKER_ID || '0';
await app.listen(port);
console.log(`Worker ${process.pid} (ID: ${workerId}) started on port ${port}`);
}
bootstrap().catch((error) => {
console.error('Failed to start application:', error);
process.exit(1);
});
}
Nginx 설정:
upstream nestjs_api {
least_conn;
server 127.0.0.1:3001 max_fails=3 fail_timeout=10s;
server 127.0.0.1:3002 max_fails=3 fail_timeout=10s;
server 127.0.0.1:3003 max_fails=3 fail_timeout=10s;
server 127.0.0.1:3004 max_fails=3 fail_timeout=10s;
server 127.0.0.1:3005 max_fails=3 fail_timeout=10s;
server 127.0.0.1:3006 max_fails=3 fail_timeout=10s;
server 127.0.0.1:3007 max_fails=3 fail_timeout=10s;
server 127.0.0.1:3008 max_fails=3 fail_timeout=10s;
keepalive 32;
}
# HTTP → HTTPS 리다이렉션
server {
listen 80;
server_name api.example.com;
return 301 https://$server_name$request_uri;
}
# HTTPS 서버 설정
server {
listen 443 ssl http2;
server_name api.example.com;
# SSL/TLS 설정
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# 보안 헤더
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "1; mode=block" always;
# 로깅
access_log /var/log/nginx/api.example.com.access.log;
error_log /var/log/nginx/api.example.com.error.log warn;
location / {
proxy_pass http://nestjs_api;
proxy_http_version 1.1;
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_connect_timeout 5s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
# 에러 페이지
error_page 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
internal;
}
}
인스턴스 개수 결정 가이드
기본 원칙: CPU 코어 수와 동일하게 설정하는 것이 일반적입니다.
고려 사항:
- I/O 중심 작업 (DB 조회, 외부 API 호출): 코어 수와 동일 또는 더 많이 설정 가능
- CPU 집약적 작업: 코어 수보다 적게 설정 (예: 4코어면 2~3개)
- 메모리 제약: 인스턴스당 메모리 사용량을 고려해 조정
실무 팁: 처음에는 코어 수와 동일하게 시작하고, 모니터링 데이터를 바탕으로 조정하는 것이 안전합니다.
Node.js 인스턴스 튜닝
멀티 프로세스 구성 외에도 Node.js 자체 설정을 최적화할 수 있습니다.
메모리 설정
node --max-old-space-size=4096 dist/main.js
- 힙 메모리 최대 크기 설정
- 메모리 사용량이 많은 애플리케이션의 경우 조정 필요
UV_THREADPOOL_SIZE
UV_THREADPOOL_SIZE=16 node dist/main.js
- libuv 스레드 풀 크기 (기본값 4)
- 파일 I/O, DNS 조회, 암호화 작업에 사용
- 파일 I/O가 많은 경우 증가시키면 성능 개선 가능
CPU 바운드 작업 분리
CPU 집약적 작업(이미지 처리, 암호화 등)은 worker_threads나 별도 프로세스로 분리하는 것을 고려하세요. 이는 이벤트 루프 블로킹을 방지합니다.
성능 측정 및 벤치마크
멀티 프로세스 구성의 효과를 측정하려면 벤치마크 도구를 사용할 수 있습니다.
벤치마크 도구
Apache Bench (ab):
# 설치 (Ubuntu/Debian)
sudo apt-get install apache2-utils
# 벤치마크 실행
ab -n 10000 -c 100 https://api.example.com/api/health
wrk (더 현대적인 도구):
# 설치
sudo apt-get install wrk
# 벤치마크 실행
wrk -t4 -c100 -d30s https://api.example.com/api/health
측정 지표:
- Requests per second (RPS): 초당 처리 가능한 요청 수
- Latency: 응답 시간 (평균, 중앙값, 95th percentile)
- Throughput: 초당 전송된 데이터량
모니터링 지표 확인
시스템 리소스 확인:
# CPU 사용률 확인
top
htop
# 네트워크 연결 확인
netstat -an | grep :3001
ss -tuln | grep :3001
예상 개선 효과:
- 단일 프로세스 대비 코어 수에 비례한 처리량 증가 (이상적으로는 코어 수만큼)
- 실제로는 I/O 대기 시간, 데이터베이스 연결 풀 등에 따라 다르지만, 2~4배 정도의 성능 향상을 기대할 수 있음
마무리
이 글에서는 Node.js에서 멀티 코어를 활용하는 방법과 Nginx를 통한 로드밸런싱 구성을 다뤘습니다.
핵심 요약:
- Node.js 멀티 코어 활용: Cluster 모듈을 사용해 CPU 코어 수만큼 워커 프로세스 실행
- Nginx 로드밸런싱:
upstream을 통해 여러 인스턴스에 요청 분산 - 성능 최적화: keepalive 연결, 버퍼 튜닝, 적절한 로드밸런싱 알고리즘 선택
- 안정성 확보: passive health check, 그레이스풀 셧다운으로 무중단 운영
다음 포스팅 예고:
멀티 프로세스와 로드밸런싱 구성만으로는 완전한 운영 환경을 구축하기 어렵습니다. 다음 포스팅에서는 다음 주제를 다룰 예정입니다:
- Node.js 프로세스 장애 감지 및 자동 재시작 전략
- Prometheus + Grafana를 활용한 메트릭 수집 및 대시보드 구성
- 알람 설정 및 장애 대응 프로세스
- 로그 중앙화 및 분석
이를 통해 안정적이고 관찰 가능한(observable) 프로덕션 환경을 완성할 수 있습니다.
참고 자료: