본문 바로가기

로드 밸런싱과 수평적 확장

확장 한계와 상태·헬스 체크·연결 예산을 함께 계산하고 여러 NestJS 인스턴스로 트래픽을 안전하게 분산합니다.

앞 글까지는 서버 하나의 응답을 빠르게 만드는 방법을 다뤘습니다. 이 글에서는 여러 NestJS 인스턴스로 트래픽을 나누는 로드 밸런싱과 수평적 확장을 다룹니다.

로드 밸런싱과 수평 확장은 서버 수를 늘리는 문제가 아니라, 언제든 교체할 수 있는 실행 단위, 요청을 받을 준비가 된 엔드포인트, 외부로 옮긴 상태, 데이터베이스 같은 하위 자원의 예산을 함께 설계하는 문제입니다.

클라이언트 요청을 로드 밸런서가 준비된 NestJS 복제본으로 분산하고 상태와 하위 자원은 외부화하는 아키텍처, 수직 확장과 수평 확장의 경계, 알고리즘별 동작과 제약

Nest · Scaling architecture

준비된 복제본과 공유 자원 경계

준비된 복제본에 분산 정책을 적용하고 세션·파일·작업 상태를 외부화합니다. DB·큐·외부 API의 연결과 처리량 예산이 확장의 실제 상한을 결정합니다.

로드 밸런서와 교체 가능한 NestJS 복제본 아키텍처 클라이언트가 로드 밸런서에 요청하면 준비 상태를 통과한 NestJS 복제본이 처리한다. 복제본은 외부 세션 저장소와 데이터베이스, 큐, 외부 API를 공유하며 총 연결과 할당량이 확장 상한을 만든다. EDGE READY APP POOL EXTERNAL STATE request ready only shared budget 클라이언트 HTTP · HTTPS 로드 밸런서 policy · health 교체 가능한 NestJS 복제본 Pod A · ready · stateless Pod B · ready · stateless Pod C · not ready → 제외 세션 · 캐시 파일 · rate limit 하위 자원 DB · queue · API pool · quota · lag 정상 예산 max replica × process/Pod × pool/process 배포·종료 추가 연결과 하위 할당량을 함께 검토한다
확장 방식과 분산 정책의 실제 의미
선택무엇을 바꾸는가주의할 경계
scale up한 실행 단위의 CPU·메모리단일 노드 한계와 장애 범위는 남음
scale out준비된 동일 역할 복제본 수외부 상태와 하위 자원 예산 필요
round robin요청을 순서대로 배정요청 비용·실제 지연은 측정하지 않음
weighted설정 가중치 비율로 배정관측 결과에 맞춘 가중치 조정 필요
least connections활성 연결이 적은 대상으로 배정연결 수가 작업 비용을 항상 대표하지 않음
source/IP hash관측 소스의 해시로 대상 선택NAT·프록시·풀 변경 때 매핑이 달라질 수 있음
준비된 복제본에만 분산하고 상태는 외부화한다 Client는LB에요청하고LB는ready복제본집합에만선택한분산정책을적용한다. A/B는준비된대상,C는notready로제외한다. 복제본은세션/파일/캐시같은외부상태와DB/큐/API를사용한다. 두하위영역의점선은자원예산과할당량이용량을제한하는관계이다. replica/process/pool은정상상태예산이고rolloutsurge와종료중연결은별도로포함해야한다. sourcehash는풀변경등에따라재매핑될수있다. 사용 사용 Client HTTP · HTTPS 로드 밸런서 policy · health ready 복제본 집합 A/B → 분산 대상 C not ready → 제외 외부 상태 세션·파일 캐시 rate limit 하위 자원 DB·큐 API pool·quota 용량과 연결 예산 정상: 복제본·proc·풀 배포·종료 추가분
request 준비된 복제본만 분산 대상
edge

로드 밸런서

health와 readiness를 통과한 대상에 선택한 정책을 적용합니다.

pool

교체 가능한 NestJS 복제본

세션·파일·rate limit 상태를 외부화해 어느 복제본도 요청을 처리하게 합니다.

scale up

한 실행 단위의 자원 증가

