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 코어를 최대한 활용하면서 안정적으로 서비스 운영

구성:

  1. Cluster 모듈로 8개의 Node.js 워커 프로세스 실행 (각각 포트 3001~3008)
  2. 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를 통한 로드밸런싱 구성을 다뤘습니다.

핵심 요약:

  1. Node.js 멀티 코어 활용: Cluster 모듈을 사용해 CPU 코어 수만큼 워커 프로세스 실행
  2. Nginx 로드밸런싱: upstream을 통해 여러 인스턴스에 요청 분산
  3. 성능 최적화: keepalive 연결, 버퍼 튜닝, 적절한 로드밸런싱 알고리즘 선택
  4. 안정성 확보: passive health check, 그레이스풀 셧다운으로 무중단 운영

다음 포스팅 예고:
멀티 프로세스와 로드밸런싱 구성만으로는 완전한 운영 환경을 구축하기 어렵습니다. 다음 포스팅에서는 다음 주제를 다룰 예정입니다:

  • Node.js 프로세스 장애 감지 및 자동 재시작 전략
  • Prometheus + Grafana를 활용한 메트릭 수집 및 대시보드 구성
  • 알람 설정 및 장애 대응 프로세스
  • 로그 중앙화 및 분석

이를 통해 안정적이고 관찰 가능한(observable) 프로덕션 환경을 완성할 수 있습니다.


참고 자료: