4-2편. LLM Serving Service(Multi Model Serving)

Gyullbb·2026년 8월 15일

LLM

목록 보기
6/7

4-1편에서는 하나의 GPU에서 하나의 모델을 서비스하는 문제를 다뤘다.

그런데 실제 서비스는 모델 하나만 필요로 하지 않는 경우가 많다.

서로 다른 모델에 대해서 각각 GPU 한 장씩 붙여 서비스하는 것은 비용 면에서 낭비다. 그렇다고 모든 모델을 항상 GPU 메모리에 올려두는 것도 메모리가 감당하지 못한다.

"제한된 GPU 메모리 위에서, 여러 개의 서로 다른 모델을 어떻게 같이 서비스할 것인가?"

이번 편에서는 Multi-Model Serving 서비스를 구현하며 방법을 확인해본다.


1. Multi-Model Serving이 왜 어려운가

4-1편의 핵심은 "요청을 어떻게 더 빨리, 더 많이 처리할 것인가"였다. 모델은 항상 하나였고, 그 모델은 항상 GPU에 올라가 있었다.

Multi-Model Serving은 여기에 축이 하나 더 늘어난다. 어떤 모델이 지금 GPU(또는 메모리)에 올라가 있는가다.

모델 A (500MB) ┐
모델 B (1.2GB) ├─ 전부 합치면 GPU Memory보다 크다
모델 C (800MB) ┘

선택지는 두 가지뿐이다.

모든 모델을 항상 올려둔다
  → 메모리 부족

요청이 올 때마다 그때그때 로드한다
  → 매 요청마다 로딩 시간(Cold Start) 발생

즉 앞에서 확인했던 "계산을 줄이는 대신 메모리를 쓴다"는 KV Cache의 Trade-off와 똑같은 구조가 여기서도 반복된다.

메모리를 아끼면 로딩 비용(Latency)이 늘고, 로딩 비용을 줄이면 메모리가 늘어난다.

이 균형을 맞추는 정책이 바로 캐시(Cache) 다. 캐시 구현을 확인해보자.


2. 전체 구조 — 4개의 계층

Client
  ↓
Server (FastAPI)
  ↓
ModelStore    ← "어떤 모델이 존재하는가" (메타데이터)
  ↓
ModelManager  ← "지금 무엇을 GPU에 올려둘 것인가" (LRU 캐시)
  ↓
ModelEngine   ← "Worker를 어떻게 만들 것인가" (Factory)
  ↓
ModelWorker   ← "실제로 어떻게 추론할 것인가" (프레임워크별 구현)

역할을 한 줄씩 정리하면 다음과 같다.

  • ModelStore : 서비스가 다룰 수 있는 모델 목록과 메타데이터를 안다. 모델을 직접 들고 있지는 않는다.
  • ModelManager : 지금 메모리에 어떤 모델이 올라가 있는지 캐시로 관리하고, 필요하면 오래된 모델을 내리고 새 모델을 올린다.
  • ModelEngine : 실제로 모델을 메모리에 올리는(Worker를 생성하는) 공장 역할을 한다. 프레임워크에 따라 어떤 Worker를 만들지 결정한다.
  • ModelWorker : 진짜 추론이 일어나는 곳이다. Transformer 모델, TorchVision 모델, Triton에 위임하는 모델이 각자 다른 방식으로 구현되어 있다.

핵심은 "어떤 모델이 존재하는가"(Store)와 "지금 무엇이 로드되어 있는가"(Manager)를 분리했다는 점이다.
이 분리 덕분에 캐시 정책(Manager)을 모델 목록(Store)이나 실제 추론 코드(Worker)를 건드리지 않고 바꿀 수 있다.


3. ModelStore — 존재하는 모델을 안다

config/models.json에는 서비스가 다룰 수 있는 모델 네 개가 등록되어 있다.

{
    "models": [
        { "id": "550e8400-...", "name": "distilbert-base-uncased-finetuned-sst-2-english",
          "type": "text",  "framework": "transformers", "description": "Sentiment analysis model" },
        { "id": "6ba7b810-...", "name": "mrm8488/bert-tiny-finetuned-sms-spam-detection",
          "type": "text",  "framework": "transformers", "description": "Spam detection model" },
        { "id": "7c9e6679-...", "name": "pytorch/vision:mobilenet_v2",
          "type": "image", "framework": "torchvision", "description": "Image classification model" },
        { "id": "8ba7b810-...", "name": "densenet_onnx",
          "type": "image", "framework": "triton", "description": "DenseNet ... served via Triton" }
    ]
}

