[CloudNeta] Hands-On LLM Serving 1주차 part 3 - LLM 서빙

연재 안내

이 글은 1주차 연재의 세 번째 편입니다.

  1. part 1 - 모델과 모델 서빙의 개념
  2. part 2 - LLM 기초
  3. part 3 - LLM 서빙 (이 글)

이번 편의 목표는 LLM 서빙이 왜 일반 모델 서빙과 질적으로 다른 문제인지에 대한 멘탈 모델을 세우는 것입니다. 시스템 세부사항에 매몰되기 전에 핵심 원리부터 이해하는 것이 목적입니다.

들어가며: LLM 서빙은 왜 따로 다루는가

파트 1을 통해 모델 서빙 속에 LLM 서빙이 있다는 사실을 살펴보았습니다. 일반 모델 서빙이 무상태 단발 호출(이미지 한 장 → 분류 결과 한 번)이라면, LLM 서빙은 part 2에서 본 자기회귀 생성 루프를 서비스로 만드는 일입니다.

운영 관점에서 이를 살펴보면 LLM 서빙의 경우 요청 하나가 "한 번의 forward pass"가 아니라 여러 decode step의 연속입니다. 즉 생성 길이가 길수록 그 요청의 GPU 점유 시간이 길어집니다. 요청마다 비용이 다르고, 요청이 상태(지금까지 생성한 시퀀스)를 끌고 다니는 워크로드인 것이죠.

개요

이러한 이유로 아래 사항을 다시 이해하고자 합니다.

LLM 서빙 최적화가 중요한 이유

추론 과정 들여다보기: prefill과 decode

part 2에서 셀프 어텐션과 자기회귀 생성 루프를 다뤘습니다. 정리하면: 각 토큰은 Query/Key/Value로 변환되고, 지금 토큰의 Query를 지금까지의 모든 토큰의 Key와 대조해 가중치를 매긴 뒤 Value를 섞어옵니다. GPT 계열은 여기에 인과 마스크를 씌워 미래 토큰을 못 보게 막고, 마지막 토큰의 hidden state만 LM 헤드에 넣어 다음 토큰을 뽑은 뒤, 그 토큰을 입력에 이어붙여 같은 과정을 반복합니다.

이 루프를 서빙 관점에서 그대로 실행하면 문제가 생깁니다. 매 스텝마다 "지금까지의 시퀀스 전체"를 다시 입력하는데, 캐시가 없다면 이전 토큰들의 Key/Value도 매번 처음부터 다시 계산해야 합니다. 토큰이 t개 쌓인 상태에서 한 스텝의 계산량이 O(t)이므로, 전체 생성 과정은 O(t²)로 불어납니다.

여기서 놓치면 안 되는 관찰 하나가 있습니다. 인과 마스크 때문에 과거 토큰의 Key/Value는 이후에 새 토큰이 추가돼도 절대 값이 바뀌지 않습니다. 이미 만들어진 토큰이 미래를 내다보고 자기 표현을 고칠 방법이 없기 때문입니다. 그렇다면 한 번 계산한 Key/Value를 버릴 이유가 없습니다. 새 토큰마다 새로 계산해야 하는 건 그 토큰의 Query뿐이고, 나머지는 저장해뒀다 재사용하면 됩니다. 이 관찰이 바로 다음 절의 KV 캐시로 이어집니다.

캐시를 전제로 실제 추론을 나누면 아래 두 국면이 나옵니다.

