1편에서는 CPU와 GPU의 구조를, 2편에서는 그 GPU 위에서 Transformer가 어떻게 동작하는지 살펴봤다.
Token은 Embedding과 Positional Encoding을 거쳐 Self-Attention과 FFN을 통과하고, 이 과정을 여러 Transformer Block에 걸쳐 반복하며 문맥을 이해한다. Decoder-only Transformer는 이렇게 만들어진 표현을 바탕으로 다음 Token을 하나씩 예측한다.
그렇다면 이제 이런 질문이 생긴다.
이 모델을 실제 서비스에서 수천, 수만 명이 동시에 사용한다면 어떻게 될까?
모델을 한 번 실행하는 것과, 모델을 서비스로 운영하는 것은 완전히 다른 문제다.
정확도가 아무리 높아도 응답이 느리거나, GPU 메모리가 부족하거나, GPU를 많이 쓰면서도 처리량이 나오지 않는다면 실제 서비스에서는 쓰기 어렵다.
그래서 LLM 시스템에서는 모델의 성능만큼이나 모델을 얼마나 빠르고 효율적으로 서비스할 수 있는가가 중요하다.
이번 편에서는 학습된 LLM을 실제 서비스로 실행하는 Inference와 Serving의 전체 구조를 살펴보고, 어떤 지점에서 병목이 생기는지, 그리고 이를 해결하기 위해 어떤 최적화 기법들이 등장했는지 전체 지도를 그려본다.
이 중 가장 중요한 두 축인 Prefill, Decode, KV Cache는 실제 수치와 함께 3-2편에서 깊이 다룬다.
Transformer를 실제 사용자에게 어떻게 빠르고 효율적으로 제공할 수 있을까?
앞서 학습과 추론에 대한 개념을 구분해보았다.
학습(Training)은 모델의 파라미터를 학습시키는 과정이다. 대규모 데이터를 모델에 입력하고 예측과 정답의 차이를 계산한 뒤, Backpropagation으로 파라미터를 업데이트한다.
Training = 모델을 학습시키는 과정
추론(Inference)는 이미 학습된 모델을 이용해 결과를 생성하는 과정이다.
Inference = 학습된 모델을 이용해 결과를 생성하는 과정
실제 서비스에서는 이 Inference를 사용자 요청에 맞춰 반복적으로 수행해야 한다. 그래서 Serving은 단순히 모델을 실행하는 것보다 더 넓은 개념이다. 요청을 받고, 토큰화하고, 요청을 관리하고, GPU에서 모델을 실행하고, 결과를 다시 사용자에게 전달하는 전체 시스템을 포함한다.
사용자가 다음과 같이 질문했다고 해보자.
"Transformer가 무엇인지 설명해줘."
이 요청이 곧바로 GPU로 전달되지는 않는다. 대략 다음과 같은 과정을 거친다.
사용자 요청
↓
Request Server
↓
Tokenizer
↓
Queue / Scheduler
↓
Batching
↓
Inference Engine
↓
GPU
↓
Detokenization
↓
Streaming Response
↓
사용자
사용자가 텍스트를 입력하면 서버가 이 요청을 받는다.
LLM은 문자열을 그대로 입력받지 않는다. Tokenizer가 텍스트를 Token ID로 변환한다. 어떻게 나뉘는지는 2편에서 다룬 것처럼 사용하는 Tokenizer에 따라 달라진다.
동시에 여러 요청이 들어오면, 하나씩 처리하는 것보다 여러 요청을 효율적으로 묶어 GPU에서 처리하는 것이 유리하다. 그래서 Serving System에는 어떤 요청을 언제 GPU에서 실행할지 결정하는 Scheduler가 필요하다.
여러 요청을 묶어 GPU에 전달한다.
Request A ─┐
Request B ─┼→ Batch → GPU
Request C ─┘
GPU는 대규모 병렬 연산에 강하기 때문에 여러 요청을 함께 처리하면 자원을 더 효율적으로 쓸 수 있다. 다만 LLM은 요청마다 입력 길이와 생성 길이가 다르기 때문에, 단순히 Batch Size만 키우는 것으로는 충분하지 않다. 이 문제는 뒤에서 다시 다룬다.
Embedding → Transformer Block(Attention, FFN) → Next Token 순으로 2편에서 살펴본 계산이 GPU에서 실행된다.
모델이 생성하는 것은 문자열이 아니라 Token ID다. 이를 다시 사람이 읽을 수 있는 텍스트로 변환한다.
LLM은 답변 전체를 한 번에 생성하지 않고 Token을 하나씩 생성한다. 그래서 생성된 Token을 바로바로 사용자에게 전달하는 Streaming 방식이 널리 쓰인다.
Token 1 → 사용자
Token 2 → 사용자
Token 3 → 사용자
...
전체 파이프라인을 보면 LLM Serving의 병목이 GPU 하나에만 있는 것이 아니라는 것을 알 수 있다.
Request → Tokenizer → Queue/Scheduler → Batching → Inference → GPU Memory → Detokenization → Network
요청이 몰리면 Queue에서 대기 시간이 길어질 수 있고, GPU 메모리가 부족하면 동시에 처리할 수 있는 요청 수가 줄어든다. 모델의 연산량이 많으면 GPU Compute가 병목이 될 수 있고, 반대로 연산량보다 데이터를 GPU Memory에서 읽어오는 시간이 더 크다면 Memory Bandwidth가 병목이 될 수도 있다.
따라서 중요한 질문은
"GPU가 느린가?"
가 아니라
"현재 시스템에서 어느 부분이 병목인가?"
이다. 이 질문에 대한 답에 따라 최적화 방법도 달라진다.
LLM Serving에서는 Latency와 Throughput이 대표적인 성능 지표다.
Latency는 요청 하나를 처리하는 데 걸리는 시간이다. LLM에서는 특히 TTFT와 TPOT가 중요하다.
요청을 보낸 뒤 첫 번째 Token이 생성될 때까지 걸리는 시간이다.
Request
│
├────── TTFT ──────┤
↓
First Token
TTFT가 짧을수록 사용자는 응답이 빠르게 시작된다고 느낀다.
첫 Token 이후 모델은 다음 Token을 하나씩 생성한다. TPOT는 출력 Token 하나를 생성하는 데 걸리는 시간이다. TPOT가 낮을수록 답변이 빠르게 출력된다.
Throughput은 일정 시간 동안 처리할 수 있는 작업량이다. LLM에서는 requests/sec, tokens/sec 등으로 측정한다. 동시에 많은 사용자가 요청하는 서비스라면, 한 요청의 Latency만 낮추는 것보다 전체적으로 얼마나 많은 Token을 처리할 수 있는가가 중요할 수 있다.
여기서 중요한 점이 있다.
Latency와 Throughput은 항상 동시에 최적화되지 않는다.
많은 요청을 Batch로 묶으면 GPU 활용률이 높아져 Throughput은 증가할 수 있다. 하지만 특정 요청은 다른 요청이 끝날 때까지 기다려야 하므로 Latency가 늘어날 수도 있다. 그래서 Serving System은 한 요청의 빠른 응답과 전체 처리량 사이에서 균형을 찾아야 한다.
LLM Serving에는 다양한 최적화 기법이 존재한다. 이번 편에서는 각각의 원리를 깊게 파고들기보다, 어떤 문제가 있고 어떤 해결책이 존재하는지 전체 지도를 그려본다.
| 기법 | 목적 | 핵심 아이디어 |
|---|---|---|
| Quantization | 메모리 절감, 연산 효율 향상 | 낮은 정밀도로 모델 표현 |
| Pruning | 파라미터 감소 | 불필요한 파라미터 제거 |
| Knowledge Distillation | 모델 경량화 | 큰 모델의 지식을 작은 모델로 전달 |
| LoRA / PEFT | 효율적인 Fine-tuning | 일부 파라미터만 학습 |
| Continuous Batching | Throughput 향상 | 실행 중인 요청을 동적으로 관리 |
| Speculative Decoding | Decode Latency 감소 | 작은 모델이 후보를 만들고 큰 모델이 검증 |
| KV Cache | 반복 계산 감소 | 이전 Token의 K/V를 저장하고 재사용 |
| PagedAttention | KV Cache 메모리 효율화 | KV Cache를 Block 단위로 관리 |
이제 각각이 어떤 문제를 해결하는지 간단히 살펴보자.
모델의 파라미터나 연산을 낮은 정밀도로 표현하는 방법이다. 예를 들어 FP16 대신 INT8이나 INT4를 사용한다.
FP16 → INT8 → INT4
정밀도가 낮아지면 모델이 차지하는 메모리가 줄어든다. 따라서 GPU Memory 사용량을 줄이고 Memory Bandwidth 부담을 낮추는 데 도움이 될 수 있다. 대표적인 방법으로 GPTQ, AWQ 등이 있다.
다만 정밀도를 낮추면 정확도 손실이 발생할 수 있고, 실제 속도 향상은 하드웨어와 Kernel이 해당 저정밀 연산을 얼마나 잘 지원하는지에 따라 달라진다.
Quantization은 단순히 모델 크기를 줄이는 것이 아니라, 정확도와 하드웨어 지원까지 함께 고려해야 하는 최적화다.
모델에서 중요하지 않은 파라미터를 제거하는 방법이다. 하지만 파라미터를 제거했다고 해서 실제 GPU 연산량이 반드시 줄어드는 것은 아니다.
Unstructured Pruning은 개별 Weight를 제거한다. 이론적으로는 많은 파라미터를 제거할 수 있지만, 일반적인 GPU의 Dense Matrix 연산에서는 제거된 부분을 건너뛰는 것이 오히려 복잡해질 수 있다.
Structured Pruning은 행, 열, Head 같은 구조적 단위로 제거한다. GPU가 처리하기 쉬운 형태로 모델 구조를 단순화할 수 있어 실제 하드웨어에서 더 유리할 수 있다.
Pruning에서는 이론적인 연산량 감소와 실제 성능 향상이 다를 수 있다.
큰 모델(Teacher)의 지식을 작은 모델(Student)에게 전달하는 방법이다.
Teacher Model → Knowledge → Student Model
목표는 더 작은 모델로 원래 모델과 비슷한 성능을 얻는 것이다. 모델 자체가 작아지기 때문에 추론에 필요한 Compute와 Memory를 함께 줄일 수 있다. 다만 별도의 학습 과정이 필요하기 때문에, 학습 비용을 추가로 지불해 서빙 비용을 줄이는 방법이라고 볼 수 있다.
PEFT(Parameter-Efficient Fine-Tuning)는 모델의 전체 파라미터가 아니라 일부 파라미터만 학습해 Fine-tuning 비용을 줄이는 방법론을 통칭하는 개념이다.
Full Fine-tuning → 전체 파라미터 업데이트
PEFT → 일부 파라미터만 업데이트
기존의 Full Fine-tuning은 모델의 모든 Weight를 업데이트하기 때문에, 모델 크기만큼의 Gradient와 Optimizer State를 GPU Memory에 올려야 한다. 모델이 클수록 이 비용은 감당하기 어려워진다. PEFT는 이 문제를 해결하기 위해 대부분의 파라미터는 고정(Freeze)한 채, 작은 일부 파라미터만 학습한다.
PEFT는 하나의 특정 기법이 아니라 접근 방식의 범주이며, 그 안에 여러 구체적인 기법이 존재한다.
| 기법 | 핵심 아이디어 |
|---|---|
| LoRA | 기존 Weight는 고정하고, 저차원 Adapter 행렬만 학습 |
| Prefix / Prompt Tuning | 입력 앞에 학습 가능한 Token(Embedding)을 추가 |
| Adapter Layers | Transformer Block 사이에 작은 모듈을 삽입해 그 부분만 학습 |
| IA³ | 기존 Activation에 곱해지는 작은 Scale 파라미터만 학습 |
이 중 실무에서 가장 널리 쓰이는 것이 LoRA다.
주로 Fine-tuning 비용을 줄이기 위한 방법이다. 기존에는 모델의 전체 파라미터를 업데이트해야 했지만, LoRA는 기존 Weight를 유지한 채 작은 Adapter를 추가해 학습한다.
Base Model + LoRA Adapter → Fine-tuned Model
Fine-tuning에 필요한 GPU Memory와 학습 비용을 줄일 수 있다. 다만 이것은 기본적으로 학습 단계의 최적화다. 서빙에서는 Adapter를 Merge할지 별도로 유지할지에 따라 실행 방식이 달라진다.
LoRA는 서빙 자체를 빠르게 만드는 기법이라기보다, 모델을 효율적으로 커스터마이징하기 위한 방법으로 이해하는 것이 좋다.
일반적인 Batch는 여러 요청을 한 번에 묶어 처리한다. 그런데 LLM은 요청마다 생성 길이가 다르다.
A → 10 tokens
B → 100 tokens
C → 30 tokens
A와 C의 생성이 끝났는데 B 때문에 Batch가 계속 유지된다면 GPU 자원이 비효율적으로 쓰인다. Continuous Batching은 실행 중인 요청이 완료되면 새로운 요청을 동적으로 Batch에 추가한다.
시간 →
A █████████
B ███████████████████
C ███████
D ███████████
E █████████
덕분에 GPU Idle Time을 줄이고 전체 Throughput을 높일 수 있다. 다만 이 과정에서는 요청별 상태를 관리하는 Scheduler가 중요하다. 이 개념은 3-2편에서 Prefill/Decode와 함께, 그리고 이후 편에서 직접 Serving Service를 구현하며 다시 살펴본다.
LLM의 Decode는 한 번에 하나의 Token만 생성해야 한다.
Token 1 → Token 2 → Token 3 → Token 4
Speculative Decoding은 이 순차적인 과정을 줄이기 위해 작은 Draft Model로 여러 후보 Token을 먼저 만들고, 큰 Target Model이 이 후보들을 한 번에 검증한다.
Draft Model → Token A → Token B → Token C → Token D
Target Model → Accept / Reject
작은 모델로 후보를 빠르게 만들고 큰 모델이 검증함으로써, 큰 모델의 순차적인 Decode 횟수를 줄이는 방식이다.
추가적인 Compute를 사용해 Decode의 순차성을 줄이는 접근이다.
이번 편에서 소개한 기법 중 가장 중요한 것이 KV Cache다.
LLM은 Token을 하나씩 생성하기 때문에, 새로운 Token을 만들 때마다 이전 Token들과의 Attention을 계산해야 한다. 그런데 이전 Token의 Key와 Value는 이미 계산한 값이다. 그 계산 결과를 저장해두고 재사용하면 반복 계산을 없앨 수 있다. 이것이 KV Cache다.
KV Cache는 반복적인 K/V 계산을 없애주지만, 그 대신 큰 GPU 메모리를 사용한다.
계산을 줄이는 대신 메모리를 사용한다.
바로 이 Trade-off에서 LLM Serving의 핵심 문제가 시작된다. 다음편에서는 이 Trade-off를 중심으로 Self-Attention의 비용, Prefill/Decode, PagedAttention 개념등을 알아본다.
지금까지의 내용을 보면 모델의 정확도와 서빙 성능은 서로 다른 문제라는 것을 알 수 있다.
큰 모델은 더 좋은 결과를 낼 수 있지만
Model Size ↑ → GPU Memory ↑ → 동시 처리 요청 ↓ → Cost ↑
가 될 수 있다. 반대로 Quantization을 적용하면
Precision ↓ → Memory ↓ → Throughput ↑
가 될 수 있지만 정확도 손실이 발생할 수 있다.
즉 실제 서비스에서는 항상 Trade-off가 존재한다. 그래서 인프라 엔지니어가 알아야 할 것은 단순히 "어떤 GPU를 사용할 것인가?"가 아니다. 더 중요한 질문은
"현재 서비스의 병목은 Compute인가, Memory인가, 아니면 Scheduling인가?"
이다. 병목을 알지 못한 채 GPU만 추가하면 비용만 늘고 원하는 성능은 얻지 못할 수도 있다.
이번 편의 목적은 모든 최적화 기법을 깊게 배우는 것이 아니라, LLM Serving이라는 문제의 전체 지도를 그리는 것이다.
LLM Serving
│
┌──────────────┼──────────────┐
↓ ↓ ↓
Compute Memory Scheduling
│ │ │
Prefill KV Cache Batching
Attention Weights Continuous
FFN Memory
│ │
└───────┬──────┘
↓
Optimization
다음편에서는 왜 Attention은 시퀀스 길이가 길어질수록 비싸지는지, KV Cache는 정확히 무엇을 저장하는지, 그리고 왜 Prefill과 Decode의 병목이 서로 다른지 직접 확인해보자.