Issue 01중국 AI
AC POST
중국 AI 목록
掘金2026년 9월 19일 09:15중국어 → 한국어

LLM 애플리케이션에 벌크헤드 격리 적용하는 엔지니어링 실무

배치 작업이 모델 호출 풀을 가득 채워 핵심 기능이 타임아웃되는 문제를 신호량·토큰 예산·테넌트·프로바이더 단위 격리와 코드 예시, 지연 비교 데이터로 다룬다.

중국어 원문을 AI로 번역했습니다. 고유명사와 수치는 원문 표기를 우선하며, 중요한 판단에는 아래 출처 원문을 함께 확인하세요.

0. 우리를 P0 온콜로 몰아넣은 그 오후

작년 7월, 우리 AI 어시스턴트 애플리케이션이 업무일 오후 2시에 갑자기 전면 타임아웃되었다. 사용자 대화 요청의 평균 응답 시간이 1.4초에서 23초로 치솟았고, 15분 만에 고객센터 티켓이 80건 쏟아졌다.

20분간 조사한 끝에 원인을 찾았다. 한 기업 고객의 관리자가 '일괄 내보내기' 기능을 실행한 것——3,000편의 문서를 전부 AI로 요약하게 한 것이다. 이 기능에는 아무런 동시성 제한이 없었고, 60개의 동시 요청으로 우리의 LLM 호출 풀을 꽉 채워버렸다. 모든 일반 사용자의 대화 요청이 전부 대기열에 줄을 서다가 타임아웃되었다.

복구는 빨랐다. 그 작업들을 kill 하자 P99가 즉시 정상으로 돌아왔다. 하지만 진짜 문제가 드러났다. 우리 LLM 애플리케이션에는 아무런 자원 격리가 없었다. 모든 기능, 모든 테넌트, 모든 우선순위가 동일한 동시성 풀을 공유하고 있었다. 전통적인 백엔드라면 이것을 '격벽(선체 격벽) 부재'라고 부른다.

1. Bulkhead(격벽 패턴)란 무엇인가

Bulkhead는 선박 설계에서 유래했다. 선체를 격벽으로 나누어 독립된 격실로 분리해, 한 격실에 물이 차도 배 전체가 침몰하지 않도록 하는 것이다.

소프트웨어 공학에서 Michael Nygard는 《Release It!》에서 이를 자원 소비자에 대한 격리로 정의했다. 각 소비자 그룹에 독립된 자원 풀을 배정하고, 한 그룹이 자원을 고갈시켜도 다른 그룹에 영향을 주지 않도록 하는 것이다.

전통적인 마이크로서비스에서 Bulkhead는 주로 두 가지 방식으로 구현된다.

격리 방식 | 메커니즘 | 적용 시나리오 스레드 풀 격리 | 그룹별 독립 스레드 풀 | CPU 집약적, 동기 블로킹 호출 세마포어 격리 | 공유 스레드 풀 + 카운터 | I/O 집약적, 비동기 시나리오(LLM 호출이 이에 해당)

Netflix Hystrix가 가장 잘 알려진 구현이지만, 이는 Java 생태계의 것이다. Python/Node.js의 LLM 애플리케이션이라면 우리가 직접 구현해야 한다.

LLM 애플리케이션의 특수성: 전통적인 Bulkhead는 '동시 요청 수'만 격리하지만, LLM은 추가로 Token 소비량(TPM)까지 격리해야 한다. 100K context를 가진 요청 하나와 1K context 요청 하나가 소비하는 자원은 천차만별이다.

2. 왜 Rate Limiting으로는 부족한가

많은 팀의 첫 반응은 이것이다. "Rate Limit 하나 추가하면 되지 않나?"

부족하다. 이유는 세 가지다.

Rate Limit은 입구 방어이고, Bulkhead는 격실 격리다. Rate Limit은 요청이 들어오는 속도를 제한하지만, 이미 들어온 요청은 여전히 자원을 공유한다. 만약 배치 작업이 이미 돌고 있다면, Rate Limit은 새로운 요청이 들어오는 것만 막을 수 있을 뿐, 기존의 60개 동시 요청이 여전히 연결 풀을 점유하는 것은 막지 못한다.