적용은 단순할 수 있지만 노드 한계와 장애 범위가 남습니다.

scale out

준비된 복제본 수 증가

DB·queue·API 예산과 상태 분리가 실제 상한을 만듭니다.

round robin

순서대로 요청 배정

요청 비용이나 실제 지연을 직접 측정하지 않습니다.

weighted

설정 가중치 비율로 배정

관측 결과에 맞춰 가중치를 조정해야 합니다.

least conn

활성 연결 수 기준

연결 수가 실제 작업 비용이나 응답 시간을 항상 뜻하지는 않습니다.

source hash

조건부 세션 지속성

NAT·프록시·풀 변경으로 매핑이 달라질 수 있어 외부 상태가 우선입니다.

로드 밸런싱, 상태 분리, 준비 상태, replica × process × pool 예산을 하나의 아키텍처 결정으로 검증합니다.

위 그림은 클라이언트의 요청을 로드 밸런서가 준비 상태를 통과한 NestJS 복제본에 나누어 보내고, 복제본들은 세션 저장소, 데이터베이스, 큐, 외부 API를 함께 쓰는 구조를 보여 줍니다. 복제본은 상태를 들고 있지 않으므로 아무 복제본이나 요청을 처리할 수 있지만, 모든 복제본이 같은 하위 자원을 나누어 쓰므로 그 자원의 연결 수와 할당량이 확장의 실제 상한이 됩니다. 아래쪽 표는 수직 확장과 수평 확장의 차이, 그리고 분산 알고리즘마다 어떻게 동작하고 무엇을 보장하지 않는지 정리합니다.


확장과 가용성

수직 확장(scale up)은 실행 단위 하나의 CPU와 메모리 같은 자원을 늘립니다. 적용은 단순할 수 있지만, 한 노드의 물리적·비용상 한계와 장애가 미치는 범위는 그대로 남습니다.

수평 확장(scale out)은 같은 역할의 실행 단위를 더 추가합니다. 하지만 용량이 무한해지지는 않습니다. 데이터베이스 연결, 외부 API 할당량, 큐 처리량, 캐시와 세션 상태, 인스턴스 사이의 조정 비용 가운데 가장 먼저 한계에 닿는 자원이 상한이 됩니다. 여러 장애 영역에 준비된 복제본을 두고 상태를 외부로 옮겼을 때 가용성도 좋아지며, 프로세스 수만 늘린다고 무중단이 보장되지는 않습니다.

Node.js는 JavaScript 콜백을 이벤트 루프에서 실행하므로, 오래 걸리는 CPU 작업이 루프를 막을 수 있습니다. I/O 중심 API는 비동기 I/O의 이점을 누리지만, CPU 중심 작업은 측정 결과에 맞춰 다음 가운데 하나를 고릅니다.

  • 작업을 작게 나누거나 알고리즘을 개선합니다.
  • worker_threads나 별도의 작업 큐로 CPU 작업을 떼어 냅니다.
  • 한 머신의 여러 프로세스나 여러 Pod로 처리량과 장애 범위를 나눕니다.

로드 밸런싱 정책 읽기

로드 밸런서는 준비된 엔드포인트들에 분산 정책을 적용합니다. 정책 이름만 보고 “가장 빠른 서버”가 선택된다고 단정할 수는 없습니다. L4와 L7 로드 밸런서의 차이, 분산 알고리즘, 헬스 확인의 기본은 로드 밸런서에서 볼 수 있습니다.

  • 라운드 로빈: 사용할 수 있는 엔드포인트에 요청을 차례로 보냅니다. 요청마다 다른 비용이나 실제 지연은 측정하지 않습니다.
  • 가중 라운드 로빈: 설정한 가중치 비율에 따라 용량이 큰 엔드포인트에 요청을 더 많이 보냅니다. 가중치는 측정 결과를 보며 조정해야 합니다.
  • 최소 연결: 활성 연결이 가장 적은 엔드포인트를 고릅니다. 연결 수가 작업 비용이나 응답 시간을 늘 대표하지는 않습니다.
  • 소스·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, Deployment, Service, 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 설정의 영향을 받으므로, 외부 로드 밸런서의 알고리즘과 같다고 가정하지 않습니다. 다음 예시는 node: 접두사 import, availableParallelism(), cluster.isPrimary를 쓰고, 짧은 시간 안에 무한히 재시작하는 것을 막습니다.

