목차
- Node.js 워크로드가 HPA와 어긋나는 지점
- 후보 셋을 비교하기 전에 기준부터 정한다
- CPU, 메모리, KEDA 항목별로 정리
- TypeScript 인터페이스로 메트릭 정의를 코드화
- 같은 부하 시나리오로 돌린 벤치마크
- 짧게 짚는 함정 하나
- 언제 어느 걸 쓸지 — 판단 기준
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
1: 0xb7bd0a node::Abort() [/usr/local/bin/node]
2: 0xa86a9b node::FatalError(char const*, char const*)
3: 0xd80b1e v8::Utils::ReportOOMFailure(...)
...
Warning Unhealthy 3s kubelet Liveness probe failed: HTTP probe failed with statuscode: 500
Normal Killing 3s kubelet Container node-api failed liveness probe, will be restarted
Back-off restarting failed container
쿠버네티스 HPA 설정 Node.js 환경에서 자주 마주치는 로그다. 단일 파드가 V8 힙 한계에 도달해 죽고, 그 트래픽이 다른 파드로 몰리면서 연쇄 OOM이 번진다. 처음에는 메모리 기반으로 스케일아웃을 켜면 끝날 거라 생각했는데, 실제로 운영해보니 그게 그렇게 단순하지 않더라. 메모리 기준만으로는 V8 GC 특성 때문에 파드가 한 번 늘어나면 거의 줄어들지 않는다. 결과적으로 비용은 늘고 안정성은 별로 나아지지 않는 상태가 된다.
반면, 이 글은 같은 Node.js 워크로드에 대해 HPA를 어떻게 구성할지, 후보 셋(CPU 기반 HPA, 메모리 기반 HPA, KEDA)을 같은 기준으로 비교한 기록이다. TypeScript로 메트릭 정의를 타입화한 코드도 함께 정리한다.
Node.js 워크로드가 HPA와 어긋나는 지점
그래서, Node.js는 단일 스레드 이벤트 루프 위에서 돈다. 이게 쿠버네티스 HPA의 CPU 기반 스케일링과 미묘하게 어긋난다. 한 워커가 한 코어를 100% 쓰고 있어도, 4코어 노드 입장에서는 25%만 쓰는 것으로 보인다. cgroup이 컨테이너 단위로 CPU를 계산하긴 하지만, 리소스 limits를 명확히 잡지 않으면 HPA가 부하를 과소평가하는 일이 흔하다.
그러나, 메모리는 또 다른 함정이다. V8 힙은 한 번 늘어났다가 GC 이후에도 RSS가 그대로 유지되는 경향이 있다. 메모리 기반 HPA를 켜면 일시적 스파이크 때문에 파드가 늘었다가 그 상태로 굳는다. 스케일다운이 거의 일어나지 않는다는 뜻이다.
그런데, 프론트엔드에서 백엔드로 넘어온 입장에서 처음 든 생각은 "CPU·메모리 둘 다 같이 보면 되겠지" 였다. 두 지표를 OR 조건으로 묶으면 메모리 누수 케이스와 CPU 폭주 케이스를 동시에 잡을 거라고 봤다. 결과는 반쪽짜리였다. 메모리가 한 번 튀면 CPU가 안정화돼도 파드는 줄지 않는다. HPA는 여러 메트릭 중 가장 많은 파드 수를 요구하는 쪽을 따른다.
후보 셋을 비교하기 전에 기준부터 정한다
따라서, 기술 선택할 때 자주 빠지는 함정이 있다. 인기 있는 도구를 먼저 보고, 거기 맞춰 평가 기준을 만든다. 그러면 결국 인기 있는 게 뽑힌다. 이번엔 반대로 했다. 세 축을 먼저 정하고 후보를 끼워 넣었다.
- 반응성: 트래픽 급증 시 첫 파드가 추가되기까지 걸리는 시간
- 운영 부담: 추가 CRD, 외부 컨트롤러, 메트릭 서버 같은 부속이 얼마나 붙는가
- 비용 효율: 평시 파드 수와 피크 시 파드 수의 차이, 그리고 스케일다운 속도
한편, 여기서 비용 효율은 "최대 파드 수가 적은 게 좋다"가 아니다. 부하가 빠진 뒤 평시 수준으로 돌아오는 데 걸리는 시간이 더 중요하다. 30분짜리 트래픽 피크 때문에 두 시간 동안 파드 9개를 유지하면 그게 비용 낭비다.
세 후보는 쿠버네티스 기본 HPA(CPU 메트릭), 기본 HPA(메모리 메트릭), KEDA(요청 큐 기반 외부 메트릭)다. 각 후보를 같은 워크로드, 같은 부하 시나리오로 돌려 비교했다.
CPU, 메모리, KEDA 항목별로 정리
| 항목 | CPU 기반 HPA | 메모리 기반 HPA | KEDA (요청 수) |
|---|---|---|---|
| 첫 스케일아웃 반응성 | 보통 (15~30초) | 느림 (60초 이상) | 빠름 (5~15초) |
| 운영 부담 | 낮음 (기본 내장) | 낮음 (기본 내장) | 중간 (CRD 설치) |
| Node.js 적합도 | 보통 | 낮음 | 높음 |
| 스케일다운 속도 | 자연스러움 | 거의 안 됨 | 빠름 |
| 외부 의존 | metrics-server | metrics-server | KEDA + Prometheus |
| 임계값 튜닝 난이도 | 낮음 | 중간 | 중간 |
CPU 기반은 가장 기본이다. kubectl autoscale deployment node-api --cpu-percent=60 --min=2 --max=10 한 줄로 끝난다. Node.js의 이벤트 루프 특성상 CPU 사용률이 70%를 찍기 전에 이미 응답 지연이 발생한다. 임계값을 50~60% 사이로 잡아야 체감 품질이 유지된다. 이벤트 루프 지연(event loop lag)을 보조 지표로 같이 봐야 정확하다.
특히, 메모리 기반은 더 까다롭다. V8 힙이 한 번 부풀면 RSS는 잘 줄지 않는다. 한 번 스케일아웃되면 파드 수가 그대로 굳어버린다. 비용 측면에서 가장 불리하다. 메모리 메트릭은 단독으로 쓰기보다 "OOMKilled가 반복되는 워크로드의 보조 알람" 용도로 한정하는 게 맞다.
그러나, KEDA는 이벤트 기반 스케일러다. 요청 큐 길이, RPS, 외부 메시지 큐(Kafka, SQS, RabbitMQ) 같은 다양한 신호를 본다. Node.js처럼 I/O 바운드가 큰 워크로드는 CPU 사용률보다 동시 요청 수가 부하를 더 정확히 반영한다. CRD 설치라는 추가 비용이 있지만 한 분기만 운영해도 본전이 빠진다. 작성 시점 기준 KEDA는 v2.13까지 나와 있고 60종 이상의 스케일러를 지원한다(출처: keda.sh 공식 문서).
TypeScript 인터페이스로 메트릭 정의를 코드화
그런데, HPA 정책을 YAML로만 관리하면 환경별 차이를 추적하기 어렵다. dev, staging, prod의 임계값이 제각각이고 누군가는 매니페스트를 손으로 바꾸고 PR 없이 적용한다. 이걸 줄이려고 메트릭 정의를 TypeScript 인터페이스로 옮겼다.
// hpa-policy.ts
type MetricKind = 'cpu' | 'memory' | 'requests';
interface MetricThreshold<T extends MetricKind> {
type: T;
targetValue: number;
// type에 따라 단위가 자동 결정된다
unit: T extends 'requests' ? 'rps' : 'percent';
}
interface HPAPolicy {
name: string;
minReplicas: number;
maxReplicas: number;
metrics: Array<
| MetricThreshold<'cpu'>
| MetricThreshold<'memory'>
| MetricThreshold<'requests'>
>;
// 스케일다운 안정화 시간(초). 짧을수록 빠르게 줄어든다
stabilizationWindowSeconds: number;
}
const prodApiPolicy: HPAPolicy = {
name: 'node-api-prod',
minReplicas: 3,
maxReplicas: 20,
metrics: [
{ type: 'cpu', targetValue: 55, unit: 'percent' },
{ type: 'requests', targetValue: 150, unit: 'rps' },
],
stabilizationWindowSeconds: 300,
};
제네릭 MetricThreshold<T>에 조건부 타입을 걸어 unit이 type에 따라 결정되도록 했다. requests면 'rps', 나머지는 'percent'로 강제된다. 매니페스트 생성 시 타입 단계에서 잘못된 단위가 걸러진다. 예를 들어 { type: 'cpu', unit: 'rps' } 같은 조합은 컴파일이 안 된다.
매니페스트 생성 함수는 정책 객체를 받아 autoscaling/v2 스펙에 맞는 JSON으로 변환한다. CI에서 이 함수의 결과를 YAML로 직렬화해 ArgoCD에 넘긴다. dev/prod 정책 차이가 객체 diff로 명확해지고, 누군가 임계값을 손으로 만지면 코드 리뷰가 강제된다. 프론트엔드에서 컴포넌트 props 검증하던 감각 그대로 인프라 정책을 다룰 수 있다는 게 가장 큰 이득이었다.
예를 들어, 여기에 더해 정책 객체를 단위 테스트로 검증한다. "prod의 minReplicas는 3 이상이어야 한다" 같은 규칙을 Jest로 잡는다. 이건 매니페스트만 관리할 때는 만들기 어렵다.
같은 부하 시나리오로 돌린 벤치마크
테스트 환경은 GKE 1.29, n2-standard-4 노드 3대, Node.js 20.11(TypeScript 5.4 컴파일 결과)이다. k6로 30분간 RPS를 0 → 800 → 0으로 변화시키는 시나리오를 돌렸다. 임계값은 CPU 60%, 메모리 70%, KEDA는 파드당 RPS 100으로 잡았다. 각 케이스는 5번 반복하고 중앙값을 적었다.
| 지표 | CPU 기반 | 메모리 기반 | KEDA |
|---|---|---|---|
| 첫 스케일아웃까지 | 약 25초 | 약 70초 | 약 8초 |
| 피크 시 파드 수 | 9 | 6 | 11 |
| 30분 후 파드 수 | 3 | 6 | 3 |
| p95 응답 시간(피크) | 480ms | 1.2s | 220ms |
| OOMKilled 발생 | 0회 | 2회 | 0회 |
예를 들어, 수치 자체는 환경에 따라 달라진다. 다만 경향은 분명했다. 메모리 기반은 반응이 느린데 스케일다운도 거의 일어나지 않았다. 부하가 빠진 뒤 30분이 지나도 파드 6개가 그대로였다. CPU 기반은 평균적으로 무난했지만 피크 응답 시간이 0.5초에 근접했다. KEDA는 빠르게 늘고 빠르게 줄었다 — 대신 순간 파드 수가 가장 많아 단기 비용은 컸다.
흥미로웠던 건 KEDA가 줄어드는 속도다. CPU 기반은 마지막 파드가 사라지는 데 10분 가까이 걸렸는데, KEDA는 3분 안에 정리됐다. cooldownPeriod 기본값이 300초인데 이걸 120초로 낮춰 본 결과다(작성 시점 KEDA v2.13 기준, 공식 문서 ScaledObject spec 참고).
그러나, 메모리 기반에서 OOMKilled가 2회 나온 이유는 따로 추적해보니 V8 old space가 GC 직후 일시적으로 limit을 살짝 넘기는 케이스였다. 이건 임계값을 60%로 더 낮추면 사라질 가능성이 있지만 그러면 평시 파드 수가 더 늘어난다. 어느 쪽이든 단독으로는 안 쓴다는 결론은 같다.
짧게 짚는 함정 하나
requests와 limits 둘 다 명시하지 않으면 HPA의 CPU/메모리 퍼센트 계산이 의미가 없어진다. requests만 설정해도 일단 동작은 하지만, 노드 압박 상황에서 OOMKilled 패턴이 달라진다. 이건 그냥 둘 다 설정하면 된다.
언제 어느 걸 쓸지 — 판단 기준
세 가지를 다 굴려보고 내린 결론은 "상황에 따라 다르다"가 아니라 "기본은 정해져 있고 예외만 가른다"였다.
그런데, CPU 기반이 맞는 상황: 트래픽 패턴이 비교적 완만하고, 평시와 피크의 차이가 3배 이내인 서비스. 운영 인력이 부족한 팀. 임계값은 50~60%로 시작해라. 응답 지연이 보이면 더 낮추면 된다. 반드시 requests와 limits를 명확히 설정해야 효과가 있다.
KEDA가 맞는 상황: I/O 바운드가 큰 워크로드(외부 API 호출이 많은 BFF, 결제, 알림), RPS 변동이 극심한 서비스(이벤트, 푸시, 광고), Prometheus가 이미 깔려 있는 환경. CRD 설치 부담이 한 번 있지만 그 이후로는 운영 비용이 거의 같다. cooldownPeriod는 기본값 그대로 두지 말고 트래픽 특성에 맞게 줄여라.
예를 들어, 메모리 기반은 단독으로 쓰지 마라: 누수가 의심되는 워크로드의 "보조 알람" 용도로 한정해라. 단독 트리거로 쓰면 평시 비용이 1.5~2배가 된다. CPU 또는 KEDA와 묶어 보조 지표로만 활용하는 게 맞다.
결국, 당장 적용할 액션은 셋이다. 첫째, 현재 운영 중인 Node.js 디플로이먼트에 resources.requests와 resources.limits가 모두 잡혀 있는지 확인해라. 한쪽이라도 비어 있으면 HPA 계산이 어긋난다. 둘째, CPU 임계값이 70% 이상이면 55%로 낮춰 한 주만 관찰해라. 평시 파드 수가 1개 늘어나는 대신 p95가 떨어진다. 셋째, RPS 변동 폭이 큰 서비스가 있다면 KEDA의 prometheus scaler를 별도 네임스페이스에 시범 적용해라(공식 문서: keda.sh, 작성 시점 v2.13 기준). 시범 운영은 한 디플로이먼트로 시작하면 충분하다.
여기까지가 단일 디플로이먼트 관점이다. 같은 노드풀을 공유하는 여러 디플로이먼트가 동시에 스케일아웃될 때의 cluster autoscaler 상호작용은 변수가 더 늘어난다. 그 영역은 아직 정리 중이라 결론을 내기엔 이르다.
관련 글
- TypeScript ESM CommonJS 오류 해결 — require/import 충돌 정리 – TypeScript ESM CommonJS 오류 해결의 핵심은 단 두 줄이다. package.json type 한 줄, tsconfig m…
- TypeScript Node.js Kubernetes 배포에서 Alpine이 답이 아닌 이유 – TypeScript Express/Fastify 앱을 Kubernetes에 올릴 때 Alpine부터 잡는 게 흔한 선택이지만, native…
- Vercel vs Netlify 비교 2026 — 무료 플랜·빌드 속도·엣지 함수 실전 분석 – 동일한 Next.js 앱을 Vercel과 Netlify에 각각 배포해 빌드 시간, 엣지 함수 응답, 무료 플랜 한도를 비교한다. 한국 사용…