요약 (TL;DR)
Claude 코드 소스 유출 사건으로 2026년 3월 31일, 512,000줄 규모의 TypeScript 코드베이스가 외부에 노출되었습니다. 아키텍처는 Claude API를 호출하고, 도구 실행을 디스패치하며, 결과를 다시 루프에 피드하는 while-루프로 요약됩니다. Anthropic SDK와 약 200줄의 파이썬 코드만으로 핵심 루프를 구현할 수 있습니다. 이 글에서는 각 구성 요소를 분석하고, 직접 재현하는 방법과 실전 코드 예제를 제공합니다.
서론
2026년 3월 31일, Anthropic은 @anthropic-ai/claude-code npm 패키지(2.1.88 버전)에 59.8MB 크기의 소스 맵 파일을 포함하여 배포했습니다. 소스 맵은 난독화된 JavaScript를 원래 소스로 복원하는 디버깅 파일입니다. Bun 번들러의 기본 설정으로 인해 전체 TypeScript 소스가 복구되었습니다.
몇 시간 만에 개발자들이 수십 개의 GitHub 저장소에 코드를 미러링했고, 커뮤니티는 마스터 에이전트 루프부터 "언더커버 모드", 가짜 도구 주입 등 숨겨진 기능까지 빠르게 분석했습니다.
반응은 다양했습니다. 일부는 보안 문제를 지적했고, 또 다른 이들은 아키텍처 설계에 주목했습니다. 가장 생산적인 질문은 “직접 만들어볼 수 있을까?”였습니다.
정답은 '예'입니다. 핵심 패턴은 단순합니다. 아래에서는 아키텍처 레이어별 분석, Anthropic의 선택, 재현 가능한 실습 코드를 차례대로 제공합니다. 또한 Apidog를 활용해 커스텀 에이전트의 API 인터랙션을 효과적으로 테스트하는 방법을 안내합니다. 이 과정은 단순 curl 명령보다 복잡한 다중 턴 API 대화 디버깅을 훨씬 쉽게 만듭니다.
유출을 통해 밝혀진 Claude Code의 아키텍처
한눈에 보는 코드베이스
코드명 "Tengu"로 불리는 Claude Code는 약 1,900개의 파일로 구성되며, 명확한 계층 구조를 가집니다:
cli/ - 터미널 UI (React + Ink)
tools/ - 40개 이상의 도구 구현체
core/ - 시스템 프롬프트, 권한, 상수
assistant/ - 에이전트 오케스트레이션
services/ - API 호출, 압축, OAuth, 텔레메트리
CLI는 Ink(React 기반 터미널 렌더러)로 빌드됩니다. 레이아웃은 Yoga(CSS flexbox), 스타일링은 ANSI 이스케이프 코드 기반입니다. 대화, 입력, 도구 호출, 권한 대화 상자는 모두 React 컴포넌트로 처리합니다.
하지만 단순히 작동하는 코딩 에이전트를 만들려면 React 기반 터미널 UI는 필요 없습니다. 최소한의 REPL 루프만으로 충분합니다.
마스터 에이전트 루프
UI, 텔레메트리, 기능 플래그를 제외하면 Claude Code의 핵심은 while-루프입니다(내부적으로 "nO"라 불림). 작동 방식은 다음과 같습니다:
- Claude API에 메시지 전송 (시스템 프롬프트 + 도구 정의 포함)
- 텍스트와/또는
tool_use블록을 포함한 응답 수신 - 요청된 각 도구를 이름-핸들러 매핑으로 실행
- 도구 실행 결과를 메시지 목록에 추가
- 추가 도구 호출이 있으면 1단계로 루프
- 도구 호출이 없는 일반 텍스트면 사용자에게 반환
"턴"은 한 번의 전체 왕복입니다. Claude가 도구 호출 없이 텍스트를 생성할 때까지 반복됩니다.
최소 파이썬 구현 예시
import anthropic
client = anthropic.Anthropic()
MODEL = "claude-sonnet-4-6"
def agent_loop(system_prompt: str, tools: list, messages: list) -> str:
"""The core agent loop - keep calling until no more tool use."""
while True:
response = client.messages.create(
model=MODEL,
max_tokens=16384,
system=system_prompt,
tools=tools,
messages=messages,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return "".join(
block.text for block in response.content
if hasattr(block, "text")
)
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = execute_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
})
messages.append({"role": "user", "content": tool_results})
약 30줄로, 나머지 복잡성은 도구 구현, 권한, 컨텍스트 관리, 메모리에서 나옵니다.
도구 시스템 구축
단일 bash 명령보다 전용 도구가 뛰어난 이유
유출된 Claude Code는 모든 작업을 bash 파이프라인으로 처리하지 않고, 파일 작업에 전용 도구를 사용합니다.
예: Read(cat 아님), Edit(sed 아님), Grep(grep 아님), Glob(find 아님) 도구.
이유는 아래와 같습니다:
- 구조화된 출력: 도구는 일관된 포맷으로 결과를 반환, 모델이 구문 분석하기 쉬움
- 안전성: 백틱/서브쉘 차단 등, 도구 구현을 통해 주입 공격 방지
- 토큰 효율성: 도구 결과는 잘라내거나 오프로드 가능. cat으로 대용량 출력 시 토큰 낭비 방지
필수 도구 세트
Claude Code는 20개 미만의 도구를 기본 제공, 60개 이상은 기능 플래그 뒤에 숨김.
DIY에서는 아래 다섯 가지 도구만 있어도 충분합니다.
TOOLS = [
{
"name": "read_file",
"description": "Read a file from the filesystem. Returns contents with line numbers.",
"input_schema": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute path to the file"
},
"offset": {
"type": "integer",
"description": "Line number to start reading from (0-indexed)"
},
"limit": {
"type": "integer",
"description": "Max lines to read. Defaults to 2000."
}
},
"required": ["file_path"]
}
},
{
"name": "write_file",
"description": "Write content to a file. Creates the file if it doesn't exist.",
"input_schema": {
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "Absolute path"},
"content": {"type": "string", "description": "File content to write"}
},
"required": ["file_path", "content"]
}
},
{
"name": "edit_file",
"description": "Replace a specific string in a file. The old_string must be unique.",
"input_schema": {
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "Absolute path"},
"old_string": {"type": "string", "description": "Text to find"},
"new_string": {"type": "string", "description": "Replacement text"}
},
"required": ["file_path", "old_string", "new_string"]
}
},
{
"name": "run_command",
"description": "Execute a shell command and return stdout/stderr.",
"input_schema": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "Shell command to run"},
"timeout": {"type": "integer", "description": "Timeout in seconds. Default 120."}
},
"required": ["command"]
}
},
{
"name": "search_code",
"description": "Search for a regex pattern across files in a directory.",
"input_schema": {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Regex pattern"},
"path": {"type": "string", "description": "Directory to search"},
"file_glob": {"type": "string", "description": "File pattern filter, e.g. '*.py'"}
},
"required": ["pattern"]
}
}
]
도구 핸들러 디스패치
도구 실행은 도구 이름 → 핸들러 함수 매핑으로 관리합니다.
import subprocess
import os
import re
def execute_tool(name: str, params: dict) -> str:
handlers = {
"read_file": handle_read_file,
"write_file": handle_write_file,
"edit_file": handle_edit_file,
"run_command": handle_run_command,
"search_code": handle_search_code,
}
handler = handlers.get(name)
if not handler:
return f"Error: Unknown tool '{name}'"
try:
return handler(params)
except Exception as e:
return f"Error: {str(e)}"
def handle_read_file(params: dict) -> str:
path = params["file_path"]
offset = params.get("offset", 0)
limit = params.get("limit", 2000)
with open(path, "r") as f:
lines = f.readlines()
selected = lines[offset:offset + limit]
numbered = [f"{i + offset + 1}\t{line}" for i, line in enumerate(selected)]
return "".join(numbered)
def handle_write_file(params: dict) -> str:
path = params["file_path"]
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write(params["content"])
return f"Successfully wrote to {path}"
def handle_edit_file(params: dict) -> str:
path = params["file_path"]
with open(path, "r") as f:
content = f.read()
old = params["old_string"]
if content.count(old) == 0:
return f"Error: '{old[:50]}...' not found in {path}"
if content.count(old) > 1:
return f"Error: '{old[:50]}...' matches {content.count(old)} locations. Be more specific."
new_content = content.replace(old, params["new_string"], 1)
with open(path, "w") as f:
f.write(new_content)
return f"Successfully edited {path}"
def handle_run_command(params: dict) -> str:
cmd = params["command"]
timeout = params.get("timeout", 120)
blocked = ["rm -rf /", "mkfs", "> /dev/"]
for pattern in blocked:
if pattern in cmd:
return f"Error: Blocked dangerous command pattern: {pattern}"
result = subprocess.run(
cmd, shell=True, capture_output=True, text=True,
timeout=timeout, cwd=os.getcwd()
)
output = ""
if result.stdout:
output += result.stdout
if result.stderr:
output += f"\nSTDERR:\n{result.stderr}"
if not output.strip():
output = f"Command completed with exit code {result.returncode}"
if len(output) > 30000:
output = output[:15000] + "\n\n... [truncated] ...\n\n" + output[-15000:]
return output
def handle_search_code(params: dict) -> str:
pattern = params["pattern"]
path = params.get("path", os.getcwd())
file_glob = params.get("file_glob", "")
cmd = ["grep", "-rn", "--include", file_glob, pattern, path] if file_glob else \
["grep", "-rn", pattern, path]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if not result.stdout.strip():
return f"No matches found for pattern: {pattern}"
lines = result.stdout.strip().split("\n")
if len(lines) > 50:
return "\n".join(lines[:50]) + f"\n\n... ({len(lines) - 50} more matches)"
return result.stdout
컨텍스트 관리: 어려운 문제
프롬프트 엔지니어링보다 컨텍스트가 더 중요
Claude Code는 시스템 프롬프트보다 컨텍스트 관리에 더 많은 공을 들입니다. 내부 컨텍스트 압축기("wU2")는 다섯 가지 전략을 사용하며, DIY에서는 아래 두 가지만 필수입니다.
- 자동 압축: 컨텍스트 창의 약 92% 사용 시 트리거, 요약을 위해 13,000 토큰 버퍼 확보
-
프로젝트 가이드라인 재주입: 매 턴마다
.claude/CLAUDE.md등 프로젝트 가이드를 재주입, 에이전트가 방향성을 잃지 않도록 보장
간단한 압축기 구현
def maybe_compact(messages: list, system_prompt: str, max_tokens: int = 180000) -> list:
"""Compact conversation when it gets too long."""
total_chars = sum(
len(str(m.get("content", ""))) for m in messages
)
estimated_tokens = total_chars // 4
if estimated_tokens < max_tokens * 0.85:
return messages
summary_response = client.messages.create(
model=MODEL,
max_tokens=4096,
system="Summarize this conversation. Keep all file paths, decisions made, errors encountered, and current task state. Be specific about what was changed and why.",
messages=messages,
)
summary_text = summary_response.content[0].text
compacted = [
{"role": "user", "content": f"[Conversation summary]\n{summary_text}"},
{"role": "assistant", "content": "I have the context from our previous conversation. What should I work on next?"},
]
compacted.extend(messages[-4:])
return compacted
프로젝트 컨텍스트 재주입
매 턴마다 .claude/CLAUDE.md 등 가이드 파일을 시스템 프롬프트에 주입합니다.
def build_system_prompt(project_dir: str) -> str:
"""Build system prompt with project context re-injection."""
base_prompt = """You are a coding assistant that helps with software engineering tasks.
You have access to tools for reading, writing, editing files, running commands, and searching code.
Always read files before modifying them. Prefer edit_file over write_file for existing files.
Keep responses concise. Focus on the code, not explanations."""
claude_md_path = os.path.join(project_dir, ".claude", "CLAUDE.md")
if os.path.exists(claude_md_path):
with open(claude_md_path, "r") as f:
project_context = f.read()
base_prompt += f"\n\n# Project guidelines\n{project_context}"
root_md = os.path.join(project_dir, "CLAUDE.md")
if os.path.exists(root_md):
with open(root_md, "r") as f:
root_context = f.read()
base_prompt += f"\n\n# Repository guidelines\n{root_context}"
return base_prompt
3계층 메모리 시스템
유출된 소스는 Claude Code가 3계층 메모리 아키텍처를 사용함을 보여줍니다.
계층 1: MEMORY.md (항상 로드됨)
항상 시스템 프롬프트에 포함되는 경량 인덱스(한 줄 150자 미만). 200줄/25KB 제한.
- [User preferences](memory/user-prefs.md) - prefers TypeScript, uses Vim keybindings
- [API conventions](memory/api-conventions.md) - REST with JSON:API spec, snake_case
- [Deploy process](memory/deploy.md) - uses GitHub Actions, deploys to AWS EKS
계층 2: 주제 파일 (요청 시 로드)
인덱스에서 관련성이 제기될 때 상세 내용 파일을 로드합니다.
계층 3: 세션 기록 (검색만, 전체 로드 없음)
전체 세션 로그는 통째로 로드하지 않고, 필요한 경우 grep 등으로 검색만 수행합니다.
최소한의 메모리 시스템 구축
import json
MEMORY_DIR = ".agent/memory"
def load_memory_index() -> str:
"""Load the memory index for system prompt injection."""
index_path = os.path.join(MEMORY_DIR, "MEMORY.md")
if os.path.exists(index_path):
with open(index_path, "r") as f:
return f.read()
return ""
def save_memory(key: str, content: str, description: str):
"""Save a memory entry and update the index."""
os.makedirs(MEMORY_DIR, exist_ok=True)
filename = f"{key.replace(' ', '-').lower()}.md"
filepath = os.path.join(MEMORY_DIR, filename)
with open(filepath, "w") as f:
f.write(f"---\nname: {key}\ndescription: {description}\n---\n\n{content}")
index_path = os.path.join(MEMORY_DIR, "MEMORY.md")
index_lines = []
if os.path.exists(index_path):
with open(index_path, "r") as f:
index_lines = f.readlines()
new_entry = f"- [{key}]({filename}) - {description}\n"
updated = False
for i, line in enumerate(index_lines):
if filename in line:
index_lines[i] = new_entry
updated = True
break
if not updated:
index_lines.append(new_entry)
with open(index_path, "w") as f:
f.writelines(index_lines)
도구 목록에 save_memory 도구를 추가하면 에이전트가 세션 간 지식을 보존할 수 있습니다.
권한 시스템 추가
유출된 코드는 다섯 가지 권한 모드를 보여주지만, DIY에서는 3단계가 실용적입니다:
# Risk levels for operations
RISK_LEVELS = {
"read_file": "low",
"search_code": "low",
"edit_file": "medium",
"write_file": "medium",
"run_command": "high",
}
def check_permission(tool_name: str, params: dict, auto_approve_low: bool = True) -> bool:
"""Check if the user approves this tool call."""
risk = RISK_LEVELS.get(tool_name, "high")
if risk == "low" and auto_approve_low:
return True
print(f"\n--- Permission check ({risk.upper()} risk) ---")
print(f"Tool: {tool_name}")
for key, value in params.items():
display = str(value)[:200]
print(f" {key}: {display}")
response = input("Allow? [y/n/always]: ").strip().lower()
if response == "always":
RISK_LEVELS[tool_name] = "low" # Auto-approve this tool going forward
return True
return response == "y"
Apidog로 에이전트의 API 호출 테스트하기
코딩 에이전트를 개발하면 Claude API로 수백 건의 요청을 보내게 됩니다. 다중 턴 대화와 도구 호출을 효율적으로 디버깅하려면 원시 로그보다 전문 도구가 필요합니다.
Apidog는 에이전트의 API 요청을 쉽게 캡처하고 테스트할 수 있도록 지원합니다. 실전 단계는 다음과 같습니다:
API 요청 캡처 및 재실행
- Apidog 실행 후 새 프로젝트 생성
- Anthropic Messages API 엔드포인트:
POST https://api.anthropic.com/v1/messages가져오기 - 시스템 프롬프트, 도구 배열, 메시지로 요청 본문 구성
- 캡처한 요청을 수정해 다양한 파라미터로 반복 테스트
이렇게 하면 전체 에이전트 루프를 돌리지 않고도 특정 도구 사용 턴만 따로 분리·실험할 수 있습니다.
다중 턴 대화 디버깅
가장 까다로운 것은 대화 상태 재현입니다. Apidog의 환경변수를 활용해 각 턴 후 messages 배열을 저장하여, 원하는 시점부터 대화를 재생할 수 있습니다.
- 각 턴 종료 후 전체
messages배열을 환경 변수에 저장 - 어느 시점이든 대화 스냅샷을 로드해 재실행
- 실행 간 도구 결과를 비교하며 이상점 추적
도구 스키마 유효성 검사
도구 정의(JSON 스키마)가 잘못되면 모델이 도구를 무시하거나 잘못된 파라미터를 전달할 수 있습니다.
Apidog의 JSON 스키마 유효성 검사기로 미리 오류를 잡으세요.
Apidog를 다운로드하여 에이전트 API 디버깅을 바로 시작할 수 있습니다.
모든 것을 통합하기: 완전한 REPL
아래는 모든 기능이 통합된 최소 REPL 예시입니다.
#!/usr/bin/env python3
"""A minimal Claude Code-style coding agent."""
import anthropic
import os
import sys
client = anthropic.Anthropic()
MODEL = "claude-sonnet-4-6"
PROJECT_DIR = os.getcwd()
def main():
system_prompt = build_system_prompt(PROJECT_DIR)
memory = load_memory_index()
if memory:
system_prompt += f"\n\n# Memory\n{memory}"
messages = []
print("Coding agent ready. Type 'quit' to exit.\n")
while True:
user_input = input("> ").strip()
if user_input.lower() in ("quit", "exit"):
break
if not user_input:
continue
messages.append({"role": "user", "content": user_input})
# Compact if needed
messages = maybe_compact(messages, system_prompt)
# Re-inject project context (Claude Code does this every turn)
current_system = build_system_prompt(PROJECT_DIR)
memory = load_memory_index()
if memory:
current_system += f"\n\n# Memory\n{memory}"
# Run the agent loop
result = agent_loop(current_system, TOOLS, messages)
print(f"\n{result}\n")
if __name__ == "__main__":
main()
이렇게 하면 300줄 미만의 파이썬 코드로 파일 읽기, 코드 편집, 명령 실행, 코드 검색, 컨텍스트 관리, 세션 간 메모리까지 가능한 코딩 에이전트를 만들 수 있습니다.
다음에 추가할 기능
유출된 소스에는 아래와 같은 추가 기능도 있습니다:
병렬 작업 서브 에이전트
독립 작업을 위해 "forked" 에이전트를 생성, 결과를 병합합니다.
패턴: 하위 작업 설명과 도구 집합을 전달해 별도 agent_loop() 실행, 결과를 반환.
파일 읽기 중복 제거
읽은 파일의 마지막 수정 시간을 추적, 변경 없으면 재읽기 생략 및 모델에 알림 → 토큰 절약.
출력 자르기 및 샘플링
도구가 대량 출력 시(예: grep 10,000줄) 결과 일부만 반환하고, 생략된 줄 개수를 명시.
파일 재주입을 통한 자동 압축
대화 요약 후, 최근 액세스 파일 내용을 다시 주입(최대 5,000토큰/파일)하여 압축 후에도 코드 컨텍스트 유지.
유출을 통해 배운 점
- 핵심 루프는 단순하다: 에이전트 패턴은 30줄 미만, 복잡성은 도구·컨텍스트·메모리 관리에서 발생
- 전용 도구가 bash보다 효율적: 구조화·안전·토큰 효율성 모두 우수
- 메모리는 계층화: 인덱스·주문형 파일·검색 전용 기록의 3계층 구조 필요
- 컨텍스트 관리 자체가 '제품': 자동 압축, 프로젝트 지침 재주입, 출력 자르기 등 긴 세션 지원
- 하네스가 곧 제품: 모델은 지능, 하네스는 인식/행동/메모리 제공 → 에이전트 구축의 핵심
다중 턴 도구 사용, 복잡한 요청 스키마, 응답 유효성 검사 등 커스텀 에이전트의 API 상호작용을 테스트·디버깅하고 싶다면 Apidog를 무료로 사용해 보세요. API 디버깅에 집중하여 에이전트 로직에만 몰입할 수 있습니다.
자주 묻는 질문 (FAQ)
Claude Code 유출에서 얻은 패턴을 합법적으로 사용할 수 있나요?
유출은 독점 알고리즘이 아닌 아키텍처 패턴을 공개했습니다. 도구 디스패치 기반 while-루프 코딩 에이전트는 Anthropic 공식 문서에도 나오는 표준 패턴입니다. 코드를 복사해서는 안 되지만, 자신만의 구현은 자유롭게 할 수 있습니다.
DIY 코딩 에이전트에 어떤 모델을 써야 하나요?
Claude Sonnet 4.6은 속도와 기능 균형이 좋습니다. Opus 4.6은 더 높은 품질, 느린 속도, 높은 비용. 간단한 작업은 Haiku 4.5(저렴함)로도 충분합니다.
자체 코딩 에이전트 실행 비용은?
Claude Sonnet 4.6 기준, 30~50턴 세션은 API 비용 $1~5 수준. 컨텍스트 창 크기가 가장 큰 비용 요인입니다. 적극적인 압축이 비용 절감에 효과적입니다.
Claude Code는 왜 터미널 앱에 React를 쓰나요?
Ink(React 기반 터미널)는 권한 대화, 스트리밍 출력, 도구 호출 표시 등 복잡 UI를 React 컴포넌트/상태 관리로 구현하기 위함입니다. DIY 프로젝트라면 간단한 input()/print() REPL이면 충분합니다.
핵심 루프 다음에 꼭 추가해야 할 기능은?
권한 시스템입니다. 없으면 모델이 파일을 덮어쓰거나 임의 명령 실행 가능. "쓰기/실행 전 확인"만으로도 대부분의 사고를 방지할 수 있습니다.
Claude Code는 도구 호출 오류를 어떻게 처리하나요?
도구 오류는 tool_result 메시지에 텍스트로 반환됩니다. 모델이 오류를 보고 재시도·우회·사용자 문의 중 선택합니다. 별도 오류 로직 없이 모델 추론에 맡깁니다.
Claude 외 다른 모델에서도 이 아키텍처를 쓸 수 있나요?
네. 함수 호출 지원 모델(GPT-4, Gemini, Llama 등)에서 적용 가능. API 호출 포맷만 맞추면, 에이전트 루프·도구·메모리 구조는 동일하게 활용 가능합니다.
에이전트가 위험한 명령 실행을 막으려면?
rm -rf /, mkfs 등 위험 패턴 차단, 모든 run_command 호출에 명시적 승인 요구. LOW/MEDIUM/HIGH 위험 분류 후 차단/프롬프트 처리. 직접 만든 도구에도 동일하게 구현하세요.

Top comments (0)