이미지 로딩 중...
AI Generated
2025. 11. 8. · 9 Views
Next.js 실전 운영 완벽 가이드 4편 - 다중 인스턴스 트래픽 분산 아키텍처
단일 서버에서 벗어나 여러 인스턴스로 트래픽을 분산시키는 방법을 배웁니다. PM2 클러스터 모드, 로드 밸런서, 헬스체크까지 실전 운영에 필요한 모든 것을 다룹니다. 대규모 트래픽을 안정적으로 처리하는 아키텍처를 구축해보세요.
목차
- PM2 클러스터 모드 기본 설정 - CPU 코어 활용 극대화
- Nginx 로드 밸런서 설정 - 여러 서버로 트래픽 분산
- 헬스체크 API 구현 - 서버 상태 모니터링
- Graceful Shutdown 구현 - 안전한 프로세스 종료
- 서버 간 세션 공유 - Redis 기반 세션 스토어
- 로그 집중화 - 여러 서버의 로그 통합 관리
- 분산 추적 시스템 - 요청 흐름 시각화
- 자동 스케일링 설정 - 트래픽에 따른 동적 확장
- 블루-그린 배포 전략 - 무중단 배포 구현
1. PM2 클러스터 모드 기본 설정 - CPU 코어 활용 극대화
시작하며
여러분의 Next.js 애플리케이션이 점점 더 많은 사용자를 받게 되면서 단일 프로세스로는 CPU를 제대로 활용하지 못하고 있나요? 서버에 8개의 코어가 있는데도 하나만 사용되고 있다면, 엄청난 자원 낭비입니다.
Node.js는 기본적으로 싱글 스레드로 동작하기 때문에, 아무리 좋은 CPU를 사용해도 하나의 코어만 활용됩니다. 이는 서버 비용 대비 효율이 매우 떨어지고, 갑작스러운 트래픽 증가에 취약한 구조입니다.
바로 이럴 때 필요한 것이 PM2 클러스터 모드입니다. 모든 CPU 코어를 활용하여 여러 개의 인스턴스를 동시에 실행시키고, 자동으로 트래픽을 분산시킬 수 있습니다.
개요
간단히 말해서, PM2 클러스터 모드는 여러 개의 Node.js 프로세스를 동시에 실행하고 요청을 자동으로 분배하는 기능입니다. 실무에서는 서버의 CPU 코어 수만큼 인스턴스를 생성하여 처리량을 극대화합니다.
예를 들어, 4코어 서버라면 4개의 프로세스가 동시에 요청을 처리하므로 이론적으로 4배의 성능 향상을 기대할 수 있습니다. 특히 CPU 집약적인 작업이나 동시 접속자가 많은 서비스에서 효과가 큽니다.
기존에는 nginx나 별도의 로드 밸런서를 설정해야 했다면, 이제는 PM2 설정 하나로 간단히 구현할 수 있습니다. 핵심 특징은 자동 로드 밸런싱, 무중단 재시작, 프로세스 장애 시 자동 복구입니다.
이러한 특징들이 실제 운영 환경에서 안정성과 가용성을 크게 향상시킵니다.
코드 예제
// ecosystem.config.js - PM2 클러스터 설정
module.exports = {
apps: [{
name: 'nextjs-app',
script: 'node_modules/next/dist/bin/next',
args: 'start',
instances: 'max', // CPU 코어 수만큼 인스턴스 생성
exec_mode: 'cluster', // 클러스터 모드 활성화
env: {
NODE_ENV: 'production',
PORT: 3000
},
max_memory_restart: '1G', // 메모리 1GB 초과 시 재시작
error_file: './logs/pm2-error.log',
out_file: './logs/pm2-out.log',
merge_logs: true,
autorestart: true, // 프로세스 장애 시 자동 재시작
watch: false
}]
};
설명
이것이 하는 일: PM2가 서버의 CPU 코어 수를 자동으로 감지하고, 그만큼의 Next.js 인스턴스를 생성하여 들어오는 요청을 균등하게 분배합니다. 첫 번째로, instances: 'max' 설정은 PM2가 시스템의 모든 CPU 코어를 감지하고 각 코어마다 하나의 프로세스를 생성하도록 합니다.
만약 특정 개수를 원한다면 instances: 4처럼 숫자로 지정할 수도 있습니다. 이렇게 하는 이유는 Node.js의 싱글 스레드 한계를 우회하여 멀티코어 CPU를 완전히 활용하기 위함입니다.
그 다음으로, exec_mode: 'cluster'가 설정되면 PM2는 내부적으로 Node.js의 cluster 모듈을 사용하여 마스터 프로세스와 워커 프로세스를 생성합니다. 마스터 프로세스가 들어오는 요청을 받아 각 워커에게 라운드 로빈 방식으로 분배하므로, 자동으로 로드 밸런싱이 이루어집니다.
세 번째로, max_memory_restart: '1G' 설정은 메모리 누수 방지를 위한 안전장치입니다. Next.js 애플리케이션이 1GB 이상의 메모리를 사용하면 해당 인스턴스만 자동으로 재시작되며, 다른 인스턴스들은 계속 트래픽을 처리하므로 서비스 중단이 없습니다.
마지막으로, autorestart: true와 함께 에러 로그 설정을 통해 프로세스가 예기치 않게 종료되더라도 즉시 재시작되고, 문제의 원인을 로그로 추적할 수 있습니다. 여러분이 이 설정을 사용하면 단일 프로세스 대비 2~4배의 처리량 향상을 기대할 수 있으며, 한 인스턴스에 문제가 생겨도 다른 인스턴스들이 계속 서비스를 제공하므로 가용성이 크게 향상됩니다.
특히 블랙 프라이데이 같은 트래픽 폭증 상황에서도 안정적인 서비스가 가능합니다.
실전 팁
💡 개발 환경에서는 instances: 1로 설정하세요. 디버깅이 훨씬 쉽고 로그 추적도 명확합니다. 클러스터 모드는 운영 환경에서만 활성화하는 것이 좋습니다.
💡 max_memory_restart는 애플리케이션의 정상 메모리 사용량보다 20-30% 높게 설정하세요. 너무 낮으면 불필요한 재시작이 빈번하고, 너무 높으면 메모리 누수 감지가 늦어집니다.
💡 로그 파일이 너무 커지지 않도록 pm2 install pm2-logrotate를 사용하여 자동 로그 로테이션을 설정하세요. 디스크 공간 부족으로 인한 장애를 예방할 수 있습니다.
💡 pm2 monit 명령어로 실시간 CPU, 메모리 사용량을 모니터링하면서 적절한 인스턴스 수를 결정하세요. 모든 코어가 100%로 동작하면 인스턴스를 더 늘릴 필요는 없습니다.
💡 Blue-Green 배포를 위해 pm2 reload ecosystem.config.js 명령어를 사용하면 무중단으로 새 버전을 배포할 수 있습니다. 각 인스턴스를 순차적으로 재시작하므로 서비스 중단이 없습니다.
2. Nginx 로드 밸런서 설정 - 여러 서버로 트래픽 분산
시작하며
PM2로 단일 서버 내에서 인스턴스를 늘렸지만, 여전히 한 서버의 한계를 넘어서는 트래픽이 몰리고 있나요? 또는 서버 한 대가 다운되면 전체 서비스가 중단되는 위험을 안고 계신가요?
단일 서버 아키텍처는 확장성에 한계가 있고, SPOF(Single Point of Failure) 문제를 안고 있습니다. 아무리 PM2로 프로세스를 늘려도 서버 자체가 죽으면 모든 것이 멈춥니다.
이는 사업 연속성에 심각한 위협입니다. 바로 이럴 때 필요한 것이 Nginx 로드 밸런서입니다.
여러 대의 서버로 트래픽을 지능적으로 분산시키고, 장애가 발생한 서버는 자동으로 제외하여 고가용성을 보장합니다.
개요
간단히 말해서, Nginx 로드 밸런서는 들어오는 HTTP 요청을 여러 백엔드 서버로 자동 분배하고, 각 서버의 상태를 체크하여 건강한 서버로만 트래픽을 보내는 역방향 프록시입니다. 실무에서는 최소 2대 이상의 Next.js 서버를 구성하고, 앞단에 Nginx를 두어 트래픽을 분산시킵니다.
예를 들어, 사용자가 10,000명 동시 접속해도 5대의 서버가 각각 2,000명씩 처리하므로 안정적입니다. 또한 한 서버에 장애가 발생해도 나머지 서버들이 자동으로 트래픽을 받아 무중단 서비스가 가능합니다.
기존에는 DNS 라운드 로빈이나 하드웨어 로드 밸런서를 사용했다면, 이제는 오픈소스 Nginx로 더 유연하고 비용 효율적인 구성이 가능합니다. 핵심 특징은 다양한 로드 밸런싱 알고리즘(라운드 로빈, least_conn, ip_hash), 자동 헬스체크, SSL/TLS 터미네이션, 정적 파일 캐싱입니다.
이러한 특징들이 대규모 트래픽 환경에서 안정성과 성능을 동시에 보장합니다.
코드 예제
# /etc/nginx/nginx.conf - Nginx 로드 밸런서 설정
upstream nextjs_backend {
# least_conn: 가장 적은 연결을 가진 서버로 분배
least_conn;
# 백엔드 Next.js 서버들 (각각 PM2 클러스터 모드 실행 중)
server 10.0.1.10:3000 max_fails=3 fail_timeout=30s;
server 10.0.1.11:3000 max_fails=3 fail_timeout=30s;
server 10.0.1.12:3000 max_fails=3 fail_timeout=30s;
# 백업 서버 (다른 서버 모두 다운 시에만 사용)
server 10.0.1.13:3000 backup;
keepalive 32; # 백엔드와의 연결 풀 유지
}
server {
listen 80;
server_name myapp.com;
# 클라이언트 요청을 백엔드로 전달
location / {
proxy_pass http://nextjs_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
설명
이것이 하는 일: Nginx가 클라이언트 요청을 받아 설정된 알고리즘에 따라 여러 백엔드 서버 중 하나를 선택하여 요청을 전달하고, 응답을 다시 클라이언트에게 돌려줍니다. 첫 번째로, upstream nextjs_backend 블록은 백엔드 서버 그룹을 정의합니다.
least_conn 알고리즘을 사용하면 현재 활성 연결이 가장 적은 서버로 요청을 보내므로, 처리 시간이 긴 요청이 있어도 균등하게 부하가 분산됩니다. 라운드 로빈보다 훨씬 공정한 분배가 가능합니다.
그 다음으로, max_fails=3 fail_timeout=30s 설정은 헬스체크 로직입니다. 한 서버에 3번 연속 요청이 실패하면 30초 동안 해당 서버를 사용하지 않습니다.
30초 후 자동으로 다시 시도하여 복구되었는지 확인합니다. 이렇게 하면 장애 서버로 트래픽이 가는 것을 방지하고, 자동 복구도 감지할 수 있습니다.
세 번째로, keepalive 32 설정은 Nginx와 백엔드 서버 간의 연결을 재사용합니다. 매번 새로운 TCP 연결을 맺는 오버헤드를 줄여 응답 시간을 5-10% 개선할 수 있습니다.
특히 HTTPS 환경에서는 TLS 핸드셰이크 비용이 크므로 효과가 더 큽니다. 네 번째로, proxy_set_header 지시문들은 클라이언트의 원본 IP, 프로토콜 정보를 Next.js 애플리케이션에 전달합니다.
이를 통해 Next.js에서 req.headers['x-forwarded-for']로 실제 클라이언트 IP를 확인할 수 있고, 로깅이나 보안 정책에 활용할 수 있습니다. 마지막으로, backup 서버는 평상시에는 사용되지 않다가 모든 주 서버가 다운되었을 때만 활성화됩니다.
이는 최후의 안전장치로서, 완전한 서비스 중단을 방지합니다. 여러분이 이 구성을 사용하면 수평 확장이 자유로워지고, 서버 한 대가 다운되어도 서비스가 계속 유지되며, 배포 시에도 무중단 배포가 가능합니다.
AWS의 ELB나 ALB 같은 관리형 서비스와 비교해도 비용 대비 성능이 우수하고 세밀한 설정이 가능합니다.
실전 팁
💡 ip_hash 알고리즘을 사용하면 같은 클라이언트는 항상 같은 서버로 연결되므로 세션 관리가 쉽습니다. 하지만 서버 간 부하가 불균등해질 수 있으니, 세션은 Redis 같은 외부 저장소에 저장하고 least_conn을 사용하는 것을 권장합니다.
💡 proxy_cache를 활성화하면 Next.js의 응답을 Nginx가 캐싱하여 백엔드 부하를 크게 줄일 수 있습니다. 특히 ISR(Incremental Static Regeneration)과 함께 사용하면 효과적입니다.
💡 헬스체크 전용 엔드포인트(/api/health)를 만들고, Nginx Plus나 별도 헬스체크 도구를 사용하면 더 정교한 상태 모니터링이 가능합니다. DB 연결, 외부 API 상태까지 체크할 수 있습니다.
💡 로그에 $upstream_addr와 $upstream_response_time 변수를 추가하면 어떤 백엔드 서버가 요청을 처리했고 얼마나 걸렸는지 추적할 수 있어 병목 지점 파악에 유용합니다.
💡 배포 시에는 한 서버씩 순차적으로 재시작하고, 각 서버가 안정화될 때까지 기다린 후 다음 서버를 재시작하면 무중단 배포가 가능합니다.
3. 헬스체크 API 구현 - 서버 상태 모니터링
시작하며
여러분의 Next.js 서버가 실행 중이지만, 데이터베이스 연결이 끊어졌거나 외부 API가 응답하지 않아 사실상 정상 동작하지 않는 상황을 겪어본 적 있나요? 로드 밸런서는 서버가 살아있다고 판단해서 계속 트래픽을 보내고, 사용자는 에러만 받게 됩니다.
단순히 HTTP 응답만 체크하는 것으로는 실제 애플리케이션의 건강 상태를 알 수 없습니다. 프로세스는 살아있지만 DB 커넥션 풀이 고갈되었거나, 메모리 누수로 느려진 상태일 수 있습니다.
이런 반쯤 죽은 상태의 서버는 오히려 완전히 죽은 서버보다 더 위험합니다. 바로 이럴 때 필요한 것이 제대로 된 헬스체크 API입니다.
단순히 200 OK를 반환하는 것이 아니라, 실제로 DB 연결, 메모리 상태, 의존성 서비스 상태까지 체크하여 진정한 건강 상태를 알려줍니다.
개요
간단히 말해서, 헬스체크 API는 애플리케이션의 모든 중요한 구성 요소가 정상 작동하는지 검증하고, 상세한 상태 정보를 JSON으로 반환하는 엔드포인트입니다. 실무에서는 Liveness와 Readiness 두 가지 타입의 헬스체크를 구현합니다.
Liveness는 "프로세스가 살아있는가?"를 체크하고, Readiness는 "트래픽을 받을 준비가 되었는가?"를 체크합니다. 예를 들어, 애플리케이션이 시작되고 DB 커넥션 풀을 초기화하는 동안에는 Liveness는 OK지만 Readiness는 NOT_READY 상태입니다.
기존에는 단순히 / 경로에 접근해보는 식의 헬스체크를 했다면, 이제는 각 의존성을 개별적으로 체크하고 전체 상태를 집계하여 더 정확한 판단이 가능합니다. 핵심 특징은 계층적 헬스체크(애플리케이션, DB, 캐시, 외부 API 등), 타임아웃 설정, 상세한 에러 정보 반환입니다.
이러한 특징들이 장애를 조기에 발견하고 정확한 원인 파악을 가능하게 합니다.
코드 예제
// app/api/health/route.ts - 포괄적인 헬스체크 API
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { redis } from '@/lib/redis';
export async function GET() {
const startTime = Date.now();
const checks = {
database: false,
redis: false,
memory: false
};
try {
// DB 연결 체크 (1초 타임아웃)
const dbPromise = db.raw('SELECT 1').timeout(1000);
await dbPromise;
checks.database = true;
} catch (error) {
console.error('DB health check failed:', error);
}
try {
// Redis 연결 체크 (500ms 타임아웃)
await redis.ping();
checks.redis = true;
} catch (error) {
console.error('Redis health check failed:', error);
}
// 메모리 사용량 체크 (80% 이상이면 경고)
const memUsage = process.memoryUsage();
const memPercent = (memUsage.heapUsed / memUsage.heapTotal) * 100;
checks.memory = memPercent < 80;
const responseTime = Date.now() - startTime;
const healthy = Object.values(checks).every(c => c);
return NextResponse.json({
status: healthy ? 'ok' : 'degraded',
timestamp: new Date().toISOString(),
checks,
metrics: {
responseTime,
memoryUsagePercent: memPercent.toFixed(2),
uptime: process.uptime()
}
}, { status: healthy ? 200 : 503 });
}
설명
이것이 하는 일: 애플리케이션의 모든 중요한 의존성을 순차적으로 테스트하고, 각각의 결과를 집계하여 전체적인 건강 상태와 상세 정보를 JSON으로 반환합니다. 첫 번째로, DB 헬스체크는 단순히 연결이 있는지만 보는 것이 아니라 실제로 쿼리를 실행해봅니다.
SELECT 1은 가장 가벼운 쿼리이지만, 실제로 DB와 통신하고 커넥션 풀에서 연결을 가져와 반환하는 전체 과정을 테스트합니다. 1초 타임아웃을 설정하여 DB가 느려진 상태도 감지할 수 있습니다.
그 다음으로, Redis 헬스체크는 PING 명령어로 빠르게 응답성을 확인합니다. 500ms라는 짧은 타임아웃을 설정한 이유는 캐시 서버는 매우 빨라야 하고, 느려진 상태는 사실상 장애이기 때문입니다.
Redis가 느려지면 전체 애플리케이션이 느려지므로 조기에 감지하는 것이 중요합니다. 세 번째로, 메모리 사용량 체크는 Node.js의 힙 메모리가 80% 이상 사용 중이면 경고를 냅니다.
메모리 누수나 과부하 상태를 사전에 감지하여, PM2의 자동 재시작이 일어나기 전에 대응할 수 있습니다. 이는 예방적 모니터링의 핵심입니다.
네 번째로, 각 체크를 try-catch로 감싸서 한 부분의 실패가 전체 헬스체크를 멈추지 않도록 합니다. 예를 들어 Redis가 다운되어도 DB와 메모리 상태는 여전히 보고되므로, 문제의 정확한 범위를 파악할 수 있습니다.
마지막으로, 응답 시간과 uptime 같은 메트릭을 함께 반환하여 시계열 모니터링에 활용할 수 있습니다. Prometheus나 Datadog 같은 모니터링 도구가 이 엔드포인트를 주기적으로 호출하여 데이터를 수집하고 알림을 설정할 수 있습니다.
여러분이 이 헬스체크를 사용하면 장애를 평균 2-3분 빠르게 감지할 수 있고, 근본 원인을 즉시 파악할 수 있으며, 로드 밸런서가 문제 있는 서버로 트래픽을 보내는 것을 자동으로 방지합니다. 특히 야간이나 휴일에 장애가 발생해도 자동 복구가 가능합니다.
실전 팁
💡 외부 API 헬스체크는 신중하게 구현하세요. 외부 API가 느리면 헬스체크 자체가 타임아웃되어 서버가 건강해도 unhealthy로 판단될 수 있습니다. Circuit Breaker 패턴과 함께 사용하는 것이 좋습니다.
💡 /health/live(Liveness)와 /health/ready(Readiness)를 분리하면 Kubernetes 같은 오케스트레이션 툴과 완벽하게 통합할 수 있습니다. Liveness는 빠르게, Readiness는 상세하게 체크하세요.
💡 헬스체크 응답을 캐싱하지 마세요. 항상 실시간 상태를 반환해야 하므로 cache: 'no-store' 헤더를 추가하고, CDN도 우회하도록 설정하세요.
💡 상태가 degraded일 때는 503 상태 코드를 반환하되, 어떤 컴포넌트가 문제인지 상세 정보를 포함시키세요. 로그만 봐도 즉시 원인을 파악할 수 있습니다.
💡 헬스체크 결과를 메트릭으로 저장하고 대시보드로 시각화하면 성능 추세를 파악하고 용량 계획을 수립하는 데 큰 도움이 됩니다.
4. Graceful Shutdown 구현 - 안전한 프로세스 종료
시작하며
배포 중에 진행 중이던 요청들이 갑자기 끊기면서 사용자가 에러를 경험하거나, 결제 트랜잭션이 중간에 끊겨서 데이터 정합성 문제가 발생한 적 있나요? 서버를 재시작할 때 단순히 프로세스를 죽이면 이런 문제가 발생합니다.
Node.js 프로세스가 갑자기 종료되면 현재 처리 중이던 모든 요청이 즉시 중단되고, DB 트랜잭션이 롤백되지 않은 채로 남으며, 연결된 WebSocket이 비정상 종료됩니다. 이는 사용자 경험을 망치고 데이터 손실로 이어질 수 있습니다.
바로 이럴 때 필요한 것이 Graceful Shutdown입니다. 종료 신호를 받으면 새로운 요청은 거부하고, 진행 중인 요청은 완료될 때까지 기다린 후 안전하게 프로세스를 종료합니다.
개요
간단히 말해서, Graceful Shutdown은 프로세스 종료 신호(SIGTERM, SIGINT)를 받았을 때 즉시 죽는 대신, 현재 작업을 완료하고 자원을 정리한 후 종료하는 패턴입니다. 실무에서는 배포, 스케일링, 서버 재시작 등 모든 상황에서 Graceful Shutdown이 필수입니다.
예를 들어, PM2가 재시작 신호를 보내면 애플리케이션은 새 요청을 거부하고(다른 인스턴스가 받음), 현재 처리 중인 요청 10개가 완료될 때까지 최대 30초 기다린 후 종료됩니다. 기존에는 process.exit(0)로 바로 종료했다면, 이제는 체계적인 종료 절차를 통해 데이터 손실과 에러를 방지할 수 있습니다.
핵심 특징은 신호 처리(SIGTERM/SIGINT), 타임아웃 설정(무한 대기 방지), 자원 정리 순서(DB 연결 종료, 파일 닫기 등)입니다. 이러한 특징들이 안정적인 배포와 운영을 가능하게 합니다.
코드 예제
// server.js - Next.js 커스텀 서버에서 Graceful Shutdown
const { createServer } = require('http');
const { parse } = require('url');
const next = require('next');
const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();
let server;
app.prepare().then(() => {
server = createServer(async (req, res) => {
const parsedUrl = parse(req.url, true);
await handle(req, res, parsedUrl);
});
server.listen(3000, (err) => {
if (err) throw err;
console.log('> Ready on http://localhost:3000');
});
// Graceful Shutdown 핸들러
const gracefulShutdown = (signal) => {
console.log(`${signal} signal received: closing HTTP server`);
// 새로운 연결 거부
server.close(async () => {
console.log('HTTP server closed');
// DB 연결 종료
await db.destroy();
console.log('Database connections closed');
// Redis 연결 종료
await redis.quit();
console.log('Redis connection closed');
process.exit(0);
});
// 30초 후에도 종료되지 않으면 강제 종료
setTimeout(() => {
console.error('Could not close connections in time, forcefully shutting down');
process.exit(1);
}, 30000);
};
// 종료 신호 리스너 등록
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
});
설명
이것이 하는 일: 운영체제나 PM2가 프로세스 종료 신호를 보내면, 애플리케이션이 이를 감지하고 체계적인 종료 절차를 실행하여 데이터 손실 없이 안전하게 종료합니다. 첫 번째로, process.on('SIGTERM') 핸들러는 PM2나 Docker가 보내는 정상 종료 신호를 받습니다.
SIGTERM은 "가능하면 정상적으로 종료하라"는 의미이고, SIGKILL은 "즉시 죽어라"는 의미인데, SIGKILL은 처리할 수 없으므로 SIGTERM을 제대로 처리하는 것이 중요합니다. 그 다음으로, server.close()를 호출하면 서버가 새로운 연결을 받지 않습니다.
하지만 이미 연결된 요청들은 계속 처리되며, 모든 요청이 완료되면 콜백 함수가 실행됩니다. 이 사이에 로드 밸런서는 헬스체크 실패를 감지하고 트래픽을 다른 서버로 보내기 시작합니다.
세 번째로, DB와 Redis 연결을 순차적으로 종료합니다. db.destroy()는 커넥션 풀의 모든 연결을 정리하고, 진행 중인 트랜잭션이 있다면 완료될 때까지 기다립니다.
이렇게 하면 데이터 정합성이 보장됩니다. 네 번째로, 30초 타임아웃은 무한 대기를 방지합니다.
만약 어떤 요청이 무한 루프에 빠지거나 외부 API가 응답하지 않아 종료가 안 되면, 30초 후 강제로 종료하여 배포 프로세스가 멈추지 않도록 합니다. 실무에서는 평균 응답 시간의 10배 정도로 설정하는 것이 좋습니다.
마지막으로, PM2의 ecosystem.config.js에 kill_timeout: 35000(35초)을 설정하면 PM2가 우리의 Graceful Shutdown이 완료될 때까지 기다립니다. 만약 애플리케이션이 30초 안에 종료되지 않으면 PM2가 5초 후 SIGKILL로 강제 종료합니다.
여러분이 이 패턴을 사용하면 무중단 배포가 가능하고, 사용자는 배포가 일어났는지조차 모르며, 데이터 손실이나 정합성 문제가 발생하지 않습니다. 특히 금융이나 커머스 서비스처럼 트랜잭션 안정성이 중요한 도메인에서 필수입니다.
실전 팁
💡 server.close()는 비동기이므로 반드시 콜백이나 Promise로 완료를 기다려야 합니다. 그렇지 않으면 요청이 처리 중인데 DB 연결이 먼저 끊어져서 에러가 발생할 수 있습니다.
💡 종료 과정을 로깅하면 배포 중 문제가 생겼을 때 어느 단계에서 멈췄는지 추적할 수 있습니다. "Shutting down", "Server closed", "DB closed" 같은 단계별 로그가 유용합니다.
💡 Next.js의 middleware.ts에서 종료 플래그를 체크하여 새 요청에 503을 반환할 수도 있습니다. 이렇게 하면 로드 밸런서가 더 빠르게 이 서버로 트래픽 보내는 것을 중단합니다.
💡 WebSocket 연결이 있다면 종료 전에 클라이언트에게 재연결하라는 메시지를 보내세요. 이렇게 하면 사용자가 연결 끊김을 거의 느끼지 못합니다.
💡 개발 환경에서도 Graceful Shutdown을 테스트하세요. kill -SIGTERM <pid> 명령어로 직접 신호를 보내보고, 로그가 제대로 출력되는지 확인하세요.
5. 서버 간 세션 공유 - Redis 기반 세션 스토어
시작하며
여러 서버로 트래픽을 분산시켰더니 사용자가 페이지를 이동할 때마다 로그아웃되는 문제를 겪어본 적 있나요? A 서버에서 로그인했는데 다음 요청이 B 서버로 가면서 세션을 찾을 수 없는 상황입니다.
기본적으로 Next.js나 Express의 세션은 각 서버의 메모리에 저장되므로, 로드 밸런서가 요청을 다른 서버로 보내면 세션이 사라집니다. IP 해시를 사용하면 같은 사용자를 같은 서버로 보낼 수 있지만, 그 서버가 다운되거나 재시작되면 모든 사용자가 로그아웃됩니다.
바로 이럴 때 필요한 것이 Redis 기반 중앙 세션 스토어입니다. 모든 서버가 동일한 Redis를 바라보므로, 어떤 서버로 요청이 가든 세션을 공유할 수 있고, 서버가 재시작되어도 세션이 유지됩니다.
개요
간단히 말해서, Redis 세션 스토어는 사용자의 세션 데이터를 각 서버의 메모리가 아닌 중앙 Redis 서버에 저장하여 모든 애플리케이션 서버가 공유할 수 있게 하는 방식입니다. 실무에서는 Redis를 세션 스토어로 사용하면서 TTL(Time To Live)을 설정하여 자동으로 만료시킵니다.
예를 들어, 사용자가 로그인하면 세션이 Redis에 저장되고 30분 동안 유효하며, 사용자가 활동할 때마다 TTL이 갱신됩니다. 10대의 서버가 있어도 모두 동일한 Redis를 사용하므로 일관성이 보장됩니다.
기존에는 메모리 세션이나 sticky session(동일 사용자를 동일 서버로)을 사용했다면, 이제는 완전히 stateless한 애플리케이션 서버를 구성할 수 있습니다. 핵심 특징은 중앙 집중식 저장소, 자동 만료(TTL), 고가용성(Redis Sentinel/Cluster), 빠른 접근 속도입니다.
이러한 특징들이 대규모 분산 환경에서 안정적인 세션 관리를 가능하게 합니다.
코드 예제
// lib/session.ts - Redis 기반 세션 관리
import { NextRequest } from 'next/server';
import Redis from 'ioredis';
import { v4 as uuidv4 } from 'uuid';
const redis = new Redis({
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT || '6379'),
password: process.env.REDIS_PASSWORD,
retryStrategy: (times) => Math.min(times * 50, 2000)
});
const SESSION_TTL = 30 * 60; // 30분
export async function createSession(userId: string, data: any) {
const sessionId = uuidv4();
const sessionKey = `session:${sessionId}`;
// 세션 데이터를 JSON으로 직렬화하여 Redis에 저장
await redis.setex(
sessionKey,
SESSION_TTL,
JSON.stringify({ userId, ...data, createdAt: Date.now() })
);
return sessionId;
}
export async function getSession(sessionId: string) {
const sessionKey = `session:${sessionId}`;
const data = await redis.get(sessionKey);
if (!data) return null;
// TTL 갱신 (활동 중인 세션 유지)
await redis.expire(sessionKey, SESSION_TTL);
return JSON.parse(data);
}
export async function deleteSession(sessionId: string) {
const sessionKey = `session:${sessionId}`;
await redis.del(sessionKey);
}
// Next.js API Route에서 사용
export async function getSessionFromRequest(req: NextRequest) {
const sessionId = req.cookies.get('sessionId')?.value;
if (!sessionId) return null;
return await getSession(sessionId);
}
설명
이것이 하는 일: 사용자가 로그인하면 세션 ID를 생성하고 데이터를 Redis에 저장한 후, 쿠키로 세션 ID를 전달하여 이후 요청에서 세션을 조회할 수 있게 합니다. 첫 번째로, createSession 함수는 UUID로 고유한 세션 ID를 생성합니다.
UUID v4는 충돌 가능성이 거의 없으므로(10^-36 수준) 안전하게 사용할 수 있습니다. 세션 키를 session:${sessionId} 형식으로 만들어 Redis에서 관리하기 쉽게 네임스페이스를 부여합니다.
그 다음으로, redis.setex()는 SET과 EXPIRE를 원자적으로 실행하여 데이터를 저장하면서 동시에 TTL을 설정합니다. 30분 후에는 자동으로 삭제되므로, 별도의 정리 작업 없이도 오래된 세션이 쌓이지 않습니다.
이는 메모리 효율성과 보안(오래된 세션 무효화)에 중요합니다. 세 번째로, getSession 함수는 세션을 조회할 때마다 redis.expire()로 TTL을 갱신합니다.
이렇게 하면 활동 중인 사용자는 30분마다가 아니라 마지막 활동 시점부터 30분 동안 세션이 유지됩니다. 사용자 경험이 크게 개선됩니다.
네 번째로, 데이터를 JSON으로 직렬화하여 저장하므로 복잡한 객체도 저장할 수 있습니다. userId 외에도 권한 정보, 선호 설정, 장바구니 내용 등 다양한 데이터를 함께 저장할 수 있습니다.
다섯 번째로, retryStrategy 설정으로 Redis 연결이 끊어져도 자동으로 재연결을 시도합니다. 첫 시도는 50ms 후, 두 번째는 100ms 후, 최대 2초까지 점진적으로 대기 시간을 늘려가며 재시도하여 일시적인 네트워크 문제를 자동 복구합니다.
여러분이 이 패턴을 사용하면 수평 확장이 자유로워지고, 서버를 재시작해도 사용자가 로그아웃되지 않으며, 로드 밸런서의 알고리즘을 자유롭게 선택할 수 있습니다(least_conn 같은 효율적인 방식). 또한 Redis의 복제 기능으로 세션 데이터의 고가용성도 확보할 수 있습니다.
실전 팁
💡 세션에는 최소한의 정보만 저장하세요. 사용자 ID와 권한 정도만 저장하고, 나머지는 DB에서 조회하는 것이 좋습니다. Redis 메모리가 비싸고, 너무 큰 세션은 네트워크 오버헤드를 증가시킵니다.
💡 Redis Sentinel이나 Cluster를 사용하면 Redis 자체의 고가용성을 확보할 수 있습니다. 단일 Redis 서버가 SPOF가 되지 않도록 주의하세요.
💡 세션 ID를 쿠키에 저장할 때 httpOnly: true, secure: true, sameSite: 'strict' 옵션을 설정하여 XSS와 CSRF 공격을 방어하세요.
💡 민감한 정보(비밀번호, 신용카드 등)는 세션에 저장하지 마세요. 필요할 때마다 DB에서 조회하고, 즉시 메모리에서 제거하세요.
💡 세션 조회 실패 시 적절한 폴백 처리를 구현하세요. Redis가 일시적으로 다운되어도 읽기 전용 모드로 서비스를 유지하거나, 로그인 페이지로 리다이렉트하는 등의 대응이 필요합니다.
6. 로그 집중화 - 여러 서버의 로그 통합 관리
시작하며
10대의 서버에서 동시에 실행 중인 애플리케이션에서 에러가 발생했는데, 어느 서버에서 발생했는지 찾기 위해 SSH로 하나씩 접속해서 로그를 확인하고 계신가요? 또는 특정 사용자의 요청이 여러 서버를 거치면서 어디서 문제가 생겼는지 추적하기 어려운 상황인가요?
분산 환경에서 각 서버의 로컬 파일에 로그를 저장하면 전체적인 상황 파악이 불가능합니다. 에러가 어느 서버에서 얼마나 발생하는지, 특정 사용자의 요청 흐름이 어떻게 되는지, 성능 병목이 어디인지 알기 어렵습니다.
장애 상황에서는 시간이 생명인데, 로그 찾는 데만 10분씩 걸립니다. 바로 이럴 때 필요한 것이 중앙 집중식 로그 시스템입니다.
모든 서버의 로그를 실시간으로 수집하여 한곳에서 검색하고, 필터링하고, 분석할 수 있습니다.
개요
간단히 말해서, 로그 집중화는 여러 서버에서 발생하는 로그를 중앙 서버(ELK Stack, CloudWatch, Datadog 등)로 실시간 전송하여 통합된 뷰를 제공하는 시스템입니다. 실무에서는 Winston 같은 로깅 라이브러리와 함께 로그 전송 에이전트를 사용합니다.
예를 들어, 애플리케이션이 구조화된 JSON 로그를 생성하면 Filebeat나 Fluentd가 이를 감지하여 Elasticsearch로 전송하고, Kibana에서 시각화합니다. 이렇게 하면 "지난 1시간 동안 500 에러가 몇 번 발생했는가?", "userId=12345의 모든 요청 추적" 같은 쿼리를 즉시 실행할 수 있습니다.
기존에는 console.log와 파일 저장을 사용했다면, 이제는 구조화된 로깅과 중앙 집중식 관리로 진정한 옵저버빌리티를 달성할 수 있습니다. 핵심 특징은 구조화된 로그(JSON), 컨텍스트 정보(traceId, userId 등), 로그 레벨 관리, 실시간 검색 및 알림입니다.
이러한 특징들이 빠른 문제 해결과 사전 예방을 가능하게 합니다.
코드 예제
// lib/logger.ts - Winston과 CloudWatch를 이용한 구조화된 로깅
import winston from 'winston';
import WinstonCloudWatch from 'winston-cloudwatch';
const logger = winston.createLogger({
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json() // JSON 형식으로 출력
),
defaultMeta: {
service: 'nextjs-app',
environment: process.env.NODE_ENV,
instanceId: process.env.INSTANCE_ID || 'unknown'
},
transports: [
// 콘솔 출력 (개발 환경)
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}),
// CloudWatch로 전송 (운영 환경)
...(process.env.NODE_ENV === 'production' ? [
new WinstonCloudWatch({
logGroupName: '/aws/nextjs/app',
logStreamName: `${process.env.INSTANCE_ID}-${new Date().toISOString().split('T')[0]}`,
awsRegion: process.env.AWS_REGION,
messageFormatter: (log) => JSON.stringify(log)
})
] : [])
]
});
// 요청 로깅 미들웨어에서 사용
export function logRequest(req: any, res: any, duration: number) {
logger.info('HTTP Request', {
method: req.method,
path: req.url,
statusCode: res.statusCode,
duration,
userAgent: req.headers['user-agent'],
userId: req.session?.userId,
traceId: req.headers['x-trace-id'] || generateTraceId()
});
}
export default logger;
설명
이것이 하는 일: Winston 라이브러리가 애플리케이션의 로그를 JSON 형식으로 생성하고, CloudWatch Transport가 이를 AWS CloudWatch로 실시간 전송하여 중앙에서 관리하고 검색할 수 있게 합니다. 첫 번째로, winston.format.json()은 모든 로그를 JSON 형식으로 출력합니다.
이렇게 하면 CloudWatch Insights나 Elasticsearch에서 필드별로 검색하고 집계할 수 있습니다. 예를 들어 statusCode: 500인 로그만 필터링하거나, userId별로 그룹화하는 것이 간단해집니다.
그 다음으로, defaultMeta에 서비스 이름, 환경, 인스턴스 ID를 추가하여 어느 서버에서 발생한 로그인지 자동으로 태깅합니다. 특히 instanceId는 PM2의 프로세스 ID나 Docker 컨테이너 ID를 사용하면, 특정 인스턴스에서만 발생하는 문제를 빠르게 찾을 수 있습니다.
세 번째로, 개발 환경에서는 컬러풀한 콘솔 출력을 사용하고, 운영 환경에서는 CloudWatch로 전송하는 조건부 설정입니다. 이렇게 하면 로컬 개발 시에는 읽기 쉬운 로그를, 운영에서는 구조화된 로그를 얻을 수 있습니다.
네 번째로, logRequest 함수는 모든 HTTP 요청에 대한 메타데이터를 로깅합니다. 특히 traceId는 요청이 여러 서비스를 거치더라도 동일한 ID로 추적할 수 있게 하는 분산 추적의 핵심입니다.
프론트엔드에서 생성한 traceId를 헤더로 전달받거나, 백엔드에서 생성하여 응답 헤더로 반환할 수 있습니다. 다섯 번째로, errors({ stack: true }) 포맷터는 에러가 발생했을 때 스택 트레이스를 자동으로 포함시킵니다.
이를 통해 에러가 코드의 어느 라인에서 발생했는지 정확히 파악할 수 있습니다. 여러분이 이 로깅 시스템을 사용하면 장애 발생 시 평균 해결 시간(MTTR)을 50% 이상 단축할 수 있고, 사용자가 보고하기 전에 에러를 감지할 수 있으며, 성능 병목을 데이터 기반으로 파악할 수 있습니다.
CloudWatch Insights로 "지난 1시간 동안 duration > 1000ms인 요청"을 쿼리하여 느린 API를 즉시 찾을 수 있습니다.
실전 팁
💡 로그 레벨을 적절히 사용하세요. error는 즉시 대응이 필요한 것, warn은 주의가 필요한 것, info는 중요한 이벤트, debug는 개발 중 추적용입니다. 운영에서는 debug 로그를 끄는 것이 비용 절감에 도움이 됩니다.
💡 민감한 정보(비밀번호, 토큰, 신용카드 등)는 절대 로깅하지 마세요. Winston의 커스텀 포맷터로 자동으로 마스킹하는 로직을 추가하는 것이 안전합니다.
💡 로그 볼륨이 많으면 샘플링을 고려하세요. 모든 200 OK 요청을 로깅하는 대신 10%만 샘플링하고, 에러는 100% 로깅하면 비용을 크게 줄일 수 있습니다.
💡 CloudWatch 알람을 설정하여 특정 패턴의 로그가 발생하면 SNS로 알림을 받으세요. 예를 들어 "1분간 error 로그 10개 이상"이면 즉시 Slack으로 알림을 받을 수 있습니다.
💡 로그 보관 정책을 설정하세요. 30일 이상 오래된 로그는 S3로 아카이빙하고, 90일 이후에는 삭제하면 비용을 최적화할 수 있습니다.
7. 분산 추적 시스템 - 요청 흐름 시각화
시작하며
사용자가 "페이지가 느려요"라고 신고했을 때, API 서버가 느린 건지, DB 쿼리가 느린 건지, 외부 API 호출이 느린 건지 정확히 파악하기 어려우신가요? 특히 요청이 API Gateway → Next.js → DB → 외부 API처럼 여러 단계를 거치면 어디가 병목인지 알 수 없습니다.
로그만으로는 각 구간의 소요 시간을 정확히 측정하기 어렵고, 여러 서비스를 거치는 요청의 전체 흐름을 한눈에 보기 힘듭니다. 각 서비스가 독립적으로 로그를 남기므로, 이들을 연결하여 전체 타임라인을 재구성하는 것은 거의 불가능합니다.
바로 이럴 때 필요한 것이 분산 추적(Distributed Tracing) 시스템입니다. 하나의 요청이 여러 서비스를 거치는 동안 각 구간의 소요 시간을 측정하고, 이를 시각화하여 병목 지점을 즉시 파악할 수 있습니다.
개요
간단히 말해서, 분산 추적은 하나의 요청에 고유한 Trace ID를 부여하고, 각 서비스에서 발생하는 작업(Span)마다 시작/종료 시간을 기록하여 전체 요청의 타임라인을 시각화하는 기술입니다. 실무에서는 OpenTelemetry 같은 표준 라이브러리를 사용하여 자동으로 HTTP 요청, DB 쿼리, 외부 API 호출을 추적합니다.
예를 들어, 사용자 요청이 들어오면 "Total: 850ms, Next.js API: 50ms, DB Query: 200ms, External API: 600ms"처럼 각 구간의 소요 시간이 워터폴 차트로 표시되어 외부 API가 병목임을 즉시 알 수 있습니다. 기존에는 각 구간에 수동으로 시간 측정 코드를 넣었다면, 이제는 자동 계측(Auto-instrumentation)으로 코드 수정 없이 추적이 가능합니다.
핵심 특징은 고유 Trace ID, 계층적 Span 구조, 자동 계측, 시각화 도구(Jaeger, Zipkin, Datadog APM)입니다. 이러한 특징들이 복잡한 마이크로서비스 환경에서도 명확한 옵저버빌리티를 제공합니다.
코드 예제
// lib/tracing.ts - OpenTelemetry를 이용한 분산 추적
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
// OpenTelemetry SDK 초기화
const sdk = new NodeSDK({
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'nextjs-app',
[SemanticResourceAttributes.SERVICE_VERSION]: '1.0.0',
}),
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318/v1/traces',
}),
instrumentations: [
// 자동으로 HTTP, DB, Redis 등을 추적
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': { enabled: false },
'@opentelemetry/instrumentation-http': { enabled: true },
'@opentelemetry/instrumentation-express': { enabled: true },
'@opentelemetry/instrumentation-pg': { enabled: true },
'@opentelemetry/instrumentation-redis': { enabled: true },
}),
],
});
// 애플리케이션 시작 시 SDK 시작
sdk.start();
// 커스텀 Span 생성 예제
import { trace } from '@opentelemetry/api';
export async function processOrder(orderId: string) {
const tracer = trace.getTracer('order-service');
// 커스텀 Span 생성
return tracer.startActiveSpan('processOrder', async (span) => {
try {
span.setAttribute('order.id', orderId);
// 결제 처리 (자동으로 하위 Span이 생성됨)
await processPayment(orderId);
// 재고 확인
await checkInventory(orderId);
span.setStatus({ code: SpanStatusCode.OK });
return { success: true };
} catch (error) {
span.recordException(error as Error);
span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
throw error;
} finally {
span.end();
}
});
}
설명
이것이 하는 일: OpenTelemetry SDK가 애플리케이션의 모든 HTTP 요청, DB 쿼리, 외부 API 호출을 자동으로 감지하고 각각의 시작/종료 시간을 기록하여 Trace Exporter를 통해 시각화 툴로 전송합니다. 첫 번째로, NodeSDK 초기화 시 SERVICE_NAME을 설정하여 여러 서비스 중 어디서 발생한 Span인지 구분합니다.
만약 Next.js 외에 별도의 API 서버, 결제 서비스 등이 있다면 각각 다른 이름을 부여하여 서비스 맵을 자동으로 생성할 수 있습니다. 그 다음으로, getNodeAutoInstrumentations()는 코드 수정 없이 HTTP 요청, PostgreSQL 쿼리, Redis 명령어 등을 자동으로 추적합니다.
예를 들어 fetch()를 호출하면 자동으로 Span이 생성되고, URL, HTTP 메서드, 응답 코드, 소요 시간이 기록됩니다. 이는 매뉴얼 계측에 비해 개발 생산성을 크게 높입니다.
세 번째로, 커스텀 Span인 processOrder는 비즈니스 로직 단위로 추적할 때 사용합니다. 이 Span 안에서 발생하는 모든 DB 쿼리와 API 호출은 자동으로 하위 Span으로 기록되어, "주문 처리 전체: 800ms (결제: 300ms, 재고확인: 100ms, DB 쿼리: 50ms)" 같은 계층 구조가 만들어집니다.
네 번째로, span.setAttribute()로 비즈니스 메타데이터를 추가합니다. orderId를 기록하면 나중에 "특정 주문의 처리 과정"을 추적할 수 있고, 에러가 발생한 주문들의 공통점을 분석할 수 있습니다.
다섯 번째로, recordException()은 에러를 Span에 기록하여 어느 구간에서 에러가 발생했는지 명확히 합니다. Span 상태를 ERROR로 설정하면 시각화 툴에서 빨간색으로 표시되어 문제가 있는 요청을 즉시 발견할 수 있습니다.
여러분이 분산 추적을 사용하면 "이 API가 왜 느린가?"라는 질문에 몇 초 만에 답할 수 있고, 캐싱이나 최적화가 필요한 정확한 지점을 데이터 기반으로 결정할 수 있으며, 장애 발생 시 근본 원인을 빠르게 찾을 수 있습니다. Jaeger 같은 툴에서 "duration > 1s"로 필터링하면 느린 요청들만 모아서 분석할 수 있습니다.
실전 팁
💡 샘플링을 적절히 설정하세요. 모든 요청을 추적하면 오버헤드가 크므로, 운영 환경에서는 10% 정도만 샘플링하고, 에러가 발생한 요청은 100% 추적하는 것이 좋습니다.
💡 Span 이름은 구체적으로 작성하세요. "DB Query"보다는 "SELECT users WHERE id=?"처럼 실제 쿼리 타입을 포함하면 어떤 쿼리가 느린지 한눈에 알 수 있습니다.
💡 Critical Path만 추적하세요. 백그라운드 작업이나 비동기 큐는 별도로 관리하고, 사용자 응답 시간에 영향을 주는 경로만 집중 추적하면 분석이 쉬워집니다.
💡 Trace ID를 로그에도 포함시키면 로그와 Trace를 연결하여 완벽한 컨텍스트를 얻을 수 있습니다. "이 에러 로그는 어떤 요청에서 발생했지?"를 즉시 알 수 있습니다.
💡 프론트엔드에서도 OpenTelemetry를 사용하면 사용자 브라우저에서 서버까지 전체 요청 흐름을 추적할 수 있습니다. 특히 느린 API가 네트워크 문제인지 서버 문제인지 구분할 수 있습니다.
8. 자동 스케일링 설정 - 트래픽에 따른 동적 확장
시작하며
평소에는 서버 3대로 충분한데, 특정 이벤트나 마케팅 캠페인으로 트래픽이 10배 증가할 때마다 수동으로 서버를 추가하고 계신가요? 또는 트래픽이 줄어든 후에도 불필요한 서버가 계속 실행되어 비용을 낭비하고 있나요?
고정된 서버 대수로 운영하면 트래픽 피크 시에는 과부하로 장애가 발생하고, 평상시에는 과잉 프로비저닝으로 비용이 낭비됩니다. 트래픽은 예측 불가능하고, 수동 대응은 느리고 실수가 잦습니다.
바로 이럴 때 필요한 것이 자동 스케일링입니다. CPU 사용률, 메모리, 요청 수 같은 메트릭을 기반으로 서버를 자동으로 추가하거나 제거하여 항상 적정 용량을 유지합니다.
개요
간단히 말해서, 자동 스케일링은 정의된 규칙에 따라 서버 인스턴스를 자동으로 늘리거나 줄여서 트래픽 변화에 동적으로 대응하는 시스템입니다. 실무에서는 AWS Auto Scaling Group이나 Kubernetes HPA(Horizontal Pod Autoscaler)를 사용합니다.
예를 들어, "평균 CPU 사용률이 70%를 넘으면 서버를 2대씩 추가하고, 30% 아래로 떨어지면 1대씩 제거"하는 정책을 설정합니다. 블랙 프라이데이에 트래픽이 폭증해도 자동으로 서버가 늘어나 안정적으로 처리하고, 이벤트가 끝나면 다시 줄어들어 비용을 절감합니다.
기존에는 예상 최대 트래픽에 맞춰 서버를 프로비저닝했다면, 이제는 최소 용량으로 시작하여 필요할 때만 확장하는 비용 효율적인 운영이 가능합니다. 핵심 특징은 메트릭 기반 스케일링(CPU, 메모리, 요청 수), 스케일 아웃/인 정책, 쿨다운 기간(너무 빠른 변경 방지), 최소/최대 인스턴스 수 제한입니다.
이러한 특징들이 안정성과 비용 효율성을 동시에 달성하게 합니다.
코드 예제
# AWS Auto Scaling Group 설정 (Terraform 예제)
resource "aws_autoscaling_group" "nextjs_asg" {
name = "nextjs-app-asg"
vpc_zone_identifier = var.private_subnet_ids
target_group_arns = [aws_lb_target_group.nextjs.arn]
health_check_type = "ELB"
health_check_grace_period = 300
min_size = 2 # 최소 2대 유지
max_size = 10 # 최대 10대까지 확장
desired_capacity = 3 # 평상시 3대
launch_template {
id = aws_launch_template.nextjs.id
version = "$Latest"
}
tag {
key = "Name"
value = "nextjs-app"
propagate_at_launch = true
}
}
# CPU 기반 스케일 아웃 정책
resource "aws_autoscaling_policy" "scale_out" {
name = "scale-out-policy"
scaling_adjustment = 2 # 한 번에 2대씩 추가
adjustment_type = "ChangeInCapacity"
cooldown = 300 # 5분 쿨다운
autoscaling_group_name = aws_autoscaling_group.nextjs_asg.name
}
# CloudWatch 알람 - CPU 70% 이상이면 스케일 아웃
resource "aws_cloudwatch_metric_alarm" "cpu_high" {
alarm_name = "nextjs-cpu-high"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2 # 2번 연속 넘으면 실행
metric_name = "CPUUtilization"
namespace = "AWS/EC2"
period = 60 # 1분마다 체크
statistic = "Average"
threshold = 70
alarm_actions = [aws_autoscaling_policy.scale_out.arn]
dimensions = {
AutoScalingGroupName = aws_autoscaling_group.nextjs_asg.name
}
}
# CPU 기반 스케일 인 정책
resource "aws_autoscaling_policy" "scale_in" {
name = "scale-in-policy"
scaling_adjustment = -1 # 한 번에 1대씩 제거
adjustment_type = "ChangeInCapacity"
cooldown = 300
autoscaling_group_name = aws_autoscaling_group.nextjs_asg.name
}
# CloudWatch 알람 - CPU 30% 이하면 스케일 인
resource "aws_cloudwatch_metric_alarm" "cpu_low" {
alarm_name = "nextjs-cpu-low"
comparison_operator = "LessThanThreshold"
evaluation_periods = 3 # 3번 연속 낮으면 실행 (신중하게)
metric_name = "CPUUtilization"
namespace = "AWS/EC2"
period = 60
statistic = "Average"
threshold = 30
alarm_actions = [aws_autoscaling_policy.scale_in.arn]
dimensions = {
AutoScalingGroupName = aws_autoscaling_group.nextjs_asg.name
}
}
설명
이것이 하는 일: CloudWatch가 EC2 인스턴스의 CPU 사용률을 1분마다 측정하고, 설정된 임계값을 넘으면 Auto Scaling Group에 신호를 보내 서버를 추가하거나 제거합니다. 첫 번째로, Auto Scaling Group은 인스턴스의 최소/최대/희망 개수를 정의합니다.
min_size: 2는 트래픽이 아무리 적어도 최소 2대는 유지하여 고가용성을 보장하고, max_size: 10은 비용 폭증을 방지합니다. 이 범위 내에서만 자동으로 조정되므로 안전합니다.
그 다음으로, 스케일 아웃 정책은 scaling_adjustment: 2로 한 번에 2대씩 추가합니다. 트래픽이 급증할 때 1대씩 추가하면 대응이 느리므로, 초기에는 공격적으로 확장하는 것이 좋습니다.
반면 스케일 인은 -1로 천천히 줄여서 갑작스러운 트래픽 증가에 대비합니다. 세 번째로, cooldown: 300(5분)은 스케일링 후 다시 스케일링하기 전 대기 시간입니다.
새로운 인스턴스가 헬스체크를 통과하고 트래픽을 받기 시작하는 데 시간이 걸리므로, 즉시 또 스케일링하면 불필요한 인스턴스가 계속 추가됩니다. 쿨다운 기간 동안은 상황을 관찰합니다.
네 번째로, CloudWatch 알람의 evaluation_periods는 몇 번 연속 조건을 만족해야 하는지를 정합니다. 스케일 아웃은 2로 빠르게 반응하고, 스케일 인은 3으로 신중하게 판단합니다.
일시적인 트래픽 감소로 서버를 줄였다가 다시 늘리는 것을 방지합니다. 다섯 번째로, health_check_type: "ELB"는 로드 밸런서의 헬스체크를 사용하여 인스턴스가 실제로 트래픽을 받을 준비가 되었는지 확인합니다.
단순히 EC2가 실행 중인 것만으로는 트래픽을 보내지 않고, 애플리케이션이 헬스체크에 응답해야만 트래픽을 받습니다. 여러분이 자동 스케일링을 사용하면 예측 불가능한 트래픽에도 안정적으로 대응하고, 평상시 비용을 30-50% 절감하며, 야간이나 주말에도 자동으로 최적화되어 인력 투입 없이 운영할 수 있습니다.
Black Friday 같은 이벤트에서도 장애 없이 서비스를 유지할 수 있습니다.
실전 팁
💡 CPU만으로는 부족할 수 있습니다. 애플리케이션의 특성에 따라 메모리 사용률, ALB의 TargetResponseTime, 활성 연결 수 등 다양한 메트릭을 조합하여 스케일링 정책을 만드세요.
💡 예측 스케일링(Predictive Scaling)을 활성화하면 ML이 과거 패턴을 학습하여 트래픽이 증가하기 전에 미리 서버를 추가합니다. 반응형 스케일링보다 더 proactive합니다.
💡 스케일 인 보호(Scale-In Protection)를 특정 인스턴스에 설정하면 중요한 작업(배치 처리 등) 중에는 종료되지 않습니다. 작업이 끝나면 보호를 해제하여 정상적으로 스케일 인되게 하세요.
💡 Launch Template에 최신 AMI와 설정을 항상 업데이트하세요. 자동으로 생성되는 인스턴스가 구 버전을 사용하면 배포가 무의미해집니다.
💡 스케일링 활동을 CloudWatch Logs에 기록하고 정기적으로 리뷰하세요. "너무 자주 스케일링되는가?", "적절한 임계값인가?"를 데이터 기반으로 튜닝할 수 있습니다.
9. 블루-그린 배포 전략 - 무중단 배포 구현
시작하며
배포할 때마다 서비스를 중단하고 "점검 중입니다" 페이지를 보여주거나, 배포 후 문제가 발견되어도 롤백하는 데 시간이 오래 걸리는 상황을 겪고 계신가요? 24시간 서비스에서 몇 분의 다운타임도 큰 손실입니다.
전통적인 배포 방식은 기존 서버를 멈추고 새 버전으로 교체하므로 필연적으로 다운타임이 발생합니다. 또한 문제가 생기면 다시 이전 버전을 배포해야 하므로 복구 시간이 깁니다.
바로 이럴 때 필요한 것이 블루-그린 배포입니다. 기존 버전(블루)을 실행하면서 동시에 새 버전(그린)을 배포하고, 트래픽을 순간적으로 전환하여 무중단 배포를 실현합니다.
문제가 생기면 트래픽을 다시 블루로 돌리기만 하면 되므로 롤백이 즉시 가능합니다.
개요
간단히 말해서, 블루-그린 배포는 두 개의 동일한 프로덕션 환경을 유지하면서 한쪽에 새 버전을 배포하고, 로드 밸런서의 트래픽을 순간적으로 전환하는 배포 전략입니다. 실무에서는 AWS의 Target Group 스위칭이나 Kubernetes의 Service Selector 변경을 사용합니다.
예를 들어, 현재 블루 환경(v1.0)이 트래픽을 받고 있는 상태에서 그린 환경에 v1.1을 배포하고 헬스체크가 성공하면, 로드 밸런서가 트래픽을 그린으로 전환합니다. 전환은 1-2초 내에 완료되고, 문제가 생기면 다시 블루로 돌립니다.
기존의 Rolling 배포는 인스턴스를 하나씩 교체하므로 시간이 오래 걸리고 두 버전이 섞여 있는 기간이 있다면, 블루-그린은 순간적으로 전환되어 항상 단일 버전만 트래픽을 받습니다. 핵심 특징은 제로 다운타임, 즉시 롤백, 프로덕션 환경에서의 테스트, 명확한 전환 시점입니다.
이러한 특징들이 안전하고 빠른 배포를 가능하게 합니다.
코드 예제
# AWS ALB 블루-그린 배포 스크립트
#!/bin/bash
# 환경 변수
ALB_LISTENER_ARN="arn:aws:elasticloadbalancing:..."
BLUE_TG_ARN="arn:aws:elasticloadbalancing:.../targetgroup/nextjs-blue"
GREEN_TG_ARN="arn:aws:elasticloadbalancing:.../targetgroup/nextjs-green"
GREEN_ASG_NAME="nextjs-green-asg"
echo "🚀 Starting Blue-Green Deployment"
# 1. 현재 활성 Target Group 확인
CURRENT_TG=$(aws elbv2 describe-listeners \
--listener-arns $ALB_LISTENER_ARN \
--query 'Listeners[0].DefaultActions[0].TargetGroupArn' \
--output text)
if [ "$CURRENT_TG" == "$BLUE_TG_ARN" ]; then
echo "📘 Current: Blue, Deploying to: Green"
NEW_TG=$GREEN_TG_ARN
OLD_TG=$BLUE_TG_ARN
else
echo "📗 Current: Green, Deploying to: Blue"
NEW_TG=$BLUE_TG_ARN
OLD_TG=$GREEN_TG_ARN
fi
# 2. Green 환경에 새 버전 배포 (Auto Scaling Group 업데이트)
echo "📦 Deploying new version to inactive environment..."
aws autoscaling update-auto-scaling-group \
--auto-scaling-group-name $GREEN_ASG_NAME \
--launch-template LaunchTemplateName=nextjs-latest
# 3. Green 인스턴스들이 Healthy 될 때까지 대기
echo "⏳ Waiting for all instances to become healthy..."
while true; do
HEALTHY_COUNT=$(aws elbv2 describe-target-health \
--target-group-arn $NEW_TG \
--query "length(TargetHealthDescriptions[?TargetHealth.State=='healthy'])" \
--output text)
TOTAL_COUNT=$(aws autoscaling describe-auto-scaling-groups \
--auto-scaling-group-names $GREEN_ASG_NAME \
--query 'AutoScalingGroups[0].DesiredCapacity' \
--output text)
echo "Healthy: $HEALTHY_COUNT / $TOTAL_COUNT"
if [ "$HEALTHY_COUNT" == "$TOTAL_COUNT" ]; then
echo "✅ All instances are healthy!"
break
fi
sleep 10
done
# 4. 프로덕션 트래픽을 Green으로 전환
echo "🔄 Switching traffic to new environment..."
aws elbv2 modify-listener \
--listener-arn $ALB_LISTENER_ARN \
--default-actions Type=forward,TargetGroupArn=$NEW_TG
echo "✅ Traffic switched successfully!"
# 5. 5분간 모니터링 (에러율, 응답시간 체크)
echo "📊 Monitoring for 5 minutes..."
sleep 300
# 6. 에러율 체크 (간단한 예제)
ERROR_RATE=$(aws cloudwatch get-metric-statistics \
--namespace AWS/ApplicationELB \
--metric-name HTTPCode_Target_5XX_Count \
--dimensions Name=TargetGroup,Value=$NEW_TG \
--start-time $(date -u -d '5 minutes ago' +%Y-%m-%dT%H:%M:%S) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
--period 300 \
--statistics Sum \
--query 'Datapoints[0].Sum' \
--output text)
if [ "$ERROR_RATE" != "None" ] && [ $(echo "$ERROR_RATE > 100" | bc) -eq 1 ]; then
echo "❌ High error rate detected! Rolling back..."
aws elbv2 modify-listener \
--listener-arn $ALB_LISTENER_ARN \
--default-actions Type=forward,TargetGroupArn=$OLD_TG
echo "🔙 Rollback completed"
exit 1
fi
echo "🎉 Deployment completed successfully!"
echo "💡 Old environment is still running. You can terminate it manually after verifying."
설명
이것이 하는 일: 스크립트가 현재 활성 환경을 확인하고, 비활성 환경에 새 버전을 배포한 후, 헬스체크가 성공하면 로드 밸런서의 트래픽을 전환하며, 모니터링 후 문제가 없으면 배포를 완료합니다. 첫 번째로, 현재 ALB 리스너가 어느 Target Group을 바라보는지 확인하여 블루와 그린 중 어디가 활성인지 판단합니다.
이를 통해 새 버전을 배포할 타겟을 결정합니다. 그 다음으로, Auto Scaling Group의 Launch Template을 최신 버전으로 업데이트합니다.
이미 실행 중인 인스턴스는 영향받지 않고, 새로 생성되는 인스턴스만 새 버전을 사용합니다. 필요하다면 인스턴스 리프레시를 트리거하여 모든 인스턴스를 새 버전으로 교체할 수도 있습니다.
세 번째로, Target Group의 헬스체크를 주기적으로 확인하여 모든 인스턴스가 healthy 상태가 될 때까지 기다립니다. 이 단계에서 문제가 발견되면(인스턴스가 계속 unhealthy) 배포를 중단하고 이전 환경을 유지합니다.
이는 잘못된 버전이 프로덕션에 투입되는 것을 방지합니다. 네 번째로, modify-listener 명령으로 ALB가 새로운 Target Group으로 트래픽을 보내도록 전환합니다.
이 작업은 1-2초 내에 완료되며, 사용자는 어떤 중단도 경험하지 않습니다. ALB가 기존 연결은 유지하면서(Graceful) 새 연결만 새로운 Target Group으로 보냅니다.
다섯 번째로, 5분간 모니터링하여 에러율, 응답 시간, CPU 사용률 등을 체크합니다. CloudWatch 메트릭에서 5XX 에러가 임계값을 넘으면 자동으로 롤백합니다.
롤백은 다시 리스너를 이전 Target Group으로 전환하기만 하면 되므로 몇 초 내에 완료됩니다. 여러분이 블루-그린 배포를 사용하면 사용자는 배포를 전혀 느끼지 못하고, 문제가 생겨도 평균 복구 시간이 30분에서 1분으로 단축되며, 프로덕션 환경에서 새 버전을 테스트할 수 있어 신뢰성이 높아집니다.
하루에 여러 번 배포하는 현대적인 개발 문화가 가능해집니다.
실전 팁
💡 블루-그린 배포는 인프라 비용이 2배로 들 수 있습니다. 배포 시에만 그린 환경을 켜고, 성공 후 1-2시간 뒤에 블루를 종료하면 비용을 절감할 수 있습니다.
💡 데이터베이스 마이그레이션이 있는 경우 주의하세요. 스키마 변경은 블루와 그린 모두 호환되도록 backward compatible하게 만들거나, 단계적으로 배포하세요.
💡 Canary 배포와 결합하면 더 안전합니다. 처음에는 트래픽의 10%만 그린으로 보내고, 문제가 없으면 점진적으로 100%로 늘리는 방식입니다.
💡 배포 스크립트를 CI/CD 파이프라인(GitHub Actions, Jenkins 등)에 통합하면 완전 자동화된 배포가 가능합니다. 개발자가 버튼 하나만 누르면 모든 과정이 자동으로 진행됩니다.
💡 Smoke Test를 자동화하세요. 트래픽 전환 전에 그린 환경에 테스트 요청을 보내 주요 기능이 작동하는지 확인하면 더 안전합니다.