LLM 추론 배칭 전략 정리 (Static, Dynamic, Continuous Batching)

limes22·2026년 8월 21일

LLM 서빙 엔진을 고르거나 파라미터를 조정할 때 가장 먼저 마주치는 개념이 배칭이다. vLLM은 continuous batching, Triton은 dynamic batching이라는 식으로 언급되지만 두 용어는 같은 층위에서 대립하는 개념이 아니다.

이 글은 세 가지 배칭 방식이 어떤 문제를 순서대로 해결해 왔는지, 그리고 각 방식이 어떤 워크로드에 맞는지를 정리한다.

0. 용어 정리

본문에 반복해서 나오는 말들을 먼저 맞춰둔다. 배칭 관련 문서가 어려운 이유의 절반은 개념 자체가 아니라, 같은 것을 여러 이름으로 부르는 데 있다.

요청 하나가 서버 안에서 이동하는 경로

사용자가 질문을 던짐
        ↓
   ┌──────────────┐
   │  waiting     │   대기 큐 — 아직 GPU에 들어가지 않음
   │  [E, F, G]   │   KV 캐시 사용량 0
   └──────────────┘
        ↓  자리가 나면 이동
   ┌──────────────┐
   │  running     │   배치 — 지금 GPU에서 처리 중
   │ [A, B, C, D] │   각자 KV 캐시 블록을 점유
   └──────────────┘
        ↓  <EOS>가 나오면
      응답 완료 + KV 캐시 블록 반납

같은 요청이 큐에 있다가 배치로 옮겨가는 것이다. 서로 다른 대상이 아니다.

용어 대응표

용어정체
시퀀스요청 하나. 프롬프트 + 지금까지 생성된 토큰들
배치지금 GPU에서 함께 처리 중인 시퀀스들의 묶음 (running)
큐 / 대기 큐아직 시작하지 못한 시퀀스들 (waiting). 둘은 같은 말
스텝스케줄러 1회 + forward 1회(chunked prefill을 끈 경우). decode 중인 시퀀스는 토큰 1개씩 받고, prefill 중인 시퀀스는 프롬프트를 한 번에 처리한다
배치 크기len(running)
배치 구성running의 내용물 — 지금 누가 들어 있는가

흔히 "토큰 스텝"이라고 부르지만, 한 스텝이 decode만으로 이루어진다는 뜻은 아니다. 스케줄러는 매 스텝 prefill과 decode를 섞어서 구성하며, 그 배분을 max_num_batched_tokens 예산 안에서 결정한다.

요청과 시퀀스는 왜 이름이 두 개인가

같은 대상을 다른 관점에서 부르는 것이다.

요청(request)   : 사용자 관점 — "질문을 하나 던졌다"
시퀀스(sequence): 엔진 관점  — "토큰이 줄줄이 이어진 덩어리"

엔진이 시퀀스라고 부르는 이유는 내용물이 토큰의 나열이기 때문이다.

시퀀스 A = ["오늘", "날씨", "는"] + ["맑", "겠", "습니다"]
            └─── 프롬프트 ────┘   └── 생성된 것 ──┘
             (prefill에서 처리)   (decode로 하나씩 추가)

forward가 돌 때마다 이 줄이 오른쪽으로 한 칸씩 길어진다. max_num_seqs가 "동시 처리 시퀀스 수"인 것도 이 맥락이다 — running에 시퀀스를 몇 개까지 담을지를 정한다.

코드로 보면

waiting = [E, F, G]        # 대기 큐
running = [A, B, C, D]     # 배치
                           # A~G 각각이 시퀀스 하나

output = model.forward(running)
# → 호출 1번. 배치 안 4개 시퀀스가 각각 토큰 1개씩 생성

# A가 <EOS>를 뱉으면
running.remove(A)
running.append(waiting.pop(0))   # E가 큐에서 배치로 이동

model.forward()는 배치를 통째로 받는다. 4개가 들어 있으면 한 번의 호출로 4개를 처리한다. 4번 나눠 도는 것이 아니다. 이때 내부적으로 텐서 shape이 [4 × hidden_size]가 된다.

한 문장으로: 시퀀스는 요청이고, 배치와 큐는 그 요청이 놓이는 두 장소다. 이 글의 나머지는 결국 언제 큐에서 배치로 옮기느냐에 관한 이야기다.


1. 전제: LLM 추론은 두 단계로 나뉜다

배칭을 이해하려면 추론 과정의 구조부터 봐야 한다.

