ClOr

ClOr

백엔드 실무 트러블슈팅과 AI 에이전트 구조 분석을 기록합니다.

Claude Code 해부학 (완결)

51만 줄 소스코드를 19편에 걸쳐 분석한 완결 시리즈

전체 시리즈 보기 →

백엔드 트러블슈팅

실무에서 겪은 장애와 해결 과정 기록

전체 시리즈 보기 →

최신 글

article thumbnail

시리즈 이전 편: [Part 7] 도구 프롬프트 분석 — 각 도구가 AI에게 어떤 지시를 받는지 살펴봤습니다.

목차

  • 사용자가 메시지를 보내면 실제로 뭐가 돌아갈까
  • QueryEngine 클래스 구조
  • submitMessage — 스트리밍 제너레이터
  • 도구 루프 패턴 — AI가 도구를 쓰고 다시 판단하는 반복
  • 재시도 로직 — API 에러와 rate limit 처리
  • 토큰 카운팅 — 입력/출력 토큰 추적
  • 그래서 뭐가 달라지나 — LLM API를 프로덕션에서 안정적으로 쓰는 법

사용자가 메시지를 보내면 실제로 뭐가 돌아갈까

Claude Code에서 "파일 만들어줘"라고 입력하면, 그 뒤에서 Claude API를 호출하고, 도구를 실행하고, 결과를 다시 AI에게 넘기는 루프가 돌아갑니다. 이 전체 과정을 책임지는 클래스가 바로 QueryEngine입니다.

이번 편에서는 QueryEngine이 어떤 구조로 되어 있고, 도구 루프가 어떻게 반복되며, API 에러를 어떻게 처리하는지를 소스 코드 기반으로 살펴보겠습니다.

QueryEngine 클래스 구조

경로: src/QueryEngine.ts

주석에 핵심이 담겨 있습니다.

/**
 * QueryEngine owns the query lifecycle and session state
 * for a conversation. One QueryEngine per conversation.
 * Each submitMessage() call starts a new turn within
 * the same conversation.
 */
export class QueryEngine {
  private config: QueryEngineConfig
  private mutableMessages: Message[]
  private abortController: AbortController
  private totalUsage: NonNullableUsage
  private permissionDenials: SDKPermissionDenial[]
  // ...
}

하나의 대화에 하나의 QueryEngine 인스턴스가 대응합니다. submitMessage()를 호출할 때마다 같은 대화 안에서 새로운 턴이 시작되는 구조입니다. 메시지 배열, 파일 캐시, 토큰 사용량 같은 상태가 턴 사이에서 유지됩니다.

QueryEngineConfig를 보면 이 클래스가 얼마나 많은 것을 제어하는지 알 수 있습니다.

export type QueryEngineConfig = {
  cwd: string
  tools: Tools
  commands: Command[]
  mcpClients: MCPServerConnection[]
  canUseTool: CanUseToolFn
  thinkingConfig?: ThinkingConfig
  maxTurns?: number
  maxBudgetUsd?: number
  jsonSchema?: Record<string, unknown>
  // ...
}

작업 디렉토리, 사용 가능한 도구 목록, MCP 클라이언트, thinking 설정, 최대 턴 수, 예산 한도까지 전부 여기서 주입받습니다.

submitMessage — 스트리밍 제너레이터

submitMessageAsyncGenerator로 구현되어 있습니다.

async *submitMessage(
  prompt: string | ContentBlockParam[],
  options?: { uuid?: string; isMeta?: boolean },
): AsyncGenerator<SDKMessage, void, unknown> {

일반 함수가 아니라 제너레이터이기 때문에, 응답이 한 번에 오는 게 아니라 스트리밍으로 메시지를 하나씩 yield합니다. SDK 소비자 쪽에서는 for await (const msg of engine.submitMessage(...)) 형태로 받아서 처리하면 됩니다.

thinking 설정도 여기서 결정됩니다. 사용자가 별도로 지정하지 않으면 기본값으로 adaptive 모드가 적용됩니다.

const initialThinkingConfig: ThinkingConfig = thinkingConfig
  ? thinkingConfig
  : shouldEnableThinkingByDefault() !== false
    ? { type: 'adaptive' }
    : { type: 'disabled' }

도구 루프 패턴 — AI가 도구를 쓰고 다시 판단하는 반복

경로: src/query.ts

QueryEngine.submitMessage()는 내부에서 query() 함수를 호출합니다. 이 함수 안에 있는 queryLoop가 실제 도구 루프의 본체입니다.

// query.ts
async function* queryLoop(params, consumedCommandUuids) {
  // ...
  while (true) {
    const { messages, toolUseContext, turnCount } = state
    yield { type: 'stream_request_start' }
    // API 호출 -> 응답 파싱 -> 도구 실행 -> 반복
  }
}

while (true) 무한 루프입니다. AI가 "도구를 쓰겠다"고 응답하면 도구를 실행하고, 결과를 메시지에 추가한 뒤, 다시 API를 호출합니다. AI가 더 이상 도구를 요청하지 않으면 루프가 끝납니다.

QueryEngine 쪽에서는 이 루프의 결과를 for await로 소비하면서 메시지 타입별로 분기 처리합니다.

for await (const message of query({ messages, systemPrompt, ... })) {
  switch (message.type) {
    case 'assistant': // AI 응답
    case 'user':      // 도구 실행 결과
    case 'stream_event': // 스트리밍 이벤트
    case 'system':    // API 에러, 컴팩트 등
    // ...
  }
}

턴 카운트도 여기서 관리됩니다. message.type === 'user'가 올 때마다 turnCount++가 되고, maxTurns에 도달하면 error_max_turns 결과를 yield하고 종료합니다.

재시도 로직 — API 에러와 rate limit 처리

경로: src/services/api/withRetry.ts

API 호출은 withRetry 함수로 감싸져 있습니다. 이것도 AsyncGenerator입니다.

export async function* withRetry<T>(
  getClient: () => Promise<Anthropic>,
  operation: (client, attempt, context) => Promise<T>,
  options: RetryOptions,
): AsyncGenerator<SystemAPIErrorMessage, T> {
  const maxRetries = getMaxRetries(options) // 기본 10회
  for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
    try {
      return await operation(client, attempt, retryContext)
    } catch (error) {
      // 에러 타입별 분기 처리
    }
  }
}

