AI 시스템의 수평 확장, 멀티테넌시 아키텍처, 속도 제한, 공정 스케줄링, 그리고 대규모 AI 서비스 운영을 위한 인프라 설계를 다룹니다.
전통적인 웹 서비스는 무상태(Stateless) 서버를 수평으로 늘리면 비교적 쉽게 확장됩니다. 로드 밸런서 뒤에 서버를 추가하면 처리량이 선형적으로 증가합니다. 그러나 AI 시스템은 여러 가지 이유로 이러한 단순한 확장이 어렵습니다.
상태 유지 대화는 채팅 기반 AI 서비스의 핵심 난제입니다. 대화 컨텍스트를 유지해야 하며, 사용자의 이전 메시지, 시스템 프롬프트, 도구 호출 결과 등이 누적됩니다. 이 상태를 요청 간에 전달해야 합니다.
가변적 처리 시간은 LLM 추론의 고유한 특성입니다. 입력 길이와 생성 길이에 따라 처리 시간이 크게 달라집니다. 100 토큰을 생성하는 요청과 4,000 토큰을 생성하는 요청의 소요 시간 차이가 40배에 달할 수 있습니다. 이는 작업 분배와 자원 할당을 복잡하게 만듭니다.
대용량 컨텍스트는 최신 모델이 200K 토큰 이상의 컨텍스트를 지원하면서 발생하는 문제입니다. 대용량 컨텍스트를 포함한 요청은 메모리와 처리 시간을 불균형하게 소비합니다.
외부 API 병목은 자체 모델을 운영하지 않는 한 피할 수 없습니다. LLM API 제공자의 속도 제한이 확장의 천장이 되며, 서버를 아무리 늘려도 API 할당량을 초과할 수 없습니다.
자체 모델을 운영하는 경우에도 GPU 자원이 병목이 됩니다. GPU 클러스터의 확장은 물리적, 비용적 제약이 크므로, 아키텍처 수준의 최적화가 더욱 중요합니다.
확장 가능한 AI 시스템의 핵심은 추론 처리 로직을 무상태로 설계하는 것입니다. 대화 상태를 워커 내부에 보관하지 않고 외부 저장소(Redis, DynamoDB 등)로 분리하면, 워커를 자유롭게 추가하거나 제거할 수 있습니다.
interface ConversationState {
sessionId: string;
messages: Array<{ role: string; content: string }>;
systemPrompt: string;
modelConfig: {
model: string;
temperature: number;
maxTokens: number;
};
metadata: {
tenantId: string;
userId: string;
createdAt: string;
lastActivityAt: string;
};
}
class InferenceWorker {
constructor(
private readonly stateStore: StateStore,
private readonly llmClient: LLMClient,
private readonly metricsCollector: MetricsCollector
) {}
async processRequest(
sessionId: string,
userMessage: string
): Promise<string> {
// 1. 외부 저장소에서 대화 상태 로드
const state = await this.stateStore.load(sessionId);
if (!state) {
throw new Error(`세션을 찾을 수 없습니다: ${sessionId}`);
}
// 2. 메시지 추가
state.messages.push({ role: "user", content: userMessage });
// 3. LLM 호출
const startTime = Date.now();
const response = await this.llmClient.chat({
model: state.modelConfig.model,
messages: [
{ role: "system", content: state.systemPrompt },
...state.messages,
],
temperature: state.modelConfig.temperature,
maxTokens: state.modelConfig.maxTokens,
});
// 4. 응답 저장 및 상태 갱신
state.messages.push({ role: "assistant", content: response.content });
state.metadata.lastActivityAt = new Date().toISOString();
await this.stateStore.save(sessionId, state);
// 5. 메트릭 기록
this.metricsCollector.record({
sessionId,
tenantId: state.metadata.tenantId,
latencyMs: Date.now() - startTime,
inputTokens: response.usage.inputTokens,
outputTokens: response.usage.outputTokens,
});
return response.content;
}
}
interface StateStore {
load(sessionId: string): Promise<ConversationState | null>;
save(sessionId: string, state: ConversationState): Promise<void>;
delete(sessionId: string): Promise<void>;
}
interface LLMClient {
chat(params: {
model: string;
messages: Array<{ role: string; content: string }>;
temperature: number;
maxTokens: number;
}): Promise<{ content: string; usage: { inputTokens: number; outputTokens: number } }>;
}
interface MetricsCollector {
record(data: {
sessionId: string;
tenantId: string;
latencyMs: number;
inputTokens: number;
outputTokens: number;
}): void;
}대화 상태를 외부 저장소에 분리했더라도, 빈번한 읽기/쓰기로 인한 지연을 줄이기 위해 세션 어피니티(Session Affinity)를 적용할 수 있습니다. 동일한 세션의 요청을 같은 워커로 라우팅하면, 로컬 캐시를 활용하여 상태 로드 시간을 단축합니다.
단, 세션 어피니티는 "힌트"로 취급해야 합니다. 해당 워커가 다운되면 다른 워커가 외부 저장소에서 상태를 로드하여 즉시 처리할 수 있어야 합니다. 어피니티를 강제 고정으로 구현하면 특정 워커에 부하가 집중되고 장애 전파의 원인이 됩니다.
SaaS 형태로 AI 서비스를 제공할 때, 여러 고객(테넌트)이 동일한 인프라를 공유하면서도 데이터와 설정이 격리되어야 합니다.
멀티테넌시 격리는 보안, 비용, 운영 복잡도 사이의 균형입니다.
| 격리 수준 | 설명 | 장점 | 단점 |
|---|---|---|---|
| 논리적 격리 | 동일 인프라, 테넌트 ID로 구분 | 비용 효율, 관리 단순 | 노이지 네이버 위험 |
| 네임스페이스 격리 | K8s 네임스페이스로 분리 | 리소스 할당 제어 가능 | 중간 수준 복잡도 |
| 전용 인프라 | 테넌트별 독립 클러스터 | 완전 격리, 커스터마이징 | 높은 비용, 운영 부담 |
대부분의 경우 논리적 격리에서 시작하여 요구사항에 따라 격리 수준을 높이는 접근이 현실적입니다.
각 테넌트는 자체적인 모델 설정, 프롬프트, 가드레일, 사용량 할당량을 가질 수 있습니다.
interface TenantConfig {
tenantId: string;
tier: "starter" | "professional" | "enterprise";
// 모델 설정
allowedModels: string[];
defaultModel: string;
customSystemPrompt?: string;
// 사용량 제한
quotas: {
requestsPerMinute: number;
requestsPerDay: number;
tokensPerMonth: number;
maxInputTokensPerRequest: number;
maxOutputTokensPerRequest: number;
};
// 기능 플래그
features: {
ragEnabled: boolean;
toolUseEnabled: boolean;
streamingEnabled: boolean;
customModelsEnabled: boolean;
};
// 데이터 격리
dataConfig: {
vectorNamespace: string;
cachePrefix: string;
logRetentionDays: number;
};
}
class TenantConfigService {
private configCache: Map<string, { config: TenantConfig; expiresAt: number }> =
new Map();
constructor(private readonly configStore: ConfigStore) {}
async getConfig(tenantId: string): Promise<TenantConfig> {
const cached = this.configCache.get(tenantId);
if (cached && Date.now() < cached.expiresAt) {
return cached.config;
}
const config = await this.configStore.loadTenantConfig(tenantId);
this.configCache.set(tenantId, {
config,
expiresAt: Date.now() + 300000, // 5분 캐시
});
return config;
}
async enforceQuota(
tenantId: string,
usage: { tokens: number }
): Promise<{ allowed: boolean; reason?: string }> {
const config = await this.getConfig(tenantId);
const monthlyUsage = await this.configStore.getMonthlyTokenUsage(tenantId);
if (monthlyUsage + usage.tokens > config.quotas.tokensPerMonth) {
return {
allowed: false,
reason: `월간 토큰 한도 초과: ${monthlyUsage}/${config.quotas.tokensPerMonth}`,
};
}
return { allowed: true };
}
}
interface ConfigStore {
loadTenantConfig(tenantId: string): Promise<TenantConfig>;
getMonthlyTokenUsage(tenantId: string): Promise<number>;
}멀티테넌시 환경에서 가장 흔한 실수는 테넌트 ID 검증을 누락하는 것입니다. 모든 데이터 접근 경로에서 테넌트 ID를 검증하지 않으면, 한 테넌트가 다른 테넌트의 데이터에 접근할 수 있는 보안 취약점이 발생합니다. 미들웨어 수준에서 테넌트 컨텍스트를 강제하는 것이 안전합니다.
여러 테넌트가 공유 인프라를 사용할 때, 한 테넌트의 과도한 사용이 다른 테넌트의 서비스 품질을 저하시키는 노이지 네이버(Noisy Neighbor) 문제가 발생합니다.
속도 제한을 단일 계층으로 구현하면 정교한 제어가 어렵습니다. 다층 구조로 설계하여 유연성을 확보합니다.
전역 제한: 시스템 전체의 처리 용량을 보호합니다. 외부 API 제공자의 속도 제한에 맞춰 설정합니다.
테넌트 제한: 테넌트 등급(tier)에 따라 차등적으로 할당합니다. Enterprise 테넌트는 Starter 대비 10배의 할당량을 가질 수 있습니다.
사용자 제한: 개별 사용자의 남용을 방지합니다. 자동화된 스크립트나 봇의 과도한 호출을 차단합니다.
속도 제한이 "누구를 차단할 것인가"의 문제라면, 공정 스케줄링(Fair Scheduling)은 "한정된 자원을 어떻게 분배할 것인가"의 문제입니다. 가중 공정 큐잉(Weighted Fair Queuing) 알고리즘을 사용하면 각 테넌트에 가중치를 부여하고, 대기열에서 가중치에 비례하여 요청을 처리합니다.
이 방식의 장점은 유휴 테넌트의 미사용 용량을 활성 테넌트가 활용할 수 있다는 것입니다. 고정 할당과 달리 자원 낭비가 줄어듭니다.
AI 워크로드의 자동 확장은 전통적인 CPU/메모리 기반 확장과 다른 신호를 사용해야 합니다.
| 신호 | 설명 | 확장 방향 |
|---|---|---|
| 큐 깊이(Queue Depth) | 대기 중인 요청 수 | 큐가 깊어지면 워커 추가 |
| 평균 대기 시간 | 요청이 큐에서 대기하는 시간 | 대기 시간 증가 시 확장 |
| P95 응답 시간 | 95번째 백분위 응답 시간 | SLA 임계값 접근 시 확장 |
| API 할당량 여유율 | 외부 API 속도 제한 대비 현재 사용률 | 여유가 없으면 확장 불필요 |
핵심 원칙은 외부 API 할당량을 고려하는 것입니다. API 제공자의 속도 제한에 이미 근접했다면, 워커를 추가해도 처리량이 증가하지 않습니다. 이 경우 확장 대신 큐잉과 백프레셔(Backpressure)로 대응합니다.
Kubernetes는 AI 워크로드의 운영에 적합한 기반을 제공합니다. HPA(Horizontal Pod Autoscaler)에 커스텀 메트릭을 연결하여 큐 깊이나 응답 시간 기반으로 파드를 확장할 수 있습니다.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: inference-worker-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: inference-worker
minReplicas: 2
maxReplicas: 20
metrics:
- type: Pods
pods:
metric:
name: inference_queue_depth
target:
type: AverageValue
averageValue: "5"
- type: Pods
pods:
metric:
name: inference_p95_latency_ms
target:
type: AverageValue
averageValue: "3000"
behavior:
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Pods
value: 4
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Pods
value: 1
periodSeconds: 120AI 워커의 스케일 다운은 보수적으로 설정해야 합니다. LLM 응답이 진행 중인 파드가 종료되면 사용자 요청이 중단됩니다. scaleDown의 stabilizationWindowSeconds를 충분히 길게 설정하고, 파드 종료 전에 진행 중인 요청을 완료할 수 있도록 terminationGracePeriodSeconds를 넉넉하게 잡아야 합니다.
모든 AI 워크로드가 상시 가동 클러스터를 필요로 하는 것은 아닙니다. 트래픽이 간헐적이거나 배치 처리 위주인 경우, 서버리스 컴퓨팅이 비용 효율적인 대안이 됩니다.
서버리스 추론의 장점은 유휴 시간에 비용이 발생하지 않는다는 것이고, 단점은 콜드 스타트 지연과 실행 시간 제한입니다. 외부 LLM API를 호출하는 경량 워커에는 적합하지만, 자체 모델 추론에는 GPU 인스턴스의 콜드 스타트가 수십 초에 달하므로 실시간 서비스에는 부적합합니다.
실무에서는 하이브리드 접근이 효과적입니다. 기본 트래픽은 상시 가동 클러스터로 처리하고, 피크 시간의 초과 트래픽은 서버리스 워커로 오버플로우 처리합니다.
이번 장에서는 AI 시스템의 수평 확장, 멀티테넌시 격리, 속도 제한, 공정 스케줄링, 자동 확장 전략을 다루었습니다. 이 패턴들을 조합하면 수십에서 수천 개의 테넌트를 안정적으로 지원하는 대규모 AI 서비스를 운영할 수 있습니다. 마지막 장에서는 시리즈 전체의 아키텍처 패턴을 종합하여 실전 프로젝트를 설계하겠습니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
시리즈 전체의 아키텍처 패턴을 종합하여 프로덕션 AI-Native 시스템을 설계합니다. 전체 아키텍처 다이어그램, 기술 선택, 배포 전략을 다룹니다.
LLM 기반 시스템의 관측 가능성 설계 — 트레이싱, 메트릭, 로깅, 프롬프트 버전 관리, 품질 모니터링, 그리고 AI 특화 대시보드 구축을 다룹니다.
AI 시스템의 장애 시나리오와 회복 탄력성 패턴 — 서킷 브레이커, 폴백, 재시도, 타임아웃, 모델 장애 조치, 그리고 그레이스풀 디그레이데이션을 다룹니다.