Claude Code 도구 시스템

인터페이스 설계부터 등록, 라우팅, 권한, 실행, 스트리밍까지 — 도구 생명주기의 모든 것.

01

개요

Claude Code가 모델에 노출하는 모든 능력 — 파일 읽기, Bash 명령 실행, 웹 검색, MCP 서버 호출 — 은 도구(Tool)로 추상화됩니다. 도구 시스템은 AI의 추론과 머신의 구체적인 사이드 이펙트 사이의 다리 역할을 합니다. 모델이 "이 파일을 읽어야 한다"고 결정하면, 도구 시스템이 그 의도를 검증하고, 권한을 확인하고, 실행하고, 결과를 직렬화하여 다시 모델에 전달합니다.

다루는 소스 파일: Tool.ts, tools.ts, tools/utils.ts, services/tools/toolOrchestration.ts, services/tools/toolExecution.ts, services/tools/StreamingToolExecutor.ts

도구 시스템의 핵심 설계 원칙:

02

아키텍처 다이어그램

도구의 정의부터 결과 반환까지 8단계 생명주기를 보여주는 종합 플로우차트입니다.

flowchart TD A["도구 인터페이스 정의
(Tool.ts)"] --> B["도구 등록
(tools.ts)"] B --> C["라우팅 / 디스패치"] C --> D["입력 검증
(Zod strictObject)"] D --> E{"권한 게이트
(checkPermissions)"} E -->|"허용"| F["도구 실행
(tool.call)"] E -->|"거부"| G["권한 오류 반환"] F --> H["결과 처리
(직렬화 + 크기 제한)"] H --> I["오케스트레이션
(toolOrchestration.ts)"] I --> J["다음 도구 배치 또는
모델에 결과 전달"] style A fill:#c47a50,color:#1a1816 style E fill:#7d9ab8,color:#1a1816 style F fill:#6e9468,color:#1a1816 style G fill:#b85c5c,color:#1a1816 style I fill:#9b7dcf,color:#1a1816

이 파이프라인의 각 단계는 단일 책임을 가지며, 검증 실패 시 후속 단계로 진행하지 않는 단계적 방어(defense in depth) 패턴을 따릅니다.

03

도구 인터페이스 (Tool.ts)

Tool<Input, Output, P> 타입은 도구 시스템의 프로토콜 계약입니다. 클래스 계층이 아닌 구조적 타입으로 정의되어 있어, 인터페이스를 만족하는 어떤 객체든 도구로 사용할 수 있습니다.

세 개의 제네릭 매개변수를 받습니다:

// Tool.ts — 도구 프로토콜의 핵심 타입 정의
export type Tool<Input extends AnyObject, Output, P extends ToolProgressData> = {
  name: string
  aliases?: string[]
  inputSchema: Input
  maxResultSizeChars: number
  call(args, context, canUseTool, parentMessage, onProgress?): Promise<ToolResult<Output>>
  checkPermissions(input, context): Promise<PermissionResult>
  isConcurrencySafe(input): boolean
  isReadOnly(input): boolean
  isDestructive?(input): boolean
}

핵심 멤버 상세

buildTool() 팩토리

buildTool()ToolDef(부분 정의)를 받아 안전한 실패-폐쇄 기본값으로 병합합니다. 도구 작성자가 명시적으로 선언하지 않은 속성은 가장 보수적인 값으로 설정됩니다:

도구 표면(tool surface)과 내부 도구 객체

내부의 Tool 객체가 그대로 모델에 노출되는 것은 아닙니다. 등록 단계에서 실제 실행용 메서드와 권한 로직을 가진 내부 객체를, 모델이 볼 수 있는 도구 표면, 즉 이름, 설명, 입력 스키마 중심의 API 정의로 변환합니다. 이 분리가 중요한 이유는 checkPermissions(), isConcurrencySafe(), contextModifier 같은 내부 실행 세부사항은 모델에게 노출할 대상이 아니기 때문입니다.

contextModifier

contextModifier는 도구가 전역 상태를 직접 mutate하지 않고, 실행 결과와 함께 후속 컨텍스트 변경 함수를 돌려주는 패턴입니다. 오케스트레이터가 이 함수를 수집한 뒤 안전한 시점에 적용하므로, 도구 구현은 실행과 상태 반영을 분리할 수 있습니다. 이 때문에 동시 실행 배치 안에서는 즉시 적용되지 않으며, 배치가 끝난 뒤 순서대로 반영됩니다.

딥 다이브 — 왜 클래스가 아닌 구조적 타입인가?

클래스 계층(AbstractTool → ReadTool, BashTool, ...)은 공유 행위를 super 호출 체인으로 강제합니다. Claude Code의 도구 시스템은 대신 프로토콜을 사용합니다: 필요한 메서드와 속성을 가진 어떤 객체든 도구가 됩니다. 이 설계는 테스트에서 목(mock) 도구를 쉽게 만들 수 있고, MCP 프록시 도구처럼 외부 프로토콜에서 생성된 도구도 동일한 파이프라인을 탈 수 있게 합니다.

04

등록 (tools.ts)

도구 등록은 3단계 조립 파이프라인으로 구성됩니다:

  1. getAllBaseTools() — 모든 내장 도구의 원시 정의를 수집합니다. 각 도구 파일(tools/bash.ts, tools/read.ts 등)에서 buildTool()로 생성된 도구 객체를 가져옵니다.
  2. getTools() — 기능 플래그(feature() 호출), 환경, 연결된 MCP 클라이언트를 반영해 실제 세션에서 쓸 도구 집합을 구성합니다. 이 단계에서 모델에 보여줄 도구 표면도 함께 준비됩니다.
  3. assembleToolPool() — 최종 도구 배열을 Anthropic API에 전송할 형태로 조립합니다. 이때 서버 측 프롬프트 캐시 브레이크포인트 보존을 위해 내장 도구와 MCP 도구를 별도의 알파벳 그룹으로 정렬합니다. 이후 권한의 deny 규칙이 적용되면, 일부 도구 표면은 모델에게 아예 숨겨질 수 있습니다.
// tools.ts — 캐시 안정 정렬
export function assembleToolPool(builtInTools, mcpTools) {
  const sortedBuiltIn = [...builtInTools].sort((a, b) => a.name.localeCompare(b.name))
  const sortedMcp = [...mcpTools].sort((a, b) => a.name.localeCompare(b.name))
  // 두 그룹을 별도 정렬 — 교차 배치하면 프롬프트 캐시 브레이크포인트 무효화
  return [...sortedBuiltIn, ...sortedMcp]
}
딥 다이브 — 캐시 안정 정렬이 왜 중요한가?

Anthropic API는 시스템 프롬프트와 도구 정의의 접두사가 이전 요청과 동일하면 프롬프트 캐시를 재사용합니다. 도구 순서가 요청마다 달라지면 캐시가 무효화되어 수만 토큰의 재처리가 발생합니다.

내장 도구는 세션 내내 고정이지만, MCP 도구는 서버 연결 상태에 따라 동적으로 추가/제거될 수 있습니다. 두 그룹을 분리하면 MCP 도구가 변경되어도 내장 도구 접두사의 캐시는 유지됩니다. 내장 도구 그룹 내에서는 알파벳순으로 정렬하여 결정적 순서를 보장합니다.

주의사항

기능 플래그로 게이팅된 도구가 세션 중간에 활성화/비활성화되면 도구 목록이 변경되어 프롬프트 캐시가 무효화됩니다. 이 때문에 대부분의 도구 게이팅은 세션 시작 시 한 번만 평가되고, 세션 중간에는 변경되지 않도록 설계되어 있습니다.

05

오케스트레이션 (toolOrchestration.ts)

모델이 한 번의 응답에서 여러 도구를 호출할 때, 오케스트레이터가 실행 순서와 동시성을 결정합니다. 핵심은 파티셔닝 알고리즘입니다.

파티셔닝 규칙

  1. 도구 호출 목록을 순서대로 순회합니다.
  2. isConcurrencySafe(input) === true인 연속된 도구들을 하나의 배치로 묶습니다.
  3. isConcurrencySafe(input) === false인 도구를 만나면 현재 배치를 중단하고, 해당 도구를 단독 직렬 실행합니다.
  4. 다음 연속 안전 도구들이 새로운 배치를 형성합니다.

예를 들어, 모델이 [Read, Read, Bash, Read, Read, Read]를 호출하면:

동시성 상한

환경 변수 CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY로 한 배치 내 최대 동시 실행 수를 제어합니다. 기본값은 10입니다. 배치에 15개 도구가 있으면 10개를 먼저 실행하고, 슬롯이 비면 나머지 5개를 실행합니다.

contextModifier 적용 시점

딥 다이브 — 입력 기반 동시성 판단

isConcurrencySafe(input)이 도구 타입이 아닌 입력값을 받는 이유는, 같은 도구라도 입력에 따라 안전성이 달라질 수 있기 때문입니다. 예를 들어, Bash 도구에서 cat file.txt(읽기)는 동시 실행에 안전하지만, rm -rf /(쓰기)는 안전하지 않습니다. 이 설계를 통해 읽기 전용 Bash 호출은 다른 읽기 도구와 병렬로 실행되어 전체 처리 시간을 단축합니다.

06

스트리밍 실행 (StreamingToolExecutor.ts)

일반적인 도구 실행은 API 응답이 완전히 도착한 후 시작됩니다. StreamingToolExecutor는 이를 최적화하여 API에서 블록이 스트리밍되는 동안 도구 실행을 시작합니다.

상태 머신

각 도구 호출은 네 가지 상태를 순차적으로 거칩니다:

  1. queued — 도구 호출 블록이 감지되었지만 아직 입력이 완전하지 않음
  2. executing — 입력 파싱 완료, call() 실행 중
  3. completed — 실행 완료, 결과 대기 중
  4. yielded — 결과가 모델 요청 순서대로 방출됨

동시성 안전과 배치 중단

동시성 비안전(isConcurrencySafe === false) 도구는 현재 실행 중인 모든 도구가 완료될 때까지 대기한 후 단독 실행됩니다. 스트리밍 환경에서도 오케스트레이션의 직렬 보장이 유지됩니다.

Bash 오류의 특별 처리

Bash 도구에서 오류가 발생하면 같은 배치의 형제 도구 실행을 중단합니다. 반면 Read, WebFetch 등의 도구 오류는 형제 도구에 영향을 주지 않습니다.

딥 다이브 — 왜 Bash만 형제 중단을 전파하나?

Bash 명령은 암묵적 의존성 체인을 가질 수 있습니다. 모델이 mkdir -p build && cd build && cmake ..cat build/config.h를 동시에 요청하면, 첫 번째 명령이 실패할 경우 두 번째 명령도 의미가 없습니다. 반면 Read("file_a.txt")Read("file_b.txt")는 완전히 독립적이어서 하나가 실패해도 다른 하나의 결과는 여전히 유효합니다.

// StreamingToolExecutor.ts — 도구 생명주기 상태
type ToolLifecycleState = 'queued' | 'executing' | 'completed' | 'yielded'

// 비안전 도구는 실행 중인 모든 도구 완료 대기
if (!tool.isConcurrencySafe(input)) {
  await waitForAllExecuting()
}
// Bash 오류만 형제 도구 중단 전파
if (tool.name === 'Bash' && result.isError) {
  abortSiblings()
}

결과 순서 보장

결과는 실행 완료 순서가 아닌 모델 요청 순서대로 방출됩니다. 도구 B가 도구 A보다 먼저 완료되어도, A의 결과가 먼저 yielded 상태로 전환됩니다. 이는 모델이 도구 결과를 요청 순서와 매칭하여 해석하기 때문입니다.

07

도구 실행 파이프라인 (toolExecution.ts)

checkPermissionsAndCallTool()는 개별 도구 호출의 전체 실행 흐름을 관리합니다. 각 단계는 이전 단계가 성공해야 다음으로 진행되는 심층 방어(defense in depth) 패턴을 따릅니다.

실행 순서

  1. Zod 검증inputSchemastrictObject로 입력을 파싱. 선언되지 않은 필드는 자동 거부
  2. 의미 검증(semantic validation) — 스키마를 통과했지만 논리적으로 유효하지 않은 입력을 검사 (예: 존재하지 않는 파일 경로)
  3. 투기적 분류기(speculative classifier) — Bash 도구 전용. 명령의 위험도를 사전 분류하여 권한 게이트 전에 빠른 판단
  4. backfillObservableInput() — 로깅과 디버깅을 위해 관찰 가능한 입력 형태를 채움. 원본의 복제본에서 작업하여 직렬화된 트랜스크립트를 보호
  5. PreToolUse 훅 — 도구 실행 전에 등록된 훅을 호출. 훅이 실행을 거부할 수 있음
  6. canUseTool() — 사용자 권한 확인. 대화형 모드에서는 사용자에게 승인을 요청할 수 있음
  7. tool.call() — 실제 도구 로직 실행
  8. PostToolUse 훅 — 실행 결과와 함께 후처리 훅 호출
  9. 결과 직렬화 — 결과를 문자열로 변환하고 maxResultSizeChars에 맞게 잘라냄
// toolExecution.ts — 심층 방어
if ('_simulatedSedEdit' in validatedInput) {
  delete validatedInput._simulatedSedEdit
  // Zod strictObject가 이미 거부해야 하지만 추가 제거
}
주의사항 — 심층 방어: _simulatedSedEdit

모델이 가끔 Bash 도구 입력에 _simulatedSedEdit 같은 내부 필드를 주입하려는 시도가 관찰되었습니다. Zod의 strictObject가 이미 이런 필드를 거부해야 하지만, toolExecution.ts는 실행 전에 이 필드를 추가로 명시적 제거합니다. 이것이 심층 방어의 전형적인 예입니다 — 한 레이어가 실패해도 다른 레이어가 보호합니다.

딥 다이브 — backfillObservableInput()이 복제본에서 작업하는 이유

원본 입력 객체를 변경하면 직렬화된 트랜스크립트가 변경됩니다. 이는 테스트의 VCR(Video Cassette Recorder) 픽스처 해시를 깨뜨립니다. VCR 테스트는 API 요청/응답을 녹화하고 재생하여 결정적 테스트를 가능하게 하는데, 입력 객체의 변경이 해시에 영향을 주면 픽스처가 무효화되어 모든 관련 테스트가 실패합니다. 복제본에서 작업하면 원본의 불변성이 보장됩니다.

08

권한 컨텍스트 (Permission Context)

도구 실행 파이프라인 전체에 걸쳐 PermissionContext 객체가 흘러다니며, 각 도구 호출의 권한 판단을 중앙 집중적으로 관리합니다. 이 컨텍스트는 현재 권한 모드, 승인된 규칙 목록, 거부 규칙 목록을 포함하며, 파이프라인의 각 단계에서 참조됩니다. 중요한 점은 권한이 두 번 작동한다는 것입니다. 먼저 도구 표면을 모델에게 노출할지 결정하고, 그다음 실제 호출 시 입력까지 포함해 다시 검사합니다.

isAllowed / isDenied 체크 흐름

권한 판단은 두 단계로 진행됩니다:

  1. isDenied() 우선 체크: 거부 규칙이 허용 규칙보다 우선합니다. 거부 목록에 매칭되면 어떤 허용 규칙이 있더라도 즉시 거부됩니다.
  2. isAllowed() 체크: 거부되지 않은 경우, 승인된 도구 목록과 규칙 패턴을 순회하며 허용 여부를 판단합니다. 매칭되는 허용 규칙이 없으면 사용자에게 권한 프롬프트를 표시합니다.

filterToolsByDenyRules()

도구 목록이 모델에 전달되기 전에 filterToolsByDenyRules()가 거부 규칙에 해당하는 도구를 사전에 제거합니다. 이는 모델이 사용할 수 없는 도구를 아예 보지 못하게 하여, 불필요한 도구 호출 시도와 권한 거부 오류를 방지합니다.

// PermissionContext — 도구 파이프라인을 관통하는 권한 컨텍스트
type PermissionContext = {
  permissionMode: PermissionMode
  approvedRules: PermissionRule[]
  denyRules: DenyRule[]
}

// 거부 규칙으로 도구 목록 사전 필터링
function filterToolsByDenyRules(
  tools: Tool[],
  context: PermissionContext
): Tool[] {
  return tools.filter(tool => {
    // 거부 규칙에 매칭되는 도구를 제거
    const denied = context.denyRules.some(
      rule => matchesToolName(rule, tool.name)
    )
    return !denied
  })
}

// 개별 도구 호출 시 권한 판단 흐름
function checkPermission(tool, input, context) {
  // 1단계: 거부 규칙 우선 — 매칭 시 즉시 거부
  if (isDenied(tool, input, context.denyRules)) {
    return { result: 'denied' }
  }
  // 2단계: 허용 규칙 체크 — 매칭 시 자동 허용
  if (isAllowed(tool, input, context.approvedRules)) {
    return { result: 'allowed' }
  }
  // 매칭 없음 — 사용자에게 권한 프롬프트 표시
  return { result: 'ask' }
}

이 설계의 핵심은 거부 우선(deny-first) 원칙입니다. 관리자가 설정한 거부 규칙은 사용자의 세션 승인보다 항상 우선하며, filterToolsByDenyRules()를 통해 모델이 거부된 도구를 인지조차 하지 못하게 합니다. 다만 도구가 노출되었다고 해서 실행이 자동 허용되는 것은 아니며, 실제 호출 단계의 checkPermissions()가 더 구체적인 입력 기준으로 다시 게이트를 겁니다.

09

핵심 요약

핵심 포인트

  • 프로토콜, 클래스 계층 아님. Tool은 구조적 타입으로 정의됩니다. buildTool()이 실패-폐쇄 원칙에 따라 안전한 기본값을 채워 넣습니다
  • 캐시 안정 정렬의 3단계 조립. getAllBaseTools()getTools()assembleToolPool() 파이프라인이 내장/MCP 도구를 별도 알파벳 그룹으로 정렬하여 프롬프트 캐시를 보존합니다
  • 동시성은 데이터 주도. isConcurrencySafe(input)은 도구 타입별이 아닌 도구 호출별로 판단됩니다. 같은 Bash 도구라도 읽기 명령과 쓰기 명령은 다르게 취급됩니다
  • 불변 컨텍스트, contextModifier를 통한 함수적 변형. 도구는 전역 상태를 직접 변경하지 않고, contextModifier 함수를 반환하여 오케스트레이터가 적절한 시점에 적용합니다
  • Bash는 특별합니다. 연쇄 중단(형제 도구 취소), 투기적 분류기(사전 위험도 판단), 방어적 내부 필드 제거(_simulatedSedEdit) 등 추가적인 안전 메커니즘이 적용됩니다
10

지식 확인

퀴즈 — 5문제

Q1. buildTool()isConcurrencySafe의 기본값을 false로 설정하는 이유는?

  • A) 대부분의 도구가 데이터만 읽기 때문
  • B) 상태 변경을 가정하는 실패-폐쇄 원칙
  • C) 병렬 실행이 더 빠르기 때문
  • D) 제공되지 않으면 오류를 발생시킴
