API Key 없이 Claude Agent 서버 만들기! #4 - 실전에서 안 죽는 서버 만들기

조현상·2026년 4월 2일

ClaudeCode

목록 보기
12/17
post-thumbnail

들어가며

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 ───── 메시지 구조 파싱 실패

하나씩 뜯어보겠습니다.


1. CLINotFoundError — "Claude Code가 어디 있어?"

발생 조건

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.")

Docker에서 자주 발생하는 이유

# 이것만으로는 부족합니다!
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가 설치되어 있는지 확인하세요.")

2. CLIConnectionError — "CLI는 있는데 연결이 안 돼"

발생 조건

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.")

Docker에서 자주 발생하는 케이스

케이스 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}")

3. ProcessError — "CLI가 실행은 됐는데 비정상 종료했어"

발생 조건

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와 원인

Exit Code흔한 원인
1인증 실패, 온보딩 미완료, 일반 에러
2잘못된 CLI 인자
137메모리 초과로 OOM Killer에 의해 종료
143SIGTERM으로 종료

자주 발생하는 원인

원인 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}")

4. CLIJSONDecodeError — "CLI 출력을 읽을 수 없어"

발생 조건

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로 확장
)

5. MessageParseError — "메시지 구조가 이상해"

발생 조건

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()}")

에러 vs 메시지 내 에러: 두 가지 에러 경로

SDK에는 에러가 전달되는 경로가 2개 있습니다. 이 구분을 모르면 에러를 놓칩니다.

경로 1: 예외 (Exception)

위에서 다룬 5가지 에러입니다. try/except로 잡습니다. 주로 연결, 프로세스, 파싱 단계에서 발생합니다.

try:
    async for message in query(prompt="...", options=options):
        pass
except ClaudeSDKError as e:
    print(f"SDK 에러: {e}")

경로 2: 메시지 내 에러

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/exceptCLI 미설치, 프로세스 죽음
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 "응답이 비어 있습니다."

에러별 빠른 진단 체크리스트

현장에서 에러를 만났을 때, 빠르게 원인을 찾는 체크리스트입니다.

CLINotFoundError가 뜨면

  • Node.js가 설치되어 있는가? (node --version)
  • Claude Code CLI가 설치되어 있는가? (which claude)
  • Docker라면, Dockerfile에서 Node.js를 설치하고 있는가?
  • cli_path 옵션에 잘못된 경로를 넣지 않았는가?

ProcessError (exit code 1)가 뜨면

  • .claude.jsonhasCompletedOnboarding: true가 있는가?
  • CLAUDE_CODE_OAUTH_TOKEN 환경변수가 설정되어 있는가?
  • OAuth 토큰이 만료되지 않았는가? (claude setup-token으로 재발급)
  • .claude.json에 토큰이 올바르게 주입되어 있는가?

ProcessError (exit code 137)가 뜨면

  • Docker 메모리 제한이 2G 이상인가?
  • 호스트의 가용 메모리가 충분한가?
  • max_turns를 제한하여 무한 루프를 방지하고 있는가?

CLIConnectionError가 뜨면

  • cwd로 지정한 디렉토리가 실제로 존재하는가?
  • 현재 사용자에게 해당 디렉토리의 읽기/쓰기 권한이 있는가?
  • Docker라면, 볼륨 마운트가 올바른가?

CLIJSONDecodeError가 뜨면

  • 에이전트가 매우 큰 파일을 읽고 있지 않은가?
  • max_buffer_size를 늘려야 하는 상황인가?
  • SDK와 CLI 버전이 호환되는가?

마치며

SDK 에러 처리의 핵심은 두 가지 에러 경로를 모두 챙기는 것입니다:

  1. try/except로 잡는 예외 — CLI 연결, 프로세스 실패
  2. 메시지 내 에러 필드 — 인증, 레이트 리밋, 도구 실패

특히 ProcessErrorstderr"Check stderr output for details"라는 무의미한 문자열인 점을 알아두세요. 진짜 원인은 환경 설정(토큰, Node.js, 온보딩)에 있는 경우가 대부분입니다.

전체 소스 코드는 GitHub에서 확인할 수 있습니다:
👉 GitHub 레포지토리 링크

profile
꿈꾸는 개발자

0개의 댓글