
LLM 게이트웨이: 라우팅(Routing), 폴백(Fallbacks), 그리고 시맨틱 캐싱(Semantic Caching)
요약
LLM 게이트웨이의 개념과 필요성을 설명하며, 라우팅, 폴백, 시맨틱 캐싱의 역할을 다룹니다. 단순한 래퍼 함수 대신 프록시 형태의 게이트웨이를 사용하여 비용 최적화, 가용성 확보, 인터페이스 통합을 달성하는 방법을 제시합니다.
핵심 포인트
- LLM 게이트웨이는 모델 제공자와 앱 사이의 프록시 역할을 수행함
- 라우팅, 폴백, 캐싱을 통해 비용 절감 및 시스템 안정성 확보 가능
- 다양한 모델 API 규격을 하나의 공용 인터페이스로 통합 관리
- 재시도, 속도 제한, 개인정보 삭제 등 공통 관심사를 중앙 집중화
놀라울 정도로 많은 기업의 프로덕션 환경에서 조용히 실행되고 있는 코드 한 줄이 있습니다:
const response = await openai.chat.completions.create({ model: "gpt-4o", messages });
이 코드는 무해해 보입니다. 하지만 이 코드가 바로 이번 달 당신의 AI 비용이 그 정도가 된 이유이며, OpenAI가 운 나쁜 오후를 보낼 때 당신의 앱이 즉시 다운되는 이유이고, 만 명의 사용자가 입력한 동일한 질문이 만 번의 추론(Inference) 호출 비용을 발생시키는 이유입니다. 그 한 줄의 코드는 벤더(Vendor), 모델(Model), 가격 계층(Pricing tier), 그리고 단일 장애점(Single point of failure)을 한꺼번에 하드코딩하고 있습니다.
LLM 게이트웨이(LLM gateway)가 그 해결책이며, 이 개념은 AI 열풍보다 더 오래되었습니다. 이것은 프록시(Proxy)입니다. 당신이 수년간 데이터베이스와 마이크로서비스(Microservices) 앞에 사용해 온 것과 동일한 패턴이지만, 당신의 앱과 당신이 통신하는 모든 모델 제공자(Model provider) 사이에 위치한다는 점이 다릅니다. 당신의 코드는 게이트웨이를 호출합니다. 게이트웨이는 어떤 모델이 실제로 응답할지, 해당 모델이 다운되었을 때 어떤 일이 일어날지, 그리고 모델을 호출할 필요가 있는지조차 결정합니다. 세 가지 역할: 라우팅(Routing), 폴백(Fallbacks), 그리고 캐싱(Caching)입니다. 마케팅 페이지에서 생략하는 각각의 주의 사항(Gotcha)이 있으므로, 하나씩 자세히 살펴보겠습니다.
왜 단순한 래퍼 함수(Wrapper Function)가 아닌 프록시인가
본능적으로는 function askLlm(prompt) { ... }와 같은 헬퍼 함수(Helper function)를 작성하고 끝내고 싶을 것입니다. 두 번째 제공자가 나타나기 전까지는 그 방식이 통합니다. 하지만 그 이후에는 모델 이름, API 키, 그리고 제공자별 특이 사항들을 호출 지점마다 전달해야 합니다. OpenAI는 messages를 원하고, Anthropic은 system을 분리하기를 원하며, Google은 또 다른 것을 원합니다. 이제 모델을 호출하는 모든 곳이 너무 많은 것을 알게 됩니다.
게이트웨이는 이 모든 것을 하나의 인터페이스로 통합합니다. 여러분은 거의 항상 OpenAI의 chat-completions 형태인 하나의 방언(dialect)으로 말하게 되는데, 이는 이것이 공용어(lingua franca)가 되었기 때문입니다. 그러면 게이트웨이가 라우팅되는 대상 제공자(provider)에 맞춰 번역해 줍니다. 이 단일 초크포인트(chokepoint, 병목 지점)가 바로 핵심입니다. 횡단 관심사(Cross-cutting concerns)는 초크포인트를 필요로 합니다. 캐싱 (Caching), 재시도 (Retries), 예산 제한 (Budget caps), 속도 제한 (Rate limiting), 감사 로그 (Audit logging), 개인정보(PII) 삭제 (PII redaction): 이 중 그 어떤 것도 코드베이스 곳곳에 흩어져 있어서는 안 됩니다. 이들은 모든 요청이 이미 통과하고 있는 단 한 곳에 위치해야 합니다.
┌────────────────────────────────────────────┐
your app │ cache? → route → call → fallback? │ → provider
──────►│ ▲ │ (OpenAI,
...
이를 직접 구축할 수도 있고 (HTTP 클라이언트를 중심으로 한 수백 줄의 Node 또는 Python 코드 수준), LiteLLM(OpenAI API 형태 뒤에서 100개 이상의 제공자와 통신함)과 같은 오픈 소스를 사용하거나 Cloudflare 또는 Vercel의 관리형 에지 게이트웨이 (managed edge gateway)를 사용할 수도 있습니다. 직접 구축할 것인지(build) 아니면 구매할 것인지(buy)에 대한 결정은, 어려운 부분(캐싱 시맨틱 (caching semantics), 장애 조치 로직 (failover logic), 관찰 가능성 (observability))을 얼마나 직접 소유하고 싶은지에 달려 있습니다. 이 내용은 나중에 다시 다루겠습니다. 먼저, 세 가지 작업에 대해 알아보겠습니다.
라우팅 (Routing): "2+2는 무엇인가"에 대해 프런티어 모델의 가격을 지불하지 마세요
대부분의 앱은 모든 요청을 가장 성능이 좋고 가장 비싼 모델로 보냅니다. 그것이 안전하다고 느껴지기 때문입니다. 하지만 이는 매우 낭비적입니다. 왜냐하면 대부분의 요청에는 프런티어 모델 (frontier model)이 필요하지 않기 때문입니다. 고객 지원 티켓 분류하기, 문장에서 날짜 추출하기, 댓글이 스팸인지 결정하기 등은 작고 저렴한 모델로도 충분히 해낼 수 있습니다. 여러분은 버거를 뒤집기 위해 미슐랭 스타급 가격을 지불하고 있는 셈입니다.
라우팅 (Routing)이란 게이트웨이가 요청마다 어떤 모델이 답변해야 할지를 결정하는 것을 의미합니다. 전략은 대략 다음과 같이 쌓입니다:
**정적 규칙 (Static rules)**은 가장 기초적인 단계입니다. 이미 보유하고 있는 필드에 따라 라우팅합니다. 예를 들어, 특정 고객 등급에는 대형 모델을 할당하고, 특정 내부 도구에는 저렴한 모델을 할당하는 방식입니다. 지능적인 판단은 없으며, 단지 설정(config)에 따릅니다. 구축 비용이 저렴하고 추론하기 쉬우며, 솔직히 많은 애플리케이션에서 충분히 유용합니다.
**지연 시간 및 비용 기반 라우팅 (Latency- and cost-based routing)**은 현재 가장 빠르거나 저렴한 모델을 선택합니다. 종종 폴백 체인 (fallback chain)을 함께 사용하여, 속도 제한 (rate-limited)에 걸린 제공업체가 자동으로 다음 제공업체로 요청을 넘기도록 합니다. 이는 LiteLLM이나 OpenRouter와 같은 게이트웨이의 핵심 기능입니다. 순서가 지정된 목록을 정의하면, 트래픽은 상태가 정상(healthy)인 첫 번째 모델로 흐르게 됩니다.
**난이도에 따른 모델 라우팅 (Model routing by difficulty)**은 매우 흥미로운 영역입니다. 작은 "라우터 모델 (router model)"이 프롬프트를 살펴보고, 저렴한 모델이 이를 처리할 수 있을지 아니면 비싼 모델이 필요할지를 예측합니다. 수치를 확인하기 전까지는 장난처럼 들릴 수 있습니다. LMSYS의 RouteLLM 연구에 따르면, GPT-4의 품질을 95% 유지하면서도 GPT-4로 보내는 쿼리는 단 14%에 불과했습니다. 나머지 86%는 훨씬 저렴한 모델로 전송되었습니다. 다른 공개된 설정들은 비용을 약 4분의 1로 줄이면서 GPT-4 정확도의 약 97%에 도달했다고 보고합니다. 이러한 절감액은 단순한 오차 범위 수준이 아닙니다. 이는 출시될 기능과 예산 검토 단계에서 폐기될 기능 사이의 차이를 만듭니다.
다음은 계층형 라우터의 형태입니다. 핵심은 정확한 코드가 아니라, 이 로직이 40개의 호출 지점에 흩어져 있는 것이 아니라 한 곳에 존재한다는 점입니다:
function route(prompt: string): string {
// 저렴한 휴리스틱(heuristic) 우선: 결정을 위한 모델 호출 없음
if (prompt.length < 200 && !needsReasoning(prompt)) {
...
Tip
화려한 ML 라우터를 찾기 전에, 단순한 버전부터 시도해 보세요. 자체 메타데이터를 통해 라우팅하는 것입니다. 여러분은 보통 요청이 높은 이해관계가 걸린 사용자 대면 답변인지, 아니면 백그라운드 배치 작업(batch job)인지 이미 알고 있습니다. 그 단 하나의 불리언(boolean) 값만으로 복잡성 없이 대부분의 비용 절감을 달성할 수 있습니다.
솔직한 트레이드오프(tradeoff)는 다음과 같습니다. 학습된 라우터(router)는 자체적인 작은 추론 비용(inference cost)을 추가하며, 어려운 질문을 약한 모델로 잘못 라우팅(misrouting)할 가능성이 있습니다. 이것이 바로 숙련된 팀들이 라우팅을 먼저 **섀도 모드(shadow mode)**로 배포하는 이유입니다. 즉, 모든 요청을 라우터가 선택한 모델과 현재의 기본(default) 모델 모두에게 보내고, 두 결과를 모두 로그로 남기되, 사용자에게는 기본 모델의 결과만 반환한 뒤 오프라인에서 비교하는 방식입니다. 라우터의 선택이 실제 트래픽에서 괜찮아 보이면, 피처 플래그(feature flag)를 사용하여 5%부터 라이브로 전환하며 점진적으로 늘려갑니다. 한 번도 실행해 보지 않은 라우팅 테이블에 프로덕션(production) 품질을 걸고 도박을 해서는 안 됩니다.
폴백(Fallbacks): 새벽 2시가 되어서야 모두가 놓치는 부분
라우팅(Routing)은 상황이 원활할 때 누가 답변할지를 결정합니다. 폴백(Fallbacks)은 상황이 원활하지 않을 때 어떤 일이 일어날지를 결정합니다. 그리고 상황은 상태 페이지(status pages)가 인정하는 것보다 더 자주 원활하지 않습니다. 제공업체(Provider)가 속도 제한(rate-limit)을 걸거나, 타임아웃(time out)이 발생하거나, 500 에러를 반환하거나, 사용자가 포기할 정도로 느려질 수 있습니다. 만약 당신의 앱에 단 하나의 모델만 하드코딩(hardcoded)되어 있다면, 이 모든 상황이 곧 당신의 서비스 장애(outage)가 됩니다.
폴백 체인(fallback chain)은 단순히 순서가 있는 목록입니다. 기본 모델을 시도하고, 실패하면 투명하게(transparently) 다음 모델을 시도합니다. 사용자는 그 경계(seam)를 절대 볼 수 없습니다.
# litellm-style fallback config
model_list:
- model_name: chat
...
하지만 순진한 재시도(retries)는 장애를 개선하는 것이 아니라 악화시킵니다. 만약 제공업체가 과부하 상태라면, 재시도로 계속 몰아붙이는 것은 기름 화재에 물을 붓는 것과 같습니다. 다음 두 가지 패턴이 당신을 올바른 방향으로 인도합니다.
**지수 백오프(Exponential backoff)**는 재시도 간격을 조절합니다. 잠시 기다렸다가, 그다음엔 조금 더 길게 기다리며, 약간의 무작위 지터(jitter)를 추가하여 모든 서버가 일제히 재시도하여 천둥 치는 들판(thundering herd) 현상을 일으키지 않도록 합니다.
**서킷 브레이킹(Circuit breaking)**은 사람들이 자주 잊어버리는 부분입니다. 제공업체가 연속으로 충분히 실패하면, 냉각 기간(cooling-off window) 동안 트래픽 전송을 완전히 중단하고, 즉시 백업 모델로 넘어가며, 고장 난 모델이 복구되었는지 확인하기 위해 가끔씩만 테스트(probe)를 수행합니다. 차단기(breaker)가 없다면, 모든 개별 요청은 장애가 발생한 제공업체에 대해 페일오버(failover)가 되기 전까지 전체 타임아웃 페널티를 그대로 감수해야 합니다. 차단기가 있다면, 즉시 페일오버가 이루어집니다.
private fails = 0;
private openUntil = 0;
...
경고 (Warning)
폴백 체인 (fallback chain)의 성능은 오직 당신의 장애 감지 (failure detection) 능력에 달려 있습니다. 빠르고 확신에 차 있으며, 완전히 틀린 답변을 내놓는 200 OK를 반환하는 제공업체는 어떤 차단기 (breaker)도 작동시키지 않을 것입니다. 이는 "장애 (failing)"가 아니라, 그저 "잘못된 (bad)" 것입니다. 상태 확인 (Health checks)은 다운타임 (downtime)은 잡아내지만, 성능 저하 (degradation)를 잡아내지는 못합니다. 그것은 다른 문제이며, 이것이 바로 전송 계층 (transport)에 대한 모니터링뿐만 아니라 출력값에 대한 평가 (evals)가 여전히 필요한 이유입니다.
시맨틱 캐싱 (Semantic Caching): 마법 같은 부분과 뼈아픈 부분
이제 핵심 기능입니다. 일반적인 캐싱 (caching)은 정확한 바이트 (exact bytes)를 키 (key)로 사용합니다. 즉, 동일한 요청이 들어오면 동일한 응답이 나갑니다. 하지만 LLM에게 이는 무용지물입니다. 왜냐하면 아무도 똑같은 문장을 두 번 입력하지 않기 때문입니다. "비밀번호를 어떻게 재설정하나요?"와 "비밀번호를 잊어버렸는데 어떻게 변경하죠?"는 일치하는 문자가 하나도 없지만 동일한 질문입니다. 정확히 일치하는 방식의 캐싱 (Exact-match caching)은 이를 두 개의 서로 다른 키로 인식하고 모델을 두 번 호출합니다.
시맨틱 캐싱 (Semantic caching)은 바이트 대신 **의미 (meaning)**를 기준으로 키를 생성합니다. 다음은 실제 작동 메커니즘이며, 이것이 바로 "내부 동작 (under the hood)"의 핵심입니다:
- 입력된 프롬프트 (prompt)를 그 의미를 인코딩하는 숫자의 벡터 (vector)인 **임베딩 (embedding)**으로 변환합니다.
- 보통 **코사인 유사도 (cosine similarity)**를 사용하여, 이미 캐싱된 모든 항목의 임베딩에 대해 **유사도 검색 (similarity search)**을 수행합니다.
- 가장 유사한 매칭 결과가 **임계값 (threshold)**을 넘으면, 해당 캐싱된 답변을 반환합니다. 그렇지 않으면 모델을 호출하고 새로운 결과를 캐싱합니다.
async function semanticLookup(prompt: string, threshold = 0.95) {
const vec = await embed(prompt); // prompt -> vector
const { match, score } = await vectorDb.nearest(vec); // cosine similarity search
...
그 보상은 실질적이며 매우 큽니다. 캐시 히트(Cache hit)는 전체 추론(Inference) 호출에 소요되는 25초 대신 한 자릿수 밀리초(single-digit milliseconds) 내에 반환되며, 비용이 전혀 들지 않습니다. 즉, 토큰 비용도, 제공업체(Provider) 호출 비용도 발생하지 않습니다. 발표된 결과에 따르면 반복적인 쿼리가 있는 워크로드에서 비용 절감 폭은 **4080% 이상**에 달하며, 널리 인용되는 한 보고서에서는 지출이 73% 감소한 것으로 측정되었습니다. 30~40% 정도의 완만한 히트율(Hit rate)만 되어도 이는 공짜 수익이자 더 빠른 앱을 의미합니다. 사용자들이 항상 똑같은 50가지 질문을 던지는 FAQ 봇이나 문서 어시스턴트의 경우, 이것이 게이트웨이가 수행하는 가장 레버리지가 높은(highest-leverage) 작업입니다.
이제 화려한 벤치마크 결과가 숨기고 있는 부분을 살펴보겠습니다.
임계값(Threshold)이 승패를 결정한다
그 threshold = 0.95는 여러분의 스택에서 가장 위험한 숫자이며, 이는 스위치가 아니라 슬라이더(Slider)입니다. 임계값을 너무 높게 설정하면 거의 아무것도 매칭되지 않습니다. 히트율(Hit rate)이 붕괴되고 캐시는 아무런 역할을 하지 못하게 됩니다. 반대로 너무 낮게 설정하면 _거짓 히트(false hits)_를 제공하기 시작합니다. 즉, 질문이 단지 비슷해 보일 뿐인데 캐시된 답변을 확신하며 반환하는 것입니다.
전형적인 예시를 들어보겠습니다. 0.85 정도의 공격적인 임계값에서는 **"비밀번호를 재설정하는 방법"**이 **"이메일을 변경하는 방법"**과 매칭될 수 있습니다. 주제상으로는 친척 관계이지만, 답변은 완전히 다릅니다. 사용자는 비밀번호 재설정을 물어봤는데 이메일 변경 방법을 안내받게 되며, 여러분의 로그에는 즐거운 캐시 히트(Cache hit)로 기록될 것입니다. 질문들이 매칭될 만큼 충분히 연관되어 있지만 답변은 틀릴 정도로 충분히 다른, **대략 0.88에서 0.94 사이의 잘 알려진 위험 구역(danger zone)**이 존재합니다.
부정어(Negation)는 훨씬 더 까다롭습니다. "라이브 데이터베이스에서 마이그레이션을 실행하는 것이 안전합니까?"와 "라이브 데이터베이스에서 마이그레이션을 실행하는 것이 안전하지 않습니까?"는 단 한 단어 차이일 뿐 벡터상으로는 거의 동일하지만, 정답은 정반대입니다. 임베딩(Embeddings)은 부정어 처리에 취약하기로 악명이 높으므로, 부주의한 임계값 설정은 잘못된 극성(Polarity)의 답변을 기꺼이 제공하게 될 것입니다.
경고 (Warning)
쿼리 유형마다 서로 다른 임계값 (Threshold)이 필요합니다. 보고된 최적의 지점(Sweet spots)은 FAQ 스타일의 쿼리(오답이 신뢰를 깎아먹는 경우)의 경우 약 0.94 근처에 형성되며, 유사한 매칭만으로도 충분한 퍼지 제품 검색 (Fuzzy product search)의 경우에는 이보다 낮습니다. 보편적으로 적용되는 "정답"인 숫자는 없습니다. 이는 사용 사례별로 조정해야 하는 정밀도(Precision) 대 히트율(Hit-rate) 사이의 다이얼과 같으며, 단순히 히트율을 축하하는 것에 그치지 않고 위양성 (False positives)을 주의 깊게 살펴봐야 합니다.
실질적인 조치는 위양성 (False-positive) 신호를 추적하는 것입니다. 만약 사용자가 캐시 히트 (Cache hit) 직후에 즉시 질문을 재구성하거나 '싫어요(Thumbs-down)'를 누른다면, 임계값이 너무 느슨한 것입니다. 그리고 어떤 것들은 절대로 캐싱해서는 안 됩니다. 개인화된 정보, 시간 민감적인 정보 ("내 주문 상태가 뭐야"), 그리고 프롬프트가 담지 못한 컨텍스트 (Context)에 의존하는 모든 것이 이에 해당합니다. 서로 다른 문서들에 대해 "이 문서 요약해줘"를 캐싱하는 것은 사용자 A의 답변을 사용자 B에게 전달하는 아주 좋은 방법이 될 것입니다. 답변이 진정으로 전역적 (Global)이지 않다면, 사용자 또는 테넌트 (Tenant) 단위로 캐시 키 (Cache keys)의 범위를 지정하십시오.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기