
Claude Agent 서버를 실제로 운영하면, 생각보다 다양한 방식으로 에러가 발생합니다.
❌ Query 실패: {"error": "Check stderr output for details", "type": "ProcessError"}
이게 대체 무슨 뜻인지, 어떻게 대응해야 하는지 모르겠죠? SDK가 던지는 에러 메시지가 막연하기 때문입니다.
이 글에서는 claude_agent_sdk가 던지는 모든 에러 타입을 분석하고, 각 에러가 언제, 왜 발생하는지, 그리고 어떻게 대응해야 하는지 정리합니다.
SDK의 모든 에러는 ClaudeSDKError를 상속합니다.
ClaudeSDKError
├── CLIConnectionError ─── CLI와의 연결 문제
│ └── CLINotFoundError ── CLI 바이너리를 못 찾음
├── ProcessError ────────── CLI 프로세스가 비정상 종료
├── CLIJSONDecodeError ──── CLI 출력을 JSON으로 못 읽음
└── MessageParseError ───── 메시지 구조 파싱 실패
하나씩 뜯어보겠습니다.
SDK는 내부적으로 Claude Code CLI를 subprocess로 실행합니다. CLI 바이너리를 찾지 못하면 이 에러가 발생합니다.
SDK가 CLI를 찾는 순서:
1. shutil.which("claude") — PATH에서 검색
2. SDK 번들 CLI
3. ~/.npm-global/bin/
4. /usr/local/bin/
5. ~/.local/bin/
6. ~/node_modules/.bin/
7. ~/.yarn/bin/
8. ~/.claude/local/
전부 실패하면 CLINotFoundError가 발생합니다.
# 1. 아예 CLI를 못 찾은 경우
CLINotFoundError("Claude Code not found. Install it with: npm install -g @anthropic-ai/claude-code")
# 2. 옵션에 지정한 경로에 CLI가 없는 경우
CLINotFoundError("Claude Code not found at: /custom/path/claude")
# 3. connect() 전에 명령을 실행하려 한 경우
CLINotFoundError("CLI path not resolved. Call connect() first.")
# 이것만으로는 부족합니다!
RUN pip install claude-agent-sdk
# Claude Code CLI는 Node.js 패키지입니다.
# Node.js를 설치해야 SDK가 내부적으로 CLI를 실행할 수 있습니다.
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs
from claude_agent_sdk import CLINotFoundError
try:
async for message in query(prompt="안녕", options=options):
pass
except CLINotFoundError as e:
# CLI 설치 상태 확인
import shutil
cli_path = shutil.which("claude")
if cli_path is None:
print("Claude Code CLI가 설치되지 않았습니다.")
print("설치: npm install -g @anthropic-ai/claude-code")
else:
print(f"CLI는 {cli_path}에 있지만 실행할 수 없습니다.")
print("Node.js가 설치되어 있는지 확인하세요.")
CLI 바이너리는 찾았지만, 프로세스를 시작하거나 통신하는 데 실패한 경우입니다. CLINotFoundError의 부모 클래스이기도 합니다.
# 1. 작업 디렉토리가 존재하지 않음
CLIConnectionError("Working directory does not exist: /app/workspace")
# 2. 프로세스 시작 실패 (권한 문제, 메모리 부족 등)
CLIConnectionError("Failed to start Claude Code: [에러 내용]")
# 3. 이미 종료된 프로세스에 쓰기 시도
CLIConnectionError("Cannot write to terminated process (exit code: 1)")
# 4. 이전 에러로 프로세스가 죽은 상태에서 쓰기 시도
CLIConnectionError("Cannot write to process that exited with error: [에러 내용]")
# 5. stdin 쓰기 실패
CLIConnectionError("Failed to write to process stdin: [에러 내용]")
# 6. 연결되지 않은 상태에서 읽기 시도
CLIConnectionError("Not connected")
# 7. ClaudeSDKClient에서 connect() 전에 메서드 호출
CLIConnectionError("Not connected. Call connect() first.")
케이스 1: 작업 디렉토리 미생성
# Dockerfile에서 workspace를 만들지 않으면
# cwd="/app/workspace"로 설정했을 때 에러 발생
RUN mkdir -p /app/workspace && chown -R claude:claude /app/workspace
케이스 2: 권한 문제
# 비루트 사용자로 전환 후, CLI 실행 권한이 없는 경우
USER claude
# → Claude CLI가 root 권한으로만 설치되어 있으면 실행 불가
from claude_agent_sdk import CLIConnectionError, CLINotFoundError
try:
async for message in query(prompt="안녕", options=options):
pass
except CLINotFoundError:
# CLI 설치 문제 — 위의 대응 참고
raise
except CLIConnectionError as e:
error_msg = str(e)
if "Working directory does not exist" in error_msg:
print("작업 디렉토리를 생성하세요.")
elif "terminated process" in error_msg:
print("CLI 프로세스가 죽었습니다. 인증 토큰을 확인하세요.")
elif "Failed to start" in error_msg:
print("CLI 실행 실패. 권한과 환경을 확인하세요.")
else:
print(f"연결 에러: {e}")
CLI subprocess가 0이 아닌 exit code로 종료된 경우입니다. 가장 자주 마주치는 에러이면서, 동시에 가장 디버깅하기 어려운 에러입니다.
@dataclass
class ProcessError(ClaudeSDKError):
exit_code: int | None # 프로세스 종료 코드
stderr: str | None # 표준 에러 출력
안타깝게도, SDK가 실제로 전달하는 stderr는 대부분 이렇습니다:
ProcessError("Command failed with exit code 1", exit_code=1, stderr="Check stderr output for details")
"Check stderr output for details"는 하드코딩된 문자열입니다. 실제 stderr 내용이 아닙니다.
| Exit Code | 흔한 원인 |
|---|---|
1 | 인증 실패, 온보딩 미완료, 일반 에러 |
2 | 잘못된 CLI 인자 |
137 | 메모리 초과로 OOM Killer에 의해 종료 |
143 | SIGTERM으로 종료 |
원인 1: 온보딩 미완료 (exit code 1)
# Claude CLI는 첫 실행 시 대화형 온보딩을 시도
# Docker(TTY 없음)에서는 바로 죽음
# 해결: .claude.json에 온보딩 완료 플래그 설정
RUN echo '{"hasCompletedOnboarding": true}' > /home/claude/.claude.json
원인 2: 인증 실패 (exit code 1)
OAuth 토큰이 없거나 만료된 경우. entrypoint.sh에서 토큰을 .claude.json에 주입해야 합니다.
원인 3: 메모리 부족 (exit code 137)
# docker-compose.yml에서 메모리 제한이 너무 낮은 경우
deploy:
resources:
limits:
memory: 512M # ← 너무 낮음, 최소 2G 권장
ProcessError는 진짜 원인을 알기 어렵기 때문에, 환경 정보를 최대한 수집하는 것이 핵심입니다.
from claude_agent_sdk import ProcessError
import os
try:
async for message in query(prompt="안녕", options=options):
pass
except ProcessError as e:
# exit_code로 1차 분류
if e.exit_code == 1:
# 인증 / 온보딩 / 일반 에러 → 환경 점검
checks = {
"토큰": "SET" if os.getenv("CLAUDE_CODE_OAUTH_TOKEN") else "NOT SET",
"홈": os.getenv("HOME"),
"Node.js": os.popen("node --version 2>&1").read().strip(),
".claude.json": os.path.exists(
os.path.expanduser("~/.claude.json")
),
}
print(f"ProcessError (exit={e.exit_code})")
for k, v in checks.items():
print(f" {k}: {v}")
elif e.exit_code == 137:
print("메모리 부족 (OOM). docker-compose.yml의 memory 제한을 늘리세요.")
else:
print(f"CLI 프로세스 에러 (exit={e.exit_code}): {e}")
CLI의 stdout 출력을 JSON으로 파싱하지 못한 경우입니다. 주로 메시지가 너무 클 때 발생합니다.
@dataclass
class CLIJSONDecodeError(ClaudeSDKError):
line: str # 파싱 실패한 원본 텍스트 (앞 100자)
original_error: Exception # 원래 발생한 에러
# CLI 출력이 max_buffer_size를 초과한 경우
CLIJSONDecodeError(
line="JSON message exceeded maximum buffer size of 104857600 bytes",
original_error=ValueError(...)
)
기본 버퍼 크기는 100MB입니다. 초과하는 경우는 드물지만, 에이전트가 매우 큰 파일을 읽거나 긴 명령 출력을 처리할 때 발생할 수 있습니다.
from claude_agent_sdk import CLIJSONDecodeError
try:
async for message in query(prompt="큰 파일 분석해줘", options=options):
pass
except CLIJSONDecodeError as e:
print(f"JSON 파싱 실패: {e.line[:100]}")
print("도구 출력이 너무 클 수 있습니다.")
print("max_buffer_size를 늘리거나, 작업 범위를 줄여보세요.")
# 버퍼 크기 조절
options = ClaudeAgentOptions(
max_buffer_size=200 * 1024 * 1024, # 200MB로 확장
)
CLI에서 받은 JSON은 정상이지만, 기대하는 메시지 구조와 다른 경우입니다.
@dataclass
class MessageParseError(ClaudeSDKError):
data: dict[str, Any] | None # 파싱 실패한 원본 데이터
# 1. 데이터가 dict가 아닌 경우
MessageParseError("Invalid message data type (expected dict, got str)")
# 2. type 필드가 없는 경우
MessageParseError("Message missing 'type' field")
# 3. 필수 필드 누락 (메시지 타입별)
MessageParseError("Missing required field in assistant message: 'content'")
MessageParseError("Missing required field in result message: 'session_id'")
이 에러는 주로 SDK와 CLI의 버전 불일치 때 발생합니다.
from claude_agent_sdk import CLIJSONDecodeError
try:
async for message in query(prompt="안녕", options=options):
pass
except Exception as e:
if "MessageParseError" in type(e).__name__ or "Missing required field" in str(e):
print("메시지 파싱 실패 — SDK와 CLI 버전을 확인하세요.")
print(f"원본 데이터: {getattr(e, 'data', 'N/A')}")
# 버전 확인
import claude_agent_sdk
print(f"SDK 버전: {claude_agent_sdk.__version__}")
print(f"CLI 버전: {os.popen('claude --version 2>&1').read().strip()}")
SDK에는 에러가 전달되는 경로가 2개 있습니다. 이 구분을 모르면 에러를 놓칩니다.
위에서 다룬 5가지 에러입니다. try/except로 잡습니다. 주로 연결, 프로세스, 파싱 단계에서 발생합니다.
try:
async for message in query(prompt="...", options=options):
pass
except ClaudeSDKError as e:
print(f"SDK 에러: {e}")
Claude가 정상적으로 응답하지만, 그 응답 자체가 에러를 포함하는 경우입니다. 예외는 발생하지 않으므로, 메시지를 직접 확인해야 합니다.
async for message in query(prompt="...", options=options):
# AssistantMessage.error — 인증 실패, 레이트 리밋 등
if isinstance(message, AssistantMessage) and message.error:
print(f"응답 에러: {message.error}")
# → "authentication_failed", "rate_limit", "billing_error" 등
# ResultMessage.is_error — 전체 실행이 에러로 종료
if isinstance(message, ResultMessage) and message.is_error:
print(f"실행 에러: {message.errors}")
# ToolResultBlock.is_error — 개별 도구 실행 실패
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, ToolResultBlock) and block.is_error:
print(f"도구 실패: {block.content}")
정리하면:
| 경로 | 발생 시점 | 잡는 방법 | 예시 |
|---|---|---|---|
| 예외 | 연결/프로세스/파싱 | try/except | CLI 미설치, 프로세스 죽음 |
AssistantMessage.error | 응답 생성 중 | 메시지 필드 확인 | 인증 만료, 레이트 리밋 |
ResultMessage.is_error | 실행 완료 시 | 메시지 필드 확인 | 전체 작업 실패 |
ToolResultBlock.is_error | 도구 실행 후 | 콘텐츠 블록 확인 | 파일 없음, 명령 에러 |
RateLimitEvent | 사용량 변화 시 | 메시지 타입 확인 | 한도 접근/초과 |
모든 에러 경로를 한 곳에서 처리하는 프로덕션 레벨의 예시입니다.
import logging
import os
from claude_agent_sdk import (
query,
ClaudeAgentOptions,
# 예외 타입
ClaudeSDKError,
CLIConnectionError,
CLINotFoundError,
ProcessError,
CLIJSONDecodeError,
# 메시지 타입
AssistantMessage,
ResultMessage,
RateLimitEvent,
TextBlock,
ToolResultBlock,
)
logger = logging.getLogger(__name__)
async def safe_query(prompt: str) -> str:
"""에러를 빠짐없이 처리하는 쿼리 함수"""
options = ClaudeAgentOptions(
permission_mode="bypassPermissions",
allowed_tools=["Read", "Write", "Edit", "Bash"],
cwd="/app/workspace",
max_turns=20, # 무한 루프 방지
)
result_text = ""
try:
async for message in query(prompt=prompt, options=options):
# ── 경로 2a: 응답 내 에러 ──
if isinstance(message, AssistantMessage):
if message.error:
logger.error(f"응답 에러: {message.error}")
if message.error == "authentication_failed":
return "인증이 만료되었습니다. 토큰을 재발급하세요."
elif message.error == "rate_limit":
return "사용량 한도에 도달했습니다. 잠시 후 다시 시도하세요."
else:
return f"Claude 에러: {message.error}"
for block in message.content:
if isinstance(block, TextBlock):
result_text += block.text
elif isinstance(block, ToolResultBlock) and block.is_error:
logger.warning(f"도구 실패: {block.content}")
# ── 경로 2b: 레이트 리밋 ──
elif isinstance(message, RateLimitEvent):
info = message.rate_limit_info
if info.status == "rejected":
return "사용량 한도를 초과했습니다."
elif info.status == "allowed_warning":
logger.warning(f"사용량 경고: {info.utilization * 100:.0f}%")
# ── 경로 2c: 최종 결과 ──
elif isinstance(message, ResultMessage):
if message.is_error:
logger.error(f"실행 에러: {message.errors}")
return f"실행 중 에러가 발생했습니다: {message.errors}"
if message.result:
result_text = message.result
# ── 경로 1: 예외 ──
except CLINotFoundError:
logger.critical("Claude Code CLI가 설치되지 않았습니다.")
return "서버 설정 오류: CLI 미설치"
except ProcessError as e:
logger.error(f"ProcessError (exit={e.exit_code}): {e}")
if e.exit_code == 137:
return "서버 메모리가 부족합니다."
# 환경 진단 정보 로깅
logger.error(f"환경: TOKEN={'SET' if os.getenv('CLAUDE_CODE_OAUTH_TOKEN') else 'NOT SET'}, "
f"NODE={os.popen('node --version 2>&1').read().strip()}")
return "Claude 실행 중 오류가 발생했습니다."
except CLIConnectionError as e:
logger.error(f"연결 에러: {e}")
return "Claude 서비스에 연결할 수 없습니다."
except CLIJSONDecodeError as e:
logger.error(f"JSON 파싱 에러: {e.line[:100]}")
return "응답을 처리할 수 없습니다. 요청을 줄여서 다시 시도하세요."
except ClaudeSDKError as e:
# 기타 SDK 에러 (미래에 추가될 수 있는 타입 포함)
logger.error(f"SDK 에러: {type(e).__name__}: {e}")
return "알 수 없는 오류가 발생했습니다."
return result_text or "응답이 비어 있습니다."
현장에서 에러를 만났을 때, 빠르게 원인을 찾는 체크리스트입니다.
node --version)which claude)cli_path 옵션에 잘못된 경로를 넣지 않았는가?.claude.json에 hasCompletedOnboarding: true가 있는가?CLAUDE_CODE_OAUTH_TOKEN 환경변수가 설정되어 있는가?claude setup-token으로 재발급).claude.json에 토큰이 올바르게 주입되어 있는가?max_turns를 제한하여 무한 루프를 방지하고 있는가?cwd로 지정한 디렉토리가 실제로 존재하는가?max_buffer_size를 늘려야 하는 상황인가?SDK 에러 처리의 핵심은 두 가지 에러 경로를 모두 챙기는 것입니다:
try/except로 잡는 예외 — CLI 연결, 프로세스 실패특히 ProcessError의 stderr가 "Check stderr output for details"라는 무의미한 문자열인 점을 알아두세요. 진짜 원인은 환경 설정(토큰, Node.js, 온보딩)에 있는 경우가 대부분입니다.
전체 소스 코드는 GitHub에서 확인할 수 있습니다:
👉 GitHub 레포지토리 링크