ClOr

ClOr

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

Claude Code 해부학 (완결)

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

전체 시리즈 보기 →

백엔드 트러블슈팅

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

전체 시리즈 보기 →

최신 글

article thumbnail

시리즈 이전 편: [Part 2] 부팅 과정 분석 — CLI가 실행되면 내부에서 무슨 일이 벌어지는지 따라가 봤습니다.

목차

  • 시스템 프롬프트가 915줄이라는 걸 처음 봤을 때
  • getSystemPrompt() — 조립 공장의 메인 라인
  • 각 섹션이 하는 일
  • 정적 vs 동적 — SYSTEM_PROMPT_DYNAMIC_BOUNDARY
  • systemPromptSection — 메모이제이션 레지스트리
  • SystemPrompt 브랜드 타입 — 실수 방지 장치
  • 프롬프트를 모듈화하면 뭐가 달라지나
  • 정리

시스템 프롬프트가 915줄이라는 걸 처음 봤을 때

Claude Code의 시스템 프롬프트는 하드코딩된 텍스트 한 덩어리가 아닙니다. prompts.ts 파일 하나가 915줄이고, 여기에 systemPromptSections.tssystemPromptType.ts가 보조 역할을 합니다. 처음 열어봤을 때 든 생각은 "이건 프롬프트가 아니라 프로그램이다"였습니다.

하나의 거대한 문자열로 관리하면 어떻게 될까요? 캐싱이 불가능하고, 조건 분기가 지저분해지고, 한 줄 고치면 전체가 깨집니다. Anthropic은 이 문제를 소프트웨어 설계 기법으로 풀었습니다.

getSystemPrompt() — 조립 공장의 메인 라인

핵심 함수는 getSystemPrompt()입니다. 이 함수가 반환하는 건 하나의 문자열이 아니라 문자열 배열 (string[])입니다.