src/main.ts (단일 VM의 cluster 대안)
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(process.env.PORT ?? 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, 로그와 지표, 프로세스 감독 정책도 따로 설계해야 합니다.

더 깊이: cluster가 연결을 나누는 두 가지 방식

Node.js cluster 문서는 연결을 나누는 방식을 두 가지로 설명합니다. 첫째는 주 프로세스(primary)가 포트에서 연결을 직접 받아 라운드 로빈으로 worker에 넘기는 방식이고, 둘째는 주 프로세스가 만든 listen 소켓을 worker들에게 넘겨 각 worker가 연결을 직접 받는 방식입니다. 둘째 방식이 이론상 더 빠르지만, 실제로는 운영체제 스케줄러 때문에 분배가 크게 치우칠 수 있습니다. 문서에는 worker 8개 가운데 2개가 전체 연결의 70% 넘게 받은 사례가 적혀 있습니다.

라운드 로빈(cluster.SCHED_RR)은 Windows를 뺀 모든 운영체제의 기본값이고, Windows에서는 분배를 운영체제에 맡기는 cluster.SCHED_NONE이 기본값입니다. 정책은 cluster.schedulingPolicy나 NODE_CLUSTER_SCHED_POLICY 환경 변수(rr 또는 none)로 바꿀 수 있지만, 첫 worker를 만들거나 setupPrimary()를 호출한 뒤에는 바뀌지 않습니다. 그래서 Windows 개발 PC에서 본 worker별 부하 분포를 Linux 서버에서도 그대로 기대하면 안 됩니다. 또 라운드 로빈도 요청이 아니라 연결을 나누는 것이므로, keep-alive로 오래 유지되는 연결이 많으면 요청 수는 고르게 나뉘지 않을 수 있습니다.


버전을 고정한 이미지 만들기

Node.js 공식 지원 표에서 LTS로 지원되는 24를 이 교재의 기준으로 삼아, 이 글을 쓰는 시점의 최신 계열인 24.22 이미지를 씁니다. 2026년 10월 28일부터는 26도 LTS입니다. 개발 의존성은 빌드 단계에만 두고, 실행 단계에는 프로덕션 의존성과 빌드 결과만 복사합니다. Dockerfile 명령 뒤에 붙인 #는 주석이 아니라 인자로 해석될 수 있으므로, 설명은 별도 줄에 둡니다.

Dockerfile
FROM node:24.22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:24.22-alpine AS production-deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force

FROM node:24.22-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를 고정하고, 애플리케이션 릴리스 태그에는 레지스트리의 변경 금지 정책을 적용하거나 배포 명세에서 digest를 씁니다.

docker build -t ghcr.io/example/nestjs-app:2026.11.01-1 .
docker push ghcr.io/example/nestjs-app:2026.11.01-1

-t의 값은 레지스트리 주소, 저장소 이름, 태그를 이은 것이므로, push할 때도 같은 이름을 써야 그 레지스트리로 올라갑니다. ghcr.io/example은 예시 주소이므로 실제로 push 권한이 있는 레지스트리와 저장소로 바꿉니다. push 결과에 나오는 sha256: digest는 앞 문단에서 말한 대로 배포 명세에 기록해 둡니다.


헬스 엔드포인트

startup은 애플리케이션 시작이 끝났는지를, liveness는 프로세스 자체가 살아 있는지를, readiness는 새 요청을 받아도 되는지를 나타냅니다. liveness에 데이터베이스 같은 외부 의존성을 넣으면, 의존성이 장애일 때 모든 Pod를 재시작하는 연쇄 장애가 생길 수 있습니다. 아래 controller를 모듈의 controllers에 등록합니다.

src/health.controller.ts
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' };
  }
}

