첫 LLM API 연동 후 일주일 만에 토큰 비용이 50달러를 넘겼다. 사용자가 긴 문서를 올릴 때마다 GPT-4가 전체를 읽고 있었다. 프롬프트 캐싱이란 걸 몰랐고, 스트리밍도 안 써서 사용자는 10초씩 빈 화면을 보고 있었다. LLM API는 REST API처럼 쓰면 안 된다는 걸 그때 깨달았다.
OpenAI와 Anthropic API를 실제로 쓰면서 배운 것들을 정리했다. 토큰 비용 최적화, 스트리밍 응답, 구조화된 출력, function calling까지. 문서에는 안 나오는 팁들이다.
첫 API 호출에서 무엇이 일어나는가
LLM API 호출은 HTTP 요청처럼 보이지만, 내부에서는 완전히 다른 일이 일어난다. 일반 REST API는 DB 쿼리 한 번이면 끝이지만, LLM은 수천억 개의 파라미터를 거쳐 토큰 하나하나를 생성한다.
import OpenAI from 'openai';
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});
async function summarizeText(text: string) {
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{
role: 'system',
content: '당신은 간결한 요약을 제공하는 어시스턴트입니다.'
},
{
role: 'user',
content: `다음 텍스트를 3문장으로 요약해주세요:\n\n${text}`
}
],
temperature: 0.3,
max_tokens: 200
});
return response.choices[0].message.content;
}
이 코드의 문제는 await에 있다. 사용자가 5000자 문서를 올리면 GPT-4가 200토큰을 생성하는 동안 15초를 기다려야 한다. 이게 내가 처음 만든 요약 기능이었다. 사용자는 로딩 스피너만 보다가 페이지를 떠났다.
핵심은 스트리밍이다. LLM은 토큰을 하나씩 생성하는데, 우리는 모든 토큰이 나올 때까지 기다릴 필요가 없다. 마치 영화 스트리밍처럼 생성되는 대로 보여주면 된다.
async function summarizeTextStreaming(text: string) {
const stream = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{
role: 'system',
content: '당신은 간결한 요약을 제공하는 어시스턴트입니다.'
},
{
role: 'user',
content: `다음 텍스트를 3문장으로 요약해주세요:\n\n${text}`
}
],
temperature: 0.3,
max_tokens: 200,
stream: true
});
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || '';
if (content) {
process.stdout.write(content); // 또는 클라이언트로 전송
}
}
}
체감 속도가 완전히 달라진다. 첫 토큰은 1-2초 안에 나오고, 사용자는 텍스트가 타이핑되는 걸 보면서 기다린다. ChatGPT가 스트리밍을 쓰는 이유다.
토큰 비용 폭탄을 막는 법
LLM API의 가장 큰 함정은 토큰 비용이다. GPT-4의 경우 input 토큰은 1M당 $2.5, output은 $10다. 처음엔 대수롭지 않게 생각했는데, 사용자가 늘자 비용이 기하급수적으로 올랐다.
첫 번째 깨달음: 토큰은 단어가 아니다. 영어는 1 단어 ≈ 1.3 토큰이지만, 한국어는 1 글자 ≈ 2-3 토큰이다. "안녕하세요"는 5글자지만 10토큰이 넘는다. 한국어 서비스는 영어보다 토큰 비용이 2-3배 높다.
두 번째 깨달음: 모델 선택이 비용의 80%를 결정한다. GPT-4는 강력하지만 비싸다. 간단한 분류나 요약은 GPT-4o-mini면 충분하다. 나는 모든 요청에 GPT-4를 쓰다가, 기능별로 모델을 나눠서 비용을 70% 줄였다.
const MODEL_SELECTION = {
// 복잡한 추론이 필요한 작업
complex: 'gpt-4o',
// 간단한 분류, 요약
simple: 'gpt-4o-mini',
// 대량 처리
batch: 'gpt-4o-mini'
};
async function classifyFeedback(text: string) {
// 감정 분류 같은 간단한 작업은 mini로 충분
const response = await openai.chat.completions.create({
model: MODEL_SELECTION.simple,
messages: [
{
role: 'system',
content: '사용자 피드백을 긍정/부정/중립으로 분류하세요.'
},
{
role: 'user',
content: text
}
],
temperature: 0, // 분류는 일관성이 중요
max_tokens: 10 // 한 단어면 충분
});
return response.choices[0].message.content;
}
세 번째 깨달음: Prompt Caching이 게임 체인저다. Anthropic의 Claude API는 프롬프트 캐싱을 지원한다. 긴 시스템 프롬프트나 문서를 캐싱하면, 두 번째 호출부터는 캐시된 토큰 비용이 90% 줄어든다.
import Anthropic from '@anthropic-ai/sdk';
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY
});
async function analyzeCodeWithCache(code: string) {
const response = await anthropic.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1024,
system: [
{
type: 'text',
text: '당신은 코드 리뷰 전문가입니다. 다음 가이드라인을 따르세요...',
cache_control: { type: 'ephemeral' } // 5분간 캐싱
}
],
messages: [
{
role: 'user',
content: `이 코드를 리뷰해주세요:\n\n${code}`
}
]
});
return response.content[0].text;
}
시스템 프롬프트가 1000토큰이라면, 캐싱 없이는 요청마다 1000토큰을 낸다. 캐싱을 쓰면 첫 요청만 1000토큰, 이후는 100토큰만 낸다. 같은 기능을 반복 호출하는 서비스라면 필수다.
Temperature와 Max Tokens의 진짜 의미
처음엔 temperature가 뭔지 몰라서 그냥 0.7로 뒀다. 결과가 매번 달라져서 당황했다. temperature는 "창의성"이 아니라 확률 분포의 무작위성이다.
LLM은 다음 토큰을 선택할 때 확률 분포를 만든다. "안녕" 다음에 "하세요" 80%, "하십니까" 15%, "히" 5% 같은 식이다. temperature는 이 분포를 얼마나 평평하게 만들지 결정한다.
- temperature = 0: 항상 확률이 가장 높은 토큰 선택. 결과가 결정적이다.
- temperature = 1: 확률 분포 그대로 샘플링. 다양한 결과가 나온다.
- temperature = 2: 확률 분포를 평평하게 만들어 더 무작위적.
// 데이터 추출/분류 - 일관성 중요
async function extractStructuredData(text: string) {
return await openai.chat.completions.create({
model: 'gpt-4o',
temperature: 0, // 매번 같은 결과
messages: [...]
});
}
// 창작 콘텐츠 - 다양성 중요
async function generateBlogIdeas(topic: string) {
return await openai.chat.completions.create({
model: 'gpt-4o',
temperature: 0.9, // 다양한 아이디어
messages: [...]
});
}
max_tokens는 비용 폭발을 막는 안전장치다. 처음에는 안 정해줬다가, GPT-4가 5000토큰짜리 에세이를 뱉어내서 요청 하나에 5센트를 낸 적이 있다. 이제는 항상 max_tokens를 정한다.
구조화된 출력 - JSON Mode와 Function Calling
LLM의 응답은 텍스트다. "긍정적입니다"를 파싱하는 건 악몽이다. 오타, 줄바꿈, 다른 표현... JSON으로 받고 싶었다.
첫 시도: 프롬프트에 "JSON으로 답하세요"
// ❌ 신뢰할 수 없음
const badResponse = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{
role: 'user',
content: '이 리뷰를 분석하고 JSON 형식으로 답하세요: {"sentiment": "positive/negative", "score": 1-5}'
}
]
});
// 결과: "물론이죠! 분석 결과는 다음과 같습니다:\n```json\n{...}\n```"
GPT-4가 코드 블록으로 감싸거나, 설명을 덧붙인다. 파싱이 엉망이 됐다.
해결책: JSON Mode (OpenAI) 또는 Tool Use (Anthropic)
// OpenAI JSON Mode
async function analyzeSentiment(review: string) {
const response = await openai.chat.completions.create({
model: 'gpt-4o',
response_format: { type: 'json_object' },
messages: [
{
role: 'system',
content: 'JSON 형식으로만 응답하세요.'
},
{
role: 'user',
content: `리뷰 분석 (sentiment: positive/negative/neutral, score: 1-5, summary: string):\n${review}`
}
]
});
return JSON.parse(response.choices[0].message.content);
}
// Anthropic Tool Use (더 강력)
async function extractProductInfo(description: string) {
const response = await anthropic.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1024,
tools: [
{
name: 'save_product',
description: '제품 정보 저장',
input_schema: {
type: 'object',
properties: {
name: { type: 'string', description: '제품명' },
price: { type: 'number', description: '가격' },
category: { type: 'string', description: '카테고리' },
features: { type: 'array', items: { type: 'string' } }
},
required: ['name', 'price', 'category']
}
}
],
messages: [
{
role: 'user',
content: `제품 정보 추출:\n${description}`
}
]
});
const toolUse = response.content.find(block => block.type === 'tool_use');
return toolUse?.input;
}
Tool use는 function calling의 진화판이다. LLM이 함수를 "호출"하는 형태로 구조화된 데이터를 반환한다. 스키마 검증까지 자동으로 돼서, JSON mode보다 훨씬 안정적이다.
에러 처리: Rate Limit, Timeout, Retry
LLM API는 실패한다. 자주. rate limit에 걸리고, 서버가 느려지고, 네트워크가 끊긴다. 처음엔 try-catch 하나로 퉁쳤는데, 프로덕션에서 계속 터졌다.
핵심 전략: Exponential Backoff + 재시도
async function callLLMWithRetry<T>(
apiCall: () => Promise<T>,
maxRetries = 3
): Promise<T> {
for (let i = 0; i < maxRetries; i++) {
try {
return await apiCall();
} catch (error: any) {
const isLastRetry = i === maxRetries - 1;
// Rate limit - 대기 후 재시도
if (error?.status === 429) {
if (isLastRetry) throw error;
const waitTime = Math.pow(2, i) * 1000; // 1s, 2s, 4s
console.log(`Rate limited. Waiting ${waitTime}ms...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
continue;
}
// Server error (500-599) - 재시도
if (error?.status >= 500 && error?.status < 600) {
if (isLastRetry) throw error;
await new Promise(resolve => setTimeout(resolve, 1000));
continue;
}
// Client error (400-499) - 재시도 불필요
throw error;
}
}
throw new Error('Max retries exceeded');
}
// 사용
const result = await callLLMWithRetry(() =>
openai.chat.completions.create({
model: 'gpt-4o',
messages: [...]
})
);
Timeout도 필수다. GPT-4가 가끔 30초 넘게 걸린다. 사용자는 그렇게 안 기다린다.
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), ms)
);
return Promise.race([promise, timeout]);
}
// 10초 제한
const result = await withTimeout(
openai.chat.completions.create({...}),
10000
);
OpenAI vs Anthropic: 실제 비교
두 API를 모두 써봤다. 각각 장단점이 명확하다.
OpenAI 장점:
- GPT-4o가 빠르고 저렴하다 (input $2.5/1M, output $10/1M)
- JSON mode가 간단하다
- Whisper, DALL-E 같은 멀티모달 API가 통합돼있다
- 문서가 풍부하다
OpenAI 단점:
- Prompt caching이 없다 (비용 최적화 한계)
- Context window가 128k로 Claude보다 작다
- Rate limit이 빡빡하다 (특히 free tier)
Anthropic 장점:
- Claude 3.5 Sonnet의 코딩/추론 능력이 GPT-4o보다 낫다
- Prompt caching으로 반복 호출 비용이 90% 줄어든다
- Context window가 200k로 크다
- Tool use가 강력하다 (JSON mode보다 안정적)
Anthropic 단점:
- 가격이 비싸다 (input $3/1M, output $15/1M)
- 스트리밍 구현이 OpenAI보다 복잡하다
- 생태계가 작다 (플러그인, 도구 부족)
나의 선택:
- 간단한 분류/요약: OpenAI GPT-4o-mini
- 복잡한 추론/코딩: Anthropic Claude 3.5 Sonnet
- 반복 호출이 많은 기능: Anthropic (prompt caching 때문)
- 대량 배치 처리: OpenAI (가격 때문)
구체적인 예제 - 스마트 FAQ 자동 생성
이론을 실전에 적용한 예다. 고객 문의를 모아서 자동으로 FAQ를 만드는 기능을 구현했다.
interface FAQ {
question: string;
answer: string;
category: string;
priority: number;
}
async function generateFAQ(customerQueries: string[]): Promise<FAQ[]> {
// 1단계: 쿼리 분류 (저렴한 모델 사용)
const categorized = await Promise.all(
customerQueries.map(query =>
callLLMWithRetry(() =>
openai.chat.completions.create({
model: 'gpt-4o-mini',
temperature: 0,
response_format: { type: 'json_object' },
messages: [
{
role: 'system',
content: 'JSON 형식으로 응답: {category: string, priority: number}'
},
{
role: 'user',
content: `이 문의를 분류하세요:\n${query}`
}
]
})
)
)
);
// 2단계: 카테고리별로 그룹화
const grouped = categorized.reduce((acc, item, index) => {
const data = JSON.parse(item.choices[0].message.content);
const category = data.category;
if (!acc[category]) acc[category] = [];
acc[category].push({ query: customerQueries[index], ...data });
return acc;
}, {} as Record<string, any[]>);
// 3단계: FAQ 생성 (강력한 모델 + 캐싱)
const faqs: FAQ[] = [];
for (const [category, queries] of Object.entries(grouped)) {
const response = await anthropic.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 2000,
system: [
{
type: 'text',
text: '당신은 고객 문의를 분석해 명확한 FAQ를 작성하는 전문가입니다. 다음 원칙을 따르세요:\n1. 질문은 명확하고 구체적으로\n2. 답변은 2-3문장으로 간결하게\n3. 전문 용어는 쉽게 풀어쓰기',
cache_control: { type: 'ephemeral' } // 시스템 프롬프트 캐싱
}
],
messages: [
{
role: 'user',
content: `카테고리: ${category}\n문의들:\n${queries.map(q => q.query).join('\n')}\n\n상위 5개 FAQ를 JSON 배열로 생성하세요.`
}
]
});
const categoryFAQs = JSON.parse(response.content[0].text);
faqs.push(...categoryFAQs);
}
return faqs;
}
이 코드는 팁을 모두 녹였다:
- 모델 선택: 단순 분류는 mini, 복잡한 생성은 Sonnet
- 병렬 처리: Promise.all로 분류를 동시 처리
- 에러 처리: retry 로직으로 안정성 확보
- 비용 최적화: 시스템 프롬프트 캐싱으로 반복 호출 비용 절감
- 구조화 출력: JSON으로 파싱 없이 바로 사용
처음엔 전체를 GPT-4로 돌려서 요청당 20센트가 나왔는데, 이렇게 바꾸니 5센트로 줄었다. 속도는 3배 빨라졌다.
비용 관리 전략
스타트업에서 LLM API 비용은 무시 못 한다. 사용자가 늘면 기하급수적으로 증가한다. 내가 쓰는 전략들이다.
1. Input 토큰 줄이기
- 긴 문서는 청크로 나눠서 필요한 부분만 전송
- 시스템 프롬프트는 최대한 짧게
- Prompt caching 활용
2. Output 토큰 제한
- max_tokens 항상 설정
- "간단히", "3문장으로" 같은 지시로 출력 길이 제어
3. 모델 믹스
- 80%는 mini/저렴한 모델로 처리
- 20%만 고급 모델 사용
4. 캐싱 전략
- 같은 요청은 DB에 캐싱 (특히 분류, 번역)
- Redis로 1시간 캐시하니 API 호출이 60% 줄었다
async function cachedLLMCall(
key: string,
apiCall: () => Promise<string>
): Promise<string> {
const cached = await redis.get(key);
if (cached) return cached;
const result = await apiCall();
await redis.setex(key, 3600, result); // 1시간 캐시
return result;
}
// 사용
const sentiment = await cachedLLMCall(
`sentiment:${hashText(review)}`,
() => analyzeSentiment(review)
);
5. 사용량 모니터링
- 모든 API 호출을 로깅
- 사용자별, 기능별 토큰 사용량 추적
- 이상 패턴 알림 (갑자기 10배 증가 등)
이렇게 관리하니 월 LLM 비용이 $200 아래로 유지됐다. MAU 1000명 기준으로 사용자당 20센트다.
LLM API는 다르다
LLM API를 일반 REST API처럼 쓰면 안 된다. 토큰마다 돈이 나가고, 응답이 느리고, 결과가 확률적이다. 하지만 제대로 쓰면 개발 시간을 10배 줄일 수 있다.
핵심 교훈:
- 스트리밍은 필수다 - 첫 토큰까지 시간이 UX를 결정한다
- 모델 선택이 비용의 80%다 - 모든 걸 GPT-4로 돌리지 마라
- Prompt caching은 게임 체인저다 - 반복 호출이 많으면 Anthropic을 써라
- 구조화된 출력을 써라 - JSON mode나 tool use로 파싱 지옥을 피해라
- 에러는 항상 일어난다 - retry 로직 없이는 프로덕션에 못 올린다
LLM API를 처음 연동할 때 느꼈던 막막함은 결국 "이게 다른 종류의 API"라는 걸 이해하면서 사라졌다. 이제는 분류, 요약, 추출, 생성 기능을 몇 시간 만에 만든다. 코드는 50줄이면 충분하다.
LLM API는 마법이 아니라 도구다. 특성을 이해하고, 비용을 관리하고, 에러를 대비하면 스타트업의 강력한 무기가 된다.