기본 최대 재시도 횟수는 10회이고, 529(과부하) 에러는 별도로 3회 제한입니다. rate limit(429)을 만나면 retry-after 헤더를 확인해서, 짧으면 그대로 기다리고 길면 fast mode를 끄고 표준 모델로 전환합니다.

QueryEngine 쪽에서는 시스템 메시지로 재시도 상태를 SDK 소비자에게 전달합니다.

if (message.subtype === 'api_error') {
  yield {
    type: 'system',
    subtype: 'api_retry',
    attempt: message.retryAttempt,
    max_retries: message.maxRetries,
    retry_delay_ms: message.retryInMs,
    error: categorizeRetryableAPIError(message.error),
  }
}

단순히 재시도만 하는 게 아니라, 401이면 OAuth 토큰을 갱신하고, ECONNRESET이면 keep-alive를 비활성화하는 등 에러 원인별로 복구 전략이 다릅니다.

토큰 카운팅 — 입력/출력 토큰 추적

경로: src/services/api/claude.ts, src/QueryEngine.ts

토큰 추적은 스트리밍 이벤트 기반으로 동작합니다. message_start에서 초기화하고, message_delta에서 갱신하고, message_stop에서 누적합니다.

case 'stream_event':
  if (message.event.type === 'message_start') {
    currentMessageUsage = EMPTY_USAGE
    currentMessageUsage = updateUsage(
      currentMessageUsage, message.event.message.usage)
  }
  if (message.event.type === 'message_stop') {
    this.totalUsage = accumulateUsage(
      this.totalUsage, currentMessageUsage)
  }

accumulateUsageinput_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens를 각각 합산합니다. 캐시 히트/미스까지 추적하는 겁니다.

예산 한도 체크도 매 턴마다 실행됩니다.

if (maxBudgetUsd !== undefined && getTotalCost() >= maxBudgetUsd) {
  yield { type: 'result', subtype: 'error_max_budget_usd', ... }
  return
}

비용이 설정한 한도를 넘으면 즉시 대화를 종료합니다. SDK 모드에서 과금 폭주를 방지하는 안전장치입니다.

그래서 뭐가 달라지나 — LLM API를 프로덕션에서 안정적으로 쓰는 법

QueryEngine 코드에서 배울 수 있는 패턴을 정리하면 이렇습니다.

첫째, 도구 루프는 while (true) + 종료 조건 패턴입니다. AI가 도구를 안 쓰면 끝나고, maxTurns나 예산 한도에 걸려도 끝납니다. 무한 루프지만 탈출 조건이 여러 겹으로 걸려 있습니다.

둘째, 재시도는 에러 타입별로 전략이 다릅니다. rate limit은 기다리고, 인증 에러는 토큰을 갱신하고, 과부하는 모델을 전환합니다. 단순한 sleep + retry가 아닙니다.

셋째, 토큰과 비용을 실시간으로 추적합니다. 스트리밍 이벤트마다 누적하고, 한도를 넘으면 즉시 중단합니다. LLM API를 프로덕션에서 쓸 때 비용 제어는 선택이 아니라 필수입니다.

넷째, 모든 것이 AsyncGenerator입니다. 스트리밍 응답, 재시도 상태, 도구 루프 결과가 전부 yield로 흘러나옵니다. 호출자가 언제든 .return()으로 중단할 수 있는 구조입니다.


다음 편 예고: [Claude Code 해부학 Part 9] 클로드 코드(Claude Code) 멀티에이전트 분석 — AI가 AI를 부리는 구조

profile

ClOr

@ClOr

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!

ClOr · 백엔드 트러블슈팅과 AI 에이전트 구조 분석을 기록합니다.