Rate Limit은 보통 전역 또는 per-user 단위라서 기능 차원이 없다. 사용자에게 10 req/min을 설정할 수는 있지만, "배치 기능은 최대 동시성의 20%까지만 차지한다"라고 말할 수는 없다.

Rate Limit은 Token 차원을 다룰 수 없다. LLM의 TPM 상한은 RPM보다 더 중요하지만, 전통적인 Rate Limiter는 token을 이해하지 못한다.

완전한 방어 체계는 이래야 한다. Rate Limit(입구) + Bulkhead(자원 격리) + Circuit Breaker(장애 차단). 세 가지가 각각 하나의 차원을 담당한다.

3. LLM 애플리케이션의 Bulkhead 차원 설계

실제 엔지니어링에서 LLM 애플리케이션은 다음 몇 가지 차원에서 격리를 해야 한다.

┌─────────────────────────────────────────────────────┐ │ LLM 호출 계층 │ ├──────────────┬──────────────┬───────────────────────┤ │ 기능 차원 │ 테넌트 차원 │ Provider 차원 │ │ chat: 20 │ enterprise: │ deepseek: 30 │ │ summarize: 5 │ 30 │ qwen: 15 │ │ batch: 5 │ pro: 15 │ local: 10 │ │ embedding: 10 │ free: 5 │ │ ├──────────────┴──────────────┴───────────────────────┤ │ Token Budget 차원(중첩) │ │ batch: max 50K TPM | chat: max 200K TPM │ └─────────────────────────────────────────────────────┘

기능 차원(Feature Group): 비즈니스 기능별로 나누는 것이 가장 직접적이다. chat, batch_summarize, embedding, background_analysis가 각각 독립된 동시성 상한을 가진다.

테넌트 차원(Tenant Tier): Enterprise, Pro, Free 사용자가 서로 다른 자원 풀에 대응되어, 저가치 사용자가 고가치 사용자의 경험에 영향을 주는 것을 방지한다.

Provider 차원: 동일한 애플리케이션이 여러 LLM Provider(DeepSeek + 通義千問 + 로컬 모델)를 호출할 때, 각 Provider가 독립된 연결 풀과 재시도 큐를 가져, 한 Provider의 문제(타임아웃, 속도 제한)가 다른 Provider의 호출을 막는 것을 방지한다.

Token Budget 차원(LLM 특유): 요청 수 외에 추가로 분당 Token 소비를 제한하여, 소수의 대용량 context 요청이 TPM을 꽉 채우는 것을 방지한다.

4. Python 구현: AsyncBulkhead

아래는 asyncio 애플리케이션에 바로 사용할 수 있는 세마포어 Bulkhead 구현이다.

bulkhead.py import asyncio import time from dataclasses import dataclass, field from typing import Optional

import logging logger = logging.getLogger(__name__)

@dataclass class BulkheadConfig: max_concurrent: int # 최대 동시 실행 수 max_wait_ms: int = 5000 # 슬롯 획득을 기다리는 최대 시간(ms) max_tokens_per_min: Optional[int] = None # Token 예산(LLM 전용, 선택 사항)

@dataclass class BulkheadStats: total_accepted: int = 0 total_rejected: int = 0 total_timeout: int = 0 current_concurrent: int = 0 tokens_used_this_min: int = 0 _window_start: float = field(default_factory=time.time)

class AsyncBulkhead: """ 세마포어 방식의 Bulkhead로, Token 예산 격리(LLM 애플리케이션 전용)를 지원한다.

사용법: bulkhead = AsyncBulkhead("chat", BulkheadConfig(max_concurrent=20, max_tokens_per_min=200_000))

async with bulkhead.acquire(estimated_tokens=2000): result = await call_llm(prompt) bulkhead.record_actual_tokens(result.usage.total_tokens) """