같은 text 타입이라도 감성 분석과 스팸 탐지는 서로 다른 모델이고, 같은 image 타입이라도 하나는 TorchVision으로 직접 실행하고 다른 하나는 Triton 서버에 위임한다. ModelStore는 이 목록을 읽어 메타데이터만 들고 있는다.

class ModelMetadata(BaseModel):
    id: str
    name: str
    type: str
    framework: str
    version: str
    description: str

class ModelStore:
    def __init__(self, config_path: str):
        self.models: Dict[str, ModelMetadata] = {}
        self._load_config(config_path)

    def get_model(self, model_id: str) -> Optional[ModelMetadata]:
        return self.models.get(model_id)

주목할 점은 ModelStore모델 가중치를 로드하지 않는다는 것이다. id로 조회하면 "이 모델의 이름과 프레임워크가 무엇인지"만 알려줄 뿐, 실제로 GPU 메모리에 올리는 일은 다른 계층의 몫이다. "모델이 존재한다"와 "모델이 로드되어 있다"를 처음부터 구분해 둔 것이다.


4. ModelWorker — 프레임워크마다 추론 방식이 다르다

ModelWorker는 추상 클래스로, 모든 Worker가 지켜야 할 두 가지 규칙만 정의한다.

class ModelWorker(ABC):
    def __init__(self, model_metadata):
        self.model_metadata = model_metadata
        self.model: Optional[torch.nn.Module] = None
        self._load_model()

    @abstractmethod
    def _load_model(self):
        pass

    @abstractmethod
    def predict(self, input_data: Any) -> Dict[str, Any]:
        pass

Worker가 __init__ 되는 순간 _load_model()이 즉시 실행된다. 즉 Worker 객체를 만드는 것 자체가 모델을 메모리에 올리는 행위다.
이 지점이 뒤에서 볼 캐시 로직과 맞물린다.

TransformerWorker — 감성 분석 / 스팸 탐지

class TransformerWorker(ModelWorker):
    def _load_model(self):
        self.model = AutoModelForSequenceClassification.from_pretrained(self.model_metadata.name)
        self.tokenizer = AutoTokenizer.from_pretrained(self.model_metadata.name)

    def predict(self, input_data: Any) -> Dict[str, Any]:
        inputs = self.tokenizer(input_data, return_tensors="pt", padding=True, truncation=True)
        with torch.no_grad():
            outputs = self.model(**inputs)
        predictions = torch.softmax(outputs.logits, dim=-1)
        return {"predictions": predictions.tolist()}

감성 분석 모델과 스팸 탐지 모델은 서로 다른 모델이지만 같은 TransformerWorker 클래스를 쓴다. models.jsonname만 다를 뿐 둘 다 AutoModelForSequenceClassification 구조이기 때문이다. 프레임워크가 같으면 Worker 코드도 재사용된다.

TorchVisionWorker — 이미지 분류 모델

class TorchVisionWorker(ModelWorker):
    def _load_model(self):
        self.model = mobilenet_v2(weights=MobileNet_V2_Weights.DEFAULT)
        self.model.eval()
        self.transform = transforms.Compose([...])

    def predict(self, input_data: Any) -> Dict[str, Any]:
        image = Image.open(input_data).convert('RGB') if isinstance(input_data, str) else input_data
        image_tensor = self.transform(image).unsqueeze(0)
        with torch.no_grad():
            outputs = self.model(image_tensor)
        return {"predictions": torch.softmax(outputs, dim=1).tolist()}

Text 입력을 Tokenizer로 바꾸던 TransformerWorker와 달리, 여기서는 이미지를 Resize·Crop·Normalize하는 전처리(transform)가 필요하다. 입력 형태가 다르면 전처리 로직도 통째로 달라진다는 것을 보여준다.

TritonWorker — 모델을 아예 우리 프로세스 밖에 둔다

