# Claude Code 소스코드를 뜯어봤다 2/3


더 많은 글은 [radarlog.kr](https://radarlog.kr)에서.

---

Claude Code의 시스템 프롬프트는 하드코딩된 문자열 하나가 아니다. 12개 이상의 섹션이 조건부로 조립되고, 그 중간에 **캐시 경계선**이 있다.

1편에서 숨겨진 커맨드와 환경변수를 다뤘다. 2편에서는 Claude Code가 **내부에서 어떻게 생각하는지** — 시스템 프롬프트가 어떻게 만들어지고, 프롬프트 캐시를 어떻게 지키고, 에이전트를 어떻게 지휘하는지를 소스코드에서 직접 확인한다.


## 시스템 프롬프트는 이렇게 조립된다

`constants/prompts.ts`에 `getSystemPrompt()` 함수가 있다. 이 함수가 반환하는 건 문자열이 아니라 **문자열 배열**이다. 각 요소가 시스템 프롬프트의 한 섹션이다.

```typescript
export async function getSystemPrompt(
  tools: Tools,
  model: string,
  additionalWorkingDirectories?: string[],
  mcpClients?: MCPServerConnection[],
): Promise<string[]> {
```

반환값의 구조를 보면 Claude Code가 어떤 순서로 "성격"을 입는지 알 수 있다.

```typescript
return [
  // --- 정적 콘텐츠 (캐시 가능) ---
  getSimpleIntroSection(outputStyleConfig),
  getSimpleSystemSection(),
  getSimpleDoingTasksSection(),
  getActionsSection(),
  getUsingYourToolsSection(enabledTools),
  getSimpleToneAndStyleSection(),
  getOutputEfficiencySection(),
  
  // === 경계선 ===
  SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
  
  // --- 동적 콘텐츠 (매 턴 변경 가능) ---
  ...resolvedDynamicSections,  // 메모리, MCP, 언어 설정 등
]
```

경계선 위의 섹션들은 모든 사용자에게 동일하다. "너는 Claude Code야", "도구는 이렇게 써", "출력은 간결하게 해" 같은 고정 규칙들. 경계선 아래는 사용자마다 다르다. CLAUDE.md 내용, MCP 서버 설정, 언어 설정 등.

이 경계선이 왜 중요한지는 바로 다음 섹션에서 설명한다.


## 프롬프트 캐시를 깨지 않기 위한 집착

소스코드에서 가장 인상적인 부분이다. Anthropic은 **프롬프트 캐시 비용**을 줄이기 위해 엔지니어링에 엄청난 공을 들였다.

`SYSTEM_PROMPT_DYNAMIC_BOUNDARY`라는 상수가 있다. 이 경계선은 시스템 프롬프트 배열에서 "여기까지는 글로벌 캐시 가능, 여기부터는 동적 콘텐츠"를 나눈다.

```typescript
export const SYSTEM_PROMPT_DYNAMIC_BOUNDARY =
  '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'
```

경계선 위의 정적 콘텐츠는 `scope: 'global'`로 캐시된다. 모든 사용자가 같은 캐시를 공유할 수 있다. 경계선 아래는 사용자별로 다르니까 캐시를 공유할 수 없다.

이게 왜 중요하냐면, Anthropic API에서 캐시 생성(cache_creation) 토큰은 비싸고 캐시 읽기(cache_read) 토큰은 싸다. 시스템 프롬프트의 정적 부분이 바뀌면 캐시가 깨지고, 다시 비싼 cache_creation 토큰이 발생한다.

소스코드에는 캐시 깨짐을 감지하는 전담 모듈까지 있다. `promptCacheBreakDetection.ts`에서 매 턴마다 시스템 프롬프트의 해시를 비교한다.

```typescript
type PreviousState = {
  systemHash: number
  toolsHash: number
  cacheControlHash: number
  toolNames: string[]
  perToolHashes: Record<string, number>
  systemCharCount: number
  model: string
  fastMode: boolean
  globalCacheStrategy: string
  betas: string[]
  // ...더 있다
}
```

도구 스키마가 바뀌면 캐시가 깨진다. MCP 서버가 연결/해제되면 캐시가 깨진다. 모델이 바뀌면 캐시가 깨진다. 이걸 전부 추적하고, 어떤 이유로 캐시가 깨졌는지 로깅한다.

소스코드 주석에 이런 문장이 있다.

```
// The dynamic agent list was ~10.2% of fleet cache_creation tokens
```

에이전트 목록이 동적으로 바뀌는 게 전체 캐시 생성 토큰의 10.2%를 차지했다는 거다. 그래서 에이전트 목록을 시스템 프롬프트가 아니라 **대화 메시지에 첨부(attachment)**하는 방식으로 바꿨다. 시스템 프롬프트가 안 바뀌니까 캐시가 안 깨진다.

게임 서버에서 핫 패스의 allocation을 줄이는 것과 같은 집착이다. "10.2%를 줄이기 위해 아키텍처를 바꿨다"는 게 Anthropic이 비용 최적화에 얼마나 진심인지 보여준다.

날짜도 마찬가지다. 시스템 프롬프트에 오늘 날짜를 넣는데, 자정이 지나면 날짜가 바뀌면서 캐시가 깨진다. 그래서 날짜를 **세션 시작 시점에 memoize**하고, 자정 이후의 날짜 변경은 캐시를 깨지 않는 attachment 메시지로 처리한다.

```typescript
// Memoized for prompt-cache stability — captures the date once at session start.
export const getSessionStartDate = memoize(getLocalISODate)
```


## 출력 효율성 — Ant 직원과 일반 사용자가 다르다

시스템 프롬프트 중 `getOutputEfficiencySection()`이 흥미롭다. `USER_TYPE`이 `ant`(Anthropic 직원)인지 아닌지에 따라 완전히 다른 프롬프트가 들어간다.

일반 사용자에게는 짧은 지시가 간다.

```
Keep your text output brief and direct. Lead with the answer or action,
not the reasoning. Skip filler words, preamble, and unnecessary transitions.
```

Anthropic 내부 직원에게는 훨씬 세밀한 지시가 간다. "사용자가 자리를 비웠다 돌아왔다고 가정하고 써라", "약어나 코드네임을 쓰지 마라", "역피라미드 구조로 써라" 같은 구체적인 글쓰기 가이드가 들어 있다.

```
When making updates, assume the person has stepped away and lost the thread.
They don't know codenames, abbreviations, or shorthand you created along the way...
```

더 놀라운 건 Ant 빌드에만 들어가는 숫자 기반 길이 제한이다.

```typescript
// Numeric length anchors — research shows ~1.2% output token reduction
// vs qualitative "be concise". Ant-only to measure quality impact first.
'Length limits: keep text between tool calls to ≤25 words.
 Keep final responses to ≤100 words unless the task requires more detail.'
```

"간결하게 써"보다 "25단어 이내로 써"가 출력 토큰을 1.2% 더 줄인다는 연구 결과를 바탕으로 한 것이다. Anthropic 내부에서 먼저 테스트하고, 품질에 문제가 없으면 일반 사용자에게도 적용할 계획으로 보인다.


## 코디네이터 모드 — AI가 AI를 지휘한다

`CLAUDE_CODE_COORDINATOR_MODE=1`을 설정하면 Claude Code의 동작이 완전히 바뀐다. Claude가 직접 코드를 수정하는 대신, **워커 에이전트를 생성하고 지휘하는 역할만** 한다.

코디네이터의 시스템 프롬프트가 소스코드에 전문이 있다. 핵심을 요약하면 이렇다.

```
You are a coordinator. Your job is to:
- Help the user achieve their goal
- Direct workers to research, implement and verify code changes
- Synthesize results and communicate with the user
- Answer questions directly when possible
```

코디네이터가 쓸 수 있는 도구는 딱 3개다. `Agent`(워커 생성), `SendMessage`(워커에게 메시지), `TaskStop`(워커 중지). 파일을 읽거나 수정하는 도구는 **없다**.

워크플로우가 단계별로 정의되어 있다. Research → Synthesis → Implementation → Verification. 각 단계를 워커가 수행하고, 코디네이터는 결과를 종합한다.

```
| Phase          | Who              | Purpose                          |
|----------------|------------------|----------------------------------|
| Research       | Workers (parallel) | Investigate codebase           |
| Synthesis      | You (coordinator)  | Craft implementation specs     |
| Implementation | Workers           | Make targeted changes, commit  |
| Verification   | Workers           | Test changes work              |
```

가장 강조되는 규칙이 있다. **"Never delegate understanding."**

```
Never write "based on your findings, fix the bug" or
"based on the research, implement it." Those phrases push
synthesis onto the agent instead of doing it yourself.
```

코디네이터는 워커의 리서치 결과를 직접 이해하고, 구체적인 파일 경로와 라인 넘버를 포함한 스펙을 작성해서 워커에게 넘겨야 한다. "네가 찾은 걸 기반으로 고쳐"는 금지다. "src/auth/validate.ts 42번 줄의 null pointer를 고쳐, Session.expired가 true인데 토큰이 캐시에 남아있을 때 user 필드가 undefined다"처럼 구체적으로 지시해야 한다.

이건 사람이 주니어 개발자에게 일을 시키는 것과 같다. "버그 고쳐"가 아니라 "이 파일 이 줄에서 이 조건일 때 이렇게 되는 걸 이렇게 바꿔"라고 해야 제대로 된 결과가 나온다.


## Fork Subagent — 자기 자신을 복제한다

에이전트 시스템에 `fork` 기능이 있다. `isForkSubagentEnabled()`로 게이트되어 있고, feature flag `FORK_SUBAGENT`로 제어된다.

일반 서브에이전트는 컨텍스트 없이 시작한다. 포크는 **부모의 전체 대화 컨텍스트를 상속**한다.

```typescript
// Fork subagent feature: when enabled, insert the "When to fork" section
// (fork semantics, directive-style prompts) and swap in fork-aware examples.
```

시스템 프롬프트에 언제 포크해야 하는지 가이드가 있다.

```
Fork yourself when the intermediate tool output isn't worth keeping
in your context. The criterion is qualitative — "will I need this
output again" — not task size.
```

포크가 효율적인 이유는 **프롬프트 캐시를 공유**하기 때문이다. 부모와 같은 시스템 프롬프트를 쓰니까 캐시가 그대로 먹힌다. 그래서 포크에는 `model` 파라미터를 설정하지 말라고 한다 — 다른 모델을 쓰면 부모의 캐시를 못 쓴다.

그리고 두 가지 핵심 규칙이 있다.

**"Don't peek."** 포크가 작업 중일 때 결과 파일을 Read로 읽지 마라. 포크의 도구 호출 노이즈가 부모 컨텍스트에 들어와서, 포크를 쓰는 의미가 없어진다.

**"Don't race."** 포크가 아직 안 끝났으면 결과를 추측하지 마라. 어떤 형태로든 — 산문, 요약, 구조화된 출력으로든 — 포크 결과를 조작하지 마라.

이건 멀티스레드 프로그래밍의 원칙과 같다. 다른 스레드의 작업이 끝나기 전에 그 결과를 읽으면 안 된다. 레이스 컨디션. 게임 서버에서 매일 보는 문제다.


## 워크트리 격리 — 에이전트가 Git 브랜치를 자동으로 만든다

에이전트에 `isolation: "worktree"` 옵션을 줄 수 있다. Git worktree를 만들어서 에이전트가 격리된 레포지토리 복사본에서 작업한다.

```
In a git repository: creates a new git worktree inside
`.claude/worktrees/` with a new branch based on HEAD
```

에이전트가 변경을 하면 워크트리와 브랜치가 유지되고, 변경이 없으면 자동으로 정리된다. 세션이 끝날 때 워크트리에 남아있으면 유지할지 삭제할지 물어본다.

이건 실전에서 강력하다. 메인 브랜치를 건드리지 않고 에이전트에게 실험적인 리팩토링을 시킬 수 있다. 결과가 마음에 들면 브랜치를 머지하고, 아니면 버린다.


## Hooks — 도구 호출 전후에 커스텀 스크립트를 건다

Claude Code의 Hooks 시스템은 도구 이벤트에 셸 커맨드를 바인딩하는 기능이다. 시스템 프롬프트에 이렇게 설명되어 있다.

```
Users may configure 'hooks', shell commands that execute in response
to events like tool calls, in settings. Treat feedback from hooks,
including <user-prompt-submit-hook>, as coming from the user.
```

도구가 실행되기 전, 후, 또는 특정 이벤트(프롬프트 제출, 알림 등)에 셸 명령을 걸 수 있다. Hook이 실패하면 Claude는 그 결과를 사용자 피드백으로 취급하고 다른 접근을 시도한다.

이걸 활용하면 Claude Code가 파일을 수정할 때마다 자동으로 lint를 돌리거나, 커밋 전에 테스트를 실행하거나, 특정 패턴의 코드를 감지해서 경고를 보내는 파이프라인을 만들 수 있다.

세션 종료 시에도 Hook이 돌 수 있다. `CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS` 환경변수로 세션 종료 Hook의 타임아웃을 조절한다.


## 다음 편 — 소름 돋는 엔지니어링 디테일

3편에서는 가장 흥미로운 부분을 다룬다. 가챠 시스템으로 동작하는 가상 펫 "Buddy", 파일을 읽기만 해도 자동 업데이트되는 "Magic Docs", 자리를 비웠다 돌아오면 요약을 해주는 "Away Summary", Anthropic 직원이 외부 레포에 기여할 때 정체를 숨기는 "Undercover Mode", 그리고 Claude Code에 내장된 크론 스케줄러.

> "에이전트 목록이 전체 캐시 생성 토큰의 10.2%를 차지했다. 그래서 아키텍처를 바꿨다."