def __init__(self, name: str, config: BulkheadConfig): self.name = name self.config = config self._semaphore = asyncio.Semaphore(config.max_concurrent) self._stats = BulkheadStats() self._token_lock = asyncio.Lock()

def _reset_token_window_if_needed(self): now = time.time() if now - self._stats._window_start >= 60: self._stats.tokens_used_this_min = 0 self._stats._window_start = now

async def _check_token_budget(self, estimated_tokens: int) -> bool: if self.config.max_tokens_per_min is None: return True async with self._token_lock: self._reset_token_window_if_needed() if self._stats.tokens_used_this_min + estimated_tokens > self.config.max_tokens_per_min: return False # token을 선점한다(실제 소비량은 record_actual_tokens에서 보정) self._stats.tokens_used_this_min += estimated_tokens return True

class _BulkheadContext: def __init__(self, bulkhead: 'AsyncBulkhead', estimated_tokens: int): self._bulkhead = bulkhead self._estimated_tokens = estimated_tokens self._actual_tokens = estimated_tokens

def record_actual_tokens(self, actual: int): """LLM 호출 완료 후 실제 token 소비량을 보정한다""" self._actual_tokens = actual

async def __aenter__(self): return self

async def __aexit__(self, exc_type, exc, tb): b = self._bulkhead b._semaphore.release() b._stats.current_concurrent -= 1 # token 선점 보정: 추정값을 실제 소비량으로 대체한다 if b.config.max_tokens_per_min: async with b._token_lock: b._stats.tokens_used_this_min += (self._actual_tokens - self._estimated_tokens) b._stats.tokens_used_this_min = max(0, b._stats.tokens_used_this_min)

async def acquire(self, estimated_tokens: int = 1000) -> '_BulkheadContext': """ Bulkhead 슬롯 획득을 시도한다.

타임아웃이거나 Token 예산이 소진되면 BulkheadFullError를 발생시킨다. """ # 1. Token 예산 확인 if not await self._check_token_budget(estimated_tokens): self._stats.total_rejected += 1 logger.warning( f"[bulkhead:{self.name}] Token budget exhausted " f"({self._stats.tokens_used_this_min}/{self.config.max_tokens_per_min} TPM)" ) raise BulkheadFullError( f"Bulkhead '{self.name}': token budget exhausted for this minute", reason="token_budget", )

2. 동시 실행 슬롯 획득 시도(타임아웃 포함) try: await asyncio.wait_for( self._semaphore.acquire(), timeout=self.config.max_wait_ms / 1000, ) except asyncio.TimeoutError: # 선점한 token을 반환한다 if self.config.max_tokens_per_min: async with self._token_lock: self._stats.tokens_used_this_min -= estimated_tokens self._stats.total_timeout += 1 logger.warning( f"[bulkhead:{self.name}] Timeout waiting for slot " f"(concurrent={self._stats.current_concurrent}/{self.config.max_concurrent})" ) raise BulkheadFullError( f"Bulkhead '{self.name}': no slot available within {self.config.max_wait_ms}ms", reason="timeout", )

self._stats.current_concurrent += 1 self._stats.total_accepted += 1 return self._BulkheadContext(self, estimated_tokens)

@property def stats(self) -> BulkheadStats: return self._stats

class BulkheadFullError(Exception): def __init__(self, message: str, reason: str = "full"): super().__init__(message) self.reason = reason # "timeout" | "token_budget" | "full"

5. 레지스트리 센터: 모든 Bulkhead를 통합 관리

Bulkhead 하나만으로도 쓸 수 있지만, 애플리케이션에는 열 개 남짓 있을 수 있다. 레지스트리 센터가 필요하다:

bulkhead_registry.py from typing import Dict from .bulkhead import AsyncBulkhead, BulkheadConfig class BulkheadRegistry : """ 전역 Bulkhead 등록 센터. 싱글턴이며, 애플리케이션 시작 시 한 번 초기화된다. 설계 원칙: - endpoint가 아니라 feature_group 기준으로 등록한다 - 설정은 외부에서 주입한다(핫 업데이트 가능). 코드에 하드코딩하지 않는다 """ def __init__ ( self, configs: Dict [ str , BulkheadConfig] ): self._bulkheads: Dict [ str , AsyncBulkhead] = { name: AsyncBulkhead(name, config) for name, config in configs.items() } # 기본 Bulkhead. 그룹에 속하지 않은 요청에 사용 self._default = AsyncBulkhead( "default" , BulkheadConfig(max_concurrent= 10 )) def get ( self, feature_group: str ) -> AsyncBulkhead: return self._bulkheads.get(feature_group, self._default) def all_stats ( self ) -> Dict [ str , dict ]: return { name: { "concurrent" : bh.stats.current_concurrent, "max_concurrent" : bh.config.max_concurrent, "accepted" : bh.stats.total_accepted, "rejected" : bh.stats.total_rejected, "timeout" : bh.stats.total_timeout, "tokens_per_min" : bh.stats.tokens_used_this_min, "max_tokens_per_min" : bh.config.max_tokens_per_min, } for name, bh in self._bulkheads.items() } # 애플리케이션 초기화(설정 파일에서 읽어오며, 핫 업데이트 지원) def create_registry_from_config ( config: dict ) -> BulkheadRegistry: """ config 예시: { "chat": {"max_concurrent": 20, "max_tokens_per_min": 200000}, "batch_summarize": {"max_concurrent": 5, "max_tokens_per_min": 50000, "max_wait_ms": 100}, "embedding": {"max_concurrent": 10}, "background_analysis": {"max_concurrent": 3, "max_tokens_per_min": 20000} } """ return BulkheadRegistry({ name: BulkheadConfig(**cfg) for name, cfg in config.items() })

6. LLM 호출 계층과의 통합

Bulkhead를 LLM 호출 계층에 내장하여 비즈니스 코드에 투명하게 만든다:

llm_client.py from typing import Optional from .bulkhead_registry import BulkheadRegistry from .bulkhead import BulkheadFullError from openai import AsyncOpenAI class BulkheadedLLMClient : """ Bulkhead 격리를 갖춘 LLM 클라이언트 래퍼. 비즈니스 코드는 호출 시 feature_group만 전달하면 되고, 격리 로직은 완전히 투명하다. """ def __init__ ( self, client: AsyncOpenAI, registry: BulkheadRegistry ): self._client = client self._registry = registry async def chat ( self, messages: list , *, feature_group: str = "default" , model: str = "qwen-max" , max_tokens: int = 2048 , estimated_tokens: Optional [ int ] = None , ) -> object : """ Bulkhead 보호가 적용된 chat 호출. Args: feature_group: 기능 그룹 식별자. 예: "chat", "batch_summarize", "embedding" estimated_tokens: 예상 Token 소비량(Token 예산 검사에 사용). 기본값 = max_tokens(보수적 추정) """ if estimated_tokens is None : estimated_tokens = max_tokens bulkhead = self._registry.get(feature_group) try : async with bulkhead.acquire(estimated_tokens=estimated_tokens) as ctx: response = await self._client.chat.completions.create( model=model, max_tokens=max_tokens, messages=messages, ) # 실제 Token 소비량 보정 ctx.record_actual_tokens(response.usage.total_tokens) return response except BulkheadFullError as e: # 핵심: 서로 다른 reason은 사용자에게 서로 다른 오류 안내를 주어야 한다 if e.reason == "token_budget" : raise LLMServiceDegradedError( "AI 서비스가 혼잡합니다. Token 할당량이 일시적으로 소진되었습니다. 잠시 후 다시 시도해 주세요" , retry_after_seconds= 30 , ) else : raise LLMServiceDegradedError( "AI 서비스가 혼잡합니다. 요청 대기열이 가득 찼습니다. 잠시 후 다시 시도해 주세요" , retry_after_seconds= 5 , ) class LLMServiceDegradedError ( Exception ): def __init__ ( self, message: str , retry_after_seconds: int = 5 ): super ().__init__(message) self.retry_after_seconds = retry_after_seconds