실패-폐쇄(fail-closed) 원칙에 따라, 도구 작성자가 명시적으로 동시 실행 안전을 선언하지 않으면 상태 변경 가능성을 가정하고 직렬 실행을 강제합니다. 이는 잠재적 경쟁 조건보다 약간의 성능 저하를 선택하는 보수적 설계입니다.

Q2. assembleToolPool()에서 내장 도구와 MCP 도구를 별도 알파벳 그룹으로 정렬하는 이유는?

  • A) 내장 도구가 항상 알파벳순으로 먼저 와야 해서
  • B) MCP 도구가 로드가 느려서
  • C) 교차 배치하면 서버 측 프롬프트 캐시 브레이크포인트가 무효화되므로
  • D) MCP 도구는 안정적으로 정렬할 수 없어서
MCP 도구는 동적으로 추가/제거될 수 있습니다. 내장 도구와 MCP 도구를 분리하면, MCP 도구가 변경되어도 내장 도구 접두사에 대한 서버 측 프롬프트 캐시가 유지됩니다. 교차 배치하면 MCP 도구 변경이 전체 도구 목록의 순서를 바꿔 캐시를 무효화합니다.

Q3. StreamingToolExecutor에서 Bash 도구 오류만 형제 도구를 중단시키는 이유는?

  • A) Bash만 예외를 발생시킬 수 있어서
  • B) Bash 명령은 암묵적 의존성 체인이 있고, Read/WebFetch 등은 독립적이므로
  • C) Bash가 항상 배치의 마지막에 실행되므로
  • D) 형제 중단은 동기 도구에만 안전하므로
