Anthropic Claude API
외부 AI 모델을 애플리케이션에 통합해야 하는 상황은 점점 흔해지고 있다. 텍스트 요약, 분류, 태깅, 번역 같은 작업을 사람이 하기엔 양이 많고, 규칙 기반 로직으로는 품질이 부족할 때 LLM API를 호출하는 방식이 현실적인 선택지가 된다.
Anthropic의 Claude API는 OpenAI GPT와 함께 가장 많이 사용되는 LLM API 중 하나다. 공식 SDK @anthropic-ai/sdk를 통해 Node.js에서 간단하게 호출할 수 있다.
OpenAI vs Anthropic: API 구조 비교
두 API 모두 HTTP 기반 메시지 전송 방식이지만, 구조적으로 미묘한 차이가 있다.
OpenAI (ChatCompletion)
const response = await openai.chat.completions.create({
model: "gpt-4",
messages: [
{ role: "system", content: "시스템 프롬프트" },
{ role: "user", content: "사용자 입력" }
],
max_tokens: 1024
});
const text = response.choices[0].message.content;
OpenAI는 system 메시지를 messages 배열 안에 넣는다.
Anthropic (Messages API)
const response = await anthropic.messages.create({
model: "claude-3-5-haiku-latest",
system: "시스템 프롬프트",
messages: [
{ role: "user", content: "사용자 입력" }
],
max_tokens: 1024
});
const text = response.content[0].text;
Anthropic은 system을 별도 필드로 분리한다. 응답 구조도 다른데, OpenAI는 choices[0].message.content로 접근하고 Anthropic은 content[0].text로 접근한다. Anthropic의 content는 배열인데, 이는 텍스트 외에도 이미지 등 여러 콘텐츠 블록이 올 수 있기 때문이다.
SDK 설치와 초기화
npm install @anthropic-ai/sdk
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({
apiKey: process.env.AI_API_KEY,
});
SDK는 생성자에 apiKey를 전달하면 된다. 환경 변수 ANTHROPIC_API_KEY가 설정되어 있으면 인자 없이 new Anthropic()만으로도 초기화할 수 있다. 하지만 명시적으로 전달하는 게 코드 가독성 면에서 더 낫다.
Messages API 기본 호출
Claude API의 핵심은 messages.create() 메서드다.
const params: Anthropic.MessageCreateParams = {
model: "claude-3-5-haiku-latest",
max_tokens: 8192,
system: "당신은 기술 블로그 글을 요약하는 전문가입니다. JSON 형식으로 응답하세요.",
messages: [
{ role: "user", content: articleContent }
],
};
const message = await client.messages.create(params);
주요 파라미터 상세
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
model | string | ✅ | 사용할 모델 ID |
max_tokens | number | ✅ | 응답 최대 토큰 수 |
messages | array | ✅ | 대화 메시지 배열 |
system | string | ❌ | 시스템 프롬프트 |
temperature | number | ❌ | 응답 랜덤성 (0~1) |
top_p | number | ❌ | 누적 확률 기반 샘플링 |
stop_sequences | string[] | ❌ | 응답 중단 시퀀스 |
stream | boolean | ❌ | 스트리밍 여부 |
model: Claude 모델은 크게 세 등급으로 나뉜다.
claude-3-5-sonnet-latest: 성능과 비용의 균형. 범용 작업에 적합claude-3-5-haiku-latest: 가장 빠르고 저렴. 분류, 태깅 같은 간단한 작업에 적합claude-3-opus-latest: 가장 강력. 복잡한 분석이나 긴 문서 처리
-latest 접미사를 쓰면 해당 등급의 최신 버전을 자동으로 사용한다. 프로덕션에서 응답 일관성이 중요하다면 claude-3-5-haiku-20241022 같은 고정 버전을 쓰는 게 안전하다.
max_tokens: Anthropic API에서는 필수 파라미터다 (OpenAI는 선택). 모델이 생성할 수 있는 최대 토큰 수를 지정한다. 응답이 이 길이에 도달하면 잘릴 수 있으므로, 예상되는 응답 길이보다 넉넉하게 설정해야 한다.
temperature: 0에 가까울수록 결정적(deterministic)이고, 1에 가까울수록 창의적이다. JSON 파싱이 필요한 구조화된 응답에는 00.3, 창작 텍스트에는 0.71.0이 일반적이다.
응답 구조 파싱
messages.create()의 응답 객체는 다음과 같은 구조를 가진다.
interface Message {
id: string; // 고유 메시지 ID
type: "message";
role: "assistant";
content: ContentBlock[]; // 응답 콘텐츠 블록 배열
model: string; // 실제 사용된 모델
stop_reason: "end_turn" | "max_tokens" | "stop_sequence";
usage: {
input_tokens: number;
output_tokens: number;
};
}
type ContentBlock = TextBlock | ImageBlock;
interface TextBlock {
type: "text";
text: string;
}
중요한 것은 stop_reason이다.
"end_turn": 모델이 응답을 자연스럽게 완료함"max_tokens": max_tokens 한도에 도달해서 응답이 잘림"stop_sequence": 지정한 중단 시퀀스를 만남
"max_tokens"가 나오면 응답이 불완전할 수 있으므로, 프로덕션에서는 반드시 이 값을 체크해야 한다.
const message = await client.messages.create(params);
if (message.stop_reason === "max_tokens") {
console.warn("응답이 잘렸습니다. max_tokens를 늘려주세요.");
}
const responseText = message.content[0].text;
usage로 비용 추적
const { input_tokens, output_tokens } = message.usage;
console.log(`입력: ${input_tokens} 토큰, 출력: ${output_tokens} 토큰`);
API 비용은 토큰 단위로 과금되므로, usage 필드를 로깅해두면 비용 모니터링에 유용하다.
시스템 프롬프트 설계
시스템 프롬프트는 모델의 행동을 제어하는 핵심 수단이다. 잘 설계된 시스템 프롬프트 하나가 복잡한 후처리 로직 수십 줄을 대체한다.
JSON 응답 강제하기
LLM 응답을 프로그래밍적으로 처리해야 한다면 JSON 형식을 강제하는 게 좋다.
const system = `당신은 기술 블로그 글을 분석하는 전문가입니다.
반드시 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 포함하지 마세요.
{
"summary": "2-3문장 요약",
"tags": {
"태그1": true,
"태그2": true
}
}`;
이렇게 하면 응답을 바로 JSON.parse()로 파싱할 수 있다.
const message = await client.messages.create({
model: "claude-3-5-haiku-latest",
max_tokens: 8192,
system,
messages: [{ role: "user", content: articleContent }],
});
const responseText = message.content[0].text
.replace(/[\n\r\t\s]+/g, " "); // 줄바꿈/공백 정리
const result = JSON.parse(responseText);
console.log(result.summary);
console.log(Object.keys(result.tags));
주의할 점은, LLM이 항상 완벽한 JSON을 반환하진 않는다는 것이다. 가끔 마크다운 코드블록(```json ... ```)으로 감싸거나, JSON 앞뒤에 설명 텍스트를 추가하는 경우가 있다. 프로덕션에서는 이런 케이스를 처리하는 파싱 로직을 추가해야 한다.
스트리밍 응답
긴 응답을 실시간으로 받아야 한다면 스트리밍을 사용한다.
// JSON 부분만 추출 (첫 번째 { 부터 마지막 } 까지)
const match = cleaned.match(/\{[\s\S]*\}/);
if (!match) throw new Error("JSON not found in response");
return JSON.parse(match[0]);
}
SDK의 .stream() 메서드는 AsyncIterable을 반환한다. for await...of로 이벤트를 순회하면서 실시간으로 텍스트를 출력할 수 있다. 채팅 UI 같은 인터랙티브 환경에서 사용자 경험을 크게 개선한다.
스트리밍 이벤트 종류:
| 이벤트 타입 | 설명 |
|---|---|
message_start | 메시지 시작, 메타데이터 포함 |
content_block_start | 콘텐츠 블록 시작 |
content_block_delta | 텍스트 조각 (실제 응답 내용) |
content_block_stop | 콘텐츠 블록 종료 |
message_delta | 메시지 메타데이터 업데이트 (stop_reason 등) |
message_stop | 메시지 완료 |
에러 처리와 재시도
API 호출은 다양한 이유로 실패할 수 있다. 에러를 두 가지로 분류하는 게 핵심이다: 재시도하면 해결될 수 있는 일시적 에러와, 재시도해도 소용없는 영구적 에러.
일시적 에러 (재시도 가능)
- 429 Rate Limit: 분당/일일 요청 한도 초과. 잠시 후 재시도
- 503 Service Unavailable: 서버 일시적 과부하
- Timeout: 네트워크 타임아웃
영구적 에러 (재시도 불가)
- 401 Unauthorized: API 키가 잘못됨
- 400 Bad Request: 요청 형식 오류
- JSON Parse Error: 응답 파싱 실패 (같은 입력이면 같은 결과)
const stream = await client.messages.stream({
model: "claude-3-5-haiku-latest",
max_tokens: 4096,
messages: [{ role: "user", content: "긴 글을 작성해주세요." }],
});
for await (const event of stream) {
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
process.stdout.write(event.delta.text);
}
}
// 스트림 완료 후 최종 메시지 객체 얻기
const finalMessage = await stream.finalMessage();
console.log(finalMessage.usage);
재시도 전략: Exponential Backoff
function isRetryableError(error: Error): boolean {
const message = error.message.toLowerCase();
// 영구적 에러
if (message.includes("invalid") || message.includes("401")) return false;
if (message.includes("json") || message.includes("parse")) return false;
// 일시적 에러
if (message.includes("rate limit") || message.includes("429")) return true;
if (message.includes("timeout") || message.includes("503")) return true;
// 판단 불가능한 에러는 일단 재시도
return true;
}
Exponential backoff는 재시도 간격을 지수적으로 늘린다. Rate limit 에러에 특히 효과적인데, 서버에 부하를 줄이면서 복구 시간을 확보할 수 있다.
deathCount 기반 재시도 패턴
메시지 큐와 조합할 때는 메시지 자체에 실패 횟수를 기록하는 패턴이 실용적이다.
async function callWithRetry<T>(
fn: () => Promise<T>,
maxRetries: number = 3
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (!isRetryableError(error) || attempt === maxRetries) {
throw error;
}
const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
console.warn(`재시도 ${attempt + 1}/${maxRetries} (${delay}ms 후)`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error("Unreachable");
}
// 사용
const message = await callWithRetry(() =>
client.messages.create(params)
);
이 패턴의 장점은 프로세스가 재시작되더라도 재시도 상태가 유지된다는 것이다. deathCount가 큐 메시지 안에 들어있기 때문에 별도의 상태 저장소가 필요 없다.
Rate Limit 대응
Claude API는 분당 요청 수(RPM)와 분당 토큰 수(TPM) 제한이 있다. 대량 처리 시 이를 고려해야 한다.
배치 처리와 동시성 제어
interface QueueItem {
id: number;
content: string;
deathCount: number;
}
async function processWithRetry(
item: QueueItem,
queue: string
): Promise<void> {
try {
const result = await callAI(item.content);
await saveResult(item.id, result);
} catch (error) {
if (isRetryableError(error) && item.deathCount < 3) {
item.deathCount++;
await redis.rpush(queue, JSON.stringify(item));
console.warn(`${item.id} 재시도 예약 (${item.deathCount}/3)`);
} else {
console.error(`${item.id} 영구 실패`);
await markAsFailed(item.id);
}
}
}
또는 Redis 큐에서 한 번에 처리할 개수를 환경 변수로 제어하는 방법도 있다.
async function processBatch(
items: QueueItem[],
concurrency: number = 5
): Promise<void> {
// Rate limit에 맞춰 동시 처리 수 제한
const chunks = [];
for (let i = 0; i < items.length; i += concurrency) {
chunks.push(items.slice(i, i + concurrency));
}
for (const chunk of chunks) {
await Promise.all(chunk.map(item => processItem(item)));
// 청크 간 딜레이로 rate limit 회피
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
이렇게 하면 배포 시 환경 변수만 변경해서 처리량을 조절할 수 있다.
프롬프트 엔지니어링 팁
Few-shot 예시 제공
모델에게 원하는 출력 형식을 예시로 보여주면 정확도가 크게 올라간다.
const batchSize = parseInt(process.env.AI_RATE_LIMIT_COUNT || "5");
const items = await loadFromQueue(batchSize);
await Promise.all(items.map(item => processItem(item)));
역할 부여
const system = `기술 블로그 글을 분석하세요.
예시 입력: "React 18에서 Suspense를 활용한 데이터 페칭 패턴을 알아봅니다."
예시 출력: {"summary": "React 18의 Suspense를 이용한 선언적 데이터 페칭 방법을 설명하는 글", "tags": {"React": true, "Suspense": true}}
반드시 위 JSON 형식으로만 응답하세요.`;
응답 제약 조건 명시
const system = `당신은 10년 경력의 시니어 개발자입니다.
기술 블로그 글을 읽고 핵심 내용을 2-3문장으로 요약합니다.
초보 개발자도 이해할 수 있는 쉬운 언어를 사용합니다.`;
제약 조건을 명시적으로 나열하면 모델이 규칙을 따를 확률이 높아진다.
TypeScript 타입 정의
SDK가 제공하는 타입을 활용하면 타입 안전성을 확보할 수 있다.
const system = `규칙:
1. 반드시 JSON으로만 응답
2. summary는 100자 이내
3. tags는 최대 5개
4. 한국어로 작성`;
SDK의 Anthropic.MessageCreateParams 타입을 사용하면 잘못된 파라미터를 컴파일 타임에 잡을 수 있다.
정리
- system 필드 분리, content 블록 배열, max_tokens 필수 — OpenAI와의 차이를 알고 써야 SDK 타입을 제대로 활용할 수 있다
- stop_reason 체크와 usage 로깅은 프로덕션 필수이고, JSON 응답 강제 시 코드블록 제거 같은 방어 파싱이 항상 필요하다
- 재시도는 에러 종류 분류가 핵심이고, exponential backoff + deathCount 패턴을 조합하면 큐 기반 대량 처리에도 대응할 수 있다
관련 문서
- AI Queue Worker - Redis 큐 기반 AI 요청 배치 처리
- RabbitMQ - 메시지 큐를 통한 비동기 작업 분리
- Redis Pipeline - 다중 커맨드 배치 실행