class TritonWorker(ModelWorker):
    def __init__(self, model_metadata):
        self.triton_url = "0.0.0.0:8009"
        self.client = httpclient.InferenceServerClient(url=self.triton_url)
        super().__init__(model_metadata)

    def _load_model(self):
        load_url = f"http://{self.triton_url}/v2/repository/models/{self.model_metadata.name}/load"
        requests.post(load_url)
        if not self.client.is_model_ready(self.model_metadata.name):
            raise RuntimeError("Model is not ready after loading")

    def predict(self, input_data: Dict[str, Any]) -> Dict[str, Any]:
        ...
        response = self.client.infer(model_name=self.model_metadata.name, inputs=inputs, outputs=[...])
        return {output_name: response.as_numpy(output_name).tolist()}

앞의 두 Worker는 self.model에 실제 PyTorch 모델 객체를 들고 있었다. TritonWorker는 다르다. self.model은 끝까지 None이고, _load_model()은 모델을 우리 프로세스의 메모리에 올리는 게 아니라 별도로 떠 있는 Triton Inference Server에 "이 모델을 로드해달라"고 HTTP로 요청할 뿐이다. predict()도 실제 연산 대신 Triton에 추론을 요청하고 결과만 받아온다.

TransformerWorker / TorchVisionWorker
  → 모델이 이 프로세스의 GPU 메모리 위에 있다

TritonWorker
  → 모델은 Triton 서버(다른 프로세스, 다른 GPU일 수도 있음)에 있다
  → 이 Worker는 그 앞의 HTTP Client일 뿐이다

즉 Multi-Model Serving의 해법이 항상 "내 프로세스 안에서 여러 모델을 돌려막기"인 것은 아니다. 모델 관리 자체를 전담 서빙 엔진에 위임하는 것도 하나의 답이다.


5. ModelEngine — Worker를 만드는 공장

class ModelEngine:
    def __init__(self):
        self.workers = {}

    def create_worker(self, model_metadata: ModelMetadata) -> ModelWorker:
        if model_metadata.id not in self.workers:
            if model_metadata.framework == "transformers":
                self.workers[model_metadata.id] = TransformerWorker(model_metadata)
            elif model_metadata.framework == "torchvision":
                self.workers[model_metadata.id] = TorchVisionWorker(model_metadata)
            elif model_metadata.framework == "triton":
                self.workers[model_metadata.id] = TritonWorker(model_metadata)
            else:
                raise ValueError(f"Unsupported framework: {model_metadata.framework}")
        return self.workers[model_metadata.id]

    def delete_worker(self, model_id: str):
        if model_id in self.workers:
            del self.workers[model_id]

ModelEngine이 하는 일은 단순하다. framework 값을 보고 어떤 Worker 클래스를 만들지 결정하는 것뿐이다(Factory 패턴).
create_worker가 호출되는 순간 Worker의 __init___load_model()이 실행되므로, 이 함수 호출 자체가 "모델을 메모리에 올린다"는 행위와 동일하다.
반대로 delete_worker는 참조를 지워 모델을 메모리에서 내린다.

ModelEngine은 "언제" 만들고 지울지는 전혀 모른다. 그 결정은 다음 계층인 ModelManager가 한다.


6. ModelManager — LRU로 "지금 누구를 태울지" 결정한다

이 서비스의 핵심이다.

class ModelManager:
    def __init__(self, model_store: ModelStore, max_models: int = 2):
        self.model_store = model_store
        self.max_models = max_models
        self.model_cache = OrderedDict()  # id -> worker, LRU 순서 유지
        self.model_engine = ModelEngine()

    def get_model_worker(self, model_id: str) -> Optional[ModelWorker]:
        # 1. 캐시에 있으면 그대로 반환 (Cache Hit)
        if model_id in self.model_cache:
            self.model_cache.move_to_end(model_id)  # 최근 사용으로 갱신
            return self.model_engine.get_worker(model_id)

        # 2. 메타데이터 확인
        model_metadata = self.model_store.get_model(model_id)
        if not model_metadata:
            return None

        # 3. 캐시가 가득 찼으면 가장 오래 안 쓴 모델을 내린다
        if len(self.model_cache) >= self.max_models:
            id, _ = self.model_cache.popitem(last=False)
            self.model_engine.delete_worker(id)

        # 4. 새 모델을 로드하고 캐시에 넣는다 (Cache Miss)
        self.model_cache[model_id] = self.model_engine.create_worker(model_metadata)
        return self.model_cache[model_id]

