로드 밸런싱과 수평적 확장
확장 한계와 상태·헬스 체크·연결 예산을 함께 계산하고 여러 NestJS 인스턴스로 트래픽을 안전하게 분산합니다.
로드 밸런싱과 수평 확장은 서버 수 자체가 아니라 교체 가능한 실행 단위, 준비된 엔드포인트, 외부 상태, 하위 자원 예산을 함께 설계하는 문제입니다.
NestJS · architecture
복제본보다 먼저 준비 상태와 공유 자원 경계를 그린다
로드 밸런서는 준비된 실행 단위에 정책을 적용할 뿐, 무한한 용량이나 무중단을 만들지는 않습니다. 세션과 작업 상태를 외부화하고 데이터베이스·큐·외부 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당 소수 프로세스를 선택합니다. 최대 데이터베이스 연결 수는 다음처럼 전체 배수를 계산합니다.
최대 앱 연결 = maxReplicas × processesPerPod × poolSizePerProcess예를 들어 아래 구성은 12 × 1 × 8 = 96개까지 앱 연결을 만들 수 있습니다. 마이그레이션·관리 연결과 장애 시 여유분을 제외한 데이터베이스 예산 안에 들어오도록 세 값을 함께 제한해야 합니다.
단일 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' };
}
}Deployment, Service, HPA 연결
다음 예시는 하나의 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: 65CPU averageUtilization은 Pod의 실제 CPU를 resources.requests.cpu와 비교한 비율입니다. 요청값이 없으면 CPU utilization이 정의되지 않아 HPA가 해당 리소스 metric으로 확장하지 못하고, 비현실적인 요청값은 판단을 왜곡합니다. 리소스 metric에는 metrics.k8s.io 제공자(보통 Metrics Server)가 별도로 필요합니다. CPU가 병목을 대표하지 않는 서비스는 metrics API 어댑터를 통해 RPS, 지연, 큐 길이 같은 Pods/Object/External 사용자 정의 지표를 사용하고, 데이터베이스 연결 예산과 외부 할당량을 별도 상한으로 둡니다.
NestJS · deployment · HPA feedback
Pod의 상태 신호와 스케일 신호를 분리한다
Pod당 한 프로세스를 기본으로 두고 startup, readiness, liveness를 서로 다른 질문에 연결합니다. HPA의 CPU 비율은 request를 기준으로 계산하며, 최대 복제본은 데이터베이스 연결 예산을 넘지 않아야 합니다.
2026.08.25-1
release tag에는 레지스트리 정책을 적용하고, 정확한 재현성이 필요하면 검증한 digest로 배포합니다.
Pod당 NestJS 프로세스 하나
Service는 HTTP 3000 포트에서 readiness를 통과한 Pod만 엔드포인트로 사용합니다.
서로 다른 세 질문
startup은 초기화, readiness는 새 요청 수용, liveness는 프로세스 생존을 확인합니다.
공급자·컨트롤러 조건
지원 환경에서만 외부 주소가 생기며, 그 밖에는 Gateway/Ingress 등을 선택합니다.
메트릭 → 권고 → desired replicas
CPU utilization은 request 대비 비율이며 metrics.k8s.io 제공자가 필요합니다. 필요하면 RPS와 queue 같은 사용자 지표를 씁니다.
12 × 1 × 8 ≤ 96
관리·마이그레이션·장애 여유를 뺀 연결 예산 안에서 최대 복제본을 정합니다.
설정과 자격 증명 분리
Pod 명세에는 값 대신 Secret의 이름과 key를 참조합니다.
HPA 피드백이 복제본을 늘려도 readiness와 하위 자원 예산이 요청을 안전하게 받을 수 있는 범위를 결정합니다.
관리형 환경에서도 원리는 같습니다. AWS의 Application Load Balancer·Network Load Balancer, GCP Cloud Load Balancing, Azure Application Gateway·Load Balancer와 EKS/GKE/AKS 같은 오케스트레이션 서비스를 조합할 수 있지만, 구체적인 계층·상태 확인·스케일 지표는 선택한 제품 문서에 맞춰야 합니다.
마지막으로 한 복제본을 제거하는 연습, 세션과 작업 상태의 외부화, readiness 실패 시 트래픽 제외, replica × process × pool 연결 예산, 최대 복제본에서의 하위 서비스 한계를 함께 검증합니다.
이것으로 9장 성능 최적화와 스케일링의 세 번째 절을 마칩니다.