목차
- Before: 월 $180, After: 월 $72
- 첫 번째 — 시멘틱 캐싱
- 두 번째 — 배치 처리
- 세 번째 — 프롬프트 압축
- 세 가지를 조합하는 순서
- 비용 모니터링 체크리스트
- 적용할 때 주의할 점
- 당장 실행할 수 있는 액션
Before: 월 $180, After: 월 $72
GPT-4o와 Gemini 1.5 Pro를 동시에 쓰는 RAG 파이프라인을 운영하고 있었다. Python LLM API 비용 최적화 같은 건 생각도 안 하고, 들어오는 요청마다 그냥 API를 쏘고 있었다. 두 달쯤 지나니까 월 청구서가 $180을 찍었다. 사이드 프로젝트 치고는 감당이 안 되는 금액이었다.
세 가지를 손봤다. 응답 캐싱, 배치 처리, 프롬프트 압축. 각각 따로 적용하면 10~30% 절감이고, 세 개를 합치니까 체감상 60% 가까이 줄었다. 아래는 변경 전후 비교다.
| 항목 | Before | After |
|---|---|---|
| 월 API 호출 수 | ~12,000건 | ~5,400건 (캐시 히트 제외) |
| 평균 프롬프트 토큰 | 2,100 | 1,250 |
| OpenAI 월 비용 | $132 | $48 |
| Gemini 월 비용 | $48 | $24 |
| 합계 | $180 | $72 |
수치는 3월 한 달간 실제 대시보드에서 뽑은 거다. 프로젝트 규모나 트래픽 패턴에 따라 달라질 수 있으니 참고 정도로만 보면 된다. 이제 각 전략별로 뭘 했는지 풀어본다.
첫 번째 — 시멘틱 캐싱
해시 캐싱의 한계
처음엔 단순했다. 요청 프롬프트를 SHA-256으로 해시해서 Redis에 넣고, 같은 해시가 오면 캐시된 응답을 돌려주는 방식. 구현은 30분이면 끝난다.
import hashlib
import redis
import json
r = redis.Redis(host="localhost", port=6379, db=0)
def get_cached_response(prompt: str, model: str) -> str | None:
# 프롬프트 + 모델명으로 캐시 키 생성
key = hashlib.sha256(f"{model}:{prompt}".encode()).hexdigest()
cached = r.get(key)
if cached:
return json.loads(cached)
return None
문제는 히트율이었다. 2주 돌려봤는데 캐시 히트율이 15%밖에 안 됐다. 사용자가 같은 의도의 질문을 미세하게 다르게 입력하기 때문이다. "Python 리스트 정렬 방법"이랑 "파이썬에서 리스트 어떻게 정렬해?"는 완전히 다른 해시가 된다. 당연한 건데 그때는 미처 생각을 못 했다.
임베딩 기반으로 전환
해시 대신 임베딩 유사도를 쓰기로 했다. 들어오는 프롬프트를 임베딩 벡터로 변환하고, 기존 캐시와 코사인 유사도를 비교해서 임계값(0.92) 이상이면 캐시 히트로 처리한다.
from openai import OpenAI
import numpy as np
client = OpenAI()
def get_embedding(text: str) -> list[float]:
# text-embedding-3-small 사용 — 비용이 거의 무시할 수준
resp = client.embeddings.create(
model="text-embedding-3-small",
input=text
)
return resp.data[0].embedding
def cosine_similarity(a: list[float], b: list[float]) -> float:
a, b = np.array(a), np.array(b)
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
임베딩 호출 자체도 비용이 들긴 하는데, text-embedding-3-small 기준 1M 토큰에 $0.02다 (출처: OpenAI Pricing, 2026-03 기준). GPT-4o 입력 토큰 $2.50/1M과 비교하면 100분의 1도 안 된다. 캐시 히트가 늘어날수록 이득이 커진다.
임계값은 0.92로 시작해서 2주간 로그를 보면서 조정했다. 0.90으로 내리면 히트율은 올라가는데, 맥락이 다른 질문에 엉뚱한 답을 돌려주는 사고가 생겼다. 0.95로 올리면 너무 보수적이라 히트율이 해시 기반이랑 별 차이가 없었다. 결국 0.92~0.93 사이가 적당하더라.
캐시 저장소는 처음에 Redis에 벡터를 JSON으로 때려넣었다가, 검색 속도가 느려져서 Qdrant로 옮겼다. 1만 건 이하면 Redis로도 충분한데, 그 이상 넘어가면 전용 벡터DB를 쓰는 게 낫다.
이렇게 바꾸니까 캐시 히트율이 15%에서 45%로 올라갔다. 호출 수 자체가 줄어드니 비용 절감이 바로 체감됐다.
두 번째 — 배치 처리
실시간 응답이 필요 없는 작업이 의외로 많다. 일일 리포트 생성, 대량 문서 요약, 주기적 분류 작업 같은 것들. 이런 건 건건이 API를 쏠 이유가 없다.
OpenAI Batch API
OpenAI는 Batch API를 제공한다. 24시간 이내 처리 조건으로 요청을 모아서 보내면 입력·출력 토큰 모두 50% 할인을 해준다 (출처: OpenAI Batch API 문서, 2026-03 기준). 이건 좀 파격적이다.
사용법은 JSONL 파일을 만들어서 업로드하는 방식이다.
{"custom_id": "req-001", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "gpt-4o", "messages": [{"role": "user", "content": "Python GIL이 뭔지 3문장으로 설명해줘"}]}}
{"custom_id": "req-002", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "gpt-4o", "messages": [{"role": "user", "content": "FastAPI에서 미들웨어 추가하는 방법"}]}}
from openai import OpenAI
client = OpenAI()
# JSONL 파일 업로드
batch_file = client.files.create(
file=open("batch_requests.jsonl", "rb"),
purpose="batch"
)
# 배치 작업 생성
batch_job = client.batches.create(
input_file_id=batch_file.id,
endpoint="/v1/chat/completions",
completion_window="24h" # 24시간 이내 처리
)
print(f"배치 ID: {batch_job.id}") # 이걸로 상태 확인
결과는 client.batches.retrieve(batch_job.id)로 폴링하면 된다. 체감상 대부분 2~4시간 안에 끝났다. 24시간은 SLA 상한선이지 실제로 그렇게 오래 걸리진 않더라.
내 경우 일일 문서 요약 작업(하루 약 200건)을 전부 배치로 돌렸더니 그것만으로 OpenAI 비용의 25%가 줄었다.
Gemini의 Context Caching
Gemini 쪽은 배치 API 대신 Context Caching이 유용하다. 긴 시스템 프롬프트나 공통 컨텍스트를 캐시해두면 해당 토큰에 대해 과금이 줄어든다. google-genai SDK 기준으로 CachedContent.create()를 쓰면 된다 (출처: Google AI 공식 문서, 2025-12 기준).
시스템 프롬프트가 1,500토큰 이상이면서 같은 프롬프트를 반복 사용하는 경우에 효과가 크다. 내 파이프라인에서 RAG용 시스템 프롬프트가 2,000토큰이었는데, 이걸 캐시에 올려놓으니 Gemini 쪽 비용이 체감상 30% 정도 줄었다.
세 번째 — 프롬프트 압축
프롬프트가 길면 비용이 올라간다. 당연한 얘긴데 의외로 많은 사람이 프롬프트 최적화 없이 그냥 쓴다. 나도 그랬다.
수동 압축부터
자동화 도구를 쓰기 전에 수동으로 할 수 있는 것부터 했다.
RAG 파이프라인에서 검색된 문서 chunk를 통째로 프롬프트에 넣고 있었는데, 실제로 답변에 필요한 부분은 chunk의 30~40%뿐이었다. chunk 크기를 1,024토큰에서 512토큰으로 줄이고, top_k를 5에서 3으로 낮췄다. 이것만으로 평균 프롬프트 길이가 2,100토큰에서 1,600토큰으로 떨어졌다.
시스템 프롬프트도 손봤다. "당신은 전문적인 기술 문서 작성자입니다. 사용자의 질문에 대해 정확하고 상세한 답변을 제공해주세요. 답변은 한국어로…" 이런 식으로 장황하게 쓰고 있었는데, 핵심만 남기니까 2,000토큰이 800토큰으로 줄었다. 답변 품질 차이는 거의 없었다.
LLMLingua로 자동 압축
수동으로는 한계가 있다. 동적으로 생성되는 프롬프트(RAG 검색 결과 등)는 매번 사람이 다듬을 수 없으니까. 여기서 LLMLingua를 도입했다.
LLMLingua (Microsoft Research, v0.2.2 기준)는 프롬프트에서 의미 보존율을 유지하면서 토큰을 줄여주는 라이브러리다. 내부적으로 작은 언어 모델을 써서 각 토큰의 중요도를 판단하고, 덜 중요한 토큰을 제거한다.
from llmlingua import PromptCompressor
# 초기 로딩에 시간이 좀 걸린다 — 내부 모델 로딩 때문
compressor = PromptCompressor(
model_name="microsoft/llmlingua-2-bert-base-multilingual-cased-meetingbank",
device_map="cpu" # GPU 없으면 cpu로
)
original_prompt = """여기에 긴 프롬프트가 들어간다고 가정하자.
RAG에서 검색된 문서 3개가 붙어서 토큰이 2000개가 넘는 상황."""
compressed = compressor.compress_prompt(
original_prompt,
rate=0.6, # 원본의 60%로 압축
force_tokens=["Python", "FastAPI", "에러"] # 이 토큰은 반드시 보존
)
print(f"원본: {len(original_prompt)}자")
print(f"압축: {len(compressed['compressed_prompt'])}자")
# 압축된 프롬프트로 API 호출
rate=0.6이면 원본의 60% 수준으로 압축한다는 뜻이다. 0.5 이하로 내리면 의미가 손상되기 시작하니까 0.55~0.65 사이를 권장한다. force_tokens로 절대 빠지면 안 되는 키워드를 지정할 수 있는데, 이게 실무에서 꽤 쓸모 있다.
주의할 점이 있다. LLMLingua는 내부적으로 BERT 기반 모델을 로딩하기 때문에 첫 호출에 5~10초 걸린다. 서버 기동 시 미리 로딩해놓거나, 배치 작업에 적용하는 게 현실적이다. 실시간 API 앞단에 붙이기에는 레이턴시 부담이 있다.
세 가지를 조합하는 순서
세 전략을 각각 독립적으로 써도 되지만, 조합할 때 순서가 중요하다. 잘못된 순서로 적용하면 캐시 히트율이 떨어지거나, 압축된 프롬프트가 캐시 키로 들어가서 혼란이 생긴다.
내가 쓰는 순서는 이렇다:
- 캐시 확인 (원본 프롬프트 기준) → 히트면 바로 반환
- 프롬프트 압축 → 캐시 미스일 때만 실행
- 배치 가능 여부 판단 → 실시간이면 즉시 호출, 아니면 배치 큐에 적재
- 응답 수신 후 캐시 저장 → 원본 프롬프트의 임베딩을 키로 저장
여기서 핵심은 캐시 키를 "원본 프롬프트의 임베딩"으로 잡는 거다. 압축된 프롬프트로 캐시 키를 만들면, 같은 원본이어도 압축 결과가 미세하게 달라질 수 있어서 히트율이 떨어진다. 이걸 모르고 처음에 압축 후 프롬프트로 캐시 키를 만들었다가 히트율이 10%대로 곤두박질친 적이 있다.
비용 모니터링 체크리스트
Python LLM API 비용 최적화를 적용한 뒤에 방치하면 안 된다. 모델 가격이 바뀌기도 하고, 트래픽 패턴이 변하면 캐시 히트율도 달라진다.
운영하면서 주기적으로 확인하는 항목들:
- 캐시 히트율: 40% 이하로 떨어지면 임계값 재조정. 로그에 히트/미스를 남겨두면 편하다.
- 배치 처리 소요 시간: 보통 2~4시간인데, 갑자기 12시간 이상 걸리면 OpenAI 쪽 이슈일 수 있다.
batch.status가in_progress로 오래 머물면 새 배치를 만드는 게 낫다. - 압축률 대비 품질: LLMLingua
rate를 너무 공격적으로 잡으면 답변 품질이 떨어진다. 주기적으로 압축 전후 응답을 샘플링해서 비교하자. - 토큰 사용량 추세: OpenAI Dashboard와 Google AI Studio 양쪽 다 주간 단위로 확인. 갑자기 튀는 날이 있으면 특정 요청 패턴이 바뀐 거다.
이건 간단하다. usage 필드를 파싱해서 DB에 쌓아두면 된다.
# API 응답에서 토큰 사용량 추적
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": compressed_prompt}]
)
# 매 요청마다 로깅
usage = response.usage
log_entry = {
"timestamp": datetime.now().isoformat(),
"model": "gpt-4o",
"prompt_tokens": usage.prompt_tokens,
"completion_tokens": usage.completion_tokens,
"total_tokens": usage.total_tokens,
"cached": False, # 캐시 미스였으므로
"compressed": True # 압축 적용 여부
}
# DB나 파일에 저장
적용할 때 주의할 점
시멘틱 캐싱은 멱등하지 않은 요청에는 쓰면 안 된다. "현재 시각 알려줘" 같은 건 캐시하면 안 되는 게 당연하고, "오늘 뉴스 요약해줘"처럼 시간에 따라 답이 달라지는 것도 마찬가지다. 캐시 대상 요청과 비대상 요청을 분류하는 로직을 앞단에 넣어야 한다.
배치 API는 에러 핸들링이 좀 번거롭다. 1,000건을 배치로 보냈는데 3건이 실패하면, 실패한 건만 골라서 재처리해야 한다. 응답 JSONL에 custom_id가 들어있으니까 이걸로 매칭하면 되는데, 처음 구현할 때 이 부분을 대충 넘겼다가 누락된 응답이 생긴 적이 있다.
프롬프트 압축은 한국어에서 영어보다 효과가 적다. LLMLingua의 multilingual 모델이 한국어를 지원하긴 하지만, 영어 기준으로 최적화되어 있어서 한국어 프롬프트 압축률은 영어 대비 70~80% 수준이었다. 이 부분은 아직 뚜렷한 대안을 못 찾았다.
당장 실행할 수 있는 액션
- OpenAI Dashboard에서 지난 30일 토큰 사용량을 확인하고, 실시간 응답이 필요 없는 작업을 골라내라. 이것만 Batch API로 돌려도 해당 작업 비용이 반으로 준다.
- 시스템 프롬프트를 열어서 불필요한 수식어와 반복 지시를 지워라. 대부분 30~50%는 줄일 수 있다. 줄인 뒤 답변 품질을 비교해보면 차이가 거의 없어서 좀 허탈하다.
- 캐싱은 트래픽이 일 1,000건 이상일 때 도입을 고려하면 된다. 그 이하면 구현 비용 대비 절감 효과가 미미하다.
개인적으로는 배치 처리가 투자 대비 수익률이 가장 높은 것 같다. 코드 몇 줄이면 끝나고, 할인율이 50%로 확정적이니까.
관련 글
- Python pytest 테스트 자동화 — unittest에서 전환하며 깨달은 것들 – 커버리지 80%를 달성했는데 버그는 왜 안 줄었나. unittest에서 pytest로 전환하면서 겪은 시행착오와 fixture·mock·커…
- FastAPI JWT 인증 구현 — 리프레시 토큰과 권한 분리까지 실무에서 겪은 것들 – FastAPI에서 JWT 인증을 직접 구현하면서 3시간을 날린 경험을 바탕으로, 토큰 설계부터 Depends 체이닝을 이용한 권한 분리까지…
- Python 비동기 크롤링 aiohttp — 단순 교체만으로는 빨라지지 않는다 – Python 비동기 크롤링 aiohttp 조합으로 requests 대비 10배 속도를 얻는 과정을 정리했다. 단순 교체가 아닌 asynci…