┌─────────────────────── Prefill ───────────────────────┐
│  입력 프롬프트 전체를 한 번에 처리                       │
│  "오늘 날씨 어때?"  →  [토큰 5개 동시 연산]              │
│  결과: KV 캐시 생성                                     │
└───────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────── Decode ────────────────────────┐
│  토큰을 하나씩 생성. 매번 forward pass 1회               │
│                                                       │
│  step 1  →  "오늘"                                     │
│  step 2  →  "은"        ← 이전 KV 캐시를 읽어서 계산      │
│  step 3  →  "맑"                                       │
│  step 4  →  "겠"                                       │
│  step 5  →  "습니다"                                    │
│  step 6  →  <EOS>  종료                                │
└───────────────────────────────────────────────────────┘

여기서 forward pass 한 번을 1 토큰 스텝이라고 부른다. 100토큰짜리 응답은 100번의 decode 스텝을 거친다.

이 사실이 중요한 이유는, 요청 하나를 처리하는 동안 배치 구성을 재조정할 기회가 100번 존재한다는 뜻이기 때문이다. 배칭 전략의 차이는 결국 이 기회를 얼마나 활용하느냐로 갈린다.


2. 왜 배칭이 필요한가 — decode는 memory-bound다

2.1 배치가 만드는 변화

어떤 레이어의 weight W[4096 × 4096]이라고 하자.

[ 배치 크기 1 ]

  x            W                 y
[1×4096]  @  [4096×4096]  =  [1×4096]

  → HBM에서 읽는 양: W 전체 (약 33MB, FP16 기준)
  → 처리하는 요청 수: 1


[ 배치 크기 32 ]

  X            W                 Y
[32×4096] @  [4096×4096]  =  [32×4096]

  → HBM에서 읽는 양: W 전체 (동일. 약 33MB)
  → 처리하는 요청 수: 32

읽어오는 weight의 양은 배치 크기와 무관하게 일정하다. 배치를 키운다는 것은 한 번 읽어온 weight를 몇 번 재사용하느냐를 늘리는 일이다.

2.2 산술 강도(Arithmetic Intensity)로 보기

FP16 기준으로 계산해 보면 관계가 명확해진다. 파라미터 수를 N, 배치 크기를 B라 할 때,

메모리 읽기량  =  2N bytes           (FP16, 파라미터당 2바이트)
연산량        =  2NB FLOPs          (곱셈-누산이므로 파라미터당 2 FLOP)

산술 강도 = 2NB / 2N = B  [FLOPs/byte]

산술 강도가 배치 크기와 같다. 배치 1이면 1바이트 읽어서 1 FLOP만 쓰는 셈이다.

GPU의 균형점(ridge point)과 비교해 보자.

GPUFP16 연산 성능HBM 대역폭균형점
A100 80GB약 312 TFLOPS약 2.0 TB/s약 150 FLOPs/byte

배치 크기가 150 근처는 되어야 GPU 연산 유닛이 제대로 일을 시작한다는 뜻이다. 배치 1로 서빙하면 연산 유닛은 대부분 놀고 있고, 시간은 전부 HBM에서 weight를 읽는 데 쓰인다. 이 상태를 memory-bound라고 한다.

결론: decode 단계의 성능은 연산 능력이 아니라 메모리 대역폭이 결정한다. 배칭은 이 대역폭을 효율적으로 쓰기 위한 수단이다.

2.3 주의할 점 — attention은 배칭 이득이 없다

위 계산은 weight 읽기에만 해당한다. KV 캐시 읽기는 시퀀스마다 내용이 다르므로 재사용이 불가능하다.

weight 읽기    : 배치가 커져도 총량 일정  →  재사용 O
KV 캐시 읽기   : 배치에 비례해 증가        →  재사용 X

따라서 배치를 키워도 attention 연산 부분은 여전히 memory-bound로 남는다. 컨텍스트가 길수록 KV 캐시 읽기 비중이 커지므로, 긴 컨텍스트 워크로드에서는 배칭의 이득이 상대적으로 줄어든다. FlashAttention이나 PagedAttention 같은 기법이 별도로 필요한 이유이기도 하다.


3. Static Batching

동작 방식

배치 크기를 고정하고, 그 수만큼 요청이 모이면 실행한다.

배치 크기 = 4로 고정

요청 도착:  A       B    C              D
시간:      ├───────┼────┼──────────────┼──────→
                                        ↑
                                   여기서 4개 충족 → 실행 시작

           └────────────────────────────┘
              A는 이만큼 대기했다

문제점

트래픽이 적을 때 지연시간이 통제되지 않는다. 3개만 들어오고 네 번째가 안 오면 무한정 기다린다. A 입장에서는 자기 요청이 언제 처리될지 예측할 수 없다.