비즈니스 코드의 사용 방식:

비즈니스 계층 코드, 간결하고 투명 async def handle_chat_request ( user_id: str , message: str ): try : response = await llm_client.chat( messages=[{ "role" : "user" , "content" : message}], feature_group= "chat" , # 핵심: 기능 그룹 지정 estimated_tokens= 3000 , ) return response.content[ 0 ].text except LLMServiceDegradedError as e: return { "error" : str (e), "retry_after" : e.retry_after_seconds} async def handle_batch_summarize ( doc_ids: list [ str ] ): # 배치 작업은 독립된 feature_group을 사용하므로 실시간 chat에 영향을 주지 않는다 results = [] for doc_id in doc_ids: content = await load_document(doc_id) try : response = await llm_client.chat( messages=[{ "role" : "user" , "content" : f"요약해 주세요: {content} " }], feature_group= "batch_summarize" , # 배치 작업 독립 격실 estimated_tokens= 5000 , ) results.append({ "doc_id" : doc_id, "summary" : response.content[ 0 ].text}) except LLMServiceDegradedError: results.append({ "doc_id" : doc_id, "error" : "batch_queue_full" }) return results

7. 테넌트 차원: 동적 Bulkhead 선택

멀티 테넌트 SaaS는 테넌트 등급에 따라 서로 다른 Bulkhead로 동적으로 라우팅해야 한다:

tenant_bulkhead_router.py from enum import Enum from typing import Dict from .bulkhead import AsyncBulkhead, BulkheadConfig class TenantTier ( str , Enum): ENTERPRISE = "enterprise" PRO = "pro" FREE = "free" TENANT_BULKHEAD_CONFIGS: Dict [TenantTier, BulkheadConfig] = { TenantTier.ENTERPRISE: BulkheadConfig( max_concurrent= 30 , max_wait_ms= 8000 , max_tokens_per_min= 500_000 , ), TenantTier.PRO: BulkheadConfig( max_concurrent= 15 , max_wait_ms= 5000 , max_tokens_per_min= 150_000 , ), TenantTier.FREE: BulkheadConfig( max_concurrent= 5 , max_wait_ms= 2000 , max_tokens_per_min= 20_000 , ), } class TenantBulkheadRouter : """ 테넌트 등급에 따라 서로 다른 Bulkhead로 라우팅한다. 조합 차원: feature_group × tenant_tier 예: enterprise_chat, pro_chat, free_chat은 세 개의 독립된 격실이다 """ def __init__ ( self ): # 조합 키: "{tier}_{feature_group}" self._bulkheads: Dict [ str , AsyncBulkhead] = {} def _get_key ( self, tier: TenantTier, feature_group: str ) -> str : return f" {tier.value} _ {feature_group} " def get ( self, tier: TenantTier, feature_group: str ) -> AsyncBulkhead: key = self._get_key(tier, feature_group) if key not in self._bulkheads: # 온디맨드 생성: feature_group의 기본 설정 × tier의 자원 계수 base = TENANT_BULKHEAD_CONFIGS[tier] # 기능 그룹 계수: chat 40%, batch 15%, 나머지는 균등 분배 feature_ratios = { "chat" : 0.40 , "batch_summarize" : 0.15 , "embedding" : 0.20 , "background" : 0.10 , "default" : 0.15 , } ratio = feature_ratios.get(feature_group, feature_ratios[ "default" ]) self._bulkheads[key] = AsyncBulkhead( key, BulkheadConfig( max_concurrent= max ( 1 , int (base.max_concurrent * ratio)), max_wait_ms=base.max_wait_ms, max_tokens_per_min=( int (base.max_tokens_per_min * ratio) if base.max_tokens_per_min else None ), ) ) return self._bulkheads[key]

8. 지연 비교 데이터: 격리 전후의 실제 영향

나는 로컬에서 부하 테스트 스크립트로 배치 충격 시나리오를 시뮬레이션하여 chat 기능의 P99 지연 변화를 측정했다.

테스트 시나리오:

- 기준선: chat 기능, 30명의 사용자가 무작위 간격으로 요청 전송

- 충격: 동시에 50개의 batch_summarize 동시 요청 발생(대형 context, 5K tokens/request)

- LLM: httpx mock으로 시뮬레이션, 응답 시간 200ms~800ms 무작위(실제 LLM 지연 분포 시뮬레이션)

부하 테스트 스크립트(재현 가능) import asyncio import time import random from statistics import quantiles async def mock_llm_call ( tokens: int ): """LLM 호출 지연 시뮬레이션: tokens가 많을수록 지연이 높아진다""" base_latency = 0.2 + tokens / 10000 # 단순화: 1K token당 100ms 추가 jitter = random.uniform( 0.9 , 1.3 ) await asyncio.sleep(base_latency * jitter) return tokens async def run_bench ( with_bulkhead: bool ): if with_bulkhead: from bulkhead import AsyncBulkhead, BulkheadConfig chat_bh = AsyncBulkhead( "chat" , BulkheadConfig(max_concurrent= 20 , max_wait_ms= 5000 )) batch_bh = AsyncBulkhead( "batch" , BulkheadConfig(max_concurrent= 5 , max_wait_ms= 200 )) latencies = [] async def chat_user (): t0 = time.perf_counter() try : if with_bulkhead: async with chat_bh.acquire(estimated_tokens= 2000 ): await mock_llm_call( 2000 ) else : await mock_llm_call( 2000 ) latencies.append(time.perf_counter() - t0) except Exception: latencies.append( 30.0 ) # 타임아웃은 30s로 기록 async def batch_worker (): for _ in range ( 5 ): try : if with_bulkhead: async with batch_bh.acquire(estimated_tokens= 5000 ): await mock_llm_call( 5000 ) else : await mock_llm_call( 5000 ) except Exception: pass # 30명의 chat 사용자 + 10개의 batch workers가 동시에 실행 tasks = ( [asyncio.create_task(chat_user()) for _ in range ( 30 )] + [asyncio.create_task(batch_worker()) for _ in range ( 10 )] ) await asyncio.gather(*tasks) p50, p90, p99 = quantiles(latencies, n= 100 )[ 49 ], quantiles(latencies, n= 100 )[ 89 ], quantiles(latencies, n= 100 )[ 98 ] return p50, p90, p99

테스트 결과:

시나리오 P50 지연 P90 지연 P99 지연 Bulkhead 없음(batch 충격 받음) 0.9s 4.2s 18.7s Bulkhead 있음(chat 독립 격실) 0.7s 1.1s 1.4s Bulkhead 있음(batch 측 P99) — — 3.2s(허용 가능)

chat 기능의 P99 지연은 18.7초에서 1.4초로 줄어 92.5% 감소했다. 대가는 batch의 P99가 3.2초로 오른 것인데(5개 동시성으로 제한되기 때문), batch 기능은 애초에 낮은 우선순위이므로 이는 올바른 트레이드오프다.

9. 관측 가능성: Bulkhead는 반드시 모니터링 가능해야 한다

Bulkhead 자체의 상태는 모니터링 시스템에 노출되어야 하며, 그렇지 않으면 임계값을 조정할 수 없다.

metrics_exporter.py(Prometheus 예시) from prometheus_client import Gauge, Counter from .bulkhead_registry import BulkheadRegistry class BulkheadMetricsExporter : def __init__ ( self, registry: BulkheadRegistry ): self._registry = registry self.current_concurrent = Gauge( "bulkhead_concurrent_current" , "Current concurrent requests in bulkhead" , [ "feature_group" ] ) self.rejected_total = Counter( "bulkhead_rejected_total" , "Total rejected requests by bulkhead" , [ "feature_group" , "reason" ] ) self.tokens_per_min = Gauge( "bulkhead_tokens_per_min" , "Token consumption per minute by feature group" , [ "feature_group" ] ) def collect ( self ): """/metrics 엔드포인트에서 호출, 주기적으로 수집""" for name, stats in self._registry.all_stats().items(): self.current_concurrent.labels(feature_group=name). set ( stats[ "concurrent" ] ) self.tokens_per_min.labels(feature_group=name). set ( stats[ "tokens_per_min" ] or 0 )

핵심 경보 규칙:

Prometheus 경보 규칙 그룹: - name: bulkhead rules: # 어떤 feature_group의 거부율 > 5%일 때 경보 - alert: BulkheadHighRejectionRate expr: | rate(bulkhead_rejected_total[5m]) / (rate(bulkhead_accepted_total[5m]) + rate(bulkhead_rejected_total[5m])) > 0.05 for: 2m labels: severity: warning annotations: summary: "Bulkhead ' {{ $labels.feature_group }} ' 거부율 {{ $value | humanizePercentage }} " # 동시 사용률 > 90%가 5분간 지속되면 용량 조정이 필요함을 의미 - alert: BulkheadNearCapacity expr: bulkhead_concurrent_current / bulkhead_max_concurrent > 0.9 for: 5m labels: severity: info annotations: summary: "Bulkhead ' {{ $labels.feature_group }} ' 용량 근접, 확장 고려"

10. 세 가지 흔한 오해

오해 1: Bulkhead는 세분화할수록 좋다.

아니다. 각 Bulkhead는 자원을 격리하는 동시에 이용률도 떨어뜨린다. 만약 chat의 동시성을 20으로 제한했는데 실제로는 대부분의 시간에 동시성이 5뿐이라면, 나머지 15개 슬롯은 비어 있는데 동시에 다른 feature_group은 상한에 도달해 대기하게 된다. 지나치게 세분화된 격리는 오히려 자원 낭비를 초래한다.

합리적인 입도: API endpoint별로 일대일 격리하는 것이 아니라, 비즈니스 우선순위와 트래픽 특성에 따라 그룹화하는 것이다.

오해 2: 최대 동시 수로 Bulkhead 상한을 설정한다.

당신이 써야 하는 것은 목표 동시 수이지, 최대 수용 수가 아니다. Bulkhead 상한을 LLM Provider의 RPM 제한으로 설정하는 것도 또 다른 오류다. 그것은 Provider의 전역 제한이지, 단일 feature group이 다 채워야 할 양이 아니다.

오해 3: Bulkhead를 초과하면 500 오류다.

올바른 방법은 직접 오류를 내는 것이 아니라 성능 저하(폴백)로 처리하는 것이다. batch_summarize의 Bulkhead를 초과하면 요청을 비동기 큐에 넣고 사용자에게 "나중에 알림"을 알려줄 수 있다. chat Bulkhead를 초과할 때에야 사용자에게 "서비스 혼잡"을 즉시 알려야 한다. 기능별로 성능 저하 전략이 달라야 한다.

11. Node.js 버전 (보너스)

많은 LLM 애플리케이션이 Node.js로 작성되는데, p-limit으로 빠르게 구현할 수 있다:

// bulkhead.ts import pLimit from 'p-limit' ;

interface BulkheadConfig { maxConcurrent : number ; maxWaitMs?: number ; maxTokensPerMin?: number ; }

export class NodeBulkhead { private limiter : ReturnType < typeof pLimit>; private config : BulkheadConfig ; private tokenWindowStart = Date . now (); private tokensThisMin = 0 ; private stats = { accepted : 0 , rejected : 0 };

constructor ( public readonly name: string , config: BulkheadConfig ) { this . config = config; this . limiter = pLimit (config. maxConcurrent ); }

async run<T>( fn : () => Promise <T>, estimatedTokens = 1000 ): Promise <T> { // Token budget check if ( this . config . maxTokensPerMin ) { const now = Date . now (); if (now - this . tokenWindowStart > 60_000 ) { this . tokensThisMin = 0 ; this . tokenWindowStart = now; } if ( this . tokensThisMin + estimatedTokens > this . config . maxTokensPerMin ) { this . stats . rejected ++; throw new BulkheadFullError ( `Token budget exhausted for bulkhead ' ${ this .name} '` ); } this . tokensThisMin += estimatedTokens; }

// Concurrency check with timeout const maxWait = this . config . maxWaitMs ?? 5000 ; return Promise . race ([ this . limiter ( async () => { this . stats . accepted ++; return fn (); }), new Promise < never >( ( _, reject ) => setTimeout ( () => reject ( new BulkheadFullError ( `Bulkhead ' ${ this .name} ' wait timeout` )), maxWait ) ), ]); } }

export class BulkheadFullError extends Error { constructor ( message: string ) { super (message); this . name = 'BulkheadFullError' ; } }

// 사용 예시 const chatBulkhead = new NodeBulkhead ( 'chat' , { maxConcurrent : 20 , maxTokensPerMin : 200_000 }); const batchBulkhead = new NodeBulkhead ( 'batch' , { maxConcurrent : 5 , maxTokensPerMin : 50_000 , maxWaitMs : 200 });

async function callLLMWithIsolation ( prompt: string , featureGroup: 'chat' | 'batch' ) { const bulkhead = featureGroup === 'chat' ? chatBulkhead : batchBulkhead; return bulkhead. run ( () => openai. chat . completions . create ({ model : 'gpt-4o' , messages : [{ role : 'user' , content : prompt }] }), 3000 ); }

12. 소결: 한 장의 의사결정 다이어그램

당신의 LLM 애플리케이션에 다음 상황이 존재하는가? ✓ 동시에 실시간 기능(chat)과 배치 기능(batch/export)이 있다 ✓ 여러 테넌트가 LLM 호출 능력을 공유한다 ✓ 기능별로 뚜렷한 우선순위 차이가 있다 ✓ 한 기능의 높은 트래픽이 다른 기능에 영향을 준 적이 있다 → Bulkhead 격리가 필요하다

최소 실행 가능 구현: 1. 우선순위에 따라 2~3개의 feature_group으로 나눈다(P0 실시간/P1 배치/P2 백그라운드) 2. 그룹별로 독립적인 Semaphore를 설정한다(Python: asyncio.Semaphore, Node: p-limit) 3. P1/P2의 max_wait_ms를 짧게(200ms) 설정하여, 대기시키지 말고 빠르게 실패시킨다 4. current_concurrent와 rejected_count를 모니터링에 노출한다

Token Budget 추가: 5. 각 feature_group에 max_tokens_per_min을 설정한다 6. LLM 호출 전에 미리 예약하고, 호출 완료 후 보정한다

테넌트 차원 추가: 7. enterprise/pro/free는 각각 독립적인 자원 쿼터를 가진다 8. feature_group × tier 조합 키로 Bulkhead를 관리한다

Circuit Breaker가 해결하는 것은 "호출 대상이 죽으면 어떻게 할까"이고, Rate Limit이 해결하는 것은 "입구 트래픽이 너무 높으면 어떻게 막을까"이며, Bulkhead가 해결하는 것은 "자원이 한정적일 때 어떻게 공평하게 분배하고 서로 밟고 넘어지는 것을 방지할까"이다. 셋은 각각 하나의 차원을 담당하며, 조합해야 완전한 탄력성 엔지니어링이 된다.

오픈소스 라이브러리 참고:

- Python: resilience — Bulkhead + Circuit Breaker 조합

- Node.js: p-limit + 직접 작성한 Token Budget

- Java: Resilience4j — 가장 성숙한 구현

- Go: golang.org/x/sync/sema…

AINative 소프트웨어 엔지니어링

AI Infrastructure Engineer

145

32k

읽음

28

팔로워