OrderedDict를 LRU 큐로 쓰는 방식은 다음과 같이 동작한다.

Cache Hit  → move_to_end()로 "방금 쓴 모델"로 갱신, 로딩 없이 즉시 반환
Cache Miss → 캐시가 가득 찼다면 popitem(last=False)로 "가장 오래 안 쓴 모델"을 내림
           → create_worker()로 새 모델을 로드

max_models=2로 설정된 상태에서 요청이 다음 순서로 들어온다고 해보자.

① 감성 분석 요청  → Cache Miss → 로드 → [감성분석]
② 스팸 탐지 요청  → Cache Miss → 로드 → [감성분석, 스팸탐지]
③ 이미지 분류 요청 → Cache Miss, 캐시 가득 참
                  → 가장 오래된 "감성분석"을 내림
                  → 이미지 분류 로드     → [스팸탐지, 이미지분류]
④ 감성 분석 요청 다시 → 이미 캐시에서 빠졌으므로 다시 Cache Miss
                     → 이번엔 "스팸탐지"를 내리고 재로드 → [이미지분류, 감성분석]

세 가지 모델을 번갈아 요청하면 매번 캐시가 밀려나는 것을 볼 수 있다. 이것이 바로 Cache Thrashing이다 — max_models보다 자주 바뀌는 모델 종류를 요청하면, 캐시가 있어도 매번 다시 로드하게 되어 캐시의 이점이 사라진다.

이 구조는 PagedAttention과 같은 발상이다.
PagedAttention이 "어떤 KV Cache Block을 GPU Memory에 남겨둘 것인가"를 관리했다면, ModelManager는 "어떤 모델 전체를 GPU Memory(혹은 프로세스 메모리)에 남겨둘 것인가"를 관리한다.
관리하는 단위가 KV Cache Block이냐 모델 전체냐만 다를 뿐, "한정된 메모리 위에서 무엇을 남기고 무엇을 내릴지 결정하는 캐시 정책"이라는 본질은 같다.


7. Server — Manager에게 결정을 위임한다

@app.post("/predict")
async def predict(request: PredictionRequest):
    worker = model_manager.get_model_worker(request.model_id)
    if not worker:
        raise HTTPException(status_code=404, detail=f"Model {request.model_id} not found")
    result = worker.predict(request.input_data)
    return result

@app.get("/models")
async def list_models():
    return {
        "available_models": model_store.list_models(),      # Store: 존재하는 모델
        "loaded_models": model_manager.list_loaded_models()  # Manager: 지금 로드된 모델
    }

/predict 엔드포인트는 요청받은 model_id가 지금 캐시에 있는지, 새로 로드해야 하는지 전혀 알지 못한다. get_model_worker() 한 줄 뒤에 그 판단이 모두 숨어 있다. /models 엔드포인트는 "존재하는 모델 목록"(Store)과 "지금 로드된 모델 목록"(Manager)을 나란히 보여주는데, 이 둘이 다를 수 있다는 사실 자체가 Multi-Model Serving의 핵심을 보여준다.


8. Cold Start — 세 번째 종류의 병목

3-2편에서는 Prefill을 Compute-bound로, Decode를 Memory-bound로 구분했다. Multi-Model Serving에는 여기에 없던 세 번째 병목이 등장한다. 바로 Model Loading Latency(Cold Start)다.

Cache Hit  → predict()만 실행 → 빠름
Cache Miss → _load_model() (가중치 로딩) + predict() → 느림

캐시에 없는 모델을 처음 요청하면, 실제 추론(predict)보다 모델 가중치를 불러오는 과정(_load_model)이 훨씬 오래 걸리는 경우가 많다. 사용자 입장에서는 같은 모델에 같은 요청을 보냈는데도 어떤 요청은 빠르고 어떤 요청은 느린 것처럼 느껴진다.

max_models를 키우면 Cold Start는 줄어들지만 메모리 사용량이 늘고, max_models를 줄이면 메모리는 아끼지만 트래픽 패턴에 따라 Cache Thrashing이 심해진다. 정답은 없고, 실제 트래픽에서 모델별 요청 비율과 GPU 메모리 여유를 보고 정해야 하는 값이다.