// src/constants/prompts.ts (L444-L449)
export async function getSystemPrompt(
  tools: Tools,
  model: string,
  additionalWorkingDirectories?: string[],
  mcpClients?: MCPServerConnection[],
): Promise<string[]> {

인자를 보면 이 함수가 왜 복잡한지 알 수 있습니다. 활성화된 도구 목록, 모델 ID, 작업 디렉터리, MCP 클라이언트까지 받아서 프롬프트를 조건부로 조립합니다. 단순 텍스트 생성이 아니라 런타임 설정에 따라 달라지는 동적 문서 빌더인 셈입니다.

최종 반환부를 보면 구조가 선명합니다.

// src/constants/prompts.ts (L560-L577)
return [
  // --- Static content (cacheable) ---
  getSimpleIntroSection(outputStyleConfig),
  getSimpleSystemSection(),
  getSimpleDoingTasksSection(),
  getActionsSection(),
  getUsingYourToolsSection(enabledTools),
  getSimpleToneAndStyleSection(),
  getOutputEfficiencySection(),
  // === BOUNDARY MARKER ===
  ...(shouldUseGlobalCacheScope()
    ? [SYSTEM_PROMPT_DYNAMIC_BOUNDARY] : []),
  // --- Dynamic content (registry-managed) ---
  ...resolvedDynamicSections,
].filter(s => s !== null)

주석이 의도적으로 "Static content"와 "Dynamic content"를 나누고 있죠. 이게 캐싱 전략의 핵심입니다.

각 섹션이 하는 일

정적 영역의 7개 섹션을 표로 정리하면 이렇습니다.

섹션 함수 역할 핵심 내용
getSimpleIntroSection 자기소개 "소프트웨어 엔지니어링 작업을 돕는 에이전트" 정체성 선언, 사이버 리스크 지침, URL 생성 금지
getSimpleSystemSection 시스템 규칙 마크다운 출력, 권한 모드, 프롬프트 인젝션 경고, 훅 시스템
getSimpleDoingTasksSection 작업 수행 원칙 과잉 엔지니어링 금지, 코드 읽기 우선, 보안 취약점 주의, 에러 진단 후 방향 전환
getActionsSection 실행 주의사항 되돌릴 수 없는 작업은 확인 요청, 파괴적 명령어 주의, "두 번 재고 한 번 실행"
getUsingYourToolsSection 도구 사용법 Bash 대신 전용 도구 사용, 병렬 호출 권장, 도구 우선순위
getSimpleToneAndStyleSection 톤 가이드 이모지 금지, 간결한 응답, 파일 경로에 줄 번호 포함
getOutputEfficiencySection 출력 효율 핵심부터 말하기, 필러 단어 제거, 한 문장으로 될 걸 세 문장 쓰지 말기

동적 영역에는 session_guidance, memory, env_info, language, mcp_instructions 같은 섹션이 이름표를 달고 레지스트리에 등록됩니다.

정적 vs 동적 — SYSTEM_PROMPT_DYNAMIC_BOUNDARY

배열 중간에 심어진 경계 마커가 있습니다.

// src/constants/prompts.ts (L114-L115)
export const SYSTEM_PROMPT_DYNAMIC_BOUNDARY =
  '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'

주석에 경고까지 달려 있습니다. "이 마커를 제거하거나 순서를 바꾸지 마라. api.tsclaude.ts의 캐시 로직이 의존한다." 마커 앞쪽(Intro~OutputEfficiency)은 모든 사용자에게 동일한 텍스트라 글로벌 캐시 대상입니다. 뒤쪽은 사용자/세션마다 바뀌는 값이라 캐시에서 제외됩니다.

이 분리가 왜 중요하냐면, Anthropic API의 프롬프트 캐싱은 앞에서부터 일치하는 만큼만 캐시하기 때문입니다. 동적 값이 중간에 끼면 뒤쪽 전체가 캐시 미스가 됩니다. 그래서 정적 텍스트를 앞으로, 동적 텍스트를 뒤로 밀어넣은 겁니다.

systemPromptSection — 메모이제이션 레지스트리

동적 영역의 각 섹션은 systemPromptSection 함수로 등록됩니다.

// src/constants/systemPromptSections.ts (L20-L25)
export function systemPromptSection(
  name: string,
  compute: ComputeFn,
): SystemPromptSection {
  return { name, compute, cacheBreak: false }
}

name으로 식별하고, compute로 값을 계산합니다. 한번 계산하면 /clear/compact 전까지 캐시에 저장합니다. 매 턴마다 다시 계산할 필요가 있는 섹션만 DANGEROUS_uncachedSystemPromptSection을 씁니다.

// src/constants/systemPromptSections.ts (L32-L38)
export function DANGEROUS_uncachedSystemPromptSection(
  name: string,
  compute: ComputeFn,
  _reason: string,
): SystemPromptSection {
  return { name, compute, cacheBreak: true }
}

함수 이름에 DANGEROUS가 붙은 건 허세가 아닙니다. cacheBreak: true는 프롬프트 캐시를 깨뜨리니까요. 실제로 _reason 파라미터를 강제해서 "왜 캐시를 깨야 하는지" 기록하게 만들었습니다. 현재 이걸 쓰는 건 MCP 연결 상태 변경 같은 경우뿐입니다.

SystemPrompt 브랜드 타입 — 실수 방지 장치

systemPromptType.ts는 고작 15줄이지만, TypeScript의 브랜드 타입 패턴을 보여줍니다.

// src/utils/systemPromptType.ts
export type SystemPrompt = readonly string[] & {
  readonly __brand: 'SystemPrompt'
}

export function asSystemPrompt(
  value: readonly string[]
): SystemPrompt {
  return value as SystemPrompt
}

readonly string[]__brand를 끼워 넣어서 일반 문자열 배열과 구분합니다. 아무 string[]나 시스템 프롬프트로 넘기면 타입 에러가 나도록 한 거죠. 주석에 "의존성 없이 어디서든 import 가능하도록 의도적으로 독립 모듈로 만들었다"고 적혀 있습니다. 순환 참조를 원천 차단하려는 설계입니다.

프롬프트를 모듈화하면 뭐가 달라지나

일반적인 LLM 프로젝트에서 시스템 프롬프트는 그냥 긴 문자열입니다. Claude Code는 이걸 소프트웨어 아키텍처로 다루고 있고, 그 결과 몇 가지 이점이 생깁니다.

문자열 방식 모듈 방식 (Claude Code)
한 줄 고치면 전체 캐시 무효화 정적/동적 분리로 앞쪽 캐시 유지
조건 분기가 문자열 안에 섞임 섹션별 함수로 조건 캡슐화
중복 계산 매 턴마다 발생 systemPromptSection이 메모이제이션
타입 안전성 없음 브랜드 타입으로 실수 차단
캐시 파괴 지점 추적 불가 DANGEROUS_ 접두어로 명시적 표시

결국 이건 비용 문제입니다. 시스템 프롬프트는 매 API 호출마다 전송됩니다. 915줄을 매번 보내면서도 캐싱을 최대로 활용하려면, 텍스트 레벨이 아니라 아키텍처 레벨에서 설계해야 했던 겁니다.

정리

Claude Code의 시스템 프롬프트는 단순한 지시문이 아니라 잘 설계된 모듈 시스템입니다. 정적 영역 7개 섹션과 동적 영역을 경계 마커로 분리하고, 메모이제이션 레지스트리로 불필요한 재계산을 막고, 브랜드 타입으로 타입 안전성까지 확보했습니다.

다음 편에서는 이 구조가 실제 API 호출에서 어떻게 캐싱되는지, 비용을 90%까지 줄이는 메커니즘을 파헤칩니다.


다음 편 예고: [Claude Code 해부학 Part 4] 클로드 코드(Claude Code) 프롬프트 캐싱 분석 — API 비용을 90% 줄이는 구조

[Claude Code 해부학] 시리즈

  • Part 0~2: 유출 사건, 프로젝트 구조, 부팅 과정
  • Part 4: 프롬프트 캐싱 분석
  • Part 5: 동적 프롬프트 분석
profile

ClOr

@ClOr

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

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