- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며
- 핵심 원리: 추론은 두 단계로 이루어진다
- 깊이 들어가기 1: continuous batching
- 깊이 들어가기 2: paged KV 캐시
- 프레임워크 비교
- 선택 가이드
- 실무: 배포 설정 예시
- 깊이 들어가기 3: 양자화와 서빙
- 깊이 들어가기 4: 병렬화 — 모델이 GPU 한 장에 안 들어갈 때
- 실무: 오토스케일링과 관측성
- 워크로드 측정: 처리량을 가늠하는 법
- 함정과 트러블슈팅
- 마치며
- 참고 자료
들어가며
LLM을 학습하는 것과 서빙하는 것은 완전히 다른 문제입니다. 학습은 한 번 잘 끝내면 되지만, 서빙은 매 순간 사용자의 요청을 받아 토큰을 뽑아내야 합니다. 같은 GPU를 쓰더라도 서빙 스택을 어떻게 구성하느냐에 따라 처리량(throughput)이 몇 배씩 차이가 납니다. 동일한 H100 한 장으로 초당 수백 토큰을 내는 사람도 있고, 수천 토큰을 내는 사람도 있습니다.
그 차이를 만드는 것이 바로 추론 서빙 엔진입니다. 2026년 현재 실무에서 가장 많이 거론되는 세 가지가 vLLM, SGLang, TensorRT-LLM입니다. 이 글에서는 먼저 LLM 추론이 왜 까다로운지 그 근본 원리를 짚고, 세 엔진이 각각 어떤 철학으로 이 문제를 푸는지 비교한 뒤, 실제로 무엇을 선택하고 어떻게 배포할지까지 정리하겠습니다.
이 글의 목표는 "무조건 이게 정답"이라고 말하는 것이 아닙니다. 워크로드의 성격에 따라 정답이 달라지기 때문입니다. 그 판단 기준을 세우는 것이 핵심입니다.
핵심 원리: 추론은 두 단계로 이루어진다
LLM이 텍스트를 생성하는 과정은 성격이 전혀 다른 두 단계로 나뉩니다. 이 구분을 이해하지 못하면 서빙 최적화의 절반을 놓치게 됩니다.
prefill 단계
prefill은 입력 프롬프트 전체를 한 번에 처리하는 단계입니다. 사용자가 보낸 프롬프트가 1,000개의 토큰이라면, 이 1,000개를 동시에 어텐션에 통과시켜 각 위치의 KV(key/value)를 계산하고 첫 출력 토큰을 만들어냅니다.
prefill은 입력 토큰 수만큼의 연산을 병렬로 수행할 수 있습니다. 즉 GPU의 연산 유닛을 가득 채울 수 있어서 연산 바운드(compute-bound) 성격이 강합니다. 행렬 곱이 크고 빽빽하게 일어나기 때문입니다.
decode 단계
decode는 토큰을 한 개씩 차례로 생성하는 단계입니다. 한 토큰을 만들려면 직전까지 만들어진 모든 토큰의 KV를 읽어와 어텐션을 계산해야 합니다. 그런데 매 스텝에서 새로 처리하는 토큰은 단 하나뿐입니다.
문제는 여기에 있습니다. 토큰 하나를 만들기 위해 모델의 전체 가중치(수십 GB)와 그동안 쌓인 KV 캐시를 메모리에서 읽어와야 하는데, 정작 계산량은 토큰 하나에 대한 것뿐입니다. 즉 **메모리 바운드(memory-bound)**입니다. GPU의 연산 유닛은 대부분 놀고, 메모리 대역폭이 병목이 됩니다.
prefill: 입력 N 토큰을 한 번에 처리 → 연산 바운드 (GPU 연산 유닛 포화)
decode: 토큰 1개씩 생성 → 메모리 바운드 (가중치/KV 읽기가 병목)
한 요청의 생애:
[프롬프트] --prefill--> [첫 토큰] --decode--> [토큰] --decode--> ... [EOS]
이 두 단계의 성격 차이가 서빙 최적화의 모든 것을 결정합니다. decode가 메모리 바운드이기 때문에 배칭으로 여러 요청을 묶어 GPU를 채우고, 양자화로 메모리 읽기량을 줄이며, KV 캐시를 효율적으로 관리하는 것이 그렇게나 중요한 것입니다.
깊이 들어가기 1: continuous batching
전통적인 배칭(static batching)은 여러 요청을 한 묶음으로 모아 동시에 처리하고, 묶음 안의 모든 요청이 끝날 때까지 기다린 다음 다음 묶음을 시작합니다. 문제는 요청마다 생성 길이가 천차만별이라는 점입니다. 어떤 요청은 토큰 10개, 어떤 요청은 1,000개를 만듭니다. static batching에서는 짧은 요청이 일찍 끝나도 긴 요청을 기다리느라 GPU가 놀게 됩니다.
continuous batching(in-flight batching이라고도 부릅니다)은 이 문제를 우아하게 해결합니다. 매 디코드 스텝마다 배치를 동적으로 재구성합니다. 끝난 요청은 즉시 배치에서 빼고, 대기 중이던 새 요청을 그 자리에 넣습니다.
static batching (낭비 발생):
스텝→ 1 2 3 4 5 6 7 8
req A ■ ■ ✓ . . . . . ← 3스텝에 끝났지만 슬롯이 8까지 묶임
req B ■ ■ ■ ■ ■ ■ ■ ✓
req C ■ ■ ■ ✓ . . . . ← 빈 슬롯이 GPU를 놀림
continuous batching (슬롯 즉시 재활용):
스텝→ 1 2 3 4 5 6 7 8
req A ■ ■ ✓
req D ■ ■ ■ ✓ ← A가 비운 자리에 D 투입
req B ■ ■ ■ ■ ■ ■ ■ ✓
req C ■ ■ ■ ✓
req E ■ ■ ■ ■ ← C가 비운 자리에 E 투입
2026년 현재 continuous batching은 모든 주요 서빙 엔진의 표준 기능입니다. 이것이 처리량을 끌어올리는 가장 기본적이면서도 강력한 기법입니다.
깊이 들어가기 2: paged KV 캐시
decode 단계에서 KV 캐시는 시퀀스가 길어질수록 계속 커집니다. 전통적인 방식은 각 요청에 대해 "최대 길이"만큼의 연속된 메모리 블록을 미리 잡아둡니다. 그런데 실제 생성 길이는 미리 알 수 없습니다. 최대 2,048 토큰을 잡아뒀는데 실제로는 50 토큰만 생성하면, 나머지 공간은 통째로 낭비됩니다.
PagedAttention은 운영체제의 가상 메모리 개념을 KV 캐시에 적용합니다. KV 캐시를 작은 고정 크기 블록(page)으로 나누고, 필요할 때마다 블록을 할당합니다. 논리적으로는 연속이지만 물리적으로는 흩어져 있어도 됩니다. 블록 테이블이 논리 위치와 물리 위치를 매핑합니다.
전통 방식 (연속 사전 할당):
req A: [■■■□□□□□□□□□□□□□] ← 16칸 잡고 3칸만 사용, 13칸 낭비
req B: [■■■■■□□□□□□□□□□□] ← 16칸 잡고 5칸만 사용
paged 방식 (블록 단위 동적 할당):
블록 풀: [b0][b1][b2][b3][b4][b5]...
req A 블록테이블: b0 -> b3 (필요한 만큼만)
req B 블록테이블: b1 -> b2 -> b4 (물리적으로 흩어져도 OK)
이 방식의 효과는 두 가지입니다. 첫째, 메모리 단편화가 거의 사라져 같은 GPU 메모리로 훨씬 많은 동시 요청을 처리할 수 있습니다. 둘째, 블록 단위로 관리하므로 여러 요청이 같은 prefix를 공유할 때 그 블록을 공유할 수 있습니다. PagedAttention은 vLLM이 처음 대중화한 기법으로, 지금은 사실상 표준이 되었습니다.
프레임워크 비교
이제 세 엔진을 하나씩 살펴봅니다. 각자 출발점과 강점이 다릅니다.
vLLM
vLLM은 PagedAttention을 세상에 알린 프로젝트이자, 가장 범용적인 선택지입니다. 폭넓은 모델 아키텍처를 빠르게 지원하고, 다양한 하드웨어(NVIDIA뿐 아니라 AMD, 그 외 가속기)에서 돌아갑니다. OpenAI 호환 API 서버를 기본 제공해 통합이 쉽습니다.
특징은 "균형"입니다. 어떤 한 가지 워크로드에 극단적으로 최적화하기보다, 다양한 상황에서 두루 좋은 성능을 냅니다. 새 모델이 나오면 가장 먼저 지원되는 경우가 많아, 최신 모델을 빠르게 띄워야 할 때 든든합니다. 잘 튜닝된 TensorRT-LLM과 비교하면 특정 시나리오에서 처리량이 다소 낮을 수 있지만, 그 차이는 보통 크지 않습니다.
TensorRT-LLM
TensorRT-LLM은 NVIDIA가 만든 컴파일 기반 엔진입니다. 모델을 NVIDIA GPU에 최적화된 엔진으로 사전 컴파일하여, 커널 융합과 정밀도 최적화를 극단까지 밀어붙입니다. 그 결과 잘 지원되는 모델과 적절히 튜닝된 설정에서는 H100 같은 NVIDIA GPU에서 vLLM 대비 대략 15~30% 더 높은 처리량을 보이는 경우가 보고됩니다(모델과 설정에 따라 편차가 큽니다).
대가는 유연성입니다. 엔진을 컴파일하는 단계가 필요하고, 새 모델이나 특이한 구성에 대한 지원이 vLLM만큼 빠르지 않을 수 있습니다. 또한 NVIDIA 생태계에 묶입니다. "정해진 모델을 NVIDIA GPU에서 최대 효율로 오래 돌릴 것"이라면 강력한 선택입니다.
SGLang
SGLang은 RadixAttention이라는 기법으로 prefix 캐시 재사용을 극대화합니다. 여러 요청이 공통된 앞부분(시스템 프롬프트, few-shot 예시, 멀티턴 대화 히스토리)을 가질 때, 그 부분의 KV 계산을 재사용합니다. RadixAttention은 prefix들을 radix tree로 관리해 공유 가능한 부분을 자동으로 찾아냅니다.
그래서 SGLang은 멀티턴 대화나 동일 시스템 프롬프트를 반복 사용하는 에이전트 워크로드에서 특히 빛납니다. 이런 환경에서는 prefix 재사용 덕에 대략 10~20% 수준의 추가 이득을 얻는 경우가 있습니다(공유되는 prefix의 비율에 크게 좌우됩니다). 구조화된 생성과 복잡한 프롬프트 프로그램에도 강합니다.
비교 테이블
| 항목 | vLLM | TensorRT-LLM | SGLang |
|---|---|---|---|
| 핵심 강점 | 범용성, 넓은 모델/하드웨어 지원 | 컴파일 최적화, 최고 처리량 | RadixAttention prefix 재사용 |
| 처리량 | 기준선 (높음) | NVIDIA에서 약 15~30% 더 높음 | prefix 공유 많으면 추가 이득 |
| 하드웨어 | NVIDIA, AMD 등 다양 | NVIDIA 중심 | 주로 NVIDIA |
| 신규 모델 지원 | 매우 빠름 | 상대적으로 느릴 수 있음 | 빠름 |
| 설정 난도 | 낮음 | 컴파일 단계로 다소 높음 | 중간 |
| 적합 워크로드 | 범용, 빠른 프로토타이핑 | 고정 모델 대규모 운영 | 멀티턴, 에이전트, 공유 prefix |
선택 가이드
표만 봐서는 막막할 수 있으니, 실제 상황별로 정리합니다.
- 빠르게 띄우고 다양한 모델을 실험해야 한다면: vLLM. 통합이 쉽고 거의 모든 모델이 바로 돕니다.
- 모델이 고정되어 있고 NVIDIA GPU에서 비용을 최대한 짜내야 한다면: TensorRT-LLM. 컴파일 비용을 감수할 가치가 있습니다.
- 멀티턴 챗봇이나 에이전트처럼 prefix가 많이 겹친다면: SGLang. RadixAttention의 효과가 큽니다.
- 잘 모르겠고 일단 시작해야 한다면: vLLM으로 시작하고, 병목이 명확해지면 다른 엔진을 벤치마크하세요.
중요한 것은 본인의 워크로드로 직접 측정하는 것입니다. 남의 벤치마크 수치는 시작점일 뿐, 입력/출력 길이 분포와 동시성 수준에 따라 결과가 크게 달라집니다.
실무: 배포 설정 예시
다음은 vLLM의 OpenAI 호환 서버를 띄우는 예시입니다.
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Llama-3.1-8B-Instruct \
--max-model-len 8192 \
--gpu-memory-utilization 0.90 \
--max-num-seqs 256 \
--enable-chunked-prefill
쿠버네티스에 배포한다면 다음과 같은 Deployment 형태가 됩니다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: vllm-llama
spec:
replicas: 2
selector:
matchLabels:
app: vllm-llama
template:
metadata:
labels:
app: vllm-llama
spec:
containers:
- name: vllm
image: vllm/vllm-openai:latest
args:
- "--model"
- "meta-llama/Llama-3.1-8B-Instruct"
- "--gpu-memory-utilization"
- "0.90"
- "--max-num-seqs"
- "256"
resources:
limits:
nvidia.com/gpu: "1"
ports:
- containerPort: 8000
SGLang 서버 역시 비슷하게 띄울 수 있습니다.
python -m sglang.launch_server \
--model-path meta-llama/Llama-3.1-8B-Instruct \
--mem-fraction-static 0.85 \
--context-length 8192
핵심 튜닝 파라미터는 공통적으로 세 가지입니다. GPU 메모리 사용 비율(높일수록 KV 캐시 공간이 늘어 동시성 증가), 최대 동시 시퀀스 수, 최대 컨텍스트 길이입니다. 메모리 사용 비율을 너무 높이면 갑작스러운 긴 요청에서 OOM이 날 수 있으니 여유를 둬야 합니다.
깊이 들어가기 3: 양자화와 서빙
decode가 메모리 바운드라는 사실을 다시 떠올리면, 메모리에서 읽어오는 데이터의 양을 줄이는 모든 기법이 곧 속도 향상으로 이어집니다. 양자화가 바로 그것입니다. 가중치를 FP16 대신 더 낮은 정밀도로 저장하면, 같은 가중치를 읽는 데 필요한 메모리 대역폭이 줄어듭니다.
정밀도별 가중치 메모리 (8B 모델 가정, 근삿값):
FP16 : 원소당 2바이트 -> 약 16 GB
INT8 : 원소당 1바이트 -> 약 8 GB
INT4 : 원소당 0.5바이트 -> 약 4 GB
decode는 매 토큰마다 가중치를 읽으므로,
읽을 데이터가 절반이면 메모리 병목이 절반으로 완화.
2026년 현재 서빙에서 자주 쓰이는 정밀도는 FP8과 INT4입니다. FP8은 H100 이후 세대 GPU의 하드웨어 지원을 받아 정확도 손실이 작으면서도 처리량 이득이 큽니다. INT4는 메모리를 가장 많이 아끼지만, 정밀도 손실이 더 커서 작업에 따라 품질을 검증해야 합니다.
양자화 선택의 직관:
품질 최우선 -> FP16 또는 FP8
메모리/비용 최우선 -> INT4 (품질 검증 필수)
균형 -> FP8 (하드웨어 지원 시 유리)
중요한 점은, 양자화가 가중치만이 아니라 KV 캐시에도 적용된다는 것입니다. 긴 컨텍스트와 높은 동시성에서는 KV 캐시가 메모리의 큰 비중을 차지하므로, KV를 FP8로 저장하면 동시성을 더 끌어올릴 수 있습니다. 다만 가중치 양자화와 KV 양자화는 별개의 설정이며, 둘 다 켜면 효과가 누적되는 경향이 있습니다.
깊이 들어가기 4: 병렬화 — 모델이 GPU 한 장에 안 들어갈 때
모델이 커서 GPU 한 장의 메모리에 들어가지 않으면, 여러 GPU에 모델을 나눠야 합니다. 서빙에서 자주 쓰는 두 가지 방식이 텐서 병렬(tensor parallel)과 파이프라인 병렬(pipeline parallel)입니다.
텐서 병렬 (tensor parallel, TP):
한 레이어의 가중치 행렬을 여러 GPU에 가로로 쪼갬.
각 GPU가 부분 계산을 하고 결과를 합침(all-reduce 통신).
GPU 간 통신이 잦아 빠른 인터커넥트(NVLink)가 중요.
[GPU0: 가중치 절반] --合-- [GPU1: 가중치 절반]
같은 레이어를 둘이 나눠 계산
파이프라인 병렬 (pipeline parallel, PP):
레이어들을 그룹으로 나눠 GPU마다 다른 레이어를 맡김.
GPU0이 앞 레이어, GPU1이 뒤 레이어를 처리.
통신은 적지만 파이프라인 버블(놀는 구간)이 생길 수 있음.
[GPU0: 1~16층] --전달--> [GPU1: 17~32층]
실무 직관은 다음과 같습니다. 한 노드 안의 GPU들처럼 인터커넥트가 빠르면 텐서 병렬이 유리합니다. 통신이 잦아도 빠른 링크가 받쳐주기 때문입니다. 노드를 넘어가는 경우처럼 링크가 느리면, 통신이 적은 파이프라인 병렬이나 두 방식의 조합을 고려합니다. 대부분의 서빙 엔진은 텐서 병렬 차수(예: TP=2, TP=4)를 설정 한 줄로 지정할 수 있습니다.
# vLLM에서 텐서 병렬 4로 큰 모델 서빙
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Llama-3.1-70B-Instruct \
--tensor-parallel-size 4 \
--gpu-memory-utilization 0.90
병렬화는 공짜가 아닙니다. 통신 오버헤드가 추가되므로, 모델이 한 장에 들어간다면 굳이 쪼개지 않는 편이 빠릅니다. 병렬화는 "어쩔 수 없이 커서 나눠야 할 때" 또는 "지연을 줄이려고 더 많은 GPU를 투입할 때"의 도구입니다.
실무: 오토스케일링과 관측성
서빙을 프로덕션에 올리면 단일 인스턴스 성능만이 문제가 아닙니다. 트래픽은 시간대에 따라 출렁이고, 그에 맞춰 인스턴스를 늘리고 줄여야 비용을 통제할 수 있습니다.
오토스케일링의 기준이 되는 신호:
- GPU 사용률 / KV 캐시 점유율
- 대기열 길이(pending 요청 수)
- TTFT(첫 토큰 지연)가 SLA를 넘는지
스케일 아웃 트리거 예:
KV 캐시 점유율 > 85% 가 일정 시간 지속 -> 인스턴스 +1
여기서 주의할 점은, LLM 인스턴스는 기동에 시간이 걸린다는 것입니다. 모델 가중치를 메모리에 올리고 엔진을 초기화하는 데 수십 초에서 수 분이 걸릴 수 있습니다. 따라서 트래픽이 몰린 뒤에 스케일하면 늦습니다. 예측적 스케일링이나 충분한 여유(headroom)를 두는 설계가 필요합니다.
관측성 측면에서는 앞서 언급한 TTFT, 토큰당 시간, 처리량을 대시보드로 상시 추적해야 합니다. 또한 요청 실패율, OOM 발생, 대기열 적체를 함께 봐야 문제를 조기에 잡을 수 있습니다.
서빙 대시보드의 핵심 패널:
1) TTFT 분포 (p50, p95, p99)
2) 토큰당 시간(TPOT) 분포
3) 처리량(초당 토큰)
4) KV 캐시 점유율 / GPU 사용률
5) 대기열 길이 / 실패율
워크로드 측정: 처리량을 가늠하는 법
엔진을 고르고 설정을 정했다면, 실제로 얼마나 받을 수 있는지 가늠해야 합니다. 정확한 수치는 측정으로만 알 수 있지만, 대략적인 상한을 추정하는 사고법은 유용합니다.
동시성 상한의 직관:
쓸 수 있는 KV 메모리 / 요청당 KV 메모리 = 동시 요청 수 상한
예) KV에 쓸 수 있는 메모리 = 40 GB
요청당 평균 KV(컨텍스트 4K 기준) = 0.5 GB
-> 동시 요청 상한 약 80개
컨텍스트를 32K로 늘리면 요청당 KV가 8배 -> 동시 약 10개
이런 추정은 정확하지 않지만, "컨텍스트를 늘리면 동시성이 급감한다"는 트레이드오프를 숫자로 체감하게 해줍니다. 실제 운영에서는 부하 테스트로 동시성을 점진적으로 올리며 TTFT와 처리량의 변곡점을 찾는 것이 정석입니다. 변곡점을 넘어 동시성을 더 올리면 처리량은 정체되고 지연만 나빠집니다.
부하 테스트로 찾는 동작점:
동시성 ↑ 하면서 측정
-> 처리량은 어느 지점까지 오르다 정체
-> 그 지점 이후로는 지연만 악화
-> 정체 직전이 효율적인 운영점
함정과 트러블슈팅
- OOM(메모리 부족): gpu-memory-utilization을 너무 높게 잡거나 max-model-len이 과한 경우가 흔합니다. 동시 요청이 모두 최대 길이로 디코드하는 최악의 경우를 가정하고 여유를 두세요.
- 처리량은 좋은데 지연이 나쁘다: 배치가 너무 커서 개별 요청의 응답이 느려진 경우입니다. 처리량과 지연은 트레이드오프 관계임을 기억하세요.
- TensorRT-LLM 컴파일 실패: 모델 구조나 정밀도 설정이 지원 범위를 벗어났을 수 있습니다. 지원 매트릭스를 먼저 확인하세요.
- SGLang인데 prefix 캐시 효과가 없다: 요청들이 실제로 prefix를 공유하지 않으면 RadixAttention의 이득이 없습니다. 시스템 프롬프트를 통일하는 등 워크로드 구조를 점검하세요.
- 벤치마크가 실제와 다르다: 합성 부하의 입출력 분포가 실제 트래픽과 다른 경우입니다. 가능한 한 실제 트래픽 샘플로 측정하세요.
마치며
LLM 추론 서빙의 핵심은 결국 두 가지로 요약됩니다. 첫째, decode가 메모리 바운드라는 사실을 받아들이고 배칭, 양자화, KV 캐시 최적화로 메모리 병목을 공략하는 것. 둘째, 자신의 워크로드 성격에 맞는 엔진을 고르는 것입니다.
vLLM은 균형 잡힌 범용 기본값, TensorRT-LLM은 고정 모델의 극한 처리량, SGLang은 prefix 공유 워크로드의 강자입니다. 어느 것도 절대적 정답은 아니며, 본인의 트래픽으로 측정한 숫자가 최종 판단의 근거가 되어야 합니다. 추론 서빙은 빠르게 진화하는 분야이니, 공식 문서를 꾸준히 따라가는 것을 권합니다.