이 controller는 DataSource를 주입받으므로, 모듈에 controller를 등록하는 것뿐 아니라 3장에서 설정한 TypeORM 연결의 provider에도 접근할 수 있어야 합니다. 연결 없이 controller만 등록하면 의존성 주입 단계에서 시작하지 못합니다. 운영에서 여러 의존성을 확인한다면 11장에서 다룰 @nestjs/terminus로 바꿀 수 있습니다.

아래 probe 경로는 global prefix가 없는 HTTP 앱을 기준으로 합니다. 8장의 configureApp()을 이어서 써서 api prefix가 있다면, probe 경로도 /api/health/startup, /api/health/live, /api/health/ready로 맞추거나 health route를 prefix에서 명시적으로 제외해야 합니다. URI 버전 관리도 쓴다면 그 설정까지 실제 route와 함께 확인합니다.


Deployment, Service, HPA 연결

평상시 연결 수가 12 × 1 × 8 = 96이 되는 것은 프로세스마다 실제 DB 풀 상한을 8로 설정했을 때뿐입니다. 배포 중의 전체 예산에는 앞에서 계산한 추가·종료 Pod도 포함합니다. DB_POOL_SIZE 환경 변수를 선언한다고 ORM 설정이 바뀌지는 않으므로, 애플리케이션에서 값을 검증해 DB 드라이버의 풀 옵션에 연결하고 실제 연결 수를 확인해야 합니다.

다음 예시는 각 Pod에서 NestJS 프로세스 하나를 실행합니다. DATABASE_URL 값은 미리 만든 Secret을 참조하고, startup probe가 성공하기 전에는 readiness와 liveness 판정을 시작하지 않습니다.

kubernetes/deployment.yaml
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.11.01-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: 3

probe의 숫자는 얼마나 기다릴지를 정합니다. startup probe는 2초마다 확인하며 30번까지 실패를 허용하므로 시작에 약 60초까지 기다리고, readiness는 5초 간격으로 3번 연속 실패하면 Pod를 트래픽에서 빼며, liveness는 10초 간격으로 3번 연속 실패하면 컨테이너를 재시작합니다. 세 probe가 모두 이름이 http인 포트를 가리키므로, containerPort를 바꿔도 probe 설정은 그대로 둘 수 있습니다.

kubernetes/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: nestjs-app
spec:
  type: LoadBalancer
  selector:
    app: nestjs-app
  ports:
    - name: http
      protocol: TCP
      port: 80
      targetPort: http

type: LoadBalancer가 외부 로드 밸런서를 실제로 만드는지는 클러스터의 클라우드 공급자 통합이나 별도 컨트롤러가 지원하는지에 달려 있습니다. 지원하지 않으면 외부 주소가 할당되지 않을 수 있으므로, 환경에 맞는 Gateway나 Ingress 같은 노출 방식을 고릅니다. Service는 readiness를 통과한 Pod만 엔드포인트로 쓰고, liveness가 실패하면 kubelet이 해당 컨테이너를 재시작합니다.

kubernetes/hpa.yaml
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가 원하는 복제본 수를 관리하기 시작한 뒤에도 같은 값이 든 manifest를 계속 적용하면, HPA가 정한 복제본 수를 다시 덮어쓸 수 있습니다. 그래서 이 단계부터는 Deployment manifest에서 spec.replicas를 빼 HPA가 관리하게 하고, 기존 필드의 소유권을 옮길 때는 현재 배포 상태를 확인합니다.

CPU의 averageUtilization은 Pod의 실제 CPU 사용량을 resources.requests.cpu와 비교한 비율입니다. 요청값이 없으면 CPU 사용률을 계산할 수 없어 HPA가 이 지표로 확장하지 못하고, 비현실적인 요청값은 판단을 왜곡합니다. 리소스 지표에는 metrics.k8s.io를 제공하는 Metrics Server 같은 구성 요소가 따로 필요합니다. CPU가 병목을 대표하지 않는 서비스라면 지표 API 어댑터를 거쳐 초당 요청 수, 지연, 큐 길이 같은 사용자 정의 지표를 쓰고, 데이터베이스 연결 예산과 외부 할당량은 별도의 상한으로 둡니다.

