티스토리 뷰
클라우드 모델 API 를 기반으로 프로젝트를 진행 중, 토큰에 대한 비용이 계속 빠져나가면서 고민에 빠졌습니다.
그러다 생각해보니 Claude Code가 깔려 있는데 이걸 그대로 쓰면 어떨까 하는 생각이 들었습니다.
그러면 토큰 비용도 없고, 원격 서버를 왕복하지 않으니 속도도 더 낫고 무엇보다 퀄리티가 더 좋지 않을까 하는 생각이 있었습니다.
그래서 챗봇의 응답 생성부를 OpenAI API에서 Anthropic의 Claude Agent SDK로 통째로 갈아끼웠습니다.
이 글은 설치부터 챗봇용 설정, 스트리밍, 도구 연결까지 실제로 동작시킨 코드로 정리한 기록입니다.
목차
OpenAI API를 걷어낸 이유
이유는 세 가지였습니다.
첫째, 비용입니다. OpenAI API는 호출할 때마다 토큰 단위로 과금됩니다. 실서비스라면 당연한 비용이지만, 학습용이나 사이드 프로젝트에서는 "켜놓고 이것저것 찔러보는" 행위 자체가 부담이 됩니다.
둘째, 이미 가지고 있는 자원입니다. 개발하는 동안 매일 Claude Code를 켜서 쓰고 있었습니다. 즉 로그인된 구독이 로컬에 이미 있다는 뜻입니다. 이걸 챗봇 백엔드로 그대로 쓰면 별도의 API 키도, 추가 과금도 필요 없습니다.
셋째, 클로드 코드를 그대로 쓸 수 있다면 높은 퀄리티가 보장되어 있다라는 것 입니다.
호출 방식이 근본적으로 다릅니다
가장 먼저 이해해야 할 건, OpenAI API와 Agent SDK가 "호출하는 방식 자체"가 다르다는 점입니다.
OpenAI는 new OpenAI({ apiKey })로 HTTP 클라이언트를 만들어 원격 서버로 요청을 보냅니다. API 키가 곧 인증이자 과금 수단입니다.
반면 Agent SDK의 query()는 HTTP 호출이 아닙니다. 내 컴퓨터에 설치된 claude CLI를 자식 프로세스로 띄워서 일을 시킵니다. 인증은 그 CLI가 이미 로그인된 구독으로 알아서 처리하므로, 코드에 API 키를 넣을 필요가 없습니다.
| 구분 | OpenAI API | Claude Agent SDK |
|---|---|---|
| 호출 방식 | 원격 HTTP API | 로컬 claude CLI 프로세스 스폰 |
| 인증 | API 키 | 구독 로그인 (키 불필요) |
| 과금 | 토큰당 과금 | 구독 한도 내 무료 |
| 실행 위치 | 어디서든 (서버리스 가능) | claude가 설치된 그 머신에서만 |
마지막 줄이 핵심 제약입니다. 로컬 CLI를 띄우는 구조라서 서버리스로 배포하거나 불특정 다수에게 서비스하기는 어렵습니다.
본인이나 팀 내부에서 쓰는 로컬 용도에 맞는 선택입니다.
설치
패키지 하나만 설치하면 됩니다.
npm install @anthropic-ai/claude-agent-sdk
# pnpm 사용 시
pnpm add @anthropic-ai/claude-agent-sdk
단, SDK는 내부적으로 claude CLI를 실행하므로 두 가지 전제가 필요합니다.
# 1) Claude Code CLI 설치
npm install -g @anthropic-ai/claude-code
# 2) 한 번 로그인 (구독 인증)
claude login
여기서 중요한 점이 하나 있습니다. ANTHROPIC_API_KEY 환경 변수를 설정하지 마세요. 이 값이 있으면 SDK가 구독 대신 API 키로 동작해서 과금이 시작됩니다. 비워둬야 로그인된 구독으로 무료로 돕니다.
기본 사용법
query()는 메시지를 비동기로 스트리밍하는 이터러블을 돌려줍니다. 가장 단순한 형태는 이렇습니다.
import { query } from '@anthropic-ai/claude-agent-sdk'
for await (const message of query({
prompt: '안녕, 한 줄로 인사해줘',
options: { model: 'sonnet' }
})) {
console.log(message)
}
model에는 'sonnet', 'opus' 같은 별칭을 쓰면 설치된 CLI가 현재 모델로 알아서 해석해줍니다. 풀 모델명을 박는 것보다 버전 호환에 안전합니다.
챗봇용으로 설정하기
기본값 그대로 쓰면 Claude Code는 파일을 읽고 명령을 실행하는 "코딩 에이전트"로 동작합니다. 일반 대화 챗봇으로 쓰려면 도구를 끄고 환경을 격리해야 합니다. 실제로 사용한 옵션은 다음과 같습니다.
import { query } from '@anthropic-ai/claude-agent-sdk'
const CHAT_SYSTEM_PROMPT = [
'당신은 친절하고 명확한 한국어 대화형 어시스턴트입니다.',
'아래는 사용자와의 대화 기록입니다. 마지막 User 메시지에 자연스럽게 이어서 답하세요.',
'코드 실행이나 로컬 파일 접근은 하지 않습니다.'
].join('\n')
const abortController = new AbortController()
const response = query({
prompt,
options: {
model, // 'sonnet' 등
systemPrompt: CHAT_SYSTEM_PROMPT, // 문자열을 주면 코딩용 기본 프롬프트를 대체
allowedTools: [], // 순수 챗봇: 모든 도구 차단
settingSources: [], // 프로젝트 CLAUDE.md / 설정 미로드 (격리)
includePartialMessages: true, // 토큰 단위 스트리밍 켜기
maxTurns: 16, // 에이전트 턴 상한
permissionMode: 'bypassPermissions',
abortController // 중단(취소) 연동
}
})
옵션별로 짚으면 이렇습니다.
systemPrompt에 문자열을 넘기면 Claude Code의 기본(코딩용) 시스템 프롬프트를 대체합니다. 챗봇 페르소나를 여기서 정합니다.allowedTools: []는 자동 승인되는 도구 목록을 비워 모든 도구를 막습니다. 순수 대화만 하게 만드는 핵심입니다.settingSources: []를 주면 사용자/프로젝트/로컬 설정과CLAUDE.md를 읽지 않습니다. 챗봇이 주변 환경에 오염되지 않도록 격리합니다.includePartialMessages: true를 켜야 토큰 단위 스트리밍 이벤트가 들어옵니다.abortController로 클라이언트가 응답을 중간에 멈출 수 있게 연결합니다.
maxTurns에서 한 번 데었습니다. 처음에 "한 번 답하고 끝"이라는 생각으로 maxTurns: 1을 줬더니, 조금만 긴 답변이면 error_max_turns로 실패했습니다. 측정해보니 도구가 하나도 없어도 긴 응답은 내부적으로 3턴 정도를 씁니다. 짧은 인사는 1턴이라 됐지만 긴 답변은 전부 터졌던 것입니다. 도구가 없으면 무한 루프 위험도 없으니 여유 있게 16으로 올렸습니다.
스트리밍 토큰 받기
includePartialMessages: true를 켜면 부분 응답이 stream_event 타입 메시지로 들어옵니다. 그 안의 content_block_delta 이벤트에서 텍스트 조각을 꺼내면 됩니다.
let full = ''
for await (const message of response) {
if (message.type === 'stream_event') {
const event = message.event
if (
event.type === 'content_block_delta' &&
event.delta.type === 'text_delta'
) {
full += event.delta.text
onToken(event.delta.text) // 클라이언트로 토큰 전송 (SSE 등)
}
continue
}
// 인증 실패·레이트리밋·잘못된 모델 등은 예외가 아니라 result 로 통보된다.
// 잡지 않으면 빈 응답이 '성공'으로 처리되니 반드시 확인한다.
if (message.type === 'result' && message.is_error) {
throw new Error(`Claude 응답 실패: ${message.subtype}`)
}
}
마지막 result 처리가 중요합니다. OpenAI는 오류가 나면 예외를 던지지만, Agent SDK는 인증 실패나 레이트리밋 같은 상황을 예외가 아니라 is_error: true인 result 메시지로 흘려보냅니다. 이걸 잡지 않으면 빈 응답이 정상 완료로 둔갑하니, result에서 반드시 오류를 확인해야 합니다.
참고로 OpenAI 시절의 같은 부분은 이렇게 생겼었습니다. 비교해보면 "함수 속만 바뀌었다"는 게 보입니다.
// 이전: OpenAI 스트리밍
const stream = await openai.chat.completions.create({
model,
messages,
stream: true
})
for await (const chunk of stream) {
const token = chunk.choices[0]?.delta?.content ?? ''
if (token) onToken(token)
}
필요한 도구만 골라 붙이기
SDK가 제공하는 기본 도구는 이렇게 분류됩니다.
| 분류 | 도구 | 성격 |
|---|---|---|
| 파일 | Read |
읽기 전용 |
| 파일 | Write, Edit |
수정 |
| 검색 | Glob, Grep |
읽기 전용 |
| 실행 | Bash, Monitor |
명령 실행 |
| 웹 | WebSearch, WebFetch |
읽기 전용 |
| 작업 | Agent, TodoWrite, TaskCreate 외 |
조율/관리 |
| 외부 연동 | MCP, ListMcpResources 외 |
케이스에 따라 다름 |
여기서 일반 챗봇에 가성비가 가장 좋은 건 WebSearch와 WebFetch입니다. 둘 다 읽기 전용이라 안전하고, 평범한 챗봇의 가장 큰 약점(최신 정보, 링크 내용 모름)을 메워줍니다. 반면 Bash나 Write는 서버에서 실제로 명령이 돌고 파일이 바뀌므로, 코딩 에이전트를 만들 게 아니라면 켜지 않는 편이 낫습니다.
웹 검색만 켜는 설정은 allowedTools에 도구 이름을 넣어주면 됩니다.
const response = query({
prompt,
options: {
model,
systemPrompt: CHAT_SYSTEM_PROMPT,
// 웹 검색/가져오기만 허용 (읽기 전용). 파일·명령 도구는 계속 차단.
allowedTools: ['WebSearch', 'WebFetch'],
settingSources: [],
includePartialMessages: true,
maxTurns: 16,
permissionMode: 'bypassPermissions',
abortController
}
})
한 가지 주의할 점은 permissionMode입니다. 'bypassPermissions'는 허용된 도구를 사람 승인 없이 자동 실행합니다. 웹 검색처럼 읽기 전용이면 괜찮지만, Bash나 Write 같은 위험한 도구에는 이 모드가 위험합니다. 위험한 도구를 켜야 한다면 canUseTool 콜백으로 실행 전에 게이트를 거는 편이 안전합니다.
실제로 "오늘 서울 날씨 검색해서 알려줘"라고 물으니, 검색을 돌리고 출처 링크까지 붙여서 답했습니다.
전체 코드
위 조각을 합치면 응답 생성 함수 하나로 정리됩니다. 토큰 콜백, 완료/실패 콜백만 바깥에서 받도록 했습니다.
import { query } from '@anthropic-ai/claude-agent-sdk'
const CHAT_SYSTEM_PROMPT = [
'당신은 친절하고 명확한 한국어 대화형 어시스턴트입니다.',
'아래는 사용자와의 대화 기록입니다. 마지막 User 메시지에 자연스럽게 이어서 답하세요.',
'최신 정보나 특정 URL 내용이 필요하면 웹 검색(WebSearch)·가져오기(WebFetch)를 사용하세요.',
'코드 실행이나 로컬 파일 접근은 하지 않습니다.'
].join('\n')
type Callbacks = {
onToken: (token: string) => void
onComplete: (content: string) => Promise<void>
onFail: () => Promise<void>
}
export const generateAssistantMessage = async (
prompt: string,
model: string,
{ onToken, onComplete, onFail }: Callbacks,
signal?: AbortSignal
): Promise<void> => {
const abortController = new AbortController()
if (signal) {
if (signal.aborted) abortController.abort()
else signal.addEventListener('abort', () => abortController.abort(), { once: true })
}
try {
const response = query({
prompt,
options: {
model,
systemPrompt: CHAT_SYSTEM_PROMPT,
allowedTools: ['WebSearch', 'WebFetch'],
settingSources: [],
includePartialMessages: true,
maxTurns: 16,
permissionMode: 'bypassPermissions',
abortController
}
})
let full = ''
for await (const message of response) {
if (signal?.aborted) break
if (message.type === 'stream_event') {
const event = message.event
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
full += event.delta.text
onToken(event.delta.text)
}
continue
}
if (message.type === 'result' && message.is_error) {
throw new Error(`Claude 응답 실패: ${message.subtype}`)
}
}
if (!signal?.aborted && full.trim() === '') {
throw new Error('Claude 응답이 비어 있습니다')
}
if (!signal?.aborted) await onComplete(full)
} catch (error) {
if (!signal?.aborted) await onFail()
console.error('[generateAssistantMessage] failed', error)
}
}
마무리
정리하면, OpenAI API의 응답 생성부를 Claude Agent SDK의 query() 한 곳으로 교체했고, 별도 API 키 없이 로컬 구독으로 동작하게 만들었습니다. 챗봇용으로는 도구를 끄고 환경을 격리하는 게 핵심이며, 최신 정보가 필요하면 읽기 전용인 웹 검색 도구만 골라 붙이는 게 가성비가 좋았습니다.
마지막으로 처음의 가설을 솔직하게 점검하면, 비용은 확실히 이득이었고 퀄리티도 보장되었지만, 속도는 기대만큼은 아니었습니다. 로컬 CLI를 프로세스로 띄우는 오버헤드가 있고, 첫 응답까지의 지연과 약 0.45초 간격의 뭉텅이 스트리밍이 있어서, 원격 API의 촘촘한 토큰 스트림보다 오히려 끊겨 느껴질 수 있습니다.
본인이나 팀 내부에서 쓰는 로컬 용도라면 비용 면에서 훌륭한 선택이지만, 불특정 다수를 위한 공개 서비스라면 구독 단일 사용자 전제와 동시성·레이트리밋 제약 때문에 한계가 있을 수는 있어보입니다. -> 이 부분을 또 어떻게 해결하냐도 고민해볼 문제라고도 생각이 듭니다.
참고 자료
'개발..' 카테고리의 다른 글
| Vite 8 마이그레이션으로 빌드 시간 3분의 1로 줄이기 (0) | 2026.07.11 |
|---|---|
| WXT로 크롬 확장 프로그램 만들기 (0) | 2026.06.21 |
| Query Factory 패턴을 Claude Code 스킬로 만들기 (0) | 2026.05.20 |
| Superpowers로 완성하는 AI 네이티브 엔지니어링 (0) | 2026.05.11 |
| 모노레포에서 CSS Layer 구조 설계하기 (0) | 2026.05.08 |
- Total
- Today
- Yesterday
- ChatGPT
- cors
- nextjs15
- 클로드 코드
- nextjs14
- Zustand
- nextjs13
- 서버 to 서버
- Ai
- NextJS
- 오블완
- seo
- vscode
- nuxt2
- 깃허브
- React
- Github Actions
- claude code
- claude
- Vite
- 티스토리챌린지
- AWS
- openAI
- 프론트엔드
- nodejs
- vue composition api
- Git
- NUXT
- github
- 타입스크립트
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 | 29 |
| 30 | 31 |