지난 글에서 어댑터 여럿을 서버 한 대에 태워 카드를 아꼈다. 그래도 트래픽이 늘면 서버 자체를 늘려야 한다. 웹 서버라면 오래된 문제이고 답도 정해져 있는데, GPU에서는 같은 답이 잘 듣지 않는다. 늘리라고 말한 뒤 실제로 늘어나기까지 몇 분이 걸리기 때문이다.
복제본 하나가 늘어나는 데 걸리는 시간
CPU 컨테이너는 이미지가 수백 MB이고 프로세스가 뜨면 곧 요청을 받는다. 몇 초다. GPU 서빙 컨테이너는 사정이 다르다.
이 그림이 왜 중요한가 하면, 오토스케일링을 처음 붙이는 사람들이 대개 ①과 ②만 보고 설정을 만지기 때문이다. 그 둘은 스케일러의 설정값이라 줄이면 바로 줄어든다. 그런데 전체의 3분의 2는 ③~⑥에 있고, 그쪽은 설정으로 안 줄어든다.
줄이는 방법은 있다. 다만 전부 미리 해 두는 종류다.
| 무엇을 | 어떻게 | 얼마나 |
|---|---|---|
| ③ 노드 확보 | 여유 노드를 미리 띄워 둔다(웜 풀) | 120초 → 0 |
| ④ 이미지 | 노드 이미지에 컨테이너를 미리 넣어 둔다 | 90초 → 5초 |
| ⑤ 가중치 | 로컬 NVMe에 캐시해 둔다. 네트워크 스토리지에서 매번 읽지 않는다 | 70초 → 25초 |
| ⑥ 워밍업 | 준비 완료 신호(readiness)를 워밍업 뒤로 미룬다 | 시간은 그대로, 대신 덜 익은 서버로 요청이 안 간다 |
⑥에 손대는 방식이 조금 다르다는 점을 눈여겨볼 만하다. 워밍업 시간 자체는 줄지 않지만, 끝나기 전에 트래픽을 받지 않게 하는 것만으로 사용자가 겪는 지연은 확 달라진다. 준비 신호를 프로세스 기동에 걸어 두면 CUDA 그래프도 안 잡힌 서버가 첫 요청에서 몇 초를 잡아먹는다.
GPU 이용률로 스케일하면 안 되는 이유
지표를 무엇으로 삼을지가 다음 문제다. 가장 먼저 떠오르는 것이 GPU 이용률이고, 가장 자주 틀리는 것도 그것이다.
nvidia-smi가 말하는 이용률은 「최근 구간에 커널이 하나라도 돌고 있던 시간의 비율」이다. 카드를 얼마나 알차게 쓰는지가 아니라 놀지 않았는지를 잰다. 그래서 배칭이 잘 도는 추론 서버는 부하가 어중간할 때 이미 90%를 넘기고, 그 뒤로는 요청이 두 배가 되든 다섯 배가 되든 95% 언저리에 붙어 있다.
지표로서는 치명적이다. 정작 늘려야 할 구간에서 값이 안 움직인다. 임계를 80%로 잡으면 여유 있을 때 늘어나고, 90%로 잡으면 이미 밀린 뒤에야 늘어난다.
쓸 만한 지표는 밀린 정도를 직접 재는 것들이다.
| 지표 | 무엇을 말하나 | 주의 |
|---|---|---|
| 대기 중인 요청 수 | 지금 못 받고 있는 양 | 복제본 수로 나눠서 봐야 비교가 된다 |
| KV 캐시 사용률 | 동시 요청을 더 받을 자리가 있는가 | 90%를 넘으면 엔진이 요청을 밀어내기 시작한다 |
| 첫 토큰 지연(TTFT) | 사용자가 겪는 것 | 요청 길이에 따라 흔들려 잡음이 많다 |
| 초당 도착 요청 | 앞으로 올 부하 | 지연 지표보다 먼저 움직인다 |
실무에서는 대기열과 KV 캐시 사용률을 함께 보는 구성이 무난하다. 둘 다 엔진이 직접 내보내는 값이고(vLLM은 vllm:num_requests_waiting, vllm:gpu_cache_usage_perc), 포화 이후에도 계속 자란다.
몇 대가 필요한지 계산하기
늘리기로 정했다면 얼마나 늘릴 것인가. 대기열 길이만 보고 한 대씩 늘리면 7분짜리 콜드 스타트 동안 대기열이 계속 자라 과하게 늘어난다.
리틀의 법칙에서 시작하는 편이 낫다. 도착률 (초당 요청), 요청 하나의 평균 처리 시간 (초), 복제본 하나가 동시에 담을 수 있는 요청 수 라 하면 필요한 복제본 수는
는 목표 이용률이다. 초당 12건이 오고 요청 하나가 8초 걸리며 복제본이 동시에 24건을 담고 목표를 70%로 두면 이라 여섯 대다.
를 1에 가깝게 두지 않는 것이 중요하다. 꽉 채워 계산하면 도착이 조금만 몰려도 대기열이 폭발한다. 여유분이 곧 콜드 스타트 7분을 견디는 완충재이므로, 스케일이 느릴수록 를 낮게 잡아야 한다.
0까지 줄일 것인가
트래픽이 없는 시간대에 복제본을 0으로 내리면 비용이 확 준다. 개발 환경이나 사내 도구라면 대개 옳은 선택이다.
대신 0에서 1로 가는 요청은 7분을 기다린다. 사용자가 있는 서비스라면 못 쓴다. 절충안이 몇 가지 있다.
- 최소 1대 유지: 가장 흔하다. 첫 사용자가 겪는 지연이 사라지고, 비용은 한 대 값이다
- 시간대 예약: 트래픽 패턴이 뚜렷하면 지표를 기다리지 말고 시간표로 미리 늘린다. 출근 시각 20분 전에 늘리는 식이다
- 작은 모델로 받아 두기: 큰 모델이 뜨는 동안 작은 모델이 응답한다. 라우팅과 캐스케이드의 구조를 그대로 쓴다
세 번째는 구현이 복잡해 보이지만, 이미 라우팅 계층이 있다면 규칙 한 줄이다.
줄일 때 깨지는 것
늘리는 쪽만 신경 쓰다 줄이는 쪽에서 사고가 난다. LLM 서빙은 요청 하나가 수십 초 동안 토큰을 흘리는 작업이라 웹 요청과 다르다.
- 진행 중인 생성을 끊으면 사용자는 문장 중간에서 끊긴 답을 받는다. 재시도도 어렵다 — 이미 절반을 스트리밍으로 보냈기 때문이다
- KV 캐시는 그 복제본에만 있다. 다른 복제본으로 옮겨 이어 쓸 수 없다
- 프리픽스 캐시도 함께 사라진다. 오래 살아 캐시가 잘 쌓인 복제본을 내리면 남은 복제본의 적중률까지 떨어진다
그래서 줄일 때는 새 요청만 끊고 진행 중인 것은 끝까지 보낸다. 쿠버네티스라면 종료 신호를 받은 뒤 준비 신호를 내리고, terminationGracePeriodSeconds를 가장 긴 생성이 끝날 만큼(보통 5~10분) 넉넉히 준다. 이 값이 기본값 30초로 남아 있는 것이 흔한 실수다.
줄이는 속도도 늘리는 속도와 달라야 한다. 빨리 늘리고 천천히 줄인다. 잘못 늘리면 잠깐 돈이 더 들지만, 잘못 줄이면 7분 동안 복구가 안 된다.
정리
| 자리 | 자주 하는 선택 | 더 나은 선택 |
|---|---|---|
| 지표 | GPU 이용률 70% | 복제본당 대기열 + KV 캐시 사용률 |
| 늘리는 양 | 한 번에 한 대 | 리틀의 법칙으로 목표 대수를 계산 |
| 최소 복제본 | 0 | 사용자 서비스면 1 이상 |
| 종료 유예 | 30초 (기본값) | 가장 긴 생성 시간 이상 |
| 콜드 스타트 | 그대로 둔다 | 이미지·가중치를 노드에 미리 둔다 |
오토스케일링의 목적은 「자동으로 알아서」가 아니라 사람이 새벽에 안 깨는 것이다. 그러려면 늦게 반응하는 것을 전제로 여유분을 들고 있어야 한다. 여유를 0으로 깎은 자동 확장은 자동으로 장애를 만든다.
읽어주셔서 감사합니다. 😊