release tag 또는 digest로 식별한 NestJS 산출물을 Deployment가 단일 프로세스 Pod로 배포하고 Service가 HTTP 3000 포트의 준비된 Pod로 요청을 보내며, CPU 요청 기준 메트릭이 HPA를 거쳐 복제본 수로 되먹임되는 Kubernetes 배포 구조

Nest · Deployment and HPA

Pod 상태와 HPA의 스케일 신호

startup·readiness·liveness는 서로 다른 상태를 확인합니다. HPA가 CPU 사용량과 request를 비교해 desired replicas를 계산하며, 최대 복제본은 실제 설정한 DB 풀 예산 안에 둡니다.

Kubernetes NestJS 배포와 HPA 피드백 경로 release tag 또는 digest로 식별한 산출물을 Deployment가 Pod 세 개로 유지한다. Service는 HTTP 3000 포트에서 readiness를 통과한 Pod에만 요청을 보낸다. Pod는 Secret의 DATABASE_URL을 참조하고 DB TCP 연결 풀을 프로세스당 8개 이하로 제한한다. CPU 메트릭은 Metrics API와 HPA를 거쳐 Deployment의 desired replicas에 되먹임된다. KUBERNETES CLUSTER 80/TCP 3000·ready rollout DB/TCP usage metrics CPU 지표 scale feedback Client HTTP · HTTPS Service LoadBalancer* target HTTP:3000 Deployment desired replicas 2026.08.25-1 tag · registry policy NestJS Pod pool Pod A · process 1 startup → ready Pod B · process 1 live · ready Pod C · process 1 not ready → 제외 Secret ref · DATABASE_URL pool/process ≤ 8 DB URL port pool≤8 정상≤96 Metrics API CPU usage · custom HPA 3 ≤ replicas ≤ 12 * 외부 LB 프로비저닝은 지원되는 cloud provider 또는 controller가 있을 때만 성립 readiness는 트래픽 제외 · liveness는 컨테이너 재시작 · startup은 초기화 시간 보호
HTTP 경로와 배포·스케일 제어를 구분한다 HTTP요청은Client→Service→readyPod이다. PodCPU관측은MetricsAPI→HPA→Deploymentdesiredreplicas로이어지고Deployment는별도제어선으로Pod를조정한다. releaseartifact는Deployment의배포입력이다. Pod가참조하는Secret과실제풀설정은환경조건이며DB연결예산은정상12×1×8=96,기본surge로추가3Pod가풀을채우는조건의15×8=120에종료중연결을더검토한다. startup/readiness/liveness의책임이같다는뜻이아니다. 배포 입력 Client HTTP · HTTPS Service 지원 환경의 외부 LB NestJS ready Pod 1 process · pool ≤8 Secret URL 참조 Metrics API CPU usage · request HPA 목표65% · 3~12 Deployment desired replicas 배포/종료 여유 Release artifact tag 정책 · digest DB 연결 예산 정상: 12×1×8=96 surge: 15×8=120 종료중 연결 별도
deploy release tag·digest → Pod당 1 프로세스
artifact

2026.08.25-1

release tag에는 레지스트리 정책을 적용하고, 정확한 재현성이 필요하면 검증한 digest로 배포합니다.

Pod pool

Pod당 NestJS 프로세스 하나

Service는 HTTP 3000 포트에서 readiness를 통과한 Pod만 엔드포인트로 사용합니다.

health

서로 다른 세 질문

startup은 초기화, readiness는 새 요청 수용, liveness는 프로세스 생존을 확인합니다. 기존 연결의 graceful 종료는 별도로 설계합니다.

LoadBalancer*

공급자·컨트롤러 조건

지원 환경에서만 외부 주소가 생기며, 그 밖에는 Gateway/Ingress 등을 선택합니다.

HPA

메트릭 → 권고 → desired replicas

본문 HPA는 CPU 목표 65%, 복제본 3~12입니다. CPU utilization은 request 대비 비율이며 metrics.k8s.io 제공자가 필요합니다. 필요하면 RPS와 queue 같은 사용자 지표를 씁니다.

DB budget

