LLM을 프로덕션 환경에서 운영할 때 실제로 발생하는 비용
요약
LLM 기반 프로덕션 환경에서 발생하는 비용, 지연 시간, 신뢰성, 폭발 반경 문제를 다룹니다. 특히 시맨틱 캐싱(Semantic Caching)을 통해 비용을 40-50% 절감하는 구체적인 방법론을 제시합니다.
핵심 포인트
- 프로덕션 환경의 4대 과제: 비용, 지연 시간, 신뢰성, 폭발 반경
- 단순 문자열 매칭이 아닌 임베딩 기반의 시맨틱 캐싱 활용
- 코사인 유사도 임계값(예: 0.95)을 통한 중복 호출 방지
- 시맨틱 캐싱 도입 시 비용을 약 40-50% 절감 가능
모든 "AI 앱 만들기" 튜토리얼은 데모 단계에서 멈춥니다. 프롬프트(Prompt)를 넣으면 응답이 나오고, 그대로 배포하면 끝이죠. 하지만 그 데모에 실제 사용자가 유입되고, 당신이 Gemini나 OpenAI 청구서를 보며 어떤 기능 때문에 비용이 이렇게 나왔는지 파악하려고 애쓰는 부분에 대해서는 아무도 다루지 않습니다.
저는 지난 몇 달 동안 거의 모든 사용자 동작마다 비전(Vision) 및 언어(Language) 호출을 실행하는 소비자용 앱의 AI 레이어를 구축하는 데 시간을 보냈습니다. 가끔 트래픽이 발생하는 챗봇(Chatbot)이 아닙니다. 모델 호출 자체가 기본적으로 제품인 서비스입니다. 제가 실제로 구축해야 했던 것들을 구축 순서대로 정리했습니다.
하나가 아닌 네 가지 문제
첫 번째는 당연히 비용입니다. 토큰(Tokens)은 측정 단위이며, 일정 수준 이상의 볼륨을 넘어서면 모든 요청에 대해 모델을 호출하는 것은 5분 전에 이미 누군가에게 제공했던 답변에 대해 다시 비용을 지불하는 것을 의미합니다.
다음은 지연 시간(Latency)입니다. 캐시 히트(Cache hit)는 밀리초(ms) 단위로 이루어집니다. 반면 콜드 모델 호출(Cold model call)은 몇 초가 걸립니다. 사용자는 이를 체감하며, 특히 카메라 기반의 기능에서 자신의 주방 조리대 위로 로딩 스피너(Loading spinner)가 돌아가는 것을 지켜봐야 할 때는 더욱 그렇습니다.
신뢰성(Reliability) 또한 중요합니다. 서비스 제공업체는 언젠가 장애가 발생하거나 성능이 저하되는 날을 겪게 됩니다. '만약'이 아니라 '언제'의 문제입니다.
그리고 폭발 반경(Blast radius)입니다. 버그 하나, 봇 하나, 혹은 트래픽 급증 하나로 인해 모두가 잠든 사이 하루 50달러였던 청구서가 하루 5,000달러로 변할 수 있습니다.
데모에서는 이 중 그 어떤 것도 볼 수 없습니다. 실제 트래픽과 함께 나타나며, 그때가 되면 처음부터 제대로 구축하는 것보다 수정하는 데 훨씬 더 많은 비용이 듭니다.
정확한 문자열이 아닌 쿼리의 의미를 기준으로 캐싱하기
캐시 키를 요청된 텍스트 그대로 설정한다면 거의 쓸모없는 것을 만든 셈입니다. "닭고기와 쌀로 무엇을 만들 수 있을까"와 "닭고기와 쌀, 무엇을 요리해야 할까"는 같은 의미를 갖지만 공유하는 문자가 거의 없습니다.
따라서 쿼리를 임베딩(Embed)하고, 이미 답변된 모든 내용에 대해 벡터 유사도 검색(Vector similarity search)을 실행하세요. 만약 어떤 결과가 높은 임계값을 통과한다면, 또 다른 호출 비용을 지불하는 대신 그 답변을 제공하십시오. 저는 0.95의 코사인 유사도(Cosine similarity)를 기준으로 사용합니다.
async function checkSemanticCache(embedding: number[], taskType: string, threshold = 0.95) {
const { data } = await db.rpc("find_similar_response", {
query_embedding: embedding,
task_type: taskType,
similarity_threshold: threshold,
});
return data?.[0] ?? null;
}
비용을 40-50% 절감하십시오. 답변 품질의 저하는 아무도 눈치채지 못할 것입니다. 알고 보니 대부분의 실제 쿼리는 원문 텍스트가 보여주는 것보다 훨씬 적은 수의 뚜렷한 의도(Intents) 주위에 군집을 이룹니다.
여기 오후 시간을 통째로 잡아먹은 버그가 있습니다. Gemini의 gemini-embedding-001은 Matryoshka 스타일의 절단(Truncation)을 지원하므로, 기본값인 3072 차원 대신 768 차원을 요청할 수 있습니다 (벡터 컬럼 크기가 768로 설정되어 있다면 이것이 원하는 방식일 것입니다). 하지만 오직 전체 3072 차원 출력만이 단위 길이(Unit length)로 사전 정규화(Pre-normalized)되어 제공됩니다. 이를 절단하면 정규화가 자동으로 유지되지 않습니다. 재정규화(Renormalizing)를 건너뛰면 코사인 유사도(Cosine similarity) 순서가 오류나 충돌 없이 조용히 저하되며, 캐시가 채워짐에 따라 근접 이웃(Nearest neighbors)의 품질이 서서히 나빠지기만 합니다. 해결책은 두 줄입니다:
const norm = Math.sqrt(values.reduce((sum, v) => sum + v * v, 0));
const normalized = norm > 0 ? values.map(v => v / norm) : values;
절단이 전체 벡터가 가진 보증을 그대로 유지할 것이라고 가정하는 대신, 모델 카드(Model card)를 더 주의 깊게 읽었더라면 그 오후 시간을 아낄 수 있었을 것입니다.
이미지의 경우, 임베딩(Embedding)을 하기 전에 해시(Hash)를 수행하십시오.
만약 당신의 제품이 사진을 분석한다면, 프롬프트 텍스트를 캐싱하는 것만으로는 진정한 기회를 놓치는 것입니다. 동일한 이미지, 혹은 그와 매우 유사한 이미지가 당신이 예상하는 것보다 더 자주 전송됩니다. 재시도, 재크롭(Re-crops), 혹은 앱이 첫 번째에 느리다고 느껴서 등록되지 않았다고 생각하고 누군가 같은 선반을 두 번 촬영하는 경우 등이 있습니다.
지각 해시(Perceptual hash)는 비전 호출(Vision call) 비용을 지불하기도 전, 심지어 임베딩 비용을 지불하기도 전에 이를 잡아냅니다. 당신은 거의 중복된(Near-duplicates) 항목들이 충돌하기를 원하는데, 이는 암호화 해시(Cryptographic hash)가 제공하는 것과는 반대되는 결과입니다. 따라서 정확한 일치(Exact match) 대신 해밍 거리(Hamming distance) 허용 오차를 사용하여 확인하십시오.
async function checkVisionCache(imageHash: string, taskType: string) {
const { data } = await db.rpc("find_by_hash", {
image_hash: imageHash,
task_type: taskType,
max_hamming_distance: 10,
});
return data?.[0] ?? null;
}
Vision(시각) 호출은 이미지가 프롬프트 토큰(prompt tokens)을 빠르게 소모하기 때문에 보통 가장 비용이 많이 드는 항목입니다. 이 계층(layer)은 여기서 다른 어떤 것보다도 빠르게 비용 회수(pays for itself)를 해냅니다.
서킷 브레이커(Circuit breaker)는 누가 비용을 지불하고 있는지 알아야 합니다.
표준 서킷 브레이커: N번의 실패 후 Open(열림), 냉각기(cooldown) 이후 Half-open(반열림) 탐색, 성공 시 다시 Closed(닫힘). 좋습니다, 그 부분은 해결되었습니다. 하지만 덜 논의되는 부분은 이것입니다: 모델의 성능이 저하되었을 때, 남은 용량을 누구에게 할당할지 결정하는 것은 인프라(infra)의 문제로 포장된 제품(product)의 결정이라는 점입니다.
제 방식은 성능 저하 시 무료 티어(free-tier) 트래픽을 가장 먼저 차단하여, 유료 사용자가 캐시된 응답(cached responses)과 Half-open 탐색이 허용하는 슬롯에 대해 우선순위를 유지하도록 합니다. "우리 제공업체에 문제가 생겼다"는 상황이 "모두가 503 에러를 받았다"가 아닌 "무료 사용자가 폴백(fallback) 화면을 보았다"로 바뀌게 됩니다. 사후 분석(postmortem) 보고서에 쓰기에도 더 나은 문구이며, 유료 사용자가 경험하기에도 더 나은 상황입니다.
function shouldRejectRequest(breaker: CircuitBreaker, isPremiumUser: boolean): boolean {
return breaker.state !== "closed" && !isPremiumUser;
}
한도에 실제 달러 금액을 설정하십시오.
속도 제한(Rate limits)은 지연 시간(latency)을 보호합니다. 하지만 귀하의 은행 잔고에는 아무런 도움이 되지 않습니다. 개별 요청이 그 자체로 남용(abusive)처럼 보였는지 여부와 상관없이, 설정된 금액에 도달하는 즉시 모든 AI 트래픽을 차단하는 별도의 실제 일일 USD 한도(daily USD cap)가 필요합니다.
if (await getGlobalDailySpend() >= DAILY_BUDGET_USD_CAP) {
return respond(503, { retryAfter: secondsUntilMidnightUtc() });
}
저는 80%에서 경고를 보내고, 실제로 트래픽을 차단하는 100%에서 다시 알림을 보냅니다. 이때 중복 제거(deduplicated)를 수행하여, 운 나쁜 하루 때문에 거부되는 모든 요청마다 팀원들에게 페이지(page)가 울리는 일이 없도록 합니다. 이는 갑작스러운 트래픽 폭증(viral spike)이나 아직 발견하지 못한 버그에 대비하는 가장 저렴한 보험입니다.
한 가지 더 주의해야 할 버그가 있습니다. 저는 여러 코드베이스에서 이와 유사한 사례를 목격했습니다. 만약 저렴하고 빠른 모델과 더 비싸고 성능이 좋은 모델을 함께 사용하는 다중 모델 계층 (multiple model tiers) 구조를 운영 중인데, 비용 추적기 (cost tracker)에 단 하나의 고정된 가격 상수 (hardcoded price constant)가 설정되어 있다면, 모든 응답이 그 단일 요율로 청구됩니다. 앱이 실제로 더 비싼 모델을 호출하는 날, 지출 추적은 조용히 과소 산정되며, 설정해둔 '엄격한' 예산 상한선은 실제 청구서가 도착할 때까지 아무도 모르게 무의미해집니다. 6개월 전에 상수를 작성할 때 염두에 두었던 가격이 아니라, 요청 시점에 실제로 사용된 모델에 따라 가격을 책정해야 합니다.
그 결과는 다음과 같았습니다.
캐싱 (Caching)은 누구도 체감할 수 없는 트레이드오프 (tradeoff) 없이 AI 지출을 40-50% 절감했습니다. 서비스 제공업체 (provider)의 성능 저하는 전체 서비스 중단 대신 계층화된 속도 저하로 전환되었습니다. 그리고 이제는 스택의 다른 모든 안전장치와 별개로, 하나의 잘못된 버그가 초래할 수 있는 피해 규모에 실질적인 상한선이 생겼습니다.
캐싱 (Caching), 서킷 브레이커 (circuit breakers), 예산 제한 (budget caps). 이 중 새로운 것은 없습니다. 이것은 실제 사용자들이 의존하기 시작했을 때, AI 기능이 하나의 제품이 될지 아니면 부채 (liability)가 될지를 결정하는 계층입니다. 만약 모델 호출이 앱이 처리하는 대부분의 요청의 임계 경로 (critical path)에 위치한다면, 이것이 정말로 필요해지기 전에 구축하십시오. 청구서는 코드 리뷰보다 훨씬 더 가혹한 스승입니다.
저는 AI 네이티브 주방 OS인 Shelfie를 만들고 있습니다. 이것은 제가 이를 소비자 규모로 운영하기 위해 결국 해결해야 했던 문제들의 집합입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기