9. Routing — 어떤 요청을 어떤 모델로 보낼 것인가

지금 이 서비스에서 라우팅은 단순하다. 클라이언트가 model_id를 직접 지정하면 그 모델로 간다.

{"model_id": "550e8400-...", "input_data": "This movie was great!"}

실제 서비스에서는 이보다 복잡한 라우팅이 필요할 수 있다.

  • 내용 기반 라우팅 : 입력이 텍스트인지 이미지인지 보고 자동으로 모델을 고른다.
  • 부하 기반 라우팅 : 같은 모델의 복제본(Replica)이 여러 개 떠 있다면, 덜 바쁜 쪽으로 보낸다.
  • 버전 기반 라우팅 : 같은 모델의 새 버전과 기존 버전에 트래픽을 나눠 A/B 테스트한다.

이 데모는 라우팅 자체보다 "선택된 모델을 어떻게 메모리에 올리고 내릴 것인가"에 집중한 구조라고 이해하면 된다.


10. 왜 Triton 같은 전용 서버가 필요한가

TritonWorker를 다시 보자. 이 Worker는 모델을 직접 들고 있지 않고, Triton Inference Server에 로드·추론을 위임했다.

우리가 만든 ModelManager
  → LRU 캐시로 "최대 2개 모델"이라는 단순한 정책만 구현

Triton Inference Server
  → 모델별 GPU 배치, Dynamic Batching, 모델 버전 관리,
    여러 GPU에 걸친 동시 실행까지 지원하는 전용 엔진

앞선 글에서 "Single Request → Batch → Streaming Batch"를 직접 구현해보고 나서야 vLLM이 왜 필요한지 체감할 수 있었던 것처럼, 여기서도 마찬가지다. ModelManager의 LRU 캐시는 "모델이 몇 개 안 되고, 정책도 단순할 때"는 충분하다. 하지만 모델 수가 늘고, GPU가 여러 장이 되고, 모델마다 트래픽 패턴이 다양해지면 이런 로직을 직접 정교하게 관리하는 것보다 Triton 같은 Multi-Model Serving 전용 플랫폼에 위임하는 편이 낫다.

Multi-Model Serving도 결국 "내가 관리할 것인가, 전용 엔진에 맡길 것인가"의 문제로 수렴한다.


마무리

  • 문제의 본질 : 한정된 GPU 메모리보다 서비스해야 할 모델의 총합이 클 때, 무엇을 올리고 내릴지 결정하는 캐시 정책이 필요하다.
  • 계층 분리 : "존재하는 모델"(Store)과 "지금 로드된 모델"(Manager)을 분리하면 캐시 정책을 독립적으로 바꿀 수 있다.
  • LRU 캐시 : 가장 오래 쓰지 않은 모델을 내리고 새 모델을 올린다. KV Cache를 Block 단위로 관리하던 PagedAttention과 같은 발상을, 모델 전체 단위로 적용한 것이다.
  • Cold Start : 캐시 미스는 모델 로딩 시간을 그대로 응답 지연에 더한다. max_models는 메모리와 Cold Start 사이의 Trade-off를 결정하는 값이다.
  • 위임이라는 선택지 : 모든 모델을 직접 관리할 필요는 없다. TritonWorker처럼 전용 서빙 엔진에 모델 관리 자체를 맡길 수도 있다.

1편에서는 GPU가 왜 딥러닝에 적합한 구조인지, 2편에서는 그 위에서 Transformer가 어떻게 계산되는지 살펴봤다. 3-1편과 3-2편에서는 이 모델을 서비스로 만들 때 어디가 병목이 되는지 — Prefill의 Compute-bound, Decode의 Memory-bound, 그리고 KV Cache의 Trade-off를 짚었다. 4편-1과 4편-2에서는 이 이론을 직접 코드로 옮겨, 하나의 모델을 효율적으로 서비스하는 문제(Batching, Streaming, vLLM)와 여러 모델을 제한된 자원 위에서 같이 서비스하는 문제(캐시, Cold Start, Triton)를 모두 다뤘다.

결국 핵심은 다음이다.

한정된 Compute와 Memory 위에서, 무엇을 언제 실행하고 무엇을 언제 내릴 것인가.

0개의 댓글