ClOr

ClOr

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

Claude Code 해부학 (완결)

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

전체 시리즈 보기 →

백엔드 트러블슈팅

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

전체 시리즈 보기 →

최신 글

article thumbnail

시리즈 이전 편: [Part 3] 시스템 프롬프트 분석 — 클로드 코드가 매번 보내는 거대한 지시문의 구조

목차

  • API 호출마다 시스템 프롬프트를 보내면 비용이 얼마나 될까
  • 프롬프트 캐싱이란
  • 정적/동적 경계 — SYSTEM_PROMPT_DYNAMIC_BOUNDARY
  • splitSysPromptPrefix() — 캐시 스코프 결정 로직
  • buildSystemPromptBlocks() — 최종 조립
  • 실제 비용 절감 효과
  • 자신의 LLM 앱에 적용하는 법
  • 다음 편 예고

API 호출마다 시스템 프롬프트를 보내면 비용이 얼마나 될까

Claude Code의 시스템 프롬프트는 수만 토큰에 달합니다. 사용자가 엔터를 칠 때마다 이 전체가 API로 전송된다면, 한 세션에 수십 번 호출하는 것만으로 토큰 비용이 눈덩이처럼 불어납니다. Anthropic이 이 문제를 어떻게 해결했는지, 소스코드를 직접 열어보겠습니다.

프롬프트 캐싱이란

Anthropic API에는 cache_control이라는 파라미터가 있습니다. 시스템 프롬프트 블록에 이 파라미터를 붙이면, 서버가 해당 블록을 캐시해두고 다음 요청에서 동일한 내용이 오면 재계산 없이 재사용합니다. 캐시된 토큰은 입력 비용의 약 10%만 청구되기 때문에, 같은 프롬프트를 반복 전송하는 구조에서는 비용 절감 효과가 극적입니다.

핵심은 "어디까지 캐시하고, 어디부터 캐시하지 않을 것인가"를 정확히 나누는 것입니다.

정적/동적 경계 — SYSTEM_PROMPT_DYNAMIC_BOUNDARY

Claude Code는 시스템 프롬프트를 "정적 영역"과 "동적 영역"으로 나눕니다. 그 경계를 표시하는 상수가 바로 이것입니다.

// 경로: src/constants/prompts.ts (line 105~115)

/**
 * Boundary marker separating static (cross-org cacheable)
 * content from dynamic content.
 * Everything BEFORE this marker can use scope: 'global'.
 * Everything AFTER contains user/session-specific content
 * and should not be cached.
 */
export const SYSTEM_PROMPT_DYNAMIC_BOUNDARY =
  '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'

실제 시스템 프롬프트 배열에서 이 마커가 어떻게 쓰이는지 보겠습니다.

// 경로: src/constants/prompts.ts (line 560~576)

return [
  // --- Static content (cacheable) ---
  getSimpleIntroSection(outputStyleConfig),
  getSimpleSystemSection(),
  getSimpleDoingTasksSection(),
  getActionsSection(),
  getUsingYourToolsSection(enabledTools),
  getSimpleToneAndStyleSection(),
  getOutputEfficiencySection(),
  // === BOUNDARY MARKER - DO NOT MOVE OR REMOVE ===
  ...(shouldUseGlobalCacheScope()
    ? [SYSTEM_PROMPT_DYNAMIC_BOUNDARY] : []),
  // --- Dynamic content (registry-managed) ---
  ...resolvedDynamicSections,
].filter(s => s !== null)

마커 위쪽은 모든 사용자에게 동일한 내용이라 전역 캐시가 가능합니다. 마커 아래쪽은 세션별로 달라지는 MCP 설정, 스킬 정보 등이 들어가서 캐시 대상에서 제외됩니다.

splitSysPromptPrefix() — 캐시 스코프 결정 로직

실제로 이 경계를 파싱해서 각 블록에 캐시 스코프를 부여하는 함수가 splitSysPromptPrefix()입니다.

// 경로: src/utils/api.ts (line 296~320, 요약)

