◆ ESSAY
같은 스크립트를 두 번 돌렸다. 코드도 부하도 같고, 다른 것은 실행 옵션 한 줄뿐이다.
NODE_OPTIONS="--max-semi-space-size=256 --max-old-space-size=3072"
이 줄 없이 돌리면 peak RSS가 201MB, 붙이고 돌리면 593MB다. 3배 가까이 부풀었는데, 그동안 살아있는 데이터의 양은 두 경우 모두 20MB 안팎으로 같았다. 데이터가 한 바이트도 늘지 않았는데 메모리만 세 배가 된 것이다.
저 조합이 낯설지 않은 분도 있을 것이다. Node GC 튜닝을 검색하면 나오는 단골 추천 값이고, 무거운 데이터 가공 서비스에서 지연이 줄었다는 후기와 함께 팀에서 팀으로 전해지곤 하는 설정이다. 문제는 같은 값이 어떤 서비스에는 튜닝이고 어떤 서비스에는 순수한 낭비라는 것, 그리고 지금이 어느 쪽인지 모른 채 복사되는 일이 잦다는 것이다.
프론트엔드 개발자에게 이건 남 얘기가 아니다. Next.js든 Remix든 BFF(Backend For Frontend, 프론트엔드 팀이 직접 관리하는 API 중간 서버)든, SSR을 하는 순간 우리는 Node.js 프로세스를 컨테이너에 담아 쿠버네티스에 올린다. 그리고 request, limit, NODE_OPTIONS 같은 손잡이들을 누군가의 설정에서 복사해 온다. 손잡이가 무슨 일을 하는지는 모른 채로.
이 글은 저 한 줄이 왜 메모리를 세 배로 만드는지 V8 힙 내부에서 역추적한다. 추측이 아니라 Node.js v24.14.1로 직접 측정해서, --max-semi-space-size가 New Space를 어떻게 부풀리는지, 단일 프로세스의 CPU 천장이 어디인지, cluster를 쓰면 그 비용이 어떻게 곱해지는지를 수치로 본다. 그리고 그 이해 위에서 CPU, 메모리, 파드 수라는 세 축으로 파드를 사이징하는 법을 정리한다.
측정 환경: Node.js v24.14.1, Apple M5(10코어), 24GB RAM, macOS. RSS는
process.memoryUsage().rss, 힙 공간은v8.getHeapSpaceStatistics(), CPU는process.cpuUsage(), GC 로그는--trace-gc로 측정했다. 부하는 약 12MB의 상주 캐시를 두고, 요청 처리를 흉내 내 3MB 남짓의 단명 객체를 계속 만드는(일부는 다음 요청까지 생존) 스크립트를 8초간 돌리는 방식이다. 컨테이너 안이 아니라 로컬에서 잰 값이라 절대치는 환경마다 다르지만, 이 글이 말하려는 건 절대치가 아니라 설정과 메모리 사이의 관계다. 그 관계는 런타임에 내장돼 있어 어디서 재도 같은 모양이 나온다.
본문에 반복해서 나오는 단어만 먼저 풀어둔다. 익숙하면 건너뛰어도 된다.
- RSS(Resident Set Size): 프로세스가 실제로 RAM에 올려둔 총량. V8 힙만이 아니라 코드·버퍼·스택까지 다 포함하고, 커널의 OOM 킬러가 보는 값이 이거다.
- New Space / Old Space: V8 힙의 두 세대. 갓 만든 객체가 태어나는 New Space(young generation)와, 거기서 살아남아 오래 머무는 Old Space(old generation)다.
- 반공간(semi-space): New Space를 이루는 두 조각(from/to). Scavenge가 한쪽에서 다른 쪽으로 객체를 복사하며 청소한다.
- Scavenge: New Space를 청소하는 가벼운 마이너 GC. 살아남은 객체만 반대편 반공간으로 옮기고 나머지는 통째로 버린다.
- working set: 실제로 살아서 계속 쓰이는 데이터의 양. major GC 직후 Old Space 크기가 여기에 가깝다.
- cgroup: 리눅스 커널이 프로세스 그룹의 CPU·메모리를 제한하고 격리하는 장치. 컨테이너의 자원 limit이 여기로 강제된다.
- OOMKill: 컨테이너가 메모리 limit(cgroup)을 넘기면 커널이 프로세스를 죽이는 것. 종료 코드 137로 찍힌다.
- kubelet: 각 노드에서 컨테이너를 실제로 띄우고 상태를 감시하는 쿠버네티스 에이전트.
- HPA(Horizontal Pod Autoscaler): 메트릭(CPU 사용률 등)을 보고 파드 개수를 자동으로 늘리고 줄이는 쿠버네티스의 표준 오토스케일러.
- bin-packing: 스케줄러가 파드의 request를 보고 노드라는 상자에 빈틈없이 채워 넣는 배치. 노드 수와 비용을 정한다.
내부로 들어가기 전에 요약부터 둔다. 이 정도만 남겨도 다음번 복사-붙여넣기를 멈출 수 있다.
request는 스케줄러가 파드를 어디 꽂을지, 오토스케일러가 몇 개를 띄울지 정하는 예약값이다. limit은 예약이 아니라 실제 사용량의 상한이고, CPU 초과는 스로틀, 메모리 초과는 OOMKill로 갈린다. 둘 다 워크로드가 실제로 쓰는 양은 아니다.--max-semi-space-size는 New Space(young generation) 반공간 하나의 상한이다. New Space는 반공간 2개(from/to)로 도니까, 커밋되는 New Space는 이 값의 약 2배까지 자란다. 256을 주면 512MB짜리 버퍼가 생긴다.--max-semi-space-size=128 하나가 총 1.7GB 안팎(순간 2GB 근처)으로 번졌다. 무거운 NODE_OPTIONS를 클러스터에 복사하는 건 같은 문제의 확장판이다.각 항목의 근거가 본문이다. 서두의 그 한 줄부터 분해한다.
가장 먼저 깔아야 할 멘탈 모델이다. 쿠버네티스의 resources.requests와 resources.limits를 "우리 서비스가 이만큼 쓴다"로 읽으면 처음부터 어긋난다.
resources:
requests:
cpu: '500m'
memory: '256Mi'
limits:
cpu: '1'
memory: '512Mi'
requests는 스케줄링용 예약이다. 스케줄러는 이 값만 보고 파드를 어느 노드에 꽂을지 정한다(bin-packing). 노드에 남은 requests 합이 부족하면 실제 사용량이 아무리 낮아도 파드는 그 노드에 못 들어간다. 오토스케일러(HPA, 그리고 뒤에서 다룰 KEDA)도 실제 사용량을 requests 대비 비율로 환산해 몇 개를 띄울지 계산한다. 즉 requests는 배치와 스케일의 기준선이다.
limits는 성격이 다르다. 실제 사용량의 상한(cap)이다. 그리고 여기서 CPU와 메모리가 비대칭으로 갈린다.
| 자원 | limit 초과 시 | 강제 방식 |
|---|---|---|
| CPU | 스로틀링 (느려짐, 안 죽음) | CFS 쿼터로 시간 배분을 조인다 |
| 메모리 | OOMKilled (죽음) | 커널이 프로세스를 종료한다 |
CPU는 시분할 자원이라 초과하면 나중에 주면 된다. 그래서 limit을 넘겨도 죽이지 않고 느리게 만든다(이 강제 장치가 CFS인데, 뒤에서 따로 다룬다). 메모리는 회수할 수 없는 자원이라 컨테이너가 limit을 넘기면 커널이 그냥 죽인다. 이 비대칭이 중요하다. 서두의 실측처럼 메모리가 593MB로 튀는데 컨테이너 limit이 512Mi라면, 서비스는 느려지는 게 아니라 재시작된다.
여기서 프론트엔드 개발자가 놓치기 쉬운 지점이 있다. Node 24는 고맙게도 컨테이너의 메모리 limit(cgroup)을 읽어 기본 힙 크기를 자동으로 맞춘다. 아무 플래그도 안 주면 V8은 사용 가능한(컨테이너에서는 cgroup으로 제약된) 메모리를 기준으로 기본 힙 상한을 잡는다. 제약이 작을수록 기본 상한도 작아진다는 게 핵심이다(정확한 비율은 Node 버전과 환경마다 다르니 파드에서 직접 확인하는 게 안전하다). 문제는 이 자동 조정이 해당 힙 플래그를 명시하는 순간, 그 플래그 몫부터 꺼진다는 것이다. 두 플래그의 자동 조정은 서로 독립적이라 명시한 쪽만 꺼지는데, 서두의 한 줄은 둘 다 명시해서 둘 다 껐다. 자세한 메커니즘과 실측 증거는 Old Space 절에서 본다.
정리하면 손잡이는 두 층에 있다. 쿠버네티스의 requests/limits가 바깥 경계고, NODE_OPTIONS의 GC 플래그가 그 안에서 V8이 얼마나 메모리를 쓸지 정한다. 서두의 한 줄은 바깥은 그대로 둔 채 안쪽 플래그만 무겁게 바꾸는 경우로, 안쪽이 바깥 경계를 밀어내게 된다. 안쪽부터 열어보자.
V8 힙은 크게 두 세대로 나뉜다. 새로 만든 객체가 태어나는 New Space(young generation)와, 거기서 살아남은 객체가 승격돼 오래 머무는 Old Space(old generation)다. 대부분의 객체는 금방 죽으므로 V8은 New Space를 아주 빠른 GC로 자주 청소하고(Scavenge, 마이너 GC), 살아남은 소수만 Old Space로 올린다.
--max-semi-space-size가 건드리는 건 이 New Space다. 이름에 semi-space가 들어간 이유가 핵심이다. New Space는 반공간(semi-space) 두 개로 이뤄진다. from과 to라고 부른다. Scavenge는 Cheney 알고리즘으로 도는데, from에 있는 살아있는 객체만 to로 복사하고 from을 통째로 비운 뒤 둘의 역할을 바꾼다. 항상 절반은 복사 대상지로 비워둬야 하므로, 물리적으로는 반공간 2개를 다 커밋하고 있어야 한다.
그래서 --max-semi-space-size=256은 "New Space를 256MB로"가 아니다. "반공간 하나를 최대 256MB로"다. 실제 커밋되는 New Space 상한은 그 2배인 512MB에 가깝다. 직접 재보면 이 2배 관계가 그대로 나온다.
--max-semi-space-size | New Space 최대 커밋 | peak RSS | 실제 생존 데이터(Old Space) |
|---|---|---|---|
| default | 128 MB | 201 MB | 20.3 MB |
| 16 | 32 MB | 104 MB | 20.3 MB |
| 64 | 128 MB | 201 MB | 20.3 MB |
| 128 | 256 MB | 332 MB | 20.4 MB |
| 256 | 512 MB | 593 MB | 20.3 MB |
읽는 법이 있다. 세로로 New Space 열을 보면 정확히 semi 값의 2배다. 16→32, 64→128, 128→256, 256→512. Node 24의 기본값은 메모리 제약이 없는 내 노트북에선 semi 64MB 수준이라(위 표에서 default 행이 semi=64 행과 완전히 같다) 예전에 흔히 알려진 16MB보다 이미 크다. 그런데 컨테이너 안에서는 V8이 이 기본값을 메모리 limit에 맞춰 훨씬 작게 잡는다. Node 공식 문서 기준으로 512MiB limit에서는 기본 semi가 1MiB까지, 2GiB 이하에서는 16MiB 미만으로 내려간다. 그리고 --max-semi-space-size를 명시하면 그 축소가 무시되고 값이 그대로 쓰인다. 즉 작은 파드에서 256을 박는 건 기본값 대비 수십 배, 512MiB 파드 기준으로는 256배를 키우는 셈이다.
참고로 V8이 힙 상한을 계산할 때 young generation 몫은 semi의 2배가 아니라 3배로 잡는다. 반공간 2개에 더해, 큰 신생 객체가 가는 new large object space 몫을 semi 하나만큼 더 예약하기 때문이다(공식 문서가 인용하는 V8의
YoungGenerationSizeFromSemiSpaceSize). 실제로 이 기기에서v8.getHeapStatistics().heap_size_limit을 재보면 기본 4288MiB(= old 상한 4096 + 3×64), semi=128이면 4480(= 4096 + 3×128), semi=256이면 4864MiB(= 4096 + 3×256)로 정확히 3배 산술을 따라 움직인다. 커밋되는 New Space 자체(from/to)는 위 표의 실측처럼 2배로 보면 된다.
그런데 진짜 중요한 건 맨 오른쪽 열이다. semi를 16에서 256으로 16배 키우는 동안, 실제로 살아남은 데이터(Old Space)는 20MB 안팎으로 요지부동이다. 워크로드는 그대로다. 커진 건 오직 New Space 버퍼뿐이고, 그 버퍼가 그대로 RSS로 올라온다. semi=256에서 RSS는 593MB까지 갔는데, 기본값(201MB)의 3.0배다. 서두에서 본 두 숫자의 정체가 이것이다. 커진 것은 데이터가 아니라 버퍼다.
그리고 부하를 걸기 전 idle 상태의 RSS는 semi 값과 무관하게 같았다. 맨 Node 프로세스 기준 40MB인데, semi=256을 줘도 똑같이 40MB다. New Space 버퍼는 시작할 때 예약되지 않는다. 할당이 들어와 Scavenge가 돌면서 상한까지 lazy하게 자란다. 그래서 이 문제는 배포 직후가 아니라 트래픽이 붙은 뒤에 드러난다. 배포 시점에 idle 그래프만 보고 문제없다고 판단하면, 알림은 한참 뒤에 온다.
한 문장으로:
--max-semi-space-size는 실제 데이터가 아니라 GC가 쓸 여유 버퍼의 크기다. 데이터가 작아도 버퍼는 정직하게 그 크기만큼 메모리를 먹는다.
--trace-gc를 켜면 이게 로그에 그대로 찍힌다. 같은 할당 부하를 semi=16과 semi=256으로 각각 돌렸을 때의 Scavenge 로그다.
# --max-semi-space-size=16
Scavenge 30.3 (47.3) -> 16.9 (47.3) MB ...
# --max-semi-space-size=256
Scavenge 278.5 (535.8) -> 29.6 (535.8) MB ...
읽는 법을 정확히 해둘 필요가 있다. 화살표 왼쪽은 GC 직전, 오른쪽은 GC 직후의 값이고, 괄호는 커밋된 크기다. 주의할 점은 이 숫자가 New Space만이 아니라 Old Space까지 합친 V8 힙 전체라는 것이다(처음엔 나도 New Space 크기로 잘못 읽었다).
그 전제로 다시 보면 두 가지가 보인다. 첫째, 오른쪽(GC 직후)은 두 경우 모두 17~30MB 수준으로 비슷하다. 이 안에는 Old Space에 상주하는 캐시 약 20MB가 들어 있으니, 실제 생존 데이터의 양은 설정과 무관하다는 뜻이다. 둘째, 왼쪽(GC 직전)은 30MB 대 278MB로 9배 차이가 난다. Old Space 몫을 빼고 셈해 보면 Scavenge 직전 New Space에 쌓여 있던 단명 객체가 각각 약 16MB와 약 258MB인데, 이는 반공간 하나의 크기(16MB/256MB)와 정확히 맞아떨어진다. 반공간이 차야 Scavenge가 돌기 때문이다. 덤으로 semi=256의 괄호(535.8MB)는 커밋된 힙 크기인데, 512MB짜리 New Space 버퍼가 그대로 들어 있는 물증이다.
semi를 키운다는 건 "New Space가 이만큼 찰 때까지는 청소하지 말고 놔둬라"는 지시다. 무거운 서비스에서는 이게 이득일 수 있다. GC를 덜 돌리니 CPU를 아끼고 지연이 줄어든다. 하지만 그 대가는 상시 커밋된 큰 버퍼다. 가벼운 서비스는 그 지연 이득을 누릴 만큼 할당이 많지도 않으면서, 버퍼 비용만 고스란히 문다. 같은 설정이 무거운 서비스에서는 튜닝이 되고 가벼운 서비스에서는 낭비가 되는 이유가 이 모양의 차이다.
서두 한 줄의 나머지 절반, --max-old-space-size=3072도 짚어야 한다. 여기서 흔한 오해가 하나 있다. "3072를 줬으니 3GB를 예약하는구나."
아니다. --max-old-space-size는 상한(ceiling)이지 예약(reservation)이 아니다. Old Space는 New Space에서 승격돼 살아남은 객체들이 쌓이는 곳이라, 실제 크기는 얼마나 많은 객체가 오래 사느냐, 즉 트래픽과 데이터 보존에 따라 정해진다. 캐시를 많이 들고 있거나 요청당 큰 객체를 오래 붙잡으면 커지고, 금방 버리면 작다. 3072를 줘도 실제로 그만큼 안 쓰면 그만큼 안 잡힌다. 이 글의 실측에서도 Old Space는 20MB 안팎에 머물렀다. --max-old-space-size=3072는 서두의 메모리 증가에 거의 기여하지 않았다. 범인은 --max-semi-space-size 쪽이었다.
그렇다고 --max-old-space-size가 무해한 건 아니다. 진짜 문제는 이게 Old Space에 대한 Node의 컨테이너 인식을 꺼버린다는 데 있다. 앞 절에서 봤듯 Node 24는 플래그가 없으면 컨테이너 메모리에 맞춰 Old Space 상한을 알아서 잡는다. 그런데 --max-old-space-size=3072를 주면 그 자동 조정이 사라지고, 컨테이너가 512Mi든 8Gi든 상관없이 상한이 3GB로 고정된다. 컨테이너 limit이 3GB보다 작으면 V8은 아직 여유 있다고 믿으며 힙을 키우다가, 커널이 먼저 프로세스를 죽인다.
앞에서 말한 "명시한 플래그만 꺼진다"의 실측 증거도 여기 둔다. 이 기기에서 heap_size_limit을 조합별로 재보면 이렇게 나온다.
플래그 없음 : 4288 MiB (old 4096 + 3×semi 64, 둘 다 자동)
--max-semi-space-size=256 만 : 4864 MiB (old 상한 4096은 그대로 유지)
--max-old-space-size=3072 만 : 3264 MiB (semi 기본 64가 그대로 유지)
둘 다 명시 : 3840 MiB (3072 + 3×256, 자동 조정 전멸)
한쪽만 명시하면 다른 쪽의 자동 감지는 살아 있다. 참고로 플래그 없음의 4288MiB는 cgroup 제약이 없는 로컬 기준 값이고, 컨테이너 안에서는 이보다 훨씬 작게 잡힌다.
여기서 죽음이 두 종류로 갈린다. V8이 자기 힙 상한에 부딪히면 FATAL ERROR: ... JavaScript heap out of memory를 JS 스택과 함께 던진다. V8 힙 OOM이다. 반면 컨테이너 cgroup limit을 넘기면 커널이 SIGKILL을 보내고, 컨테이너는 exit code 137에 OOMKilled로 죽는다. JS 스택은 없다. 후자가 훨씬 흔하고 원인 찾기도 더 어렵다. 로그에 아무것도 안 남고 파드가 그냥 재시작되기 때문이다.
그래서 실무 규칙은 단순하다. 컨테이너 힙은 플래그로 강제하기보다 Node의 컨테이너 인식에 맡기거나, 굳이 정한다면 컨테이너 limit보다 낮게 논힙(non-heap) 여유를 남기고 잡는다. 이걸 위해 Node에는 --max-old-space-size-percentage(사용 가능한, 컨테이너에서는 cgroup 제약 메모리의 %로 old space를 지정)도 있다.
논힙이라는 말이 나왔으니 마저 짚자. RSS는 V8 힙만이 아니다. New Space + Old Space 외에도 컴파일된 코드, ArrayBuffer/Buffer 같은 외부 메모리, 네이티브 애드온, 스레드 스택이 다 RSS에 잡힌다. 두 GC 플래그는 이 논힙을 하나도 통제하지 못한다. 그래서 --max-old-space-size를 컨테이너 limit과 똑같이 맞추면 안 되고, 논힙 몫만큼 항상 여유를 남겨야 한다.
한 문장으로: New Space는 "설정이 정하는" 고정 버퍼, Old Space는 "트래픽이 정하는" 가변 데이터다. 그래서 메모리 사이징은 설정과 컨테이너를 함께 움직여야 한다.
내부 용어가 한꺼번에 나왔으니 잠깐 쉬면서, 식당에 비유해 정리해 둔다.
| V8 세계 | 식당 비유 |
|---|---|
New Space 버퍼 (semi × 2) | 설거지통 크기. 사장(설정)이 정한다 |
| Old Space | 냉장고 속 재료. 손님(트래픽)이 정한다 |
| Scavenge | 설거지통이 차면 하는 설거지. 통이 클수록 횟수는 줄지만 |
| RSS | 주방 전체 면적. 설거지통을 키우면 재료가 그대로여도 주방이 커진다 |
| 컨테이너 메모리 limit | 임대한 매장 면적. 주방이 이걸 넘으면 강제 퇴거(OOMKill) |
기억할 것은 세 가지다. 첫째, --max-semi-space-size는 데이터가 아니라 설거지통(버퍼)의 크기라서, 손님이 그대로여도 그만큼 메모리를 먹는다. 둘째, 이 통은 장사가 시작돼야(트래픽이 붙어야) 상한까지 커진다. 셋째, --max-old-space-size는 예약이 아니라 상한이지만, 명시하는 순간 컨테이너 크기에 맞춰주는 자동 조정이 꺼진다. 여기까지가 메모리 편이고, 다음은 CPU 편이다.
메모리를 봤으니 CPU로 넘어간다. 여기엔 프론트엔드 개발자가 자주 걸려 넘어지는 직관 오류가 있다. "파드가 느리니 CPU limit을 4로 올리자."
Node.js는 그렇게 동작하지 않는다. 자바스크립트는 단일 스레드(이벤트 루프)에서 돈다. 그래서 순수 JS 연산은 아무리 바빠도 1코어를 넘지 못한다. 직접 재보면 깔끔하게 나온다. 측정은 cores used = CPU 시간 / 벽시계 시간으로 계산했다.
| 워크로드 | 벽시계(ms) | CPU(ms) | cores used |
|---|---|---|---|
| 순수 JS 연산 (단일 스레드) | 3000 | 2984 | 0.99 |
| JS + 단명 할당 (SSR 유사, 대부분 즉시 죽음) | 3000 | 3049 | 1.02 |
| JS + 대량 생존 할당 (승격 압박, 병렬 GC) | 3000 | 9625 | 3.21 |
| 비동기 crypto ×16 (스레드풀 4) | 235 | 920 | 3.91 |
| 비동기 crypto ×16 (스레드풀 8) | 157 | 1170 | 7.45 |
첫 줄이 핵심이다. 순수 JS는 1코어에서 포화한다. 둘째 줄처럼 SSR과 비슷하게 대부분 금방 죽는 객체를 대량으로 할당해도 1.02코어로 거의 늘지 않는다. 그런데 셋째 줄이 이번 측정의 발견이었다. 오래 살아남는 객체를 대량으로 만들어 승격을 압박하자, V8의 병렬 Scavenge와 동시 마킹 스레드가 붙으면서 3.21코어까지 올라갔다. 자주 인용되는 "약 1.25코어"는 JS 1코어에 GC·JIT 몫을 얹은 어림값인데, Node나 V8 어디에도 없는 공식 상수일 뿐 아니라, 그 GC 몫 자체가 상수가 아니라 할당의 모양을 탄다. 단명 위주면 0.03코어 수준이고, 승격이 많으면 2코어를 넘긴다. React SSR의 문자열 렌더링은 렌더 중 만드는 객체 대부분이 단명이라 실전에서는 1코어 근처에서 노는 경우가 많고, 흔히 보는 0.1~0.3코어의 초과분은 요청당 압축·crypto 같은 부수적 스레드풀 오프로드가 얹힌 값에 가깝다.
그럼 3.91, 7.45는 뭔가. crypto.pbkdf2, zlib, 파일 I/O처럼 libuv 스레드풀로 내려가는 작업들이다. 이것들만은 스레드풀 크기(UV_THREADPOOL_SIZE, 기본 4)만큼 여러 코어를 쓸 수 있다. 하지만 SSR의 컴포넌트 렌더링, JSON 직렬화, 템플릿 조립은 전부 JS 메인 스레드에서 돈다. 스레드풀로 안 내려간다. 그래서 프론트엔드 서비스의 CPU 병목은 거의 항상 저 1코어 벽이다.
그래서 "1.25코어"는 CPU request의 출발점으로는 괜찮지만, 그걸 limit으로 못박으면 위험하다. 응답을 gzip/brotli로 압축하거나(zlib) 토큰을 해싱하는(crypto) 서비스는 스레드풀로 정당하게 1코어를 넘겨 쓰고, 승격이 많은 순간엔 GC 스레드도 코어를 먹는다. 그 상태에서 꽉 조인 CPU limit은 CFS 스로틀로 되돌아온다. "1.25코어를 넘을 리 없다"는 가정이 틀리는 지점이다.
결론은 방향이 다르다는 것이다. JS CPU가 부족하면 컨테이너를 키우는 게 아니라 프로세스를 늘려야 한다. 파드를 더 띄우거나(수평 확장), 한 파드 안에서 cluster로 워커를 늘리는 것이다. CPU limit을 2, 4로 올려봐야 단일 이벤트 루프는 그중 1코어어치밖에 못 쓴다. 나머지는 예약만 하고 노는 자원이다.
앞에서 "1코어를 limit으로 못박으면 위험하다"고 했는데, 그 메커니즘이 CFS(Completely Fair Scheduler, 리눅스 커널이 CPU 시간을 프로세스들에 나눠주는 스케줄러) 스로틀이다. 알아둘 값어치가 있다.
쿠버네티스의 CPU limit은 커널의 CFS 쿼터로 강제된다. 방식은 이렇다. 기본 100ms(cpu.cfs_period_us=100000)마다 컨테이너에 limit_cores × 100ms만큼의 CPU 시간을 준다. limit이 1코어면 100ms 주기마다 100ms어치를 쓸 수 있다. 그 쿼터를 주기 안에서 다 쓰면, 노드에 놀고 있는 코어가 아무리 많아도 다음 주기까지 그 컨테이너는 멈춘다.
여기서 Node가 취약한 이유가 나온다. JS는 단일 스레드지만 프로세스는 그렇지 않다. 앞에서 본 libuv 스레드풀(gzip/brotli 압축, crypto)과 V8의 동시 GC·JIT 스레드가 짧은 순간 여러 코어를 동시에 쓴다(위 CPU 표의 "승격 압박" 행이 정확히 이 그림이다. GC 스레드가 붙자 프로세스가 3코어를 넘겼다). 요청이 몰려 GC가 돌고 응답 압축이 겹치는 100ms 창에서 1코어 쿼터는 순식간에 바닥나고, 그러면 이벤트 루프 스레드까지 통째로 멈춘다. 평균 사용률은 60%인데 p99 지연이 튀는 전형적인 그림이 이거다. 평균은 스로틀을 숨긴다.
그래서 지연에 민감한 Node 계층에는 실무에서 CPU limit을 빼고 request만 정직하게 잡는 처방이 흔하다. AWS EKS 모범 사례 문서도 "CPU에는 resource limit을 걸지 말라"고 대놓고 권한다. request는 스케줄링과 공정 분배의 기준으로 남기고, limit은 없애 버스트를 허용하는 것이다. 대신 노이즈 이웃 격리와 Guaranteed QoS를 잃는 트레이드오프가 있으니, 멀티테넌시가 빡빡하면 limit을 request보다 넉넉히 위로 잡아 스로틀이 안 걸리게 하는 절충도 있다. 어느 쪽이든 메모리 limit은 request와 같게 유지한다. 메모리는 압축 불가 자원이라 초과하면 스로틀이 아니라 OOMKill이니까.
QoS 클래스: 파드의 request/limit 설정으로 쿠버네티스가 매기는 등급(Guaranteed/Burstable/BestEffort). request와 limit이 같으면 Guaranteed다. 노드가 메모리 압박을 받을 때 어떤 파드를 먼저 축출할지가 이 등급으로 갈린다.
그리고 container_cpu_cfs_throttled_periods_total은 지켜볼 만한 값이다. 전체 주기 대비 스로틀된 주기 비율이 경험적 눈금인데, 5%를 넘으면 지연에 영향이 나타나기 시작하고 20%를 넘으면 사용자가 체감하는 수준이라고 알려져 있다.
한 문장으로: CPU limit은 "이만큼까지 빨라도 돼"가 아니라 "이만큼 쓰면 멈춰"다. 버스트가 정상인 Node에는 특히 아프다.
여기서 자연스럽게 세 번째 멘탈 모델이 나온다. 트래픽 스파이크는 파드당 메모리 버퍼가 아니라 파드 수가 흡수한다.
오토스케일링이 걸려 있는 서비스의 그래프를 보면 이게 잘 드러난다. 하루 동안 트래픽이 몇 배로 출렁여도, 파드당 워크로드(메모리, 처리 시간)는 좁은 범위 안에서만 움직인다. 트래픽이 8배가 된다고 파드 하나가 8배 일하는 게 아니다. 파드 개수가 늘어 부하를 나눠 가지고, 파드 하나는 여전히 비슷한 양의 요청을 처리한다.
이게 왜 중요한가. "스파이크에 대비해 파드 메모리를 넉넉히 잡자"는 흔한 대응이 틀린 방향이기 때문이다. 파드당 메모리를 두 배로 키워도 스파이크는 막지 못한다. 스파이크는 동시 요청 수의 문제고, 그건 수평 확장(파드 수)으로 흡수하는 것이다. 파드당 메모리를 키우는 건 앞에서 본 GC 버퍼처럼 상시 비용만 늘리고 스파이크 대응력은 안 준다. 넉넉함으로 대비할 곳은 파드당 버퍼가 아니라 오토스케일링의 여유와 속도다.
물론 이 말에는 전제가 있다. 파드당 메모리가 동시 요청 수에 비례해 자라지 않아야 한다는 것이다. 요청을 받고, 렌더하고, 응답을 보내면 끝나는 무상태 SSR/BFF는 대체로 이 전제를 만족한다. 요청 하나의 컨텍스트는 수십 ms에서 수백 ms만 살다 죽으니, 어느 순간이든 파드가 붙잡고 있는 메모리는 크지 않다. 하지만 트래픽이 그대로 파드 메모리가 되는 워크로드도 분명히 있다.
이런 워크로드라면 파드당 메모리를 트래픽과 무관한 상수로 볼 수 없다. 그렇다고 해법이 "메모리를 넉넉히"로 돌아가는 건 아니라고 생각한다. 방향은 파드당 수용량을 상수로 만들어, 다시 개수의 문제로 되돌리는 것이다. 연결형 워크로드는 파드당 최대 연결 수를 정해 두고 연결 수 자체를 스케일 신호로 쓴다(뒤에 나올 KEDA가 이런 커스텀 신호에 강하다). 페이로드는 스트리밍으로 흘려보내 버퍼링을 없애고, 다운스트림 지연에는 타임아웃과 동시성 상한을 걸고, 캐시는 상한(LRU)을 두거나 Redis 같은 외부 저장소로 뺀다. 파드당 사용량이 예측 가능해야 메모리 limit도, 스케일 계산도 성립한다.
단 두 가지 단서가 붙는다. 첫째, 이 자동 흡수는 트래픽과 상관있는 신호로 스케일할 때만 성립한다. CPU나 요청 수(RPS)로 스케일해야지, 메모리로 스케일하면 Node에서는 깨진다. V8 RSS는 트래픽이 빠져도 잘 안 내려가기 때문이다. 힙을 고수위로 잡아두고 페이지를 OS에 아주 느리게만 반납한다. 그래서 메모리 기반 HPA는 Node를 제대로 올리지도 내리지도 못한다. 둘째, 오토스케일러는 반응이 느리다. HPA 동기화 주기는 기본 15초고 새 파드가 뜨는 데는 콜드 스타트(새 파드가 떠서 첫 요청을 받기까지의 준비 시간)까지 수십 초가 걸린다. 그 사이 갑작스러운 스파이크의 앞머리는 결국 기존 파드의 여유가 큐잉으로 받아낸다. 그래서 minReplicaCount로 바닥을 깔아두는 여유는 여전히 필요하다. 넉넉함을 파드당 힙 버퍼에 두지 말라는 것이지, 아무 여유도 두지 말라는 게 아니다.
CPU 천장을 뚫으려고 cluster를 쓰기로 했다고 하자. 한 파드 안에서 워커 프로세스를 여럿 띄워 여러 코어를 쓰는 방식이다. 여기서 서두의 문제가 곱셈으로 재발한다.
cluster의 각 워커는 독립된 V8 인스턴스다. JS 힙을 공유하지 않는다. 그러니 앞에서 본 GC 예약, 즉 New Space 버퍼도 워커마다 따로 문다. --max-semi-space-size=128을 걸고 워커 수를 늘리며 같은 부하에서 총 RSS를 재봤다.
| 워커 수 | primary RSS | 워커당 peak RSS | 총 RSS |
|---|---|---|---|
| 1 | 43 MB | 333 | 376 MB |
| 2 | 43 MB | 333, 334 | 710 MB |
| 4 | 45 MB | 333, 334, 628, 631 | 1672~1970 MB |
primary는 43MB 안팎으로 고정이고, 워커 하나가 약 333MB(semi=128의 New Space 256MB + 캐시와 베이스)를 문다. 워커가 늘면 총 메모리는 그만큼 선형으로 곱해진다. 그런데 4워커에서는 그보다 나쁜 현상도 반복해서 관찰됐다. 코어 경쟁으로 major GC가 밀린 일부 워커가 순간 630MB 안팎까지 튀면서, 총합이 1.7GB에서 2GB 근처를 오갔다. 곱셈은 선형에서 끝나지 않고, 붐비면 더 나빠질 수 있다는 뜻이다.
여기가 진짜 위험한 버전이다. 무거운 NODE_OPTIONS가 단일 프로세스에서 메모리를 3배로 만들었다면, cluster에서는 그 배수가 다시 워커 수만큼 곱해진다. --max-semi-space-size를 크게 잡은 채 워커를 8개 띄우면 New Space 버퍼만으로 수 GB가 상시 예약된다. 클러스터에서는 GC 플래그를 오히려 더 보수적으로 잡을 필요가 있다.
한 문장으로: cluster는 CPU를 곱하는 대신 메모리 예약도 곱한다. 워커 수와
NODE_OPTIONS는 항상 같이 계산해야 한다.
CPU 편도 비유로 눌러 담아 둔다. Node 프로세스는 계산대가 하나뿐인 가게다.
다음은 운영 편이다. 파드 안에 프로세스를 몇 개 두는 게 좋은지, 그리고 누가 그 프로세스를 돌봐야 하는지의 이야기다.
cluster로 메모리가 곱해지는 걸 봤으니 근본 질문으로 가자. 파드 하나에 Node 프로세스를 하나만 둘까, 여러 개(cluster 워커)를 둘까. 프론트엔드 팀에서 자주 갈리는 지점이다.
먼저 프레임을 바로잡자. 진짜 결정 변수는 "프로세스 1개냐 N개냐"가 아니라 파드 하나에 CPU를 몇 코어 주느냐다. 파드에 4코어를 주고 Node를 하나만 돌리면 3코어가 논다(JS는 1코어 천장이니까). 그러니 질문은 이렇게 바뀐다. 파드를 약 1코어로 잘게 썰어 많이 띄울까, 아니면 큰 파드에 워커를 여러 개 packing할까.
기본값은 약 1코어짜리 파드에 프로세스 하나, 그리고 레플리카(파드 수)로 확장이다. 쿠버네티스에게 슈퍼바이저 역할을 온전히 맡기는 쪽이다. 왜 이게 이기는지는 packing으로 잃는 것들을 나열하면 분명해진다.
restartCount와 CrashLoopBackOff에 잡힌다. 한 컨테이너 뒤에 워커 N개를 숨기면 kubelet은 건강 신호를 하나로만 보고, 먹통이 된 워커 하나를 골라 재시작하지 못한다.OOMKilled 하나로 기록한다.Karpenter: AWS의 노드 오토스케일러. 파드의 request를 보고 필요한 EC2 인스턴스를 즉석에서 띄우고, 비는 노드를 뭉쳐 없앤다(consolidation). 기존 Cluster Autoscaler보다 인스턴스 선택과 packing이 유연하다.
그럼 파드당 고정 오버헤드가 얼마길래. 실측하면 bare Node 프로세스가 약 40MB, 최소 HTTP 서버가 43MB다. 여기에 사이드카(파드 안에 본 컨테이너와 나란히 뜨는 보조 컨테이너. 예를 들어 서비스 메시 Istio의 Envoy 프록시가 약 40MB)와 VPC IP 하나가 붙는다. packing은 딱 이만큼을 아낀다. 그래서 "작은 파드를 많이"가 공짜는 아니다. 너무 잘게 쪼개면 CPU가 포화되기 전에 노드당 IP·파드 밀도 상한에 먼저 부딪힌다. 비용상 옳은 기본값은 정직한 request로 잘 맞춘 약 1코어 파드를 적당한 수로, 그리고 Karpenter로 뭉치기지, 무한정 잘게 쪼개기가 아니다.
여기서 흔한 혼동 하나를 풀자. "큰 노드"와 "큰 파드"는 별개다. 큰 노드가 주는 이득(DaemonSet, 즉 노드마다 하나씩 뜨는 파드와 system-reserved 복사본이 줄고 컨트롤 플레인 부하가 준다)은 큰 노드 위에 작은 1프로세스 파드를 많이 얹어도 그대로 챙긴다. 워커를 여러 개 packing해서 추가로 얻는 건 오직 파드당 오버헤드(사이드카 + 베이스 RSS + IP)뿐이다. 그러니 워커 packing으로 넘어가기 전에 그 특정 오버헤드가 정말 큰지부터 재보는 것이 순서다.
packing이 정당화되는 경우는 분명히 있다. 대개 셋 중 하나다. (1) 사이드카 메시가 파드마다 프록시를 띄워 오버헤드가 클 때. 단 Istio ambient 모드는 프록시를 노드 단위 데몬셋으로 옮겨 이 이유를 없앤다. 메시부터 고치는 게 순서다. (2) IP/ENI 고갈. ENI는 EC2의 네트워크 인터페이스로, 노드가 파드에 줄 수 있는 IP 수를 제한한다. 단 prefix delegation(/28 단위로 IP를 묶어 받아 최대 16배)이 AWS가 권하는 해법이고 대개 packing보다 낫다. (3) AWS Fargate. 파드 하나가 노드 하나라 bin-packing 자체가 없다. 이게 가장 강한 이유고, 비용 절에서 다시 본다.
정말 packing을 한다면 **pm2가 아니라 내장 node:cluster나 worker_threads**를 쓰고, 워커 수를 CPU limit에 고정한다(절대 -i max가 아니다). 왜 pm2가 아닌지가 바로 다음 절이다.
VM 시절 습관대로 컨테이너 안에 pm2를 넣는 팀이 많다. 결론부터 말하면, 쿠버네티스 안에 두 번째 슈퍼바이저 겸 오토스케일러를 중첩하지 않는 것이 좋다. 다만 이걸 "pm2는 나쁘다"로 오해하면 안 된다. 먼저 pm2가 뭘 잘하는지부터 공정하게 볼 필요가 있다.
pm2가 VM 시절에 사랑받은 건 이유가 있다. 크래시 난 프로세스를 자동으로 되살리고, pm2 reload로 워커를 무중단 재시작하고, 클러스터 모드로 한 줄에 멀티코어를 쓰고, 로그를 모아 회전시키고, 메모리를 넘긴 워커를 재시작하고(max_memory_restart), 프로세스 상태를 대시보드로 보여준다. 오케스트레이터가 없는 단일 서버라면 이게 전부 있어야 할 기능이고, pm2는 이걸 잘한다.
문제는 쿠버네티스에는 그 오케스트레이터가 이미 있다는 것이다. pm2가 주는 것 하나하나가 K8s에서는 한 층 위에서, 대개 더 낫게 제공된다.
| pm2가 주는 것 | K8s가 이미 하는 것 |
|---|---|
| 크래시 자동 재시작 | restartPolicy + kubelet, 재시작이 restartCount·CrashLoopBackOff로 드러난다 |
무중단 배포(reload) | 롤링 Deployment, 새 이미지로 파드를 교체한다 |
| 클러스터 모드(멀티코어) | 레플리카로 확장, 파드 안이 꼭 필요하면 node:cluster |
| 메모리 초과 재시작 | 메모리 limit + OOMKill |
| 로그 수집·회전 | stdout → 노드 로그 에이전트(Fluent Bit 등) |
| 메트릭·대시보드 | Prometheus / OTel |
오른쪽이 왼쪽을 대체할 뿐 아니라 더 높은 층위에서 한다. K8s의 재시작은 클러스터 전체가 보는 이벤트고, K8s의 무중단 배포는 GitOps로 추적되는 이미지 교체다. 반면 pm2를 파드 안에 또 넣으면 같은 일이 파드 경계 안에서 일어나 밖에서는 안 보인다. 기능이 겹치는 데서 끝나지 않고, 두 번째 층이 첫 번째 층의 눈을 가린다.
"pm2는 안티패턴"이라는 말은 방향은 맞지만 너무 단정적이다. 정확히는 두 가지를 구분해야 한다. K8s 안에 중복 슈퍼바이저를 중첩하는 것은 기본적으로 틀렸다. 하지만 pm2가 파드 안에서 유일하게 고유하게 주는 가치, 즉 **"파드 하나에서 여러 코어 쓰기"**는 node:cluster가 의존성 없이 더 깔끔하게 한다. 그래서 pm2를 꼭 써야 할 이유가 좁아진다.
굳이 pm2를 쓴다면 pm2-runtime이 필수다(맨 pm2도, npm start도 안 된다). 맨 pm2 start는 데몬으로 떨어져 나가 백그라운드로 가버리므로, PID 1이 되면 컨테이너가 그대로 종료되거나 워커가 감독 없이 돈다. pm2-runtime은 포그라운드로 붙어 PID 1 신호와 그레이스풀 셧다운을 처리하는 "node 바이너리 대체품"이다.
그런데 pm2를 끼우는 순간 네이티브 단일 프로세스에는 없는 버그 이음새가 여럿 생긴다. 하나하나가 실제로 물린다.
PM2_KILL_SIGNAL=SIGTERM으로 맞춰야 한다. (드레인: 새 요청은 받지 않으면서 처리 중이던 요청을 마저 끝내는 정리 절차)kill_timeout 기본값 1600ms. K8s의 30초 grace보다 한참 짧다. K8s는 더 기다려줄 텐데 pm2가 1.6초 만에 드레인 중인 워커를 SIGKILL한다. drain 예산에 맞춰 올리되 terminationGracePeriodSeconds보다는 낮게 둔다.OOMKilled도, 재시작 카운트도 안 남는다. 오토스케일러나 VPA(파드의 request를 실사용에 맞게 자동 조정해주는 도구)가 참고할 신호가 통째로 사라진다. 네이티브라면 OOM은 일급 파드 이벤트다.restartCount를 올리지도 CrashLoopBackOff를 띄우지도 않는다.-i max를 4코어 파드에 걸면 앞에서 본 New Space 약 2×semi를 포함한 단일 프로세스 발자국이 약 4배로 잡힌다. 프로세스 하나 기준으로 잡은 limit은 워커별로 OOMKill된다.특히 -i max(또는 0)는 지뢰다. 감지된 CPU 수만큼 워커를 포크하는데, os.availableParallelism/libuv가 오랫동안 cgroup 쿼터가 아니라 호스트 코어 수를 돌려줬다(libuv #4146). 그래서 64코어 EKS 노드 위의 2코어 파드에서 -i max는 논리 코어 수만큼, 즉 워커를 최대 64개 포크하고, 곧장 CFS 스로틀 지옥과 tail latency 폭발로 간다. cluster를 쓴다면 워커 수는 CPU limit에 고정하는 것이 안전하다. max는 피해야 한다.
pm2를 정당화하는 논거로 자주 나오는 "PID 1 문제"는 진짜지만 pm2가 답은 아니다. Node를 PID 1로 그냥 띄우면 커널이 기본 SIGTERM 처리를 설치하지 않고(init 프로세스라서), 좀비 프로세스도 거둬야 한다. 하지만 이건 tini/dumb-init나 도커의 --init 한 줄과 명시적 process.on('SIGTERM') 핸들러로 싸게 해결된다. pm2의 이중 슈퍼바이저 부작용 없이 신호 처리와 좀비 수거를 다 얻는다.
그래서 왜 별개로 가는가. pm2의 장점은 진짜지만, 그건 오케스트레이터가 없을 때 진짜다. K8s 위에서는 그 장점을 이미 한 층 위에서 얻고 있고, pm2를 겹쳐 놓으면 장점을 한 번 더 얻는 대신 재시작·OOM·크래시라는 관측 신호를 잃는다. 유일하게 겹치지 않는 "파드 안 멀티코어"조차 node:cluster로 충분하다. 결국 pm2를 빼서 잃는 건 없고(K8s가 다 한다), 넣어서 잃는 건 관측성이다. 그러니 층을 나눈다. 슈퍼바이징은 K8s에, 애플리케이션은 파드당 Node 하나에. 정말 pm2가 필요한 좁은 경우는 앞 절 packing 조건(Fargate 등)과 같고, 그때도 pm2보다 node:cluster가 낫다.
한 문장으로: K8s에서 pm2가 고유하게 주는 건 "파드 안 멀티코어"뿐이고, 그건
node:cluster가 더 깔끔하게 한다. 나머지는 전부 K8s가 이미 하는 일의 중복이거나, K8s의 눈을 가리는 부작용이다.
토폴로지 선택이 신뢰성으로 이어지는 지점이 종료 처리다. 프론트엔드 서비스가 롤아웃마다 5xx를 흘리는 흔한 원인이기도 하다.
파드가 죽는 순서는 이렇다. 삭제 요청이 오면 API 서버가 grace period를 시작하고, kubelet이 PID 1에 SIGTERM을 보낸다. 그리고 terminationGracePeriodSeconds(기본 30초)만큼 기다린 뒤 SIGKILL한다. Node는 이 사이에 SIGTERM을 받아 server.close()로 새 연결을 끊고, 처리 중이던 요청을 마저 흘려보낸 뒤 종료해야 한다. 핸들러를 달면 Node의 기본 동작(exit 143)이 대체되어 드레인할 틈이 생긴다. 안 하면 처리 중이던 요청이 뚝 끊겨 클라이언트가 5xx를 받는다.
여기서 PID 1 함정이 다시 나온다. CMD npm start나 셸 형식 CMD로 띄우면 PID 1이 sh나 npm이 되는데, 이들은 SIGTERM을 자식에게 전달하지 않는다. 그래서 앱은 종료 신호를 영영 못 받고, 파드는 30초를 꽉 채워 매달렸다가 SIGKILL된다. 롤아웃마다 반복된다. node를 직접 PID 1로 띄우면 신호는 닿지만 기본 처리가 없어서, 핸들러를 명시하지 않으면 SIGTERM을 조용히 무시한다. 해법은 exec 형식 JSON CMD와 명시적 핸들러, 또는 tini/--init이다.
테스트에서는 안 보이는 함정이 하나 더 있다. 종료 시 API 서버는 kubelet(SIGTERM)과 엔드포인트 컨트롤러에 동시에, 순서 없이 신호를 보낸다. 그래서 SIGTERM을 받은 뒤에도 iptables·로드밸런서가 수렴할 때까지 새 요청이 계속 들어온다. server.close()만으로는 부족하다. preStop 훅(kubelet이 SIGTERM을 보내기 직전에 컨테이너 안에서 실행해 주는 명령)에 sleep 10~20초를 걸어 라우팅이 먼저 빠지게 한 뒤 드레인을 시작하는 패턴이 필요하다(AWS 문서도 sleep을 예시로 든다). 그리고 terminationGracePeriodSeconds는 preStop sleep + 최장 in-flight drain + 버퍼보다 크게 잡는다.
pm2를 쓴다면 이 절의 함정(신호, kill_timeout, PID 1)이 전부 앞 절에서 본 pm2 설정 문제로 되돌아온다. 프로세스를 하나만 두면 애초에 없다.
운영 편의 핵심은 "돌보는 사람은 한 명이어야 한다"는 것이다.
npm start나 셸을 사이에 끼우면 신호가 중간에서 사라지고, 롤아웃마다 5xx가 샌다. exec 형식 CMD, SIGTERM 핸들러, preStop sleep 세 가지가 세트다.이제 이 이해를 실제 사이징 절차로 묶는다.
지금까지 본 걸 사이징 절차로 묶으면 세 축이 된다.
CPU request. p90에서 피크 실사용량에 맞춘다. 단일 프로세스는 앞에서 봤듯 JS로는 1코어 근처가 천장이라, request를 1코어 넘게 잡는 건 대개 낭비다. 1코어로 부족하면 request를 키우는 게 아니라 파드 수나 워커 수로 간다. request를 실사용보다 부풀리면(과예약) 클러스터가 실제보다 "CPU가 꽉 찼다"고 오판해 불필요하게 노드를 늘린다. 정직한 CPU request가 클러스터의 자원 판단을 정직하게 만든다.
메모리. working set은 New Space(설정이 정하는 고정 버퍼) + Old Space(트래픽이 정하는 가변 데이터) + 논힙으로 이뤄진다. --max-semi-space-size와 --max-old-space-size, 그리고 컨테이너 limit을 함께 정한다. 어느 하나만 복사해 오면 앞에서 본 그림이 그대로 재현된다.
파드 수. 필요한 총 CPU 수요를 파드 하나가 감당할 양으로 나눈다.
파드 수 = ceil( 총 CPU 수요 / (파드당 CPU request × 목표 사용률) )
목표 사용률은 오토스케일러가 붙잡는 타깃(예: 70%)이다. 파드 하나가 request × 0.7만큼 일한다고 보고, 전체 수요를 그걸로 나누면 필요한 파드 수가 나온다. 이건 HPA가 내부적으로 쓰는 desiredReplicas = ceil(currentReplicas × 현재사용량 / 목표사용량) 공식과 같은 계산을 사이징 관점에서 뒤집어 쓴 것이다. 둘 다 "파드 하나가 목표치만큼 일하도록 개수를 맞춘다"는 같은 이야기다.
단 이 공식은 정상 상태의 어림값이지 정확한 값이 아니다. HPA는 목표에서 기본 ±10% 안쪽이면 아예 조정하지 않으므로(tolerance dead-band), 실제 파드 수는 공식값 근처의 밴드로 앉는다. 또 파드마다 런타임·힙 베이스·사이드카 같은 고정 오버헤드가 있어서 총 수요가 파드 수에 완전히 비례하지도 않는다. 그러니 이 값은 minReplicas/maxReplicas의 하한 추정으로 쓰고, 여유는 평균이 아니라 파드당 피크에 맞춰 잡는 게 안전하다.
결국 이 글의 전부는 복사 대신 측정이라는 이야기다. 절차는 짧다.
--trace-gc로 돌린다.Old Space + New Space(약 2×semi) + 논힙 여유로 잡는다.--max-semi-space-size는 건드리지 않는 게 기본이다. GC CPU가 실제로 병목이라고 측정됐을 때만 올린다.남의 NODE_OPTIONS를 복사하는 대신 이 다섯 줄을 우리 서비스에 돌리면, 추측이 아니라 우리 숫자가 나온다.
파드 수를 정적으로 정해두면 트래픽이 출렁일 때 낭비 아니면 부족이 된다. 오토스케일링이 필요하고, 프론트엔드 트래픽에는 두 가지 성격이 섞여 있다. 예측 가능한 주기(출근 시간대에 오르고 새벽에 내리는 일간 패턴)와 예측 불가능한 스파이크(이벤트, 유입 급증)다. KEDA(HPA를 확장해 cron이나 큐 길이 같은 이벤트 신호로도 스케일할 수 있게 해주는 오픈소스)는 이 둘을 다른 트리거로 나눠 잡는다.
KEDA는 내부적으로 HPA를 만들어 관리하면서, 여기에 이벤트 기반 스케일러를 얹는다. 예측 가능한 주기는 cron 스케일러로 시간표를 짠다.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: ssr-frontend
spec:
scaleTargetRef:
name: ssr-frontend
minReplicaCount: 3
maxReplicaCount: 40
triggers:
- type: cron
metadata:
timezone: Asia/Seoul
start: '0 8 * * *' # 08:00에
end: '0 23 * * *' # 23:00까지
desiredReplicas: '12' # 최소 12개를 깔아둔다
- type: cpu
metadata:
type: Utilization
value: '70' # 그 위로 CPU가 70% 넘으면 더 띄운다
cron 트리거는 출근 시간대에 파드 바닥을 미리 깔아 콜드 스타트로 인한 초기 지연을 없앤다. 예측되는 부하는 예측으로 대응하는 것이다. 그리고 그 위에 얹힌 cpu 트리거가 예상 못 한 스파이크를 실시간으로 받는다. 두 트리거 중 더 많은 파드를 요구하는 쪽이 이긴다. 예측 가능한 부분은 시간표로, 나머지는 반응형으로. 앞에서 본 "트래픽은 파드 수가 흡수한다"를 운영으로 옮기면 이 모양이 된다.
여기까지의 모든 이야기(정직한 request, 약 1코어 파드, 안 부풀린 힙)가 결국 돈으로 만난다. EKS 비용의 뼈대는 단순하다. 너는 파드가 아니라 노드(EC2 인스턴스) 값을 낸다. 컨트롤 플레인은 표준 지원 기준 클러스터당 시간 $0.10 정액이고(가격은 늘 확인할 것), 비용의 대부분은 EC2다. 그리고 노드를 몇 대 띄울지는 request가 정한다. 스케줄러도, Cluster Autoscaler도, Karpenter도 전부 request로 bin-packing한다. 실사용량도 limit도 아니다.
여기서 흔한 오해를 정밀하게 고치자. "request를 부풀리면 EC2 청구서가 곧장 오른다"는 선형이 아니라 계단 함수다. 노드는 대수 단위로 돈을 내므로, request를 조금 늘려도 bin 경계를 넘어 노드를 하나 더(또는 더 큰 걸로) 띄우기 전까지는 추가 비용이 $0이다. 게다가 bin-packing은 2차원이라 묶이는(binding) 자원만 과금에 반영된다. 메모리로 먼저 꽉 차는 노드에서 CPU를 과예약하는 건 공짜고, 그 반대도 마찬가지다. Node SSR/BFF는 워커당 V8 New Space 때문에 대개 메모리가 먼저 묶인다. 그러니 달러를 말하기 전에 어느 차원이 포화됐는지부터 재는 게 순서다.
두 번째 레버는 인스턴스 패밀리다. vCPU당 메모리 비율이 고정돼 있다. c 계열 1:2, m 계열 1:4, r 계열 1:8 (GiB/vCPU). 문제는 r 계열이 vCPU당 c보다 약 48%, m보다 약 31% 비싸다는 것이다(2026년 us-east-1 온디맨드 기준, 가격은 늘 확인할 것). 힙과 New Space를 부풀리면 파드가 메모리 때문에 r 계열로 밀려난다. 메모리 request를 vCPU당 2~4GiB 쪽으로 정직하게 줄이면 같은 파드가 더 싼 m/c로 packing되는데, 이건 노드 수도 줄이고 패밀리 등급도 낮추므로 대개 단일 최고 레버리지 비용 절감이다. arm64(Graviton)로 빌드하면 여기서 다시 약 20% 빠진다.
자원 병목이 최적화 방향을 정한다는 말의 실체가 이것이다. 메모리로 묶인 클러스터는 r 계열을 강제당하고, 정직한 메모리는 클러스터를 c/m으로 돌려보낸다.
숫자로 감을 잡자. 순전히 예시용 산수이고, 함대 규모와 "메모리가 binding"이라는 가정은 측정이 아니라 고른 값이다.
서두의 그 플래그를 함대에 복사했다고 하자. --max-semi-space-size=256은 이 글의 실측에서 프로세스당 peak RSS를 기본 대비 약 390MB 더 쓰게 만들었다(201MB → 593MB, 델타 392MB. 대부분이 New Space 버퍼 몫이다). 이걸 100개 프로세스에 뿌리면 약 39GB가 순수 예약으로 사라진다. 메모리가 binding 차원이라면, 39GB는 대략 **r7i.2xlarge(8vCPU/64GiB) 한 대의 60%**다. 온디맨드로 월 약 $390, Spot(중간에 회수될 수 있는 대신 크게 할인된 EC2)이면 약 $160(2026년 기준, 확인 필요)짜리 인스턴스다. 플래그 한 줄이 함대에 곱해져 메모리 최적화 노드 절반 이상, 월 수백 달러의 상시 지출이 되는데, 그 돈이 사는 건 아무것도 없다.
마지막으로 두 가지. Fargate에서는 경제학이 뒤집힌다. Fargate는 파드 하나가 노드 하나라 bin-packing이 없고, 파드의 request를 그대로 과금한다. 게다가 요청에 256MB 오버헤드를 더한 뒤 정해진 구성으로 올림한다(AWS 예시: 1vCPU+8GB 요청이 2vCPU+9GB로 올림되어 vCPU 비용이 두 배). 그래서 Fargate에서는 과예약이 즉각적·연속적·파드별로 물리고, 앞에서 본 packing(한 파드에 코어 여러 개)이 비로소 값을 한다. 반대로 EC2 기반 EKS에서는 정직한 request + Karpenter consolidation + Spot(최대 90% 할인, 무상태 SSR/BFF에 적합) + Savings Plan(사용량 약정 할인) 베이스라인이 비용상으로도 가장 깔끔하다. 여기에 KEDA의 scale-to-zero(트래픽이 없으면 파드를 0개까지 줄이는 것. 순수 HPA는 0으로 못 내린다)와 Karpenter의 빈 노드 제거를 얹으면, 새벽에 노는 프론트엔드 트래픽의 유휴 함대 비용이 사라진다.
한 문장으로: request는 사용량이 아니라 청구서다. 정직한 request는 더 싼 인스턴스 패밀리에 더 촘촘히 packing되고, 부풀린 힙은 함대 규모로 곱해져 노드 청구서가 된다.
글이 길었으니, 우리 서비스에 바로 적용할 수 있는 점검만 추려 둔다. 각 항목은 명령 한 줄과 "이렇게 나오면 손볼 여지가 있다"는 기준으로 구성했다. deploy/my-app은 각자 서비스 이름으로 바꿔 읽으면 된다.
1. 지금 뜬 파드의 NODE_OPTIONS부터 확인한다.
kubectl exec deploy/my-app -- printenv NODE_OPTIONS
값이 있는데 그 출처와 근거를 아무도 설명하지 못한다면, 어딘가에서 검증 없이 복사돼 온 값일 가능성이 있다. 특히 --max-semi-space-size가 크게 잡혀 있다면 New Space 버퍼(약 2배)를 파드 메모리 limit과 대조해 본다.
2. V8이 컨테이너를 어떻게 인식했는지 본다.
kubectl exec deploy/my-app -- node -p "require('v8').getHeapStatistics().heap_size_limit / 1048576"
이 값(MiB)이 컨테이너 메모리 limit보다 크다면, V8은 쓸 수 있다고 믿는 힙이 실제보다 크다는 뜻이고 OOMKill이 예약된 상태에 가깝다. 논힙 몫까지 생각하면 limit보다 충분히 작아야 안전하다.
3. 조용한 OOMKill 이력을 찾는다.
kubectl get pods -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.containerStatuses[0].restartCount}{"\t"}{.status.containerStatuses[0].lastState.terminated.exitCode}{"\n"}{end}'
exit code 137이 보이면 OOMKill이다. 재시작 카운트는 0인데 파드 안에서 pm2를 쓰고 있다면, 워커 OOMKill이 pm2 뒤에 숨어 있지 않은지 의심해 볼 만하다.
4. PID 1이 무엇인지 본다.
kubectl exec deploy/my-app -- ps -o pid,comm -p 1
sh나 npm이면 SIGTERM이 앱에 닿지 않고 있을 가능성이 높다. Dockerfile의 CMD를 exec 형식(JSON 배열)으로 바꾸거나 tini를 앞세운다. node라면 process.on('SIGTERM') 핸들러가 실제로 있는지도 함께 확인한다.
5. CPU 스로틀 비율을 잰다. (Prometheus가 있다면)
rate(container_cpu_cfs_throttled_periods_total[5m])
/ rate(container_cpu_cfs_periods_total[5m])
5%를 넘으면 CPU limit이 지연에 영향을 주고 있을 수 있다. limit을 빼거나 넉넉히 올리는 선택지를 검토한다.
6. HPA가 무엇으로 스케일하는지 확인한다.
kubectl get hpa -o yaml | grep -B2 -A6 "metrics:"
Node 서비스인데 memory 기반이라면 스케일이 제대로 동작하지 않을 가능성이 높다(V8은 RSS를 잘 반납하지 않는다). CPU나 RPS 기반으로 바꾸는 것을 검토한다.
7. cluster/pm2 워커 수를 확인한다.
-i max(pm2)나 os.availableParallelism() 기반 fork가 있는지 코드와 매니페스트를 확인한다. 워커 수가 파드 CPU limit보다 크면, 큰 노드에서 워커가 폭증해 스로틀로 이어질 수 있다. 워커 수는 CPU limit에 상수로 고정한다.
8. 종료 처리 설정을 본다.
kubectl get deploy my-app -o yaml | grep -A6 "lifecycle:"
preStop의 sleep 없이 server.close()만 있다면 롤아웃 때 5xx가 섞일 수 있다. preStop sleep + 드레인 시간 < terminationGracePeriodSeconds가 성립하는지도 함께 확인한다.
한 줄의 플래그가 메모리를 세 배로 만드는 데에 트릭은 없었다. 런타임에 원래 있던 동작들이 설정값을 정직하게 따라갔을 뿐이고, 문제는 그 동작을 모른 채 값만 옮겨 적는 복사에 있었다. 글에서 확인한 것들을 풀어놓으면 이렇게 남는다.
request는 예약, limit은 상한이다. 둘 다 실사용량이 아니다. request는 배치와 스케일의 기준선이고, limit 초과는 CPU면 스로틀, 메모리면 OOMKill로 비대칭이다.--max-semi-space-size는 데이터가 아니라 GC 버퍼를 키운다. New Space는 반공간 2개라 설정값의 약 2배까지 커밋되고, live 데이터가 그대로여도 RSS를 밀어올린다. 이 버퍼는 부하 시 lazy하게 자란다.--max-old-space-size는 예약이 아니라 상한이고, 트래픽이 실제 크기를 정한다. Node 24는 컨테이너 limit을 읽어 힙을 자동으로 맞추지만, 플래그를 명시하면 그 플래그 몫의 인식이 꺼진다. 굳이 정한다면 컨테이너 limit보다 낮게, 논힙 여유를 남겨 잡아야 OOMKill을 피한다.NODE_OPTIONS는 같이 계산한다.node:cluster를 CPU limit에 고정해 쓰고, PID 1과 SIGTERM 문제는 tini/--init으로 푼다.한 줄로 남기면 이렇다. "표준" 설정은 없다. 설정은 서비스의 모양(트래픽, 할당 패턴, 프로세스 모델)에 맞춰야 하고, 남의 서비스 모양에 맞춘 값을 복사하는 순간 그건 우리 서비스에서 틀린 값이 된다. 옆 팀의 NODE_OPTIONS가 궁금하면, 복사하기 전에 그 팀 서비스가 우리와 같은 모양인지부터 물어보는 것이 순서라고 생각한다.