ClOr

ClOr

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

Claude Code 해부학 (완결)

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

전체 시리즈 보기 →

백엔드 트러블슈팅

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

전체 시리즈 보기 →

최신 글

article thumbnail

시리즈 이전 편: [Part 4] 프롬프트 캐싱 분석

목차

  • 같은 Claude인데, 왜 매번 다르게 대답할까
  • buildEffectiveSystemPrompt() — 우선순위 체계
  • 동적 섹션들 — 매 세션마다 달라지는 프롬프트 조각
    • 환경 정보 — computeSimpleEnvInfo()
    • 언어 설정 — getLanguageSection()
    • 출력 스타일 — getOutputStyleSection()
    • MCP 지시 — getMcpInstructionsSection()
  • 메모리 통합 — loadMemoryPrompt()
  • 피처 플래그 — 프롬프트 분기의 핵심
  • 그래서 뭐가 달라지나

같은 Claude인데, 왜 매번 다르게 대답할까

같은 Claude Code를 쓰는데 누구는 한국어로 답변을 받고, 누구는 MCP 서버 안내가 붙고, 누구는 프로액티브 모드로 자율 에이전트처럼 동작합니다. 모델이 달라서가 아닙니다. 시스템 프롬프트를 매 세션마다 "조립"하기 때문입니다.

이번 편에서는 Claude Code가 프롬프트를 동적으로 구성하는 내부 구조를 소스 레벨에서 뜯어봅니다.

buildEffectiveSystemPrompt() — 우선순위 체계

프롬프트가 여러 출처에서 올 수 있으니, 누가 이기느냐를 정해야 합니다. src/utils/systemPrompt.ts에 정의된 buildEffectiveSystemPrompt()가 그 심판입니다.

// src/utils/systemPrompt.ts
export function buildEffectiveSystemPrompt({
  mainThreadAgentDefinition,
  toolUseContext,
  customSystemPrompt,
  defaultSystemPrompt,
  appendSystemPrompt,
  overrideSystemPrompt,
}: { ... }): SystemPrompt {
  if (overrideSystemPrompt) {
    return asSystemPrompt([overrideSystemPrompt])
  }
  // ... coordinator, agent, custom, default 순으로 폴백
}

우선순위는 명확합니다. Override > Coordinator > Agent > Custom > Default 순서이고, appendSystemPrompt는 override를 제외한 모든 경우에 끝에 붙습니다. override가 설정되면 다른 건 전부 무시됩니다. loop 모드 같은 특수 상황에서 프롬프트를 완전히 교체할 때 쓰입니다.

Coordinator 모드가 활성화되면 getCoordinatorSystemPrompt()가 default를 대체합니다. Agent가 지정되면 Agent 프롬프트가 default를 대체하는데, 프로액티브 모드에서는 예외적으로 default에 "추가"됩니다.

동적 섹션들 — 매 세션마다 달라지는 프롬프트 조각

src/constants/prompts.ts에서 실제 default 프롬프트를 조립할 때, 정적 영역과 동적 영역이 SYSTEM_PROMPT_DYNAMIC_BOUNDARY로 구분됩니다.

// src/constants/prompts.ts (560행 부근)
return [
  // --- Static content (cacheable) ---
  getSimpleIntroSection(outputStyleConfig),
  getSimpleSystemSection(),
  // ... 생략 ...
  // === BOUNDARY MARKER ===
  ...(shouldUseGlobalCacheScope()
    ? [SYSTEM_PROMPT_DYNAMIC_BOUNDARY] : []),
  // --- Dynamic content (registry-managed) ---
  ...resolvedDynamicSections,
].filter(s => s !== null)

경계선 위쪽은 프롬프트 캐싱 대상이 되는 정적 콘텐츠, 아래쪽은 매 세션 조건에 따라 달라지는 동적 콘텐츠입니다. Part 4에서 다뤘던 캐싱이 여기서 연결됩니다.

환경 정보 — computeSimpleEnvInfo()

운영체제, 셸, git 여부, 모델명, 지식 컷오프까지 런타임에 수집합니다.

// src/constants/prompts.ts (651행)
export async function computeSimpleEnvInfo(
  modelId: string,
  additionalWorkingDirectories?: string[],
): Promise<string> {
  const [isGit, unameSR] = await Promise.all([
    getIsGit(), getUnameSR(),
  ])
  // 모델명, 플랫폼, 셸, OS 버전 등을 조합
}

Windows에서 쓰면 Platform: win32가 들어가고, Mac에서 쓰면 darwin이 들어갑니다. Claude가 OS별로 다른 명령어를 제안하는 이유가 여기 있습니다.

언어 설정 — getLanguageSection()

