목차
- GPT-4o vs gpt-4o-mini — 16배 단가 차이가 만드는 비용 구조
- 기존 접근이 실패한 이유
- 전략 1 — 프롬프트 압축으로 입력 토큰 줄이기
- 전략 2 — OpenAI 네이티브 프롬프트 캐싱 활용
- 전략 3 — 시멘틱 캐싱으로 반복 질의 차단
- 전략 4 — 모델 라우팅으로 요청별 최적 모델 배정
- Batch API — 실시간이 아니면 반값
- 비용 모니터링 — 줄이기 전에 측정부터
- 세 가지 전략의 조합 결과
- 당장 실행할 수 있는 액션
GPT-4o vs gpt-4o-mini — 16배 단가 차이가 만드는 비용 구조
GPT-4o의 입력 토큰 단가는 100만 토큰당 $2.50이고, gpt-4o-mini는 $0.15다. 출력 기준으로는 $10.00 대 $0.60으로 격차가 더 벌어진다 (출처: OpenAI API Pricing, 2025년 기준). ChatGPT API 비용의 대부분은 이 토큰 단가 구조에서 결정되는데, 같은 기능을 만들어도 어떤 모델을 어떤 요청에 배정하느냐에 따라 월 청구서가 3~4배 차이 난다.
직접 운영하던 챗봇 프로젝트에서 이걸 체감했다. 모든 요청을 GPT-4o로 처리하고 있었는데, 트래픽이 하루 2,000건을 넘기면서 월 비용이 $300을 돌파했다. 문제는 요청의 절반 이상이 "영업시간 알려줘", "배송 조회해줘" 같은 단순 질의였다는 거다. 이런 요청에 GPT-4o를 쓸 이유가 없었다.
| 모델 | 입력 단가 (/1M tokens) | 출력 단가 (/1M tokens) | 입력 기준 배율 |
|---|---|---|---|
| GPT-4o | $2.50 | $10.00 | 1x |
| GPT-4o-mini | $0.15 | $0.60 | ~16.7x 저렴 |
| o3-mini | $1.10 | $4.40 | ~2.3x 저렴 |
이 테이블만 봐도 감이 온다. ChatGPT API 비용 최적화의 첫 번째 레버는 "비싼 모델을 덜 호출하는 것"이다. 나머지는 전부 이 원칙의 변주에 가깝다.
기존 접근이 실패한 이유
비용이 치솟았을 때 처음 시도한 건 max_tokens를 줄이는 거였다. 기존에 1024로 잡아둔 걸 512, 256까지 내렸다. 결과는 처참했다. 응답이 중간에 잘리는 빈도가 급격히 올라갔고, 사용자 불만이 쏟아졌다. 이틀을 날리고 원복했다.
두 번째로 시도한 건 모델 다운그레이드였다. 전체 트래픽을 gpt-4o-mini로 돌렸더니 비용은 확실히 줄었다. 그런데 복잡한 질문 — 예를 들어 여러 조건을 조합한 추천이나 긴 문맥이 필요한 대화 — 에서 정확도가 눈에 띄게 떨어졌다. 체감상 복잡한 질의의 절반 정도는 쓸 수 없는 답변이 나왔다.
max_tokens 축소의 함정
max_tokens는 비용 절감 도구가 아니다. 이건 응답 길이의 상한선일 뿐이고, 실제로 생성된 토큰만큼 과금된다. 1024로 설정해도 응답이 200토큰이면 200토큰만 청구된다. max_tokens를 줄여서 비용이 줄어드는 건 오직 모델이 장황하게 답변하는 경우뿐인데, 그건 프롬프트 설계의 문제지 max_tokens 문제가 아니다.
무조건 다운그레이드의 한계
모델을 일괄 다운그레이드하면 비용은 줄지만 품질 저하가 특정 유형의 요청에 집중된다. 단순 질의는 gpt-4o-mini로도 충분한데, 추론이 필요한 질의에서 실패한다. 결국 필요한 건 "요청별로 다른 모델을 쓰는 것"이었다.
전략 1 — 프롬프트 압축으로 입력 토큰 줄이기
ChatGPT API 비용에서 간과하기 쉬운 부분이 시스템 프롬프트다. 매 요청마다 동일한 시스템 프롬프트가 입력 토큰으로 들어간다. 내 경우 시스템 프롬프트가 한국어 기준 약 2,000토큰이었다. 하루 2,000건이면 시스템 프롬프트만으로 매일 400만 토큰이 소모된다.
프롬프트 압축은 생각보다 단순하다. 핵심은 세 가지다:
- 중복 지시 제거: "친절하게 답변하세요"와 "예의 바르게 대화하세요"가 동시에 있으면 하나만 남긴다
- 예시 최소화: few-shot 예시를 5개에서 2개로 줄여도 대부분 성능 차이가 미미하다
- 구조화: 자연어 문장을 YAML이나 짧은 키워드 형태로 변환
# 압축 전 시스템 프롬프트 (약 2,000 토큰)
system_prompt_before = """
당신은 고객 서비스 챗봇입니다. 항상 친절하고 예의 바르게 답변해야 합니다.
고객이 질문하면 정확한 정보를 제공하되, 모르는 내용은 솔직하게 모른다고 답변하세요.
답변은 한국어로 작성하고, 전문 용어는 쉽게 풀어서 설명해주세요.
아래는 답변 예시입니다:
질문: 배송은 얼마나 걸리나요?
답변: 일반 배송은 2-3일, 빠른 배송은 당일 도착합니다...
(이하 예시 4개 더...)
"""
# 압축 후 시스템 프롬프트 (약 800 토큰)
system_prompt_after = """
역할: 고객서비스 챗봇
규칙:
- 언어: 한국어
- 모를 때: "확인 후 안내드리겠습니다"
- 전문용어: 쉽게 풀어 설명
- 톤: 정중
예시:
Q: 배송 기간?
A: 일반 2-3일, 빠른배송 당일.
"""
2,000토큰에서 800토큰으로 줄었다. 60% 감소. 하루 기준으로 400만 토큰이 160만 토큰이 되니까, GPT-4o 입력 단가 기준 하루 $6 절약이다. 한 달이면 $180. 프롬프트 텍스트 몇 줄 고친 것 치고 효과가 크다.
응답 품질은 어떨까? 100건 샘플로 비교해봤는데 유의미한 차이를 못 느꼈다. 프롬프트에서 "항상 친절하고 예의 바르게"를 빼도 모델은 기본적으로 예의 바르게 답한다. 불필요한 지시가 생각보다 많다.
전략 2 — OpenAI 네이티브 프롬프트 캐싱 활용
OpenAI는 2024년 10월부터 Prompt Caching 기능을 제공하고 있다 (출처: OpenAI Prompt Caching Guide). GPT-4o, GPT-4o-mini, o1 시리즈 등 주요 모델에서 지원되며, 캐시된 입력 토큰에 대해 50% 할인이 적용된다.
동작 원리
별도 설정이 필요 없다. 프롬프트의 앞부분이 1,024토큰 이상 동일하면 자동으로 캐싱된다. 응답의 usage 필드에 cached_tokens 항목이 포함되어 있으면 캐시가 적용된 거다.
import openai
client = openai.OpenAI()
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": long_system_prompt}, # 1,024토큰 이상이면 캐시 대상
{"role": "user", "content": user_query}
]
)
# 캐시 적용 여부 확인
usage = response.usage
print(f"총 입력 토큰: {usage.prompt_tokens}")
if hasattr(usage, 'prompt_tokens_details'):
cached = usage.prompt_tokens_details.cached_tokens
print(f"캐시된 토큰: {cached}") # 이 값이 0보다 크면 캐시 적용
주의할 점이 있다. 캐시는 프롬프트의 **앞부분(prefix)**부터 매칭된다. 시스템 프롬프트가 동일하더라도 그 앞에 다른 내용이 끼어 있으면 캐시가 깨진다. 메시지 순서를 일관되게 유지하는 게 중요하다. 또한 캐시 유효 시간은 5~10분 정도이므로, 트래픽이 간헐적인 서비스에서는 캐시 히트율이 낮을 수 있다.
캐시 히트율을 높이는 설계
캐시 효율을 극대화하려면 프롬프트 구조를 "공통 부분을 앞에, 가변 부분을 뒤에" 배치해야 한다. 실제로 적용했을 때 캐시 히트율이 체감상 60~70% 정도 나왔다. 시스템 프롬프트 + 공통 컨텍스트를 앞에 두고, 사용자 메시지를 맨 뒤에 넣는 구조다.
# 캐시 효율이 높은 메시지 구조
messages = [
{"role": "system", "content": system_prompt}, # 고정 (캐시 대상)
{"role": "user", "content": "공통 컨텍스트: ..."}, # 반복되는 컨텍스트
{"role": "assistant", "content": "네, 이해했습니다."}, # 고정 응답
{"role": "user", "content": actual_user_query} # 가변 (매번 다름)
]
이 구조에서 앞의 세 메시지가 1,024토큰을 넘기면 해당 부분이 캐시된다. 가변 부분인 사용자 질의만 새로 처리되니 입력 비용이 크게 줄어든다.
전략 3 — 시멘틱 캐싱으로 반복 질의 차단
OpenAI의 네이티브 캐싱은 프롬프트가 정확히 동일해야 동작한다. 하지만 실제 서비스에서는 같은 의도의 질문이 다른 표현으로 들어온다. "배송 며칠 걸려요?", "배송 기간이 어떻게 되나요?", "언제 도착해요?" — 전부 같은 질문이지만 프롬프트 캐싱으로는 잡을 수 없다.
시멘틱 캐싱은 질문의 임베딩 벡터를 기준으로 유사도를 비교해서, 충분히 비슷한 질문이면 이전 응답을 재사용하는 방식이다. Redis에 벡터 검색 기능이 있어서 이걸 활용했다.
구현 흐름
전체 흐름은 이렇다:
- 사용자 질문이 들어오면 임베딩 생성 (text-embedding-3-small 사용, 100만 토큰당 $0.02)
- Redis에서 코사인 유사도 0.95 이상인 기존 캐시 검색
- 히트하면 캐시된 응답 반환, 미스하면 GPT 호출 후 결과를 캐시에 저장
import numpy as np
import redis
import json
from openai import OpenAI
client = OpenAI()
r = redis.Redis(host='localhost', port=6379, db=0)
SIMILARITY_THRESHOLD = 0.95 # 이 값을 낮추면 캐시 히트율은 오르지만 정확도가 떨어진다
CACHE_TTL = 3600 # 1시간
def get_embedding(text: str) -> list[float]:
"""텍스트의 임베딩 벡터를 반환"""
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)))
def query_with_cache(user_query: str, system_prompt: str) -> str:
query_embedding = get_embedding(user_query)
# Redis에 저장된 캐시 키 순회 (프로덕션에서는 Redis Vector Search 사용 권장)
for key in r.scan_iter("cache:*"):
cached = json.loads(r.get(key))
sim = cosine_similarity(query_embedding, cached["embedding"])
if sim >= SIMILARITY_THRESHOLD:
return cached["response"] # 캐시 히트
# 캐시 미스 — API 호출
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_query}
]
)
answer = response.choices[0].message.content
# 결과를 캐시에 저장
cache_data = json.dumps({
"embedding": query_embedding,
"response": answer,
"original_query": user_query
})
r.setex(f"cache:{hash(user_query)}", CACHE_TTL, cache_data)
return answer
위 코드에서 scan_iter로 전체 키를 순회하는 건 캐시 엔트리가 수천 개를 넘으면 느려진다. 프로덕션 환경에서는 Redis Stack의 FT.SEARCH를 쓰거나 pgvector 같은 벡터 DB를 쓰는 게 맞다. 여기서는 개념 설명 목적으로 단순하게 작성했다.
유사도 임계값 튜닝
0.95라는 임계값은 꽤 보수적인 수치다. 이걸 0.92로 낮추면 캐시 히트율이 올라가지만, "배송 기간"과 "반품 기간"을 같은 질문으로 판단하는 오류가 생길 수 있다. 서비스 특성에 따라 0.93~0.96 사이에서 조정하되, 처음에는 0.95로 시작해서 로그를 보면서 내리는 게 안전하다. 내 경우 0.94까지 내려도 오답이 거의 없었지만, 도메인마다 다를 수 있어서 일반화하기는 어렵다.
전략 4 — 모델 라우팅으로 요청별 최적 모델 배정
이 전략이 ChatGPT API 비용 절감에서 가장 효과가 컸다. 아이디어는 간단하다. 단순한 질문은 gpt-4o-mini가 처리하고, 복잡한 질문만 gpt-4o가 처리하게 만드는 거다.
분류 기준을 어떻게 잡을까? 처음에는 질문 길이로 분류해봤다. 50토큰 이하면 단순, 이상이면 복잡. 이건 너무 단순해서 정확도가 떨어졌다. 짧지만 복잡한 질문("이 상품과 비슷한 대안 3개를 가격대별로 추천해줘")이 있고, 길지만 단순한 질문("안녕하세요 저는 서울에 사는데요 배송이 보통 며칠 정도 걸리는지 궁금해서요")도 있다.
결국 키워드 기반 규칙 + 간단한 분류기를 조합하는 방식으로 갔다. 비교, 추천, 분석, 요약 같은 키워드가 포함되면 복잡한 질의로 분류하고, FAQ에 해당하는 패턴이면 단순으로 분류한다.
import re
# 복잡한 질의를 나타내는 패턴
COMPLEX_PATTERNS = [
r"비교|추천|분석|요약|설명해|차이점|장단점",
r"왜|어떻게.*해야|어떤 방법",
r"여러|다양한|모든|전체",
]
# 단순 질의 패턴 (FAQ 유형)
SIMPLE_PATTERNS = [
r"영업시간|운영시간|몇\s*시",
r"배송.*며칠|배송.*기간|언제.*도착",
r"가격|얼마|비용|요금",
r"전화번호|연락처|주소|위치",
]
def route_model(user_query: str) -> str:
"""질의 복잡도에 따라 모델을 선택"""
for pattern in SIMPLE_PATTERNS:
if re.search(pattern, user_query):
return "gpt-4o-mini"
for pattern in COMPLEX_PATTERNS:
if re.search(pattern, user_query):
return "gpt-4o"
# 기본값은 gpt-4o-mini (비용 우선)
return "gpt-4o-mini"
이 방식으로 전체 트래픽의 약 65%가 gpt-4o-mini로 라우팅됐다. 나머지 35%만 GPT-4o를 사용한다. 단순 계산으로도, 전체를 GPT-4o로 처리할 때 대비 입력 비용만 60% 이상 줄어든다.
키워드 기반 분류의 한계는 분명 있다. 패턴에 안 걸리는 복잡한 질문이 gpt-4o-mini로 빠지는 경우가 간혹 생긴다. 더 정교하게 하려면 임베딩 기반 분류기나 경량 LLM을 분류용으로 쓰는 방법이 있는데, 분류 비용 자체가 추가되니 트래픽 규모에 따라 손익을 따져봐야 한다. 내 규모(하루 2,000건)에서는 키워드 기반으로 충분했다.
Batch API — 실시간이 아니면 반값
실시간 응답이 필요 없는 작업이 있다면 OpenAI Batch API를 고려할 만하다. Batch API는 요청을 모아서 24시간 이내에 처리하는 대신 50% 할인을 제공한다 (출처: OpenAI Batch API Docs).
챗봇처럼 즉시 응답이 필요한 서비스에는 못 쓴다. 하지만 매일 밤 쌓인 고객 리뷰를 분석한다거나, 대량의 텍스트를 분류하는 배치 작업에는 적합하다. 내 경우 매일 자정에 하루치 대화 로그를 요약하는 작업이 있었는데, 이걸 Batch API로 전환해서 해당 작업의 비용을 절반으로 줄였다.
사용법 자체는 JSONL 파일을 업로드하고 결과를 폴링하는 구조라 복잡하지 않다. 실시간성이 필요 없는 작업이 전체 API 사용량에서 얼마나 되는지 먼저 파악하는 게 선행돼야 한다.
비용 모니터링 — 줄이기 전에 측정부터
최적화를 적용하기 전에 현재 비용 구조를 정확히 파악하는 게 먼저다. OpenAI 대시보드의 Usage 페이지에서 일별 비용과 모델별 사용량을 확인할 수 있는데, 이것만으로는 "어떤 기능이 비용을 많이 쓰는지"를 알기 어렵다.
API 호출마다 usage 필드를 로깅하는 래퍼를 하나 만들어두면 편하다.
import logging
import time
from functools import wraps
from openai import OpenAI
client = OpenAI()
logger = logging.getLogger("api_cost")
# 모델별 단가 (2025년 기준, 공식 pricing 페이지에서 최신 값 확인 필요)
PRICING = {
"gpt-4o": {"input": 2.50, "output": 10.00, "cached_input": 1.25},
"gpt-4o-mini": {"input": 0.15, "output": 0.60, "cached_input": 0.075},
}
def estimate_cost(model: str, usage) -> float:
"""API 응답의 usage 객체로 예상 비용 계산 (단위: USD)"""
prices = PRICING.get(model, PRICING["gpt-4o"])
input_tokens = usage.prompt_tokens
output_tokens = usage.completion_tokens
# 캐시된 토큰이 있으면 분리 계산
cached_tokens = 0
if hasattr(usage, 'prompt_tokens_details') and usage.prompt_tokens_details:
cached_tokens = getattr(usage.prompt_tokens_details, 'cached_tokens', 0)
uncached_input = input_tokens - cached_tokens
cost = (
(uncached_input / 1_000_000) * prices["input"]
+ (cached_tokens / 1_000_000) * prices["cached_input"]
+ (output_tokens / 1_000_000) * prices["output"]
)
return round(cost, 6)
이런 로깅을 2주 정도 돌리면 어디서 비용이 새는지 보인다. 내 경우 전체 비용의 40%가 시스템 프롬프트 입력 토큰이었다는 걸 이걸 통해 알았다. 측정 없이 감으로 최적화했으면 엉뚱한 곳을 건드렸을 거다.
세 가지 전략의 조합 결과
:::stats 월 $300 → $85 (72% 절감)
- 프롬프트 압축: -$55/월
- 시멘틱 캐싱 (히트율 ~35%): -$90/월
- 모델 라우팅 (65% mini 전환): -$70/월 :::
각 전략의 절감 효과는 단순 합산이 아니라 복합적으로 작용한다. 프롬프트를 압축하면 캐싱 효율도 올라가고(프롬프트가 짧으면 캐시 매칭이 빨라진다), 캐시 히트가 나면 모델 라우팅 자체가 불필요해진다. 세 가지를 같이 적용했을 때 시너지가 있다.
적용 순서도 중요하다. 가장 먼저 할 건 프롬프트 압축이다. 코드 변경 없이 프롬프트 텍스트만 수정하면 되고, 효과가 즉시 나타난다. 두 번째로 모델 라우팅. 라우팅 로직은 단순 분기문이라 구현이 가볍다. 시멘틱 캐싱은 Redis 같은 인프라가 필요하니 마지막에 적용하는 게 현실적이다.
한 가지 더. 이 수치는 내 서비스(고객 서비스 챗봇, 하루 ~2,000건, 반복 질문 비율 높음)에서의 결과다. 반복 질문이 적은 서비스에서는 시멘틱 캐싱 효과가 훨씬 작을 수 있고, 요청이 전부 복잡한 분석 작업이면 모델 라우팅 여지도 줄어든다. 자기 서비스의 트래픽 패턴을 먼저 분석하는 게 전제 조건이다.
당장 실행할 수 있는 액션
첫째, OpenAI 대시보드에서 지난 30일 Usage를 확인하고 모델별 비용 비중을 확인한다. 둘째, 시스템 프롬프트의 토큰 수를 tiktoken으로 측정해서 1,000토큰 이상이면 압축을 시도한다. 셋째, API 호출 로그에서 반복 질의 비율을 추정해보고, 30% 이상이면 시멘틱 캐싱 도입을 검토한다.
OpenAI의 가격 정책은 계속 변하고 있다. 2024년에만 GPT-4o 가격이 두 번 인하됐고, 새 모델이 나올 때마다 단가 구조가 바뀐다. 여기서 다룬 구체적 수치는 시간이 지나면 달라질 수 있으니 OpenAI Pricing 페이지에서 최신 단가를 확인하는 게 좋다. 다만 캐싱, 라우팅, 프롬프트 압축이라는 전략 자체는 단가가 바뀌어도 유효하다 — 아직 검증 못 한 건 GPT-5 계열이 나왔을 때 라우팅 기준을 어떻게 재설정해야 하는지인데, 이건 나와봐야 판단할 수 있는 영역이다.
관련 글
- Python LLM API 비용 최적화 — 캐싱, 배치, 프롬프트 압축으로 청구서 반토막 낸 방법 – 월 $180이던 LLM API 비용을 $72까지 줄인 Python LLM API 비용 최적화 실전기. 시멘틱 캐싱, OpenAI Batch…
- Gemini 2.0 프로토타입 가이드 — Google AI Studio에서 데모 빠르게 만들기 – GPU 서버 없이 무료 티어만으로 Gemini 2.0 Flash 기반 문서 요약 데모를 3일 만에 만든 과정이다. Google AI Stu…
- Gemini API 시작하기 — Python으로 멀티모달 AI 앱 만들기 – OpenAI Vision API에서 이미지 1장에 765토큰이 소모되던 프로젝트를 Gemini API로 전환한 과정을 기록했다. 신구 SD…