시리즈 이전 편: [Part 2] 부팅 과정 분석 — CLI가 실행되면 내부에서 무슨 일이 벌어지는지 따라가 봤습니다.
목차
- 시스템 프롬프트가 915줄이라는 걸 처음 봤을 때
- getSystemPrompt() — 조립 공장의 메인 라인
- 각 섹션이 하는 일
- 정적 vs 동적 — SYSTEM_PROMPT_DYNAMIC_BOUNDARY
- systemPromptSection — 메모이제이션 레지스트리
- SystemPrompt 브랜드 타입 — 실수 방지 장치
- 프롬프트를 모듈화하면 뭐가 달라지나
- 정리
시스템 프롬프트가 915줄이라는 걸 처음 봤을 때
Claude Code의 시스템 프롬프트는 하드코딩된 텍스트 한 덩어리가 아닙니다. prompts.ts 파일 하나가 915줄이고, 여기에 systemPromptSections.ts와 systemPromptType.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.ts와 claude.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: 동적 프롬프트 분석
'AI 코딩 에이전트' 카테고리의 다른 글
| Claude Code 동적 프롬프트 분석: 매번 다르게 말하는 비밀 (해부학 Part 5) (0) | 2026.04.01 |
|---|---|
| Claude Code 프롬프트 캐싱 분석: API 비용을 90% 줄이는 구조 (해부학 Part 4) (0) | 2026.04.01 |
| Claude Code 부팅 과정 분석: 실행 후 0.5초 안에 벌어지는 일들 (해부학 Part 2) (0) | 2026.04.01 |
| Claude Code 프로젝트 구조 분석: 1,900개 파일의 지도 그리기 (해부학 Part 1) (2) | 2026.04.01 |
| Claude Code BashTool 분석: 도구 프롬프트가 500줄인 이유 (해부학 Part 7) (0) | 2026.04.01 |
