왜 테넌트마다 모델을 띄우면 안 되는가

테넌트(고객사)별로 파인튜닝한 모델을 각각 독립 프로세스로 올리면 가장 큰 병목은 GPU 메모리다. 7B 모델을 fp16으로 올리면 약 14GB를 차지한다. 테넌트가 30개면 30장의 GPU가 필요하지만, 실제로 동시에 요청이 몰리는 테넌트는 몇 개 되지 않는다. 유휴 GPU가 대부분인 구조가 된다.

LoRA(Low-Rank Adaptation)는 베이스 모델의 가중치를 그대로 두고, 각 레이어에 저랭크 행렬 두 개(A, B)만 추가로 학습한다. 어댑터 하나의 크기는 보통 수십~수백 MB에 불과하다. 따라서 베이스 모델 한 벌을 GPU에 상주시키고, 요청마다 테넌트의 어댑터만 갈아 끼우는(hotswap) 구조가 성립한다.

핫스왑의 핵심 아이디어

추론 시점의 가중치는 개념적으로 W = W_base + (B·A)·(α/r) 형태다. 여기서 W_base는 공유되고 B·A만 테넌트별로 다르다. 두 가지 전략이 있다.

  • Merge 방식: 어댑터를 베이스에 합쳐 단일 가중치로 만든다. 추론 오버헤드는 0이지만 스왑할 때 merge/unmerge 비용이 크고, 배치 안에 여러 테넌트를 섞을 수 없다.
  • Unmerged(동적) 방식: 어댑터를 분리 유지하고 추론 중 B·A를 더한다. 약간의 연산 오버헤드가 있지만, 서로 다른 테넌트 요청을 한 배치에 묶는 것이 가능하다. vLLM의 Multi-LoRA가 이 방식이다.
항목테넌트별 전체 모델LoRA 핫스왑
테넌트당 추가 메모리~14GB수십~수백 MB
배치 혼합불가가능(unmerged)
신규 테넌트 온보딩모델 로드어댑터 파일 등록

vLLM으로 다중 어댑터 서빙하기

vLLM은 요청 단위로 어댑터를 지정할 수 있어 별도 로직 없이 멀티테넌트 서빙이 된다. --max-loras는 GPU에 동시 상주시킬 어댑터 수, --max-lora-rank는 허용 랭크 상한이다.

python -m vllm.entrypoints.openai.api_server \
  --model meta-llama/Llama-3.1-8B \
  --enable-lora \
  --max-loras 8 \
  --max-lora-rank 32 \
  --max-cpu-loras 64 \
  --lora-modules \
    tenant_a=/models/adapters/tenant_a \
    tenant_b=/models/adapters/tenant_b

요청에서는 model 필드에 어댑터 이름을 넣으면 해당 테넌트의 어댑터로 응답한다.

curl http://localhost:8000/v1/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "tenant_a",
    "prompt": "고객 문의를 요약해줘: ...",
    "max_tokens": 256
  }'

런타임에 어댑터 추가/제거

테넌트가 늘어날 때마다 서버를 재시작하면 다른 테넌트의 요청이 끊긴다. vLLM은 무중단으로 어댑터를 등록/해제하는 API를 제공한다(VLLM_ALLOW_RUNTIME_LORA_UPDATING=1 필요).

curl -X POST http://localhost:8000/v1/load_lora_adapter \
  -H "Content-Type: application/json" \
  -d '{"lora_name": "tenant_c", "lora_path": "/models/adapters/tenant_c"}'

# 사용 종료된 테넌트 해제(CPU 캐시에서 내림)
curl -X POST http://localhost:8000/v1/unload_lora_adapter \
  -H "Content-Type: application/json" \
  -d '{"lora_name": "tenant_c"}'

max_loras를 넘는 어댑터는 요청 시 CPU→GPU로 스왑인된다. 이 스왑 비용을 줄이려면 활성 테넌트를 GPU에 유지하는 LRU 정책을 앞단에서 관리하는 것이 좋다.

어댑터 레지스트리 설계

어떤 테넌트가 어떤 어댑터 버전을 쓰는지 추적하려면 가벼운 메타데이터 테이블이 필요하다. 롤백과 A/B 테스트를 위해 버전을 분리해 두는 것이 핵심이다.

CREATE TABLE tenant_adapters (
  tenant_id   TEXT NOT NULL,
  version     INT  NOT NULL,
  path        TEXT NOT NULL,
  rank        INT  NOT NULL,
  base_model  TEXT NOT NULL,
  is_active   BOOLEAN DEFAULT FALSE,
  created_at  TIMESTAMPTZ DEFAULT now(),
  PRIMARY KEY (tenant_id, version)
);

-- 활성 어댑터만 조회해 게이트웨이가 라우팅
SELECT tenant_id, path, rank
FROM tenant_adapters
WHERE is_active = TRUE AND base_model = 'Llama-3.1-8B';

운영 시 주의점

  • 베이스 모델이 반드시 같아야 한다. 어댑터는 학습 당시의 베이스에 종속된다. 게이트웨이에서 base_model 불일치 요청은 거부해야 조용한 품질 저하를 막는다.
  • 랭크가 섞이면 배치 효율이 떨어진다. max_lora_rank는 상한이므로 낮은 랭크로 통일할수록 메모리와 커널 효율이 좋다. 테넌트별로 r=8, r=64가 뒤섞이면 상한을 크게 잡아야 해 손해다.
  • 콜드 스왑 지연을 측정하라. 처음 호출되는 테넌트는 어댑터 로드 지연이 first-token latency에 더해진다. p99를 볼 때 스왑인 비용을 분리해 관측해야 한다.
  • 테넌트 격리. 배치 혼합은 성능상 이득이지만, 어댑터 이름 검증과 요청 인증을 분리해 다른 테넌트의 어댑터가 지정되지 않도록 게이트웨이에서 강제한다.

정리하면, 활성 테넌트가 소수이고 베이스 모델을 공유할 수 있는 환경에서 LoRA 핫스왑은 GPU 대수를 테넌트 수가 아니라 동시 부하에 비례하게 만든다. 다만 베이스 일치·랭크 통일·콜드 스왑 관측이라는 세 가지 운영 규칙을 지키는 것이 전제다.