12 × 1 × 8 ≤ 96

정상 상태에서 프로세스당 실제 풀 상한을 8로 설정한 경우입니다. 기본 surge의 추가 3 Pod는 최대 120개를 요구할 수 있고 종료 중 연결은 별도입니다. 환경 변수만 선언하면 적용되지 않으므로 드라이버 설정에 연결합니다. 관리·마이그레이션·장애 여유를 뺀 연결 예산 안에서 최대 복제본을 정합니다.

Secret ref

설정과 자격 증명 분리

Pod 명세에는 값 대신 Secret의 이름과 key를 참조합니다.

HPA 피드백이 복제본을 늘려도 readiness와 하위 자원 예산이 요청을 안전하게 받을 수 있는 범위를 결정합니다.

위 그림은 릴리스 태그나 digest로 식별한 이미지를 Deployment가 Pod로 배포하고, Service가 HTTP 3000번 포트에서 준비된 Pod로만 요청을 보내며, Pod의 CPU 사용량이 HPA를 거쳐 다시 복제본 수로 되먹임되는 구조를 보여 줍니다. startup, readiness, liveness는 서로 다른 상태를 확인하고, HPA의 최대 복제본은 실제로 설정한 DB 풀 예산 안에 두어야 한다는 점이 그림 위쪽에 적혀 있습니다.

관리형 환경에서도 원리는 같습니다. AWS의 Application Load Balancer와 Network Load Balancer, GCP Cloud Load Balancing, Azure Application Gateway와 Load Balancer, 그리고 EKS·GKE·AKS 같은 오케스트레이션 서비스를 조합할 수 있지만, 구체적인 계층, 헬스 확인, 확장 지표는 고른 제품의 문서에 맞춰야 합니다.

마지막으로 복제본 하나를 일부러 없애 보는 연습, 세션과 작업 상태의 외부화, readiness 실패 때 트래픽에서 빠지는지, replica × process × pool 연결 예산, 최대 복제본에서 하위 서비스가 버티는지를 함께 확인합니다.

더 깊이: HPA가 복제본 수를 계산하는 방식

HPA 문서에 따르면 HPA 컨트롤러는 기본 15초마다 지표를 읽고, 현재 복제본 수에 '현재 지표 값 ÷ 목표 값'을 곱한 뒤 올림해 원하는 복제본 수를 정합니다. 예를 들어 목표 CPU 사용률이 70%인데 평균이 140%이면 복제본을 두 배로, 35%이면 절반으로 만들려고 합니다. 다만 이 비율이 1.0에 충분히 가까우면, 즉 기본 허용 오차인 10% 안이면 아무것도 바꾸지 않습니다. 지표가 없는 Pod는 축소할 때 목표의 100%를, 확장할 때 0%를 쓰는 것으로 보수적으로 계산하고, 아직 준비되지 않은 Pod도 확장 계산에서는 0%로 봅니다.

축소는 확장보다 천천히 일어납니다. 기본 동작에서 축소에는 300초의 안정화 창(stabilization window)이 있어, 그동안 나온 권고 가운데 가장 큰 값을 따릅니다. 확장에는 안정화 창이 없고, 15초마다 현재 복제본의 100%와 Pod 4개 가운데 큰 쪽만큼 늘릴 수 있습니다. 그래서 트래픽이 갑자기 줄어도 늘어난 Pod는 5분 정도 남아 있으므로, 그동안 열려 있을 수 있는 DB 연결도 예산 계산에 넣어 둡니다.


정리

  • 수평 확장의 상한은 서버 수가 아니라 데이터베이스 연결, 외부 할당량, 큐 처리량처럼 가장 먼저 한계에 닿는 하위 자원이 정합니다.
  • 분산 알고리즘과 스티키 세션은 “항상 같은 서버”나 “가장 빠른 서버”를 보장하지 않으므로, 세션과 파일 같은 상태는 외부 저장소로 옮깁니다.
  • 평상시 연결 수는 최대 복제본 × Pod당 프로세스 × 프로세스당 풀이며, 롤링 배포의 추가 Pod와 종료 중인 연결까지 예산에 넣어야 합니다.
  • startup, liveness, readiness는 서로 다른 상태를 나타내며, liveness에는 데이터베이스 같은 외부 의존성을 넣지 않습니다.
  • HPA의 CPU 지표는 requests.cpu를 기준으로 계산되므로 요청값을 설정하고, HPA가 관리하는 Deployment에서는 replicas를 뺍니다.