Bash 명령은 파일 생성, 디렉토리 변경 등 암묵적 의존성 체인을 가질 수 있어 하나가 실패하면 관련 명령도 무의미해집니다. 반면 ReadWebFetch는 서로 독립적이어서 하나의 실패가 다른 호출의 유효성에 영향을 주지 않습니다.

Q4. backfillObservableInput()이 복제본에서 작업하는 목적은?

  • A) Zod 라이브러리가 복제를 요구해서
  • B) TypeScript 컴파일러의 타입 오류를 피하기 위해
  • C) 원본을 변경하면 직렬화된 트랜스크립트가 변경되어 테스트의 VCR 픽스처 해시가 깨지므로
  • D) PostToolUse 훅에 원본이 필요해서
VCR 테스트는 API 요청/응답을 녹화하여 결정적 테스트를 수행합니다. 원본 입력 객체를 변경하면 직렬화된 트랜스크립트의 해시가 달라져 녹화된 픽스처와 불일치가 발생합니다. 복제본에서 작업하면 원본의 불변성이 보장되어 테스트 안정성이 유지됩니다.

Q5. 도구가 동시 배치에서 실행된 경우 contextModifier는 언제 적용되나?

  • A) 도구의 call()이 해결될 때 즉시
  • B) 다음 쿼리 턴 시작 시에만
  • C) 배치의 모든 동시 도구가 완료된 후
  • D) 동시 도구는 contextModifier를 사용할 수 없음 — 무시됨
동시 배치에서는 개별 도구 완료 시점이 비결정적이므로, 모든 도구가 완료된 후에 contextModifier를 일괄 적용합니다. 이는 배치 내 도구 간 컨텍스트 변경의 비결정성을 방지하면서도 배치 완료 후 후속 도구에 업데이트된 컨텍스트를 제공합니다.
0 / 5