function getLanguageSection(
  languagePreference: string | undefined,
): string | null {
  if (!languagePreference) return null
  return `# Language
Always respond in ${languagePreference}.`
}

설정에서 language를 지정하면 해당 언어로 응답하라는 지시가 프롬프트에 주입됩니다. 기술 용어는 원문 그대로 유지하라는 조건도 함께 들어갑니다.

출력 스타일 — getOutputStyleSection()

function getOutputStyleSection(
  outputStyleConfig: OutputStyleConfig | null,
): string | null {
  if (outputStyleConfig === null) return null
  return `# Output Style: ${outputStyleConfig.name}
${outputStyleConfig.prompt}`
}

사용자가 커스텀 출력 스타일을 설정하면 프롬프트에 # Output Style 섹션이 추가됩니다. 설정하지 않으면 null을 반환해서 프롬프트에 아예 포함되지 않습니다.

MCP 지시 — getMcpInstructionsSection()

MCP 서버가 연결되어 있으면 해당 서버의 사용법이 프롬프트에 주입됩니다. 서버가 없으면 null입니다. 흥미로운 점은 이 섹션이 DANGEROUS_uncachedSystemPromptSection으로 등록된다는 것입니다. MCP 서버는 턴 사이에 연결/해제될 수 있어서 캐싱하면 안 되기 때문입니다.

메모리 통합 — loadMemoryPrompt()

src/memdir/memdir.tsloadMemoryPrompt()는 MEMORY.md 파일을 읽어 프롬프트에 주입합니다.

// src/memdir/memdir.ts (419행)
export async function loadMemoryPrompt():
  Promise<string | null> {
  const autoEnabled = isAutoMemoryEnabled()

  if (feature('KAIROS') && autoEnabled
      && getKairosActive()) {
    return buildAssistantDailyLogPrompt(skipIndex)
  }
  // TEAMMEM -> 팀 메모리 결합
  // autoEnabled -> 개인 메모리 로드
}

KAIROS 모드에서는 일일 로그 형식으로, TEAMMEM 피처가 켜져 있으면 팀 메모리와 개인 메모리를 결합해서 반환합니다. MEMORY.md는 200줄, 25KB 제한이 있고(MAX_ENTRYPOINT_LINES = 200, MAX_ENTRYPOINT_BYTES = 25_000), 초과하면 경고와 함께 잘립니다.

피처 플래그 — 프롬프트 분기의 핵심

소스 전반에 걸쳐 feature('PROACTIVE'), feature('KAIROS'), feature('TEAMMEM') 같은 빌드타임 피처 플래그가 프롬프트 구성을 분기합니다.

// src/utils/systemPrompt.ts (18행)
const proactiveModule =
  feature('PROACTIVE') || feature('KAIROS')
    ? require('../proactive/index.js')
    : null

feature() 호출은 Bun 번들러의 dead code elimination과 연동됩니다. 피처가 꺼진 빌드에서는 관련 모듈이 아예 번들에 포함되지 않습니다. PROACTIVE 모드가 활성화되면 프롬프트 첫 줄부터 완전히 달라집니다. "소프트웨어 엔지니어링 작업을 돕는다"가 "자율 에이전트로서 유용한 작업을 한다"로 바뀝니다.

그래서 뭐가 달라지나

Claude Code의 프롬프트는 정적 템플릿이 아닙니다. 매 세션마다 다음 조건들이 조합됩니다.

  • 실행 환경: OS, 셸, git 여부, 모델 ID
  • 사용자 설정: 언어, 출력 스타일, 커스텀 프롬프트
  • 연결 상태: MCP 서버 목록, 활성화된 도구 셋
  • 메모리: MEMORY.md 내용, 팀 메모리 여부
  • 피처 플래그: PROACTIVE, KAIROS, TEAMMEM, COORDINATOR_MODE
  • 모드: 에이전트 정의, loop 모드, 코워크 모드

이 조건들의 조합이 매번 다른 시스템 프롬프트를 만들어내고, 같은 질문에도 다른 행동을 이끌어냅니다. 정적인 부분은 캐싱하고 동적인 부분만 매번 재계산하는 경계선(SYSTEM_PROMPT_DYNAMIC_BOUNDARY)을 두어 성능과 유연성을 동시에 잡았습니다.

결국 Claude Code가 "똑똑하게" 느껴지는 이유의 상당 부분은, 모델 자체의 능력이 아니라 컨텍스트에 맞게 프롬프트를 정밀하게 조립하는 이 엔지니어링에 있습니다.


다음 편 예고: [Claude Code 해부학 Part 6] 클로드 코드(Claude Code) 도구 시스템 분석 — AI에게 손발을 달아주는 40개 Tool

profile

ClOr

@ClOr

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

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