연습 문제

1. 프로세스를 늘리면 연결 예산은

관리·배포 여유분을 뺀 평상시 앱 연결 예산이 160개라고 가정합니다. 최대 12 Pod에서 Pod당 프로세스를 1개에서 2개로 늘리고 풀 상한 8을 유지하면 예산 안에 들어올까요? 같은 기본 25% surge로 원하는 복제본 12에서 배포할 때, 종료 중인 Pod를 뺀 연결 수는 얼마일까요?

해설 보기

평상시는 12 × 2 × 8 = 192개이므로 160개를 넘습니다. 배포 중에는 추가 3 Pod를 포함해 (12 + 3) × 2 × 8 = 240개이며, 종료 중인 연결은 따로 더해야 합니다.

CPU에 여유가 있다는 이유로 프로세스 수를 늘리면 하위 데이터베이스 예산을 먼저 넘을 수 있습니다. 프로세스 수, 풀 크기, 최대 복제본, 배포 정책을 함께 조정해야 합니다.

2. liveness에 DB 확인을 넣으면

누군가 /health/live에도 SELECT 1을 넣었습니다. 위 Deployment 설정에서 데이터베이스가 2분 동안 응답하지 않으면 어떤 일이 일어날까요?

해설 보기

liveness probe는 10초마다 확인하고 연속 3번 실패하면 실패로 판정하므로, DB가 멈춘 뒤 약 30초가 지나면 모든 Pod의 컨테이너가 재시작됩니다. 재시작한 컨테이너는 3장의 TypeOrmModule 설정대로 시작할 때 DB 연결부터 기다립니다(기본으로 3초 간격을 두고 10번까지 시도). 그동안 app.listen()에 이르지 못하므로, DB를 확인하지 않는 startup probe조차 응답을 받지 못합니다. 연결 시도가 모두 실패해 시작이 중단되거나 startup probe의 허용 시간인 약 60초를 넘기면 컨테이너가 다시 재시작되므로, DB가 멈춘 2분 동안 재시작이 되풀이됩니다.

원래 설정이라면 readiness만 실패해 Pod들이 트래픽에서 잠시 빠질 뿐 프로세스는 그대로 살아 있고, DB가 돌아오는 즉시 다시 요청을 받습니다. 재시작이 반복되면 연결 재수립과 초기화 비용까지 겹쳐 복구가 더 늦어지므로, liveness는 프로세스 자체의 상태만 확인해야 합니다.

3. 메모리 세션과 축소

세션을 각 프로세스의 메모리에 저장하고, 로드 밸런서의 쿠키 기반 스티키 세션으로 같은 사용자를 같은 Pod에 보내고 있습니다. HPA가 부하가 줄어 복제본을 3개에서 2개로 줄였습니다. 어떤 사용자에게 무슨 일이 생길까요?

해설 보기

제거된 Pod에 고정되어 있던 사용자의 세션은 그 프로세스의 메모리와 함께 사라집니다. 로드 밸런서는 이 사용자들을 남은 Pod로 다시 보내지만, 그 Pod에는 세션이 없으므로 로그인이 풀리거나 진행 중이던 작업 상태를 잃게 됩니다.

확장과 축소가 잦은 환경에서는 세션을 Redis 같은 외부 저장소에 두어 어느 Pod든 같은 세션을 읽게 하거나, 서버에 상태를 두지 않는 토큰 방식을 씁니다. 스티키 세션은 캐시 효율 같은 보조 목적으로만 쓰고, 정확성을 기대하지 않습니다.

다음 글 성능 모니터링과 프로파일링에서는 확장한 애플리케이션이 실제로 어디서 시간을 쓰는지 지표와 프로파일링으로 찾는 방법을 다룹니다.