junyeokk
Blog
AI·2025. 09. 03

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)

typescript
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)

typescript
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 설치와 초기화

bash
npm install @anthropic-ai/sdk
typescript
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() 메서드다.

typescript
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);

주요 파라미터 상세

파라미터타입필수설명
modelstring사용할 모델 ID
max_tokensnumber응답 최대 토큰 수
messagesarray대화 메시지 배열
systemstring시스템 프롬프트
temperaturenumber응답 랜덤성 (0~1)
top_pnumber누적 확률 기반 샘플링
stop_sequencesstring[]응답 중단 시퀀스
streamboolean스트리밍 여부

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()의 응답 객체는 다음과 같은 구조를 가진다.

typescript
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"가 나오면 응답이 불완전할 수 있으므로, 프로덕션에서는 반드시 이 값을 체크해야 한다.

typescript
const message = await client.messages.create(params);

if (message.stop_reason === "max_tokens") {
  console.warn("응답이 잘렸습니다. max_tokens를 늘려주세요.");
}

const responseText = message.content[0].text;

usage로 비용 추적

typescript
const { input_tokens, output_tokens } = message.usage;
console.log(`입력: ${input_tokens} 토큰, 출력: ${output_tokens} 토큰`);

API 비용은 토큰 단위로 과금되므로, usage 필드를 로깅해두면 비용 모니터링에 유용하다.


시스템 프롬프트 설계

시스템 프롬프트는 모델의 행동을 제어하는 핵심 수단이다. 잘 설계된 시스템 프롬프트 하나가 복잡한 후처리 로직 수십 줄을 대체한다.

JSON 응답 강제하기

LLM 응답을 프로그래밍적으로 처리해야 한다면 JSON 형식을 강제하는 게 좋다.

typescript
const system = `당신은 기술 블로그 글을 분석하는 전문가입니다.

반드시 아래 JSON 형식으로만 응답하세요. 다른 텍스트는 포함하지 마세요.

{
  "summary": "2-3문장 요약",
  "tags": {
    "태그1": true,
    "태그2": true
  }
}`;

이렇게 하면 응답을 바로 JSON.parse()로 파싱할 수 있다.

typescript
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 앞뒤에 설명 텍스트를 추가하는 경우가 있다. 프로덕션에서는 이런 케이스를 처리하는 파싱 로직을 추가해야 한다.

typescript


스트리밍 응답

긴 응답을 실시간으로 받아야 한다면 스트리밍을 사용한다.

typescript
  
  // 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: 응답 파싱 실패 (같은 입력이면 같은 결과)
typescript
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

typescript
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 기반 재시도 패턴

메시지 큐와 조합할 때는 메시지 자체에 실패 횟수를 기록하는 패턴이 실용적이다.

typescript
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) 제한이 있다. 대량 처리 시 이를 고려해야 한다.

배치 처리와 동시성 제어

typescript
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 큐에서 한 번에 처리할 개수를 환경 변수로 제어하는 방법도 있다.

typescript
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 예시 제공

모델에게 원하는 출력 형식을 예시로 보여주면 정확도가 크게 올라간다.

typescript
const batchSize = parseInt(process.env.AI_RATE_LIMIT_COUNT || "5");
const items = await loadFromQueue(batchSize);
await Promise.all(items.map(item => processItem(item)));

역할 부여

typescript
const system = `기술 블로그 글을 분석하세요.

예시 입력: "React 18에서 Suspense를 활용한 데이터 페칭 패턴을 알아봅니다."
예시 출력: {"summary": "React 18의 Suspense를 이용한 선언적 데이터 페칭 방법을 설명하는 글", "tags": {"React": true, "Suspense": true}}

반드시 위 JSON 형식으로만 응답하세요.`;

응답 제약 조건 명시

typescript
const system = `당신은 10년 경력의 시니어 개발자입니다.
기술 블로그 글을 읽고 핵심 내용을 2-3문장으로 요약합니다.
초보 개발자도 이해할 수 있는 쉬운 언어를 사용합니다.`;

제약 조건을 명시적으로 나열하면 모델이 규칙을 따를 확률이 높아진다.


TypeScript 타입 정의

SDK가 제공하는 타입을 활용하면 타입 안전성을 확보할 수 있다.

typescript
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 패턴을 조합하면 큐 기반 대량 처리에도 대응할 수 있다

관련 문서