---
title: prefill과 decode
---
flowchart LR
    subgraph prefill["prefill (1회)"]
        direction LR
        p1["프롬프트 전체
(N 토큰)"] --> p2["N개 토큰을
한 번에 병렬 처리"] --> p3["모든 토큰의
K/V를 캐시에 채움"] end subgraph decode["decode (반복)"] direction LR d1["새 토큰 1개"] --> d2["새 Query만 계산
캐시된 K/V와 대조"] --> d3["다음 토큰 예측
→ 캐시에 추가"] d3 -.반복.-> d1 end prefill --> decode
prefill decode
처리 단위 프롬프트 전체 토큰을 한 번에 새 토큰 1개씩, 순차
병렬화 토큰끼리 서로 안 기다림 → 완전 병렬 이전 토큰에 의존 → 순차 실행
자원 병목 연산(compute-bound): 행렬곱이 GPU 코어를 채움 메모리 대역폭(memory-bound): 매 스텝 캐시를 HBM에서 읽어옴[1]
대응하는 지표 TTFT(Time To First Token) TPS(초당 생성 토큰 수)

같은 어텐션 연산인데도 "서로 독립인 토큰들을 한꺼번에 처리(prefill)"와 "이전 상태에 의존해 하나씩 처리(decode)"라는 성격 차이가 그대로 자원 프로파일의 차이로 이어지는 셈입니다. part 1에서 "요청 하나가 한 번의 forward pass가 아니라 여러 decode step의 연속"이라고 했던 문장의 실체가 바로 이겁니다.

KV 캐시와 재사용

앞 절에서 "과거 토큰의 Key/Value는 재사용하면 된다"고 했는데, 그 저장소가 KV 캐시입니다. 정확히는 모델의 레이어마다, 어텐션 헤드마다 지금까지 나온 모든 토큰의 Key와 Value 벡터를 쌓아두는 구조입니다. Query는 캐시하지 않습니다 — Query는 "지금 무엇을 찾고 있는가"라서 매 스텝 새 토큰마다 새로 만들어야 하는 값이고, Key/Value는 "내가 가진 정보"라서 한 번 확정되면 안 바뀌는 값이기 때문입니다.

캐시가 얼마나 커지는가

KV 캐시 크기는 공짜가 아닙니다. 토큰 하나를 저장하는 데 드는 용량은 대략 이렇게 계산됩니다.

토큰당 KV 캐시 크기 = 2(K, V) × 레이어 수 × hidden 차원 × dtype 바이트 수

PagedAttention 논문이 예시로 든 OPT-13B 기준으로 넣어보면: 2 × 40(레이어) × 5,120(hidden) × 2byte(fp16) = 토큰 1개당 약 800KB[2]입니다. part 2에서 GPT-2의 임베딩 테이블이 "50,257 × 768 ≈ 3,860만 개"였던 것과 같은 방식의 계산인데, 이번엔 파라미터가 아니라 요청마다, 토큰마다 새로 쌓이는 런타임 메모리라는 게 다릅니다.

이 800KB가 왜 무서우냐면, 토큰 하나가 아니라 시퀀스 길이 × 동시 요청(배치) 수만큼 곱해지기 때문입니다.

전체 KV 캐시 = 토큰당 크기 × 시퀀스 길이 × 배치 내 동시 요청 수

예를 들어 800KB/토큰짜리 모델로 2,000토큰짜리 대화를 32개 동시에 서빙한다면 800KB × 2,000 × 32 ≈ 51GB입니다. 웬만한 GPU 메모리를 캐시 하나가 다 먹어버릴 수 있는 규모죠. 그래서 긴 컨텍스트·많은 동시 사용자일수록 KV 캐시가 모델 가중치보다 커지는 역전 현상이 실제로 벌어집니다.

재사용이 성능의 핵심인 이유

decode 단계에서 매 스텝 GPU가 하는 일의 대부분이 "이 커진 캐시를 HBM에서 읽어오는 것"이라고 앞서 말씀드렸습니다. 그러니 캐시를 잘 관리하는 것 자체가 곧 서빙 성능입니다. 여러 요청이 같은 시스템 프롬프트나 few-shot 예시로 시작한다면, 그 앞부분의 Key/Value는 요청마다 계산해봐야 항상 같은 값이 나옵니다. 이걸 한 번만 계산해 재사용하는 것이 prefix caching이고, prefill 비용만 줄여주는 최적화입니다(캐시를 읽어야 하는 decode 자체에는 도움이 안 됩니다).

미리보기: 캐시를 다루는 실전 기법들

캐시가 이렇게 크고 요청마다 길이가 들쭉날쭉하다 보니, "이 가변 크기 메모리를 어떻게 할당·관리할 것인가"가 별도의 엔지니어링 문제가 됩니다. PagedAttention은 OS의 가상 메모리 페이징처럼 캐시를 고정 크기 블록으로 잘라 비연속 메모리에 매핑해 낭비를 줄이고, FP8 양자화 같은 기법은 캐시 자체를 압축해 읽어올 바이트 수를 줄입니다. 자세한 내부 동작은 이후 연재에서 다루겠습니다.

이 기초가 왜 중요한가

여기까지의 내용을 병목 진단 관점에서 정리하면 아래처럼 됩니다.

이렇게 병목의 위치를 구분할 수 있어야, 아래처럼 증상에 맞는 처방을 고를 수 있습니다.

증상 원인 대응 기법
TTFT가 김 (prefill 병목) 긴 프롬프트가 한 스텝을 독점 chunked prefill (prefill을 잘게 쪼개 decode와 인터리빙)
TTFT가 김 (prefill 병목) 같은 접두사를 매번 재계산 prefix caching
TPS가 낮음 (decode 병목) 캐시 읽기 대역폭 부족 KV 캐시 양자화(FP8 등), PagedAttention
GPU가 노는 시간이 많음 요청 도착·종료 시점이 제각각인데 배치를 정적으로 묶음 continuous batching (뒤에서 다룸)

그리고 이건 뒤에 나올 배치 서빙 이야기와도 곧바로 연결됩니다. decode는 토큰 하나하나가 독립적인 스텝이라고 했는데, 뒤집어 말하면 요청마다 출력 길이가 다 다르니 "언제 배치에서 빠지고 언제 새로 들어올지"가 매 스텝 달라진다는 뜻이기도 합니다. 이 문제를 어떻게 풀지가 바로 다음 절의 주제입니다.

스트리밍 서빙 기초 (LLM Streaming Serving Basics)

왜 스트리밍이 필요한가

vLLM의 기본 llm.generate() 호출은 동기(sync) + 블로킹(blocking) 방식입니다. part 2에서 본 것처럼 모델 내부는 자기회귀 특성상 토큰을 하나씩 순차 생성하는데, API는 전체 출력이 완성될 때까지 기다렸다가 한 번에 반환합니다. 챗봇처럼 실시간 대화가 필요한 서비스라면 사용자가 몇 초에서 몇 분간 빈 화면만 보게 되어 UX가 나빠집니다.

해결책이 스트리밍입니다. 토큰이 생성되는 즉시 하나씩(또는 작은 청크 단위로) 클라이언트에 바로 전달하는 방식이죠. 어차피 한 토큰씩 나오는 걸("토큰 100개 답변 = 실행 100번") 모아뒀다 주지 말고, 나오는 대로 흘려보내자는 발상입니다.

vLLM으로 구현하면

핵심 차이는 표준 LLM 클래스 대신 AsyncLLMEngine 으로 엔진을 초기화한다는 점입니다[3]. generate()가 비동기 스트림을 반환하므로, async for 루프로 새로 생성된 토큰을 하나씩 받아볼 수 있습니다.

from vllm.engine.arg_utils import AsyncEngineArgs
from vllm.engine.async_llm_engine import AsyncLLMEngine
from vllm.sampling_params import SamplingParams

engine_args = AsyncEngineArgs(model="Qwen/Qwen2.5-0.5B", dtype="float16", ...)
engine = AsyncLLMEngine.from_engine_args(engine_args)

async def generate_text(prompt: str, max_tokens: int = 100):
    sampling_params = SamplingParams(temperature=0.0, max_tokens=max_tokens)
    results_generator = engine.generate(
        prompt=prompt,
        sampling_params=sampling_params,
        request_id="test-request",  # 이 요청의 고유 식별자
    )
    # 토큰이 생성될 때마다 루프가 한 번씩 돕니다
    async for request_output in results_generator:
        for chunk in request_output.outputs:
            print(chunk.text, end="", flush=True)
            # 웹 서비스라면 여기서 청크를 yield 하면 됩니다

실행하면 출력이 이렇게 자라나는 걸 볼 수 있습니다:

The
The capital
The capital of
The capital of the
...
The capital of the United States is Washington, D.C.

스트리밍의 부가 장점이 하나 더 있는데, 생성 도중 취소가 가능하다는 점입니다. 출력이 원치 않는 방향으로 가고 있으면 engine.abort(request_id)로 해당 요청만 끊을 수 있습니다. 사용자 경험 개선이면서 동시에 컴퓨팅 자원 절약이기도 합니다. 어차피 버릴 출력을 끝까지 생성하는 GPU 시간이 곧 비용이니까요.

실무 고려사항

스트리밍은 체감을 바꾸는 기술이지, 비용을 줄이는 기술이 아닙니다. 첫 토큰이 빨리 보이니 사용자는 빠르다고 느끼지만, backend의 총 decode 연산량은 그대로입니다. 그래서 frontend UX(TTFT 체감)와 backend 용량 계획(총 decode 비용)은 분리해서 계산해야 합니다. "스트리밍 붙였으니 서버 부하도 줄겠지"는 성립하지 않는 추론입니다.

배치 서빙 기초 (LLM Batch Serving Basics)

왜 배치가 필요한가

지금까지의 예제는 프롬프트를 한 번에 하나씩 처리했습니다. 프로토타입에서는 문제없지만, 실무 시나리오 에서 요청을 하나씩 순차 처리하면 GPU가 대부분의 시간을 놀게 됩니다. 이를테면 문서 10만 건 요약, PDF 5천 개 인덱싱, 동시 사용자 수만 명의 챗봇 요청 같은 경우가 있겠네요.

배치(batch)란 여러 입력 요청을 묶어서 모델의 한 번의 forward pass에 동시에 통과시키는 것입니다. 트랜스포머에서 배치가 특히 효과적인 이유는 세 가지입니다.

  1. 행렬곱과 어텐션 연산은 시퀀스 차원으로 병렬화가 가능합니다
  2. 모델 가중치는 모든 요청이 공유하므로, 배치를 키워도 가중치를 다시 읽어올 필요가 없습니다(메모리 대역폭 재사용)
  3. GPU는 애초에 대규모 병렬 연산에 최적화된 하드웨어라, 배치로 묶을수록 코어를 꽉 채워 쓰게 됩니다

실험: 배치 vs 순차

같은 프롬프트 4개를 배치로 한 번에 넘기는 경우와, 하나씩 4번 호출하는 경우를 비교해 봅니다[3:1].

from vllm import LLM, SamplingParams

llm = LLM(model="Qwen/Qwen2.5-0.5B", dtype="float16", max_model_len=2048)
prompts = ["What is the meaning of life?", "Write a short story...",
           "Explain quantum physics...", "Translate 'Hello, world!'..."]
sampling_params = SamplingParams(temperature=0.8, top_p=0.95, max_tokens=100)

# 배치 처리: 리스트 전체를 한 번에 → vLLM이 4개 시퀀스를 하나의 배치로 묶어 동시 처리
vllm_outputs = llm.generate(prompts, sampling_params)

# 순차 처리: 한 번에 하나씩 4번 호출
for prompt in prompts:
    vllm_outputs = llm.generate([prompt], sampling_params)
vLLM generation time for 4 prompts in a batch:     1.3777 seconds
vLLM generation time for 4 prompts one by one:     2.4753 seconds

배치가 순차보다 약 2배 빠릅니다(실행마다 1.8~2.2배 수준). 그런데 "완벽한 병렬화라면 4배 가까이 나와야 하지 않나?"라는 의문이 남죠. 못 미치는 이유는:

배칭의 세 가지 방식: Static, Dynamic, Continuous

"어떻게 묶을 것인가"에 따라 세 세대로 나뉩니다.

방식 동작 한계/특징
Static batching 배치가 꽉 찰 때까지 기다렸다가 추론 시작 먼저 온 요청이 배치가 찰 때까지 대기. 짧은 응답이 먼저 끝나도 슬롯이 비지 않고 배치 전체가 끝날 때까지 기다림
Dynamic batching 배치가 차거나 설정된 시간(timeout)이 지나면 시작 static의 대기시간 문제를 시간 제한으로 완화
Continuous batching 추론을 끊임없이 돌리며, 슬롯이 비는 즉시 새 요청을 끼워 넣음 (토큰 단위 배칭) GPU가 쉬는 시간 없이 유지됨. 프로덕션급 기법

Continuous batching은 vLLM, SGLang, TensorRT-LLM(여기서는 "in-flight batching"이라 부름) 같은 추론 엔진들이 구현하고 있으며, Anyscale의 2023년 실험에서는 static batching 대비 최대 23배의 처리량 향상이 보고됐습니다[4]. 즉 이 섹션의 흐름은 "단일 처리(느림) → 정적 배치(~2배) → 연속 배치(최대 23배)"로 이어지는 성능 최적화 스토리입니다. continuous batching의 내부 동작은 이후 연재에서 자세히 다루겠습니다.

트레이드오프, 그리고 오토스케일링과의 연결

배치 크기는 공짜가 아닙니다. 배치 크기 ↑ → 처리량 ↑, 하지만 개별 요청의 지연시간은 나빠질 수 있습니다. 한 요청이 배치 안의 다른 요청들과 묶여 처리되기 때문입니다. 정답은 없고, 모델·인스턴스·지연시간 목표·예산에 맞춰 여러 배치 크기로 성능 테스트를 해서 적정선을 찾아야 합니다.

이 트레이드오프는 오토스케일링 설정과 직결됩니다. 트래픽 기반 오토스케일링을 제대로 운영하려면 "인스턴스 하나가 동시 요청을 몇 개까지 소화할 수 있는가"를 알아야 하는데:

part 1에서 "요청 처리시간이 제각각이라 라운드 로빈으로는 부족하다"고 했던 라우팅 문제와, 여기의 배칭·오토스케일링 문제는 같은 원인이죠.

마치며

이번 편의 흐름을 한 줄로 요약하면 "이론 → 저수준 원리 → 실전 도구 → 성능 전략" 입니다.

  1. 이론: 디코더 전용(decoder-only) 트랜스포머와 자기회귀 토큰 생성 (part 2에서 다진 기초)
  2. 저수준 원리: prefill(연산 집약)과 decode(메모리 대역폭 집약)의 단계 구분, 그리고 KV 캐시의 특성 차이가 LLM 서빙 최적화의 핵심 포인트입니다
  3. 실전 도구: vLLM 같은 서빙 프레임워크가 저수준 복잡도를 추상화하면서 훨씬 뛰어난 성능을 제공합니다
  4. 성능 전략: 스트리밍(체감 지연시간 개선)과 배칭(처리량 증대)

다음 주차에서는 이 서빙 역량을 실제 웹 서비스로 감싸는 방법을 다룰 예정입니다. 주로 API 설계, 아키텍처 결정, 운영 모범 사례에 대한 이야기를 하겠군요.

이전 편: 1주차 part 2 - LLM 기초
처음부터: 1주차 part 1 - 모델과 모델 서빙의 개념

감사합니다.


  1. prefill=compute-bound, decode=memory-bound라는 구분은 KV Cache Explained (Medium)Inside vLLM: Anatomy of a High-Throughput LLM Inference System (vLLM Blog)에서 공통적으로 설명하는 내용입니다. ↩︎

  2. 계산식과 OPT-13B 예시(2 × 40레이어 × 5,120hidden × 2byte(fp16) ≈ 800KB/토큰)는 Efficient Memory Management for Large Language Model Serving with PagedAttention (arXiv) 논문 및 이를 풀어 설명한 PagedAttention explained (intuitivepapers.ai)를 참고했습니다. ↩︎

  3. 예제 코드는 스터디 교재 저장소 orca3/llm-model-inference의 ch2_Streaming/ch2_Batching 노트북을 참고했습니다. Colab에서 실행 시 vLLM 버전 호환성 문제가 있어 vllm==0.6.6.post1 고정 설치와 세션 재시작이 필요할 수 있습니다(2025.07 기준). ↩︎ ↩︎

  4. How continuous batching enables 23x throughput in LLM inference (Anyscale) ↩︎