0. Contents
1. Overview
위에서 아래로 시간 순서 — 회색 점선 상자를 동시 요청 단계마다 되풀이한다. 측정 컨테이너는 서버와 같은 네트워크라 127.0.0.1 로 닿고, 들어오는 포트는 없다
방향 변경 (2026-11-03)
- 이 글의 외부 개발자용 API 계획은 이후 접었다
- 측정 결과와 방법은 그대로 유효하고, 사용자 클러스터 애드온의 측정과 429 기준선의 원형이 된다
측정 목적: GPU 한 장의 동시 처리량
- 외부 개발자에게 LLM API 를 열기 전에 쟀다
- 잰 것: GPU 한 장이 약속한 속도를 지키며 동시에 몇 요청까지 처리하는지
- 클러스터를 만들기 전에 이 숫자가 있어야 정할 수 있는 것
- GPU 를 몇 장 둘지
- 한 달에 얼마인지
- 몇 요청부터 429 로 돌려보낼지
측정 환경
- AWS 서울 리전의 g6e.xlarge(NVIDIA L40S 1장) 한 대
- 모델 네 개를 vLLM 0.32.0 으로 띄웠다
- 동시 요청을 1 에서 64 까지 늘리며 첫 토큰 시간과 토큰 간격을 기록했다
| 모델 | SLA 초안 안 최대 동시 요청 |
|---|---|
| gpt-oss-20b | 32 |
| Qwen3-30B-A3B (FP8) | 16 (촘촘히 재면 20, KV 캐시 FP8 이면 24) |
| Qwen3-8B | 16 |
| Qwen3.8-27B (FP8) | 4 |
결과 요약
- 두 MoE 모델이 dense 모델보다 빨랐다
- 27B dense 모델은 메모리 대역폭에 막혀 한 장으로는 서빙용이 아니었다(7장)
- KV 캐시를 FP8 로 저장하니 같은 GPU 로 20% 를 더 받았다(8장)
- 품질은 이 글에서 재지 않았다
2. Background — 외부 개발자용 LLM API
1단계 숫자가 2단계 게이트웨이의 429 기준선과 3단계 자동 확장 기준이 된다
계획: 오픈소스 LLM API
- 오픈소스 LLM 을 우리 GPU 에 올린다
- 외부 개발자가 API 키로 OpenAI 처럼 쓰게 한다
단계 네 개
- 실측
- 전용 EKS 와 게이트웨이(키·한도·429)
- 자동 확장과 베타
- prefill·decode 분리 서빙 실험
순서: 클러스터보다 실측 먼저
- 2단계 이후의 설정값이 전부 1단계 숫자에서 나온다
- 게이트웨이가 몇 번째 요청부터 429 를 낼지
- 워커를 언제 늘릴지
- GPU 노드를 최대 몇 대까지 둘지
- 토스의 서빙 발표(SlashLive, 2026-10-14)도 배포 전에 “SLA 를 지키는 데 필요한 GPU 수와 비용” 을 먼저 계산한다고 했다
- 우리는 그 계산을 실제 GPU 한 대로 한다
GPU 선택: L40S(48GB)
- 7B~30B 급 모델이 한 장에 들어간다
- 서울 리전에 1장 단위 인스턴스(g6e.xlarge, 시간당 $2.29)가 있다
- H100 같은 P 계열은 8장 묶음뿐이라 1단계에는 맞지 않는다
3. SLA 초안 — 첫 토큰과 토큰 간격
약속은 둘 — 글자가 나오기 시작하는 시간, 그리고 글자가 나오는 속도
채팅형 API 의 약속: 두 숫자
- 채팅형 API 의 약속을 아래 두 숫자로 잡았다
| 지표 | 초안 | 뜻 |
|---|---|---|
| TTFT p95 | ≤ 1초 | 요청 후 첫 토큰까지. 1초 안에 글자가 나오기 시작하면 바로 반응한다고 느낀다 |
| ITL p95 | ≤ 50ms | 토큰과 토큰 사이. 초당 20토큰 이상이면 읽는 속도보다 빠르다 |
판정 기준: 두 조건 동시 충족
- 두 조건을 동시에 지키는 가장 높은 동시 요청 수를 “GPU 1장의 실력” 으로 본다
- p95 를 쓰는 이유: 평균이 아니라 대부분의 사용자가 겪는 쪽을 보기 위해서다
동시 요청 수와 사용자 수의 차이
- 동시 요청 수는 사용자 수가 아니다 — 같은 순간 답을 만들고 있는 요청의 수다
- 답 하나에 5초쯤 걸리고 한 사람이 1분에 한 번 묻는다면
- 동시 16 요청은 쉬지 않고 쓰는 사람 약 190명에 해당한다
- 몰리는 시간을 생각하면 여유를 둬야 한다
- 그래도 한 장이 수십~백 명대를 감당한다는 감은 이 계산에서 나온다
4. 측정 서버 — GPU 한 대와 비용 장치
정의 위치와 적용 방식
- 측정 서버는 별도 레포(kukie-gpu-infra)의 Terraform root
terraform/llm-bench로 만들었다 - 잠깐 쓰고 지울 실험 서버라 관리자 로컬 적용으로 두었다
| 항목 | 값 |
|---|---|
| 인스턴스 | g6e.xlarge 온디맨드, Deep Learning Base AMI (AL2023, NVIDIA 드라이버·Docker 포함) |
| 디스크 | gp3 200GB — 모델과 이미지를 남겨 다시 켤 때 1~2분 |
| 네트워크 | 전용 VPC, 들어오는 규칙 없음, 나가는 방향 TCP 443 만 |
| 접속 | SSM Session Manager |
| 권한 | SSM 관리형 정책이 여는 Parameter Store 읽기를 인라인 Deny 로 막음 — 서드파티 모델 코드가 돌기 때문 |
비용 장치: 두 겹
- GPU 는 켜 둔 시간만큼 돈이 나간다
- 끄는 걸 잊는 일을 막으려고 장치를 두 겹 두었다
중지는 GPU 요금이 멈추고 디스크(모델·이미지)는 남는다. 종료하면 디스크까지 사라진다
리뷰 수정: 타이머 값을 인스턴스 태그로
- 처음에는 타이머 분 값을 user_data 에 넣었다
- 리뷰 지적: “user_data 는 첫 부팅에만 실행되니 값을 바꿔도 서버에 반영되지 않는다”
- 지금은 분 값을 인스턴스 태그로 넘긴다
- 부팅마다 systemd 유닛이 IMDS에서 태그를 읽는다
- 값을 바꾸면 태그만 바뀌고 서버는 다시 만들어지지 않는다
첫 apply 중단
- 첫 apply 는 중간에 멈췄다
Terraform 은 이미 만든 IAM 네 개를 state 에 남겨, 두 번째 apply 는 나머지 아홉 개만 만들었다
원인: 서울 리전 VPC 한도
- 서울 리전의 VPC 한도는 기본 5개였고 이미 다 차 있었다
- 기한이 지난 실습 클러스터를 지우고 한도 증가를 요청했다 → 같은 날 승인됐다
- Terraform 은 실패 전에 만든 IAM 네 개를 state 에 남겨 둔다 → 두 번째 apply 는 나머지만 만들었다
5. 측정 방법 — 동시 요청 단계와 지표 기록
measure.sh 는 서버를 띄우지 않는다 — EC2 는 bench.sh, EKS 는 Job 이 같은 스크립트를 부른다
부하 생성: vllm bench serve
- 부하는 vLLM 에 들어 있는
vllm bench serve로 만들었다 - 요청은 무작위 문장이다
- 입력 약 1,000토큰
- 출력은
--ignore-eos로 정확히 200토큰
- 단계마다 요청을
max(40, 동시 수 × 8)개 보내 p95 를 낼 표본을 확보했다
vllm bench serve --model "$MODEL" --base-url "$BASE_URL" \
--dataset-name random --random-input-len 1000 --random-output-len 200 --ignore-eos \
--num-prompts "$prompts" --max-concurrency "$c" \
--percentile-metrics ttft,tpot,itl,e2el --metric-percentiles 50,95,99
서버 지표 기록
- 같은 시간 동안 서버의
/metrics에서 2초마다 기록했다- 대기 요청 수
- KV 캐시 사용률
- SLA 가 깨지기 직전의 이 두 값이 429 기준선 후보다
클라이언트 위치: 서버와 같은 호스트
- 측정 클라이언트는 서버와 같은 호스트에서 돌렸다
- 집에서 인터넷을 거쳐 재면 네트워크 지연이 TTFT 에 섞인다 → 엔진 실력과 구분되지 않는다
측정 스크립트와 서버의 분리
- 측정 스크립트(
measure.sh)는 서버를 띄우지 않고BASE_URL만 받는다 - EC2:
bench.sh가 vLLM 컨테이너를 띄운 뒤 이 스크립트를 부른다 - EKS: 같은 스크립트를 Job 으로 vLLM Service 에 돌린다(아직 구현 전)
6. 결과 — 모델 네 개
가로·세로 모두 로그 눈금. 빨간 점선이 SLA 초안 1초
토큰 간격은 완만하게 늘다가 한계에서 꺾인다. Qwen3.8-27B 는 출발점부터 높다
Qwen3-30B-A3B 의 연한 막대는 16~32 를 4씩 다시 잰 값(20)과 KV FP8 (24)
| 모델 | 구조 · 정밀도 | 크기 | 혼자일 때 ITL | 최대 동시 | 무엇에서 깨지나 |
|---|---|---|---|---|---|
| gpt-oss-20b | MoE (활성 3.6B) · MXFP4 | 약 13GB | 6.8ms | 32 | 64 에서 ITL |
| Qwen3-30B-A3B-Instruct-2507 | MoE (활성 3.3B) · FP8 | 약 31GB | 8.1ms | 16 → 20 | 32 에서 ITL |
| Qwen3-8B | dense · BF16 | 약 16GB | 22.5ms | 16 | 32 에서 TTFT |
| Qwen3.8-27B | dense (Mamba 하이브리드) · FP8 | 약 31GB | 40.3ms | 4 | 8 에서 TTFT |
깨지는 모양 두 가지
- Qwen3-8B 와 Qwen3.8-27B: 첫 토큰이 늦어지며 깨졌다
- 요청이 줄을 선다
- 두 MoE 모델: 토큰 간격이 튀며 깨졌다
- 새 요청의 입력 처리가 진행 중인 요청의 토큰 생성을 밀어낸다
Qwen3.8-27B 기동 실패: Mamba 캐시 블록 부족
- Qwen3.8-27B 는 처음에 아예 뜨지 않았다
- 하이브리드(Mamba) 구조라 동시 요청마다 Mamba 캐시 블록이 하나씩 필요하다
- vLLM 기본 최대 동시 수 256 이 이 GPU 에 남은 185 블록을 넘었다
--max-num-seqs 128로 다시 쟀다
7. 해석 — 메모리 대역폭과 KV 캐시
진한 막대 = 실측(동시 1, ITL p50). 연한 막대 = 읽는 바이트 ÷ 864GB/s 로 계산한 하한
동시 1 의 토큰 간격: 가중치 ÷ 대역폭
- 혼자 쓸 때(동시 1)의 토큰 간격은 “토큰 하나에 읽어야 하는 가중치 ÷ GPU 메모리 대역폭” 으로 거의 설명된다
- 다음 토큰을 만들려면 가중치를 처음부터 끝까지 한 번 읽어야 한다
- L40S 는 초당 864GB 를 읽는다
모델별 읽는 양과 하한
- Qwen3.8-27B
- 270억 개를 FP8(1바이트)로 전부 읽는다
- 27.8GB ÷ 864GB/s ≈ 32ms 가 하한이고 실측은 40ms 였다
- 기준 50ms 와의 여유가 처음부터 10ms 뿐이다 → 동시 요청을 거의 받지 못한다
- Qwen3-30B-A3B 와 gpt-oss-20b
- 전체는 크지만 토큰마다 일부 전문가(expert)만 읽는다
- 읽는 양이 3.3GB, 약 1.9GB 로 작아 하한이 몇 ms 다
- 실측이 하한의 2~3배인 것은 라우팅, 어텐션, KV 캐시 읽기 같은 고정 비용 때문이다
동시 요청이 늘 때의 한계: KV 캐시 자리
- 동시 요청이 늘면 다른 자원이 한계가 된다
- 요청마다 KV 캐시가 GPU 메모리를 차지한다
- Qwen3.8-27B: 가중치가 31GB 라 KV 캐시에 남은 자리가 약 9.3GB(11만 8천 토큰)뿐이었다
- 동시 64 에서 KV 사용률이 99% 에 닿았다
- gpt-oss-20b: 가중치가 13GB 라 같은 동시 64 에서도 8% 였다
정리: L40S 1장에 유리한 모델
- L40S 1장으로 많은 동시 요청을 받으려면 토큰마다 읽는 양이 작고(MoE·낮은 정밀도) 가중치가 작아 KV 캐시 자리가 넉넉한 모델이 유리하다
- 27B dense 모델은 품질이 확실히 좋다고 확인되면 GPU 두 장 구성(텐서 병렬)으로 따로 잰다
8. 엔진 설정 — KV 캐시 FP8
칸 안 숫자는 초당 출력 토큰. 초록 테두리 = SLA 안. KV FP8 만 24 를 지켰다
재측정 대상과 범위
- 모델: 가장 유력한 후보인 Qwen3-30B-A3B
- 기본값이 깨지던 구간(동시 16~32)을 4씩 다시 쟀다
| 설정 | 최대 동시 | 동시 24 초당 출력 토큰 |
|---|---|---|
| 기본값 | 20 | 723 |
--max-num-batched-tokens 2048 | 20 | 722 |
위 + --kv-cache-dtype fp8 | 24 | 880 |
prefill 분할: 차이 없음
- prefill 을 작게 나누는 설정은 차이가 없었다
- vLLM 이 이미 비슷한 값을 쓰는 것으로 보인다
KV 캐시 FP8: 20 → 24
- KV 캐시를 FP8 로 저장하니 같은 메모리에 두 배의 토큰이 들어갔다
- 대기 없이 받는 요청이 20 에서 24 로 늘었다
- 처리량은 22% 올랐다
- 설정마다 한 번씩만 쟀다 → 몇 % 차이는 오차일 수 있다
- 그래도 20 과 24 의 차이는 분명했다 → 배포 인자 후보로 둔다
9. 리뷰에서 고친 것 — 네트워크 네임스페이스와 pipefail
측정 스크립트 PR 리뷰
- 측정 스크립트 PR 의 리뷰에서 두 가지를 고쳤다
hop limit 1 은 브리지를 한 번 더 지나는 컨테이너만 막는다 — host 네트워크는 그 장치를 비껴간다
측정 컨테이너의 네트워크
- 처음에는 측정 컨테이너를
--network host로 띄웠다- 서버 주소(
127.0.0.1:8000)에 바로 닿게 하려는 것이었다
- 서버 주소(
- 문제: host 네트워크 컨테이너는 호스트와 같은 홉에 있어 IMDS hop limit 1 장치를 비껴간다
- 서드파티 이미지 안의 코드가 인스턴스 역할을 읽을 수 있는 구성이었다
- 수정: 서버 컨테이너의 네트워크 네임스페이스를 공유하는
--network container:vllm으로 바꿨다- 같은 주소를 쓰면서 브리지 뒤에 남는다
지표 기록이 조용히 멈추는 문제
- 지표를 2초마다 기록하는 루프가 스크립트의
set -euo pipefail을 물려받았다 curl이 한 번 실패하거나grep이 한 줄도 못 찾으면 → 그 단계 동안 기록이 끊겼다- 오류 없이 표에 “-” 만 찍힌다
- 이 값이 429 기준선의 근거라 루프 안의 파이프라인 끝에
|| true를 붙였다
curl -s "$BASE_URL/metrics" |
grep -E '^vllm:(num_requests_waiting|kv_cache_usage_perc)' |
sed "s/^/$(date +%s) /" || true
10. 다음 단계
속도 다음은 품질 — 그 뒤 같은 측정을 클러스터 안 Job 으로
이후 진행 (2026-11-03 기준)
| 항목 | 상태 |
|---|---|
| 품질 비교 | 공개 지수로 먼저 비교했다. 격차가 커서 방향을 바꿨다. 한국어 질문 세트는 아직 없다 |
| 첫 모델 | gpt-oss-20b 로 Kukie GPU 화면 데모와 오퍼레이터 샘플을 구성했다. 카탈로그 선정은 아직이다 |
전용 EKS kukie-gpu | 적용됐다(GPU 노드 0~2대, 평소 0대) |
measure.sh 의 Job 전환 | 구현 전. 애드온 2층의 측정 Job 이 된다 |
| 429 기준선 | 구현 전. 게이트웨이가 아니라 애드온 어드미션에서 쓴다 |
측정 비용
- 측정 서버를 켜 둔 시간: 약 2시간 7분
- GPU 비용: 약 $4.9
관련 문서
- TTFT — 첫 토큰까지의 시간
- ITL — 토큰 사이 간격
- Concurrency — 동시 요청과 지연이 꺾이는 지점
- Goodput — SLA 를 지킨 요청만 센 처리량
- Percentile — p95 로 읽는 법
- KV 캐시 — 요청마다 차지하는 메모리와 FP8
- MoE — 전체 파라미터와 활성 파라미터
- IMDS — hop limit 과 컨테이너