배치 크기를 작게 잡으면 대기는 줄지만 GPU 효율이 떨어지고, 크게 잡으면 효율은 오르지만 대기가 길어진다. 트래픽 패턴이 일정하지 않은 실서비스에서 쓰기 어렵다.


4. Dynamic Batching

동작 방식

대기 시간에 상한을 둔다. 배치가 차거나 시간이 만료되거나, 둘 중 먼저 오는 조건에서 실행한다.

배치 크기 상한 = 4,  max_queue_delay = 50ms

요청 도착:  A       B    C
시간:      ├───────┼────┼─ ─ ─ ─ ─ ─ ─ ┤
           └──────── 50ms 경과 ────────┘
                                        ↑
                              3개만 모였지만 실행 시작

배치 크기가 상황에 따라 달라지므로 dynamic이다. Triton Inference Server의 기본 배처가 이 방식이다.

관련 설정:

dynamic_batching {
  preferred_batch_size: [ 4, 8 ]
  max_queue_delay_microseconds: 50000
}

남은 문제

지연시간 상한은 확보했지만, 한 번 실행된 배치는 안의 모든 요청이 끝날 때까지 구성이 고정된다.

출력 크기가 일정한 모델(이미지 분류, 임베딩, 객체 탐지 등)에서는 이게 문제가 되지 않는다. 배치 안의 요청들이 거의 동시에 끝나기 때문이다.

LLM은 다르다. 출력 길이가 요청마다 제각각이다.

시퀀스 A(5토큰), B(100토큰)가 함께 실행 중, C는 대기 중

step 1~5    : running = [A, B]
step 6      : A 완료 → running = [B]     ← 배치 크기가 2에서 1로 줄어든다
step 7~100  : running = [B]              ← C는 들어오지 못한다
step 101    : 배치 종료. 이제야 C 투입

배치가 끝나는 시점은 가장 긴 요청이 결정한다. 그리고 그때까지 배치 크기가 계속 줄어든다.


5. Continuous Batching

동작 방식

배치를 하나의 완결된 작업 단위로 보지 않는다. 매 토큰 스텝마다 배치 구성을 다시 계산한다. 완료된 시퀀스는 즉시 빠지고, 대기 큐의 요청이 그 자리에 바로 들어간다.

vLLM에서 continuous batching, TensorRT-LLM에서 in-flight batching이라 부르는 것이 같은 개념이다.

Continuous — running 리스트가 매 스텝 갱신된다

step 1~5    : running = [A, B, C, D]     배치 크기 4
step 6      : A 완료 → running = [E, B, C, D]
step 7      : C 완료 → running = [E, B, F, D]
step 10     : D 완료 → running = [E, B, F, G]
                                          배치 크기 4 유지

다만 신규 시퀀스는 투입되자마자 토큰을 뽑지 못한다. 프롬프트가 아직 처리되지 않았으므로 prefill부터 수행해야 하고, 그다음 스텝부터 decode 대열에 합류한다.

그래서 실제 스케줄러는 매 스텝마다 이런 것들을 결정한다 — 대기 큐에서 몇 개를 prefill에 넣을지, 진행 중인 시퀀스 중 누구를 decode 시킬지, 캐시가 부족하면 누구를 밀어낼지(preemption). max_num_batched_tokens가 여기서 일한다. prefill은 프롬프트 길이만큼 예산을 먹고 decode는 시퀀스당 1토큰만 먹으므로, 이 예산 안에서 둘을 배분하는 것이 스케줄러의 판단이다.

Dynamic — 구성이 고정이라 배치 크기가 줄어든다

step 1~5    : running = [A, B, C, D]     크기 4
step 6      : A 완료 → running = [B, C, D]      크기 3
step 7      : C 완료 → running = [B, D]         크기 2
step 10     : D 완료 → running = [B]            크기 1
              E·F·G는 B가 끝날 때까지 대기

낭비의 실체는 두 가지다.

첫째, 산술 강도가 무너진다. 2.2절에서 산술 강도가 배치 크기와 같다고 했다. 마지막 구간에서 배치 크기가 1이면 산술 강도도 1이다. GPU는 매 스텝 weight 전체를 HBM에서 읽어오는데 그것으로 처리하는 시퀀스는 하나뿐이다.

둘째, KV 캐시 풀의 블록이 회수되지 않는다. A·C·D가 끝났어도 그 블록들은 배치가 종료될 때까지 반납되지 않는다. 대기 중인 E·F·G는 캐시 공간이 남아 있는데도 시작하지 못한다.

누가 배치를 다시 짜는가 — 스케줄러는 CPU에 있다

