LLM API 부하 테스트(Load Testing LLM APIs) — k6와 Locust가 거짓말을 하는 이유
전통적인 부하 테스터(load tester)는 스트리밍 응답(streaming response), 가변 출력 길이(variable output length), 토큰 단위 지표(token-level metric), GPU 포화(GPU saturation)를 위해 설계되지 않았습니다. 대부분의 팀은 두 가지 함정에 걸립니다. 첫째는 GIL 함정(GIL trap)입니다. Locust의 토큰 단위 측정은 Python GIL 아래에서 토크나이징(tokenizing)을 실행하므로, 높은 동시성(high concurrency) 환경에서는 토크나이저가 요청 생성과 자원을 두고 경쟁합니다. 그 결과 토크나이징 백로그(tokenization backlog)가 쌓이면서 보고되는 토큰 간 지연(inter-token latency)이 부풀려집니다. 느린 쪽은 서버가 아니라 클라이언트입니다. 둘째는 프롬프트 균질성 함정(prompt-uniformity trap)입니다. 동일한 프롬프트를 반복문(loop)으로 보내면 토큰 분포(token distribution)의 한 지점만 시험하게 됩니다. 실제 트래픽(traffic)은 길이도 다양하고 접두 일치(prefix match)도 다양합니다. LLMPerf는 --mean-input-tokens + --stddev-input-tokens 옵션으로 이 문제를 고칩니다. 2026년의 도구 매핑(tool mapping)은 다음과 같습니다. 토큰 단위 정확도가 필요할 때는 LLM 특화 도구(LLM-specialized; GenAI-Perf, LLMPerf, LLM-Locust, guidellm)를 씁니다. k6 v2026.1.0과 k6 Operator 1.0 GA(2025년 9월) 조합은 스트리밍을 인식(streaming-aware)하고, TestRun/PrivateLoadZone CRD를 통한 Kubernetes 네이티브 분산 실행을 지원하므로 CI/CD 게이트(gate)에 가장 적합합니다. Vegeta는 Go 기반의 고정 속도 포화(constant-rate saturation) 테스트에 적합합니다. Locust 2.43.3은 스트리밍을 다룰 때 LLM-Locust 확장(extension)을 붙여야만 사용할 수 있습니다. 부하 패턴(load pattern)은 정상 상태(steady-state), 램프(ramp), 스파이크(spike; 자동 확장 테스트), 소크(soak; 메모리 누수 탐지) 네 가지입니다.
유형: Build
언어: Python (표준 라이브러리, 토이 수준의 현실적 프롬프트 생성기와 지연 시간 수집기)
선수 지식: Phase 17 · 08 (Inference Metrics), Phase 17 · 03 (GPU Autoscaling)
예상 시간: 약 75분
학습 목표
- 범용 부하 테스터가 LLM API에서 거짓말을 하게 만드는 두 가지 안티패턴(anti-pattern), 즉 GIL 함정과 프롬프트 균질성 함정을 설명합니다.
- 목적에 맞는 도구를 고릅니다. 성능 측정(benchmark run)에는 LLMPerf, CI 게이트에는 k6 + 스트리밍 확장, 대규모 합성 부하(large-scale synthetic)에는 guidellm, NVIDIA 레퍼런스 측정에는 GenAI-Perf를 선택합니다.
- 네 가지 부하 패턴(정상 상태, 램프, 스파이크, 소크)을 설계하고 각 패턴이 잡아내는 실패 모드(failure mode)를 짚어냅니다.
- 고정 길이가 아니라 입력 토큰의 평균(mean)과 표준편차(stddev)를 사용해 현실적인 프롬프트 분포(realistic prompt distribution)를 구성합니다.
문제
k6로 LLM 엔드포인트(endpoint)를 동시 사용자 500명 조건에서 시험했습니다. 잘 버텼습니다. 그래서 그대로 배포(ship)했습니다. 그런데 운영 환경에서 실제 사용자가 200명만 들어왔는데도 서비스가 무너졌습니다. P99 TTFT(첫 토큰까지의 시간; Time To First Token)가 폭발했고 GPU는 100%로 고정(pinned)되었습니다.
두 가지 일이 동시에 일어났기 때문입니다. 첫째, k6는 동일한 프롬프트(identical prompt) 500개를 보냈습니다. 요청 합치기(request coalescing)와 접두 캐시(prefix caching) 덕분에 실제로는 디코드(decode) 하나만 처리하고 있는데도 마치 500개의 동시 디코드를 처리하는 것처럼 보였습니다. 둘째, k6는 스트리밍 응답의 토큰 간 지연 시간을 사용자가 체감하는 방식으로 추적하지 않습니다. 다양한 간격으로 도착하는 500개의 토큰이 아니라 하나의 HTTP 연결로만 인식합니다.
LLM 부하 테스트는 그 자체로 별도의 분야(discipline)입니다.
개념
GIL 함정(GIL trap; Locust)
Locust는 Python으로 작성되어 있고, 클라이언트 쪽 토크나이징을 GIL(전역 인터프리터 락; Global Interpreter Lock) 아래에서 수행합니다. 동시성이 높아질수록 토크나이저는 요청 생성 뒤에서 차례를 기다리게 됩니다. 그 결과 보고되는 토큰 간 지연 시간에는 클라이언트 쪽 토크나이징 백로그가 섞여 들어갑니다. 사용자는 서버가 느리다고 생각하지만, 실제 병목(bottleneck)은 테스트 하니스(test harness) 자체입니다.
해결책은 두 가지입니다. LLM-Locust 확장을 사용해 토크나이징을 별도 프로세스로 분리하거나, 컴파일 언어 기반 하니스(compiled-language harness; k6 또는 tokenizers.rs를 사용하는 LLMPerf)를 쓰는 것입니다.
알려진 부하 테스터 대부분은 프롬프트를 하나만 설정하도록 되어 있습니다. 1만 회 반복 시험을 돌리면 정확히 같은 프롬프트가 매번 전송됩니다. 서버는 매번 같은 접두(prefix)를 보게 되고, 접두 캐시 적중률(prefix cache hit)은 100%에 가까워지며, 처리량(throughput)은 실제보다 훨씬 좋아 보이게 됩니다.
해결책은 프롬프트 분포에서 표본 추출(sample)하는 것입니다. LLMPerf는 --mean-input-tokens 500 --stddev-input-tokens 150처럼 평균과 표준편차를 옵션으로 받아 길이와 내용을 다양하게 만듭니다.
네 가지 부하 패턴
- 정상 상태(Steady-state) — 30~60분 동안 일정한 RPS(초당 요청 수; Requests Per Second)를 유지합니다. 기준 성능 회귀(baseline performance regression)를 잡아냅니다.
- 램프(Ramp) — 15분 동안 RPS를 0에서 목표치까지 선형으로 증가시킵니다. 용량 한계점(capacity breakpoint)과 워밍업 이상(warm-up anomaly)을 잡아냅니다.
- 스파이크(Spike) — 2분 동안 갑작스럽게 3~10배의 RPS를 부여한 뒤 원래 수준으로 되돌립니다. 자동 확장 지연(autoscaling latency), 큐 포화(queue saturation), 콜드 스타트 영향(cold-start impact)을 잡아냅니다.
- 소크(Soak) — 4~8시간 동안 정상 상태를 유지합니다. 메모리 누수(memory leak), 연결 풀 누수(connection-pool drift), 관측 데이터 폭주(observability overflow) 같은 장기 결함을 잡아냅니다.
2026년 도구 매핑
LLMPerf(Anyscale) — Python이지만 토크나이징은 Rust 기반(Rust-backed)으로 처리합니다. 평균/표준편차 기반 프롬프트와 스트리밍 인식을 지원합니다. 성능 측정 실행의 기본 선택지로 가장 적합합니다.
NVIDIA GenAI-Perf — NVIDIA의 레퍼런스 도구입니다. Triton 클라이언트를 사용하며 지표 적용 범위(metric coverage)가 포괄적입니다. 이 도구의 ITL(토큰 간 지연; Inter-Token Latency)은 TTFT를 제외하지만, LLMPerf의 ITL에는 TTFT가 포함된다는 점에 주의해야 합니다. 같은 서버를 측정해도 두 도구는 서로 다른 TPOT(출력 토큰당 시간; Time Per Output Token) 값을 보고합니다.
LLM-Locust(TrueFoundry) — GIL 함정을 해결한 Locust 확장입니다. 익숙한 Locust DSL을 그대로 쓰면서 스트리밍 지표를 함께 얻을 수 있습니다.
guidellm — 대규모 합성 부하 벤치마킹을 위한 도구입니다.
k6 v2026.1.0 + k6 Operator 1.0 GA(2025년 9월):
- k6 본체는 Go로 작성되어 컴파일된 바이너리이며 GIL이 없습니다. 이번 버전에서 스트리밍 인식 지표가 추가되었습니다.
- k6 Operator는 TestRun과 PrivateLoadZone CRD를 사용해 Kubernetes 네이티브 분산 테스트(distributed testing)를 제공합니다.
- CI/CD 게이트와 SLA(서비스 수준 합의; Service Level Agreement) 테스트에 가장 잘 어울립니다.
Vegeta — Go 기반이며 k6보다 단순합니다. 고정 속도 HTTP 포화 테스트에 강합니다. LLM 인식 기능은 없지만 게이트웨이(gateway)나 속도 제한(rate-limit) 테스트에는 좋은 선택입니다.
Locust 2.43.3 기본 버전(stock) — LLM 측정에는 GIL 함정이 그대로 남아 있습니다. LLM-Locust 확장을 붙였을 때에만 사용해야 합니다.
CI에서의 SLA 게이트
PR(Pull Request)에서 k6를 실행합니다.
- 기준 RPS로 각각 30~50회 반복 실행합니다.
- 게이트 조건: P50/P95 TTFT, 5xx 오류율 5% 미만, TPOT가 임계값(threshold) 아래일 것.
- 위반이 발생하면 빌드를 실패시킵니다.
현실적인 프롬프트 분포
실제 트래픽 표본이 있다면 그 자료로 구성합니다. 없다면 공개된 분포(published distribution)를 사용합니다. 채팅용은 ShareGPT 프롬프트, 코드용은 HumanEval 같은 자료를 활용할 수 있습니다. 그 분포의 평균과 표준편차를 LLMPerf에 입력합니다. 단일 프롬프트를 반복하는 형태(loop-with-one-prompt)는 반드시 피합니다.
기억해 두어야 할 숫자
- k6 Operator 1.0 GA: 2025년 9월.
- k6 v2026.1.0: 스트리밍 인식 지표.
- 전형적인 LLMPerf 실행: 특정 동시성에서 100~1000개의 요청.
- 전형적인 CI 게이트: PR마다 30~50회 반복 실행.
- 네 가지 패턴: 정상 상태, 램프, 스파이크, 소크.
사용해보기
code/main.py는 현실적인 프롬프트 분포를 가진 부하 테스트를 시뮬레이션하고, 유효 TPOT(effective TPOT)를 측정하며, 균일 프롬프트(uniform-prompt) 함정을 시각적으로 보여줍니다.
산출물 만들기
이 강의는 outputs/skill-load-test-plan.md를 만듭니다. 워크로드(workload)와 SLA가 주어졌을 때 도구를 선택하고 네 가지 부하 패턴을 설계하는 역할을 합니다.
연습문제
- 쉬움:
code/main.py를 실행합니다. 균일 분포와 현실적 분포를 비교했을 때, 격차(gap)가 어디에서 발생하는지 설명합니다.
- 중간: CI 게이트용 k6 스크립트를 작성합니다. 조건은 동시성 100, TTFT P95 800ms 미만, 실행 시간 5분입니다.
- 중간: 소크 테스트에서 메모리가 시간당 50MB씩 증가합니다. 가능한 원인 세 가지를 제시하고, 어떤 계측(instrumentation)으로 원인을 구분할 수 있는지 설명합니다.
- 어려움: 스파이크 테스트로 10 RPS에서 100 RPS까지 끌어올립니다. Karpenter와 vLLM production-stack이 함께 배치되어 있다면 예상되는 복구 시간(recovery time)은 얼마입니까? (Phase 17 · 03 + 18)
- 어려움: 같은 서버에서 GenAI-Perf는 TPOT=6ms, LLMPerf는 TPOT=11ms를 보고합니다. 이 차이가 발생하는 이유를 설명합니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| LLMPerf | "the LLM harness" | 스트리밍을 인식하는 Anyscale의 벤치마크 도구 |
| GenAI-Perf | "NVIDIA tool" | NVIDIA가 제공하는 레퍼런스 하니스 |
| LLM-Locust | "Locust for LLMs" | GIL 함정을 해결한 Locust 확장 |
| guidellm | "synthetic benchmark" | 대규모 합성 부하 도구 |
| k6 Operator | "K8s k6" | CRD 기반 분산 k6 |
| GIL 함정(GIL trap) | "Python client overhead" | 토크나이징 백로그가 보고 지연 시간을 부풀리는 문제 |
| 프롬프트 균질성 함정(Prompt-uniformity trap) | "single-prompt lie" | 같은 프롬프트 반복으로 캐시 적중률이 올라가 처리량이 부풀려지는 문제 |
| 정상 상태(Steady-state) | "constant load" | N분 동안 평탄한 RPS를 유지 |
| 램프(Ramp) | "linear up" | 주어진 시간 동안 0에서 목표치까지 선형 증가 |
| 스파이크(Spike) | "burst test" | 갑작스러운 배수 증가 후 원상 복귀 |
| 소크(Soak) | "long test" | 누수 탐지를 위한 수 시간 단위의 장기 테스트 |
더 읽을거리