// 동작 모드 3가지:
// 1. MCP 도구 있음 (skipGlobalCacheForSystemPrompt=true)
//    → cacheScope='org' (조직 단위 캐시)
// 2. 글로벌 캐시 모드 + boundary 발견 (1P 전용)
//    → 정적 블록: cacheScope='global'
//    → 동적 블록: cacheScope=null (캐시 안 함)
// 3. 기본 모드 (3P 또는 boundary 없음)
//    → cacheScope='org'

스코프별 의미를 정리하면 이렇습니다.

  • global: 모든 조직, 모든 사용자가 공유하는 캐시. 시스템 프롬프트의 정적 부분에 적용됩니다. 캐시 적중률이 가장 높아 비용 절감이 극대화됩니다.
  • org: 같은 조직 내에서 공유하는 캐시. MCP 도구가 연결되어 있으면 시스템 프롬프트가 사용자마다 달라지므로, 전역 캐시 대신 조직 단위로 범위를 좁힙니다.
  • null: 캐시하지 않음. 동적 콘텐츠나 빌링 헤더처럼 매번 달라지는 부분입니다.

buildSystemPromptBlocks() — 최종 조립

splitSysPromptPrefix()가 나눈 블록들은 buildSystemPromptBlocks()에서 API 요청 형태로 변환됩니다.

// 경로: src/services/api/claude.ts (line 3213~3237)

export function buildSystemPromptBlocks(
  systemPrompt, enablePromptCaching, options
) {
  return splitSysPromptPrefix(systemPrompt, {
    skipGlobalCacheForSystemPrompt:
      options?.skipGlobalCacheForSystemPrompt,
  }).map(block => ({
    type: 'text',
    text: block.text,
    ...(enablePromptCaching && block.cacheScope !== null
      && { cache_control: getCacheControl({
        scope: block.cacheScope,
      }) }),
  }))
}

cacheScopenull이 아닌 블록에만 cache_control이 붙습니다. 단순하지만 정확한 분기입니다.

실제 비용 절감 효과

Claude Code의 시스템 프롬프트는 대략 1.5만

2만 토큰 규모입니다. 이 중 정적 영역이 약 70

80%를 차지합니다. 캐시된 입력 토큰은 원래 가격의 10%만 청구되므로, 단순 계산으로도 시스템 프롬프트 비용이 한 자릿수로 떨어집니다.

한 세션에서 20번 API를 호출한다고 가정하면, 첫 호출만 전체 비용을 내고 나머지 19번은 정적 영역에 대해 90% 할인을 받는 셈입니다. 세션이 길어질수록 절감 효과는 더 커집니다.

또 하나 주목할 점은 getSessionSpecificGuidanceSection() 함수의 주석입니다. 조건부 블록을 경계 마커 앞에 놓으면 "2^N 변형"이 생겨 캐시 적중률이 급락한다고 경고합니다. 캐시 효율을 지키려면 정적 영역의 내용이 진짜로 불변이어야 합니다.

자신의 LLM 앱에 적용하는 법

이 패턴은 Claude Code에만 해당하는 것이 아닙니다. Anthropic API를 쓰는 어떤 앱이든 동일하게 적용할 수 있습니다.

첫째, 시스템 프롬프트를 "모든 사용자에게 동일한 부분"과 "세션마다 달라지는 부분"으로 물리적으로 나누세요. 둘째, 정적 부분에 cache_control: { type: 'ephemeral' }을 붙이세요. 셋째, 동적 부분은 정적 부분 뒤에 별도 블록으로 배치하세요. 순서가 바뀌면 캐시가 무효화됩니다.

조건부 토글(A/B 테스트, 기능 플래그 등)은 동적 영역으로 밀어내야 합니다. Claude Code 소스에서도 이 실수로 캐시가 깨진 버그가 두 번이나 발생했습니다(PR #24490, #24171).

다음 편 예고

이번 편에서는 시스템 프롬프트의 정적 영역을 다뤘습니다. 하지만 경계 마커 아래의 동적 영역 — 매 세션, 매 환경마다 달라지는 부분 — 에도 흥미로운 구조가 숨어 있습니다. 다음 편에서는 그 동적 프롬프트가 어떻게 조립되는지 파헤쳐 보겠습니다.


다음 편 예고: [Claude Code 해부학 Part 5] 클로드 코드(Claude Code) 동적 프롬프트 분석 — 매번 다르게 말하는 비밀

profile

ClOr

@ClOr

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

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