"매 스텝 배치 구성을 다시 계산한다"고 했지만, 이 계산은 GPU가 하지 않는다. 스케줄러는 CPU에서 도는 파이썬 코드다.

┌─────────── CPU ───────────────────┐
│  스케줄러                          │
│   - waiting / running 리스트       │
│   - 블록 매니저 (블록 점유 장부)    │
│   → "이번 스텝엔 누구를 넣을지" 결정 │
└───────────────────────────────────┘
              ↓ 배치 구성 + block table 전달
┌─────────── GPU (VRAM) ────────────┐
│  weight                            │
│  KV 캐시 풀                        │
│  활성화                            │
│  → forward 연산만 수행              │
└───────────────────────────────────┘

running = [A, B, C, D] 같은 리스트는 수 킬로바이트짜리 파이썬 객체다. GPU로 넘어가는 것은 그 시퀀스들의 데이터(입력 토큰, KV 캐시 위치)이지 명단 자체가 아니다.

블록 배분도 마찬가지다. 블록의 실제 데이터는 VRAM에 있지만, "누가 어느 블록을 쓰는지"라는 장부는 CPU가 관리한다. 이 장부가 매 스텝 block table 형태로 GPU에 전달된다.

한 스텝의 전체 흐름

CPU ────────────────────────────────────────
  1. waiting/running 정리, 배치 구성 결정
  2. KV 캐시 블록 할당 (장부 갱신)
  3. block table + 입력 텐서 준비
             ↓
GPU ────────────────────────────────────────
  4. forward 실행
     - weight 읽기
     - block table로 각 시퀀스의 KV 캐시 위치 참조
     - 활성화 생성 → 소멸
  5. 출력 토큰 반환
             ↓
CPU ────────────────────────────────────────
  6. 각 시퀀스에 토큰 추가, <EOS> 확인
  7. 종료된 시퀀스 정리 → 1번으로

GPU는 지시받은 대로 계산만 한다. 판단은 전부 CPU에서 이루어진다.

continuous batching은 하드웨어 기능이 아니라 CPU 스케줄링 로직이다. 위 흐름에서 6번과 1번 사이에 리스트를 고치느냐 아니냐, 그것이 dynamic batching과의 차이 전부다.

부수 효과 — CPU가 병목이 될 수 있다

스케줄러가 CPU에서 돌기 때문에, 배치가 커질수록 스케줄링 비용도 커진다. 배치 크기가 256이면 매 스텝마다 파이썬이 256개 시퀀스의 상태를 훑고 블록 할당을 계산해야 한다.

decode 스텝 자체는 수 밀리초 단위인데 스케줄링에 그만큼 시간이 들면 GPU가 대기하게 된다. GPU 사용률이 100%에 못 미치는데 원인을 찾기 어려울 때 의심해 볼 지점이며, vLLM v1에서 스케줄러 경로를 대폭 재작성한 배경이기도 하다.

왜 이게 가능한가 — PagedAttention 전제

매 스텝마다 시퀀스가 교체되려면 KV 캐시를 자유롭게 할당·해제할 수 있어야 한다.

기존 방식은 시퀀스마다 max_seq_len 길이만큼 연속된 메모리를 미리 예약했다. 이 구조에서는 시퀀스를 넣고 빼는 과정에서 메모리가 파편화되어 금방 쓸 수 없게 된다. 애초에 시퀀스 수 × 최대 길이만큼 예약하면 KV 캐시 풀이 바로 고갈된다.

[ 연속 예약 방식 ]
┌──────────────────────────────────────┐
│ A(실사용 200) │      낭비 7992       │  ← max_seq_len 8192 예약
├──────────────────────────────────────┤
│ B(실사용 150) │      낭비 8042       │
└──────────────────────────────────────┘

[ PagedAttention — 블록 단위 할당 ]
블록 크기 16토큰

A의 논리 블록:  [0][1][2] ...
                 ↓  ↓  ↓        block table로 매핑
KV 캐시 풀:    [..][A0][B0][A1][..][A2][B1][..]
                     └─ 물리적으로 흩어져 있어도 무방

여기서 말하는 물리 블록은 GPU의 물리 페이지가 아니라, 기동 시 통째로 확보해 둔 KV 캐시 풀 안에서의 블록 번호다. 풀 자체는 연속된 하나의 영역이다.

블록 단위로 관리하므로 시퀀스가 수시로 붙었다 떨어져도 파편화가 발생하지 않는다. continuous batching과 PagedAttention은 독립적인 두 기법이 아니라, 후자가 전자의 구현 조건이다.


6. 세 방식 비교

