로드 밸런싱과 수평적 확장
확장 한계와 상태·헬스 체크·연결 예산을 함께 계산하고 여러 NestJS 인스턴스로 트래픽을 안전하게 분산합니다.
로드 밸런싱과 수평 확장은 서버 수 자체가 아니라 교체 가능한 실행 단위, 준비된 엔드포인트, 외부 상태, 하위 자원 예산을 함께 설계하는 문제입니다.
Nest · Scaling architecture
준비된 복제본과 공유 자원 경계
준비된 복제본에 분산 정책을 적용하고 세션·파일·작업 상태를 외부화합니다. DB·큐·외부 API의 연결과 처리량 예산이 확장의 실제 상한을 결정합니다.
| 선택 | 무엇을 바꾸는가 | 주의할 경계 |
|---|---|---|
| scale up | 한 실행 단위의 CPU·메모리 | 단일 노드 한계와 장애 범위는 남음 |
| scale out | 준비된 동일 역할 복제본 수 | 외부 상태와 하위 자원 예산 필요 |
| round robin | 요청을 순서대로 배정 | 요청 비용·실제 지연은 측정하지 않음 |
| weighted | 설정 가중치 비율로 배정 | 관측 결과에 맞춘 가중치 조정 필요 |
| least connections | 활성 연결이 적은 대상으로 배정 | 연결 수가 작업 비용을 항상 대표하지 않음 |
| source/IP hash | 관측 소스의 해시로 대상 선택 | NAT·프록시·풀 변경 때 매핑이 달라질 수 있음 |
로드 밸런서
health와 readiness를 통과한 대상에 선택한 정책을 적용합니다.
교체 가능한 NestJS 복제본
세션·파일·rate limit 상태를 외부화해 어느 복제본도 요청을 처리하게 합니다.
한 실행 단위의 자원 증가
적용은 단순할 수 있지만 노드 한계와 장애 범위가 남습니다.
준비된 복제본 수 증가
DB·queue·API 예산과 상태 분리가 실제 상한을 만듭니다.
순서대로 요청 배정
요청 비용이나 실제 지연을 직접 측정하지 않습니다.
설정 가중치 비율로 배정
관측 결과에 맞춰 가중치를 조정해야 합니다.
활성 연결 수 기준
연결 수가 실제 작업 비용이나 응답 시간을 항상 뜻하지는 않습니다.
조건부 세션 지속성
NAT·프록시·풀 변경으로 매핑이 달라질 수 있어 외부 상태가 우선입니다.
로드 밸런싱, 상태 분리, 준비 상태, replica × process × pool 예산을 하나의 아키텍처 결정으로 검증합니다.
스케일링과 가용성의 경계
수직 확장(scale up)은 한 실행 단위의 CPU·메모리 같은 자원을 늘립니다. 적용은 단순할 수 있지만 한 노드의 물리적·비용 한계와 장애 범위는 남습니다.
수평 확장(scale out)은 같은 역할의 실행 단위를 추가합니다. 다만 용량은 무한하지 않습니다. 데이터베이스 연결, 외부 API 할당량, 큐 처리량, 캐시와 세션 상태, 조정 비용 가운데 가장 먼저 포화되는 자원이 상한이 됩니다. 여러 장애 도메인에 준비된 복제본을 두고 상태를 외부화했을 때 가용성도 개선됩니다. 단순히 프로세스 수만 늘린다고 무중단이 보장되지는 않습니다.
Node.js는 JavaScript 콜백을 이벤트 루프에서 실행하므로 긴 CPU 작업이 루프를 막을 수 있습니다. I/O 중심 API는 비동기 I/O의 이점을 얻지만, CPU 중심 작업은 다음 대안 가운데 측정 결과에 맞는 것을 선택합니다.
- 작은 작업으로 나누거나 알고리즘을 개선합니다.
worker_threads또는 별도 작업 큐로 CPU 작업을 격리합니다.- 한 머신의 여러 프로세스 또는 여러 Pod로 처리량과 장애 범위를 나눕니다.
로드 밸런싱 정책을 읽는 법
로드 밸런서는 준비된 엔드포인트 집합에 정책을 적용합니다. 정책 이름만으로 “가장 빠른 서버”가 선택된다고 단정할 수 없습니다.
- 라운드 로빈은 사용 가능한 엔드포인트에 요청을 순서대로 보냅니다. 요청 비용 차이와 실제 지연을 직접 측정하지는 않습니다.
- 가중 라운드 로빈은 설정한 가중치 비율에 따라 더 큰 용량의 엔드포인트에 더 많은 요청을 배정합니다. 가중치는 관측 결과에 맞게 조정해야 합니다.
- 최소 연결은 활성 연결 수가 가장 적은 엔드포인트를 고릅니다. 연결 수가 작업 비용이나 응답 시간을 항상 대표하는 것은 아닙니다.
- 소스/IP 해시는 로드 밸런서가 관측한 소스 값을 해시해 대상을 고릅니다. 프록시·NAT, 풀 구성 변경, 제품별 구현에 따라 매핑이 바뀔 수 있으므로 “항상 같은 서버”를 보장하는 일반 규칙이 아닙니다.
세션 지속성(sticky session)은 특정 제품이 쿠키나 소스 해시 등으로 제공하는 구성 가능한 동작입니다. 장애나 스케일 변경 때 재매핑될 수 있으므로, 가능하면 세션·rate limit·업로드 파일 같은 상태를 외부 저장소에 두고 어느 복제본도 요청을 처리할 수 있게 합니다.
구현 위치는 요구에 따라 달라집니다. NGINX·HAProxy 같은 소프트웨어, 하드웨어 장비, Kubernetes Gateway/Ingress 또는 Service 구현, AWS Elastic Load Balancing의 Application Load Balancer·Network Load Balancer 같은 관리형 제품을 사용할 수 있습니다. 각 제품이 지원하는 계층, 알고리즘, 상태 확인, 세션 지속성의 의미를 문서에서 확인해야 합니다.
NestJS 실행 단위 선택
Kubernetes에서는 Pod당 NestJS 프로세스 하나를 기본 모델로 두면 자원 요청, 재시작, readiness, HPA의 관측 단위가 일치합니다. 벤치마크와 연결 예산이 허용할 때만 Pod당 소수 프로세스를 선택합니다. 원하는 최대 복제본이 유지되는 정상 상태의 앱 연결 예산은 다음처럼 전체 배수를 계산합니다. 이 식에는 롤링 배포의 일시적 추가 Pod와 종료 중인 프로세스의 연결은 아직 포함되지 않습니다.
최대 앱 연결 = maxReplicas × processesPerPod × poolSizePerProcess예를 들어 아래 구성의 정상 상태는 12 × 1 × 8 = 96개까지 앱 연결을 만들 수 있습니다. 이것을 배포 중에도 넘지 않는 전체 연결 상한으로 읽으면 안 됩니다. 마이그레이션·관리 연결과 배포·종료 때의 여유분을 포함해 데이터베이스 예산 안에 들어오도록 세 값과 배포 정책을 함께 제한해야 합니다.
예시 롤링 배포 때 추가 연결을 계산하기
아래 Deployment는 전략을 지정하지 않아 기본 RollingUpdate와 maxSurge: 25%를 사용합니다. 원하는 복제본이 12인 시점에는 ceil(12 × 0.25) = 3개의 추가 Pod를 허용하므로, 종료 중인 Pod를 제외한 15개가 각각 풀 8개를 채우는 조건에서는 15 × 1 × 8 = 120개입니다. 종료 중인 이전 Pod가 연결을 아직 닫지 않았다면 더 필요할 수 있습니다. Kubernetes Deployment 문서의 surge와 종료 조건을 확인하고, 실제 풀 사용량과 종료 시 연결 해제를 함께 측정합니다. HPA의 maxReplicas: 12만으로 이 추가분까지 제한되지는 않습니다.
단일 VM의 대안: Node.js cluster
cluster는 한 머신에서 여러 Node.js 프로세스가 포트를 공유하는 대안입니다. 프로세스 분배 방식은 플랫폼과 cluster.schedulingPolicy 설정의 영향을 받으므로 외부 로드 밸런서 알고리즘과 동일하다고 가정하지 않습니다. 다음 예시는 현재 API인 node: 임포트, availableParallelism(), cluster.isPrimary를 사용하고 짧은 시간의 무한 재시작을 막습니다.
import cluster from 'node:cluster';
import { availableParallelism } from 'node:os';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
const workerTarget = Math.max(1, availableParallelism());
const restartWindowMs = 60_000;
const maxRestartsPerWindow = 5;
const restartTimes: number[] = [];
const pendingRestarts = new Set<NodeJS.Timeout>();
let shuttingDown = false;
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
if (cluster.isPrimary) {
const forkWorker = () => cluster.fork();
const stopCluster = () => {
if (shuttingDown) return;
shuttingDown = true;
for (const timer of pendingRestarts) clearTimeout(timer);
pendingRestarts.clear();
cluster.disconnect(() => process.exit(1));
setTimeout(() => process.exit(1), 10_000).unref();
};
for (let index = 0; index < workerTarget; index += 1) {
forkWorker();
}
cluster.on('exit', (worker, code, signal) => {
if (shuttingDown) return;
const now = Date.now();
while (restartTimes[0] && now - restartTimes[0] > restartWindowMs) {
restartTimes.shift();
}
restartTimes.push(now);
console.error('worker exited', {
pid: worker.process.pid,
code,
signal,
});
if (restartTimes.length > maxRestartsPerWindow) {
console.error('restart budget exhausted; let the process supervisor intervene');
stopCluster();
return;
}
const delayMs = Math.min(1_000 * 2 ** (restartTimes.length - 1), 30_000);
const timer = setTimeout(() => {
pendingRestarts.delete(timer);
if (!shuttingDown) forkWorker();
}, delayMs);
pendingRestarts.add(timer);
});
} else {
void bootstrap();
}이 코드는 단일 머신의 CPU 활용 예시일 뿐 고가용성 구성이 아닙니다. 종료 신호 전달, graceful shutdown, 로그·메트릭, 프로세스 감독 정책도 별도로 설계해야 합니다.
버전 고정 이미지 만들기
Node.js 공식 지원 표에서 현재 LTS인 v24를 기준으로 예시를 작성합니다. 개발 의존성은 빌드 단계에만 두고, 런타임에는 프로덕션 의존성과 빌드 산출물만 복사합니다. Dockerfile 명령 뒤의 #는 줄 중간 주석이 아니라 인수가 될 수 있으므로 설명은 별도 줄에 둡니다.
FROM node:24.19-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24.19-alpine AS production-deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
FROM node:24.19-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --chown=node:node package*.json ./
COPY --from=production-deps --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]latest 대신 버전 태그를 사용하면 의도하지 않은 버전 이동을 줄일 수 있지만, 레지스트리에서 태그가 가리키는 이미지를 바꿀 수 있습니다. 바이트 단위 재현성이 필요하면 레지스트리에서 확인한 이미지 digest를 고정하고, 애플리케이션 release tag에는 레지스트리의 immutability 정책을 적용하거나 배포 명세에서 digest를 사용합니다.
docker build -t ghcr.io/example/nestjs-app:2026.08.25-1 .
docker push ghcr.io/example/nestjs-app:2026.08.25-1실행 가능한 헬스 엔드포인트
startup은 애플리케이션 시작이 완료됐는지를, liveness는 프로세스 자체가 살아 있는지를, readiness는 새 요청을 받아도 되는지를 나타냅니다. liveness에 데이터베이스 같은 외부 의존성을 넣으면 의존성 장애 때 모든 Pod를 재시작하는 연쇄 장애가 생길 수 있습니다. 아래 컨트롤러를 모듈의 controllers에 등록합니다.
import { Controller, Get } from '@nestjs/common';
import { DataSource } from 'typeorm';
@Controller('health')
export class HealthController {
constructor(private readonly dataSource: DataSource) {}
@Get('startup')
startup() {
return { status: 'ok' };
}
@Get('live')
live() {
return { status: 'ok' };
}
@Get('ready')
async ready() {
await this.dataSource.query('SELECT 1');
return { status: 'ok' };
}
}위 컨트롤러는 DataSource를 주입받으므로 모듈의 controller 등록뿐 아니라 앞서 선택한 TypeORM 연결의 provider가 접근 가능한 상태여야 합니다. 연결 없이 controller만 등록하면 DI 단계에서 시작하지 못합니다. 아래 probe URL은 전역 prefix가 없는 HTTP 앱 기준입니다. 8장의 configureApp()을 이어 api prefix를 유지한다면 /api/health/startup, /api/health/live, /api/health/ready로 probe 경로도 맞추거나 health route를 prefix에서 명시적으로 제외해야 합니다. URI versioning도 사용한다면 그 설정까지 실제 route와 함께 확인합니다.
Deployment, Service, HPA 연결
정상 상태 연결 예산은 프로세스당 실제 DB 풀 상한을 8로 설정한 경우에만 12 × 1 × 8 = 96입니다. 배포 중 전체 예산에는 위의 추가·종료 Pod 조건도 포함합니다. DB_POOL_SIZE 환경 변수 선언만으로 ORM 설정이 바뀌지는 않습니다. 애플리케이션에서 값을 검증하고 사용하는 DB 드라이버의 풀 옵션에 연결한 뒤 실제 연결 수를 확인해야 합니다.
다음 예시는 하나의 NestJS 프로세스를 각 Pod에서 실행합니다. DATABASE_URL 값은 미리 만든 Secret을 참조하고, startup 성공 전에는 readiness와 liveness 판단을 시작하지 않습니다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: nestjs-app
spec:
replicas: 3
selector:
matchLabels:
app: nestjs-app
template:
metadata:
labels:
app: nestjs-app
spec:
containers:
- name: app
image: ghcr.io/example/nestjs-app:2026.08.25-1
ports:
- name: http
containerPort: 3000
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: nestjs-app-secrets
key: database-url
- name: DB_POOL_SIZE
value: "8"
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
startupProbe:
httpGet:
path: /health/startup
port: http
periodSeconds: 2
failureThreshold: 30
readinessProbe:
httpGet:
path: /health/ready
port: http
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
livenessProbe:
httpGet:
path: /health/live
port: http
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3apiVersion: v1
kind: Service
metadata:
name: nestjs-app
spec:
type: LoadBalancer
selector:
app: nestjs-app
ports:
- name: http
protocol: TCP
port: 80
targetPort: httptype: LoadBalancer가 외부 로드 밸런서를 만드는지는 클러스터의 클라우드 공급자 통합 또는 별도 컨트롤러 지원에 달려 있습니다. 지원이 없으면 외부 주소가 할당되지 않을 수 있으며, 환경에 맞는 Gateway/Ingress나 다른 노출 방식을 선택해야 합니다. Service는 readiness를 통과한 Pod만 엔드포인트로 사용하고, liveness 실패는 kubelet이 해당 컨테이너를 재시작하게 합니다.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: nestjs-app
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: nestjs-app
minReplicas: 3
maxReplicas: 12
behavior:
scaleDown:
stabilizationWindowSeconds: 300
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 65위 Deployment의 replicas: 3은 HPA를 연결하기 전의 초기 예시입니다. HPA가 desired replicas를 관리하기 시작한 뒤 같은 값으로 manifest를 계속 적용하면 관측된 스케일을 다시 덮어쓸 수 있습니다. 이 단계부터는 Deployment manifest에서 spec.replicas를 생략해 HPA가 관리하도록 하고, 기존 필드 소유권을 이전할 때는 현재 배포 상태를 확인합니다. 원 YAML의 초기값과 운영 중 관리 주체를 구분합니다.
CPU averageUtilization은 Pod의 실제 CPU를 resources.requests.cpu와 비교한 비율입니다. 요청값이 없으면 CPU utilization이 정의되지 않아 HPA가 해당 리소스 metric으로 확장하지 못하고, 비현실적인 요청값은 판단을 왜곡합니다. 리소스 metric에는 metrics.k8s.io 제공자(보통 Metrics Server)가 별도로 필요합니다. CPU가 병목을 대표하지 않는 서비스는 metrics API 어댑터를 통해 RPS, 지연, 큐 길이 같은 Pods/Object/External 사용자 정의 지표를 사용하고, 데이터베이스 연결 예산과 외부 할당량을 별도 상한으로 둡니다.
Nest · Deployment and HPA
Pod 상태와 HPA의 스케일 신호
startup·readiness·liveness는 서로 다른 상태를 확인합니다. HPA가 CPU 사용량과 request를 비교해 desired replicas를 계산하며, 최대 복제본은 실제 설정한 DB 풀 예산 안에 둡니다.
2026.08.25-1
release tag에는 레지스트리 정책을 적용하고, 정확한 재현성이 필요하면 검증한 digest로 배포합니다.
Pod당 NestJS 프로세스 하나
Service는 HTTP 3000 포트에서 readiness를 통과한 Pod만 엔드포인트로 사용합니다.
서로 다른 세 질문
startup은 초기화, readiness는 새 요청 수용, liveness는 프로세스 생존을 확인합니다. 기존 연결의 graceful 종료는 별도로 설계합니다.
공급자·컨트롤러 조건
지원 환경에서만 외부 주소가 생기며, 그 밖에는 Gateway/Ingress 등을 선택합니다.
메트릭 → 권고 → desired replicas
본문 HPA는 CPU 목표 65%, 복제본 3~12입니다. CPU utilization은 request 대비 비율이며 metrics.k8s.io 제공자가 필요합니다. 필요하면 RPS와 queue 같은 사용자 지표를 씁니다.
12 × 1 × 8 ≤ 96
정상 상태에서 프로세스당 실제 풀 상한을 8로 설정한 경우입니다. 기본 surge의 추가 3 Pod는 최대 120개를 요구할 수 있고 종료 중 연결은 별도입니다. 환경 변수만 선언하면 적용되지 않으므로 드라이버 설정에 연결합니다. 관리·마이그레이션·장애 여유를 뺀 연결 예산 안에서 최대 복제본을 정합니다.
설정과 자격 증명 분리
Pod 명세에는 값 대신 Secret의 이름과 key를 참조합니다.
HPA 피드백이 복제본을 늘려도 readiness와 하위 자원 예산이 요청을 안전하게 받을 수 있는 범위를 결정합니다.
관리형 환경에서도 원리는 같습니다. AWS의 Application Load Balancer·Network Load Balancer, GCP Cloud Load Balancing, Azure Application Gateway·Load Balancer와 EKS/GKE/AKS 같은 오케스트레이션 서비스를 조합할 수 있지만, 구체적인 계층·상태 확인·스케일 지표는 선택한 제품 문서에 맞춰야 합니다.
직접 판단하기 관리·배포 여유분을 뺀 정상 상태 앱 예산이 160개라고 가정합니다. 최대 12 Pod에서 프로세스를 1개에서 2개로 늘리고 각 풀 상한 8을 유지하면 예산 안에 들어올까요? 같은 기본 25% surge에서 원하는 복제본 12로 배포할 때, 종료 중인 Pod를 제외한 계산은 얼마일까요?
정상 상태와 배포 상태를 따로 계산하기
정상 상태는 12 × 2 × 8 = 192개이므로 160개를 초과합니다. 배포에서는 추가 3 Pod를 포함해 (12 + 3) × 2 × 8 = 240개이며 종료 중 연결은 별도입니다. CPU가 여유롭다는 이유로 프로세스 수를 늘리면 하위 DB 예산을 먼저 넘을 수 있습니다. 프로세스·풀·최대 복제본·배포 정책을 함께 조정해야 합니다.
마지막으로 한 복제본을 제거하는 연습, 세션과 작업 상태의 외부화, readiness 실패 시 트래픽 제외, replica × process × pool 연결 예산, 최대 복제본에서의 하위 서비스 한계를 함께 검증합니다.
이것으로 9장 성능 최적화와 스케일링의 세 번째 절을 마칩니다.