StaticDynamicContinuous
배치 크기고정가변 (시간 상한)가변 (스텝마다)
실행 중 구성 변경불가불가가능
재구성 주기배치 단위토큰 스텝 단위
지연시간 예측성낮음확보확보
출력 길이 편차 대응불가불가가능
대표 구현Triton dynamic batchervLLM, TensorRT-LLM
적합 워크로드출력 크기 고정 모델LLM 생성

용어가 헷갈리는 이유는 두 단어의 대립항이 서로 다르기 때문이다.

static  ──개선──▶  dynamic  ──개선──▶  continuous
   │                  │                    │
"크기 고정"      "크기 가변"          "구성 가변"
                                   (배치가 끊기지 않음)

dynamic의 반대는 static이고, continuous의 반대는 '완결되는 배치'다. 나란히 놓고 비교할 짝이 아니라 순차적인 개선 계보로 보는 편이 정확하다.

참고: Triton은 dynamic batching만 가능한 서버가 아니다. TensorRT-LLM 백엔드를 사용하면 Triton에서도 in-flight batching이 동작한다. "Triton = dynamic"은 기본 배처를 기준으로 한 단순화다.


7. 실무 파라미터

vLLM 기준으로 배칭과 직접 연결되는 설정들이다.

파라미터역할상향 시
max_num_seqs동시 처리 시퀀스 수 상한처리량 ↑ / TPOT ↑
max_num_batched_tokens한 스텝의 토큰 예산prefill·decode 혼합 비율에 영향
gpu_memory_utilizationKV 캐시 풀 크기 결정동시 시퀀스 수 ↑ / OOM 위험 ↑
max_model_len시퀀스당 최대 컨텍스트시퀀스당 KV 예약량 ↑
enable_chunked_prefill긴 prefill을 분할 처리TTFT 편차 ↓

자주 하는 오해

무엇을 세는가결정 요소
max_num_seqs시퀀스 개수설정값 (기본 256)
KV 캐시 블록 수캐시 용량풀 크기 ÷ 블록 크기

max_num_seqs를 올리면 그만큼 동시 처리가 늘어난다 — 아니다. 이 값은 상한선이고, 실제 동시 처리 수는 KV 캐시 용량이 결정한다. 상한이 256개여도 캐시가 40개분밖에 없으면 40개까지만 돈다.

동시 처리량을 늘리려는 목적이라면 손대는 순서는 대체로 이렇다.

1. gpu_memory_utilization 상향     → KV 캐시 풀 확대
2. max_model_len 재검토            → 과대 설정이면 시퀀스당 예약이 낭비
3. max_num_seqs 상향               → 시퀀스 수 상한 확대
4. chunked prefill 활성화          → head-of-line blocking 완화
5. 양자화                          → weight 메모리를 줄여 캐시 공간 확보

지연시간과의 관계

배치를 키우면 처리량은 오르지만 개별 요청의 TPOT(토큰 간 간격)이 늘어난다. 그리고 평균보다 P99가 먼저 무너진다. 큐 대기가 분포의 꼬리에 집중되기 때문이다.

따라서 튜닝 순서는 "처리량을 최대로 올린다"가 아니라 "SLO를 먼저 정하고 그 안에서 최대 동시성을 찾는다"가 되어야 한다. 예를 들어 TTFT P99 2초, TPOT P99 50ms를 기준으로 두고 concurrency를 올려가며 그 선을 넘는 지점을 찾는 방식이다.


8. 정리

  • decode 단계는 memory-bound이며, 산술 강도는 배치 크기와 같다. 배칭은 한 번 읽어온 weight의 재사용 횟수를 늘리는 기법이다
  • static → dynamic → continuous는 각각 지연시간 통제출력 길이 편차라는 별개의 문제를 순서대로 해결한 계보다
  • continuous batching의 본질은 재구성 주기를 배치 단위에서 토큰 스텝 단위로 내린 것이다
  • 이는 KV 캐시를 블록 단위로 관리하는 PagedAttention이 있어야 성립한다
  • 배칭 파라미터의 실질적 제약은 스퀀스 수 상한이 아니라 KV 캐시 용량이다

참고

  • Kwon et al., Efficient Memory Management for Large Language Model Serving with PagedAttention (SOSP 2023)
  • Yu et al., Orca: A Distributed Serving System for Transformer-Based Generative Models (OSDI 2022) — continuous batching의 원형
  • vLLM 공식 문서, Engine Arguments
  • NVIDIA Triton Inference Server 문서, Dynamic Batcher
profile
GPU 인프라 / MLOps 엔지니어 오수진입니다. 쿠버네티스 기반 GPU 스케줄링(HAMi·KAI·Volcano)과 LLM 서빙 인프라를 다룹니다.

0개의 댓글