Published on

Claude Tool Use 400 에러 - JSON 스키마 해결법

Authors

서버에서 Claude Tool Use를 붙이다 보면, 모델 응답이 아니라 HTTP 400 (Bad Request) 로 바로 떨어지는 순간이 있습니다. 대개는 인증/쿼터 문제가 아니라, 요청 바디에 포함된 tool 정의의 JSON Schema가 Claude가 기대하는 형태와 어긋났을 때 발생합니다.

이 글에서는 “왜 400이 나는지”를 로그에서 빠르게 판별하고, 가장 자주 터지는 스키마 실수(특히 input_schema)를 검증 가능한 형태로 고치는 방법을 정리합니다. 마지막에는 운영에서 재발을 막는 테스트/가드레일까지 다룹니다.

> 외부 API 장애(503)처럼 재시도로 해결되는 유형이 아니라, 400은 대부분 요청 자체가 잘못된 것이라 재시도해도 계속 실패합니다. 장애 대응 관점은 OpenAI Responses API 503 멈춤 - 재시도·폴백 설계 글의 “재시도 가능한 실패 vs 불가능한 실패” 구분이 그대로 적용됩니다.

1) Claude Tool Use에서 400이 의미하는 것

Claude Tool Use 흐름은 간단히 말해:

  1. 클라이언트가 tools 배열에 도구 이름/설명 + 입력 JSON Schema 를 전달
  2. 모델이 필요 시 tool_use를 생성
  3. 서버가 tool을 실행하고 tool_result를 다시 모델에 전달

여기서 400은 보통 1단계(요청)에서 터집니다. 흔한 원인은:

  • tools[].input_schemaJSON Schema 규격을 만족하지 않음
  • 스키마 타입/필드가 Claude가 허용하는 subset과 불일치(예: type 누락)
  • required가 배열이 아닌 문자열, 또는 존재하지 않는 프로퍼티를 가리킴
  • additionalProperties/oneOf/anyOf 등 고급 키워드를 잘못 사용
  • tools[].name 규칙 위반(공백/특수문자/중복 등)

핵심은 “모델이 이해 못 해서”가 아니라 “서버가 요청을 거절” 한다는 점입니다.

2) 가장 흔한 스키마 실수 Top 7

2.1 input_schema.type 누락

JSON Schema에서 객체를 받으려면 보통 아래가 필요합니다.

{
  "type": "object",
  "properties": {
    "query": { "type": "string" }
  },
  "required": ["query"]
}

type이 빠지면(혹은 properties만 있고 type이 없으면) 400이 나는 케이스가 많습니다.

2.2 required 타입 오류

required는 반드시 문자열 배열이어야 합니다.

{
  "type": "object",
  "properties": { "q": { "type": "string" } },
  "required": "q" 
}

위처럼 문자열로 주면 실패합니다. 아래처럼 고치세요.

{
  "type": "object",
  "properties": { "q": { "type": "string" } },
  "required": ["q"]
}

2.3 required에 없는 프로퍼티를 넣음

{
  "type": "object",
  "properties": { "q": { "type": "string" } },
  "required": ["q", "page"]
}

pageproperties에 없으면 거절될 수 있습니다. 스키마를 정합하게 맞추세요.

2.4 properties 값이 스키마 객체가 아님

{
  "type": "object",
  "properties": {
    "q": "string"
  }
}

각 프로퍼티는 { "type": "string" } 같은 스키마 객체여야 합니다.

2.5 enum 타입 불일치

{
  "type": "object",
  "properties": {
    "mode": { "type": "string", "enum": ["fast", 1] }
  }
}

type이 string이면 enum도 문자열로 통일해야 합니다.

2.6 배열 스키마에서 items 누락/오류

{
  "type": "object",
  "properties": {
    "ids": { "type": "array" }
  }
}

items를 명시하는 편이 안전합니다.

{
  "type": "object",
  "properties": {
    "ids": { "type": "array", "items": { "type": "string" } }
  }
}

2.7 additionalProperties 처리 미흡

운영에서는 “모델이 스키마에 없는 필드를 넣어도 허용할 것인지”를 결정해야 합니다.

  • 엄격 모드: additionalProperties: false
  • 관대 모드: additionalProperties: true 또는 생략

엄격 모드로 갈 때는 required/properties 정합성이 더 중요해집니다.

3) (실전) 400을 재현하는 잘못된 Tool 정의 vs 정상 정의

아래는 의도적으로 400을 유발하기 쉬운 예시입니다.

3.1 잘못된 예

{
  "name": "search_docs",
  "description": "Search internal docs",
  "input_schema": {
    "properties": {
      "query": { "type": "string" }
    },
    "required": "query"
  }
}

문제점:

  • type: object 누락
  • required가 문자열

3.2 올바른 예

{
  "name": "search_docs",
  "description": "Search internal docs",
  "input_schema": {
    "type": "object",
    "properties": {
      "query": {
        "type": "string",
        "description": "검색어"
      },
      "limit": {
        "type": "integer",
        "minimum": 1,
        "maximum": 10,
        "default": 5
      }
    },
    "required": ["query"],
    "additionalProperties": false
  }
}

이 정도 형태가 가장 예측 가능하고 운영 친화적입니다.

4) Node.js(Typescript)에서 요청 구성: 최소 예제

아래는 “도구 정의 + 메시지”를 구성할 때 스키마를 안전하게 넣는 최소 예제입니다. (SDK 버전에 따라 필드명은 다를 수 있으니, 핵심은 tool input_schema가 유효한 JSON Schema여야 한다는 점입니다.)

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });

const tools = [
  {
    name: "search_docs",
    description: "Search internal docs",
    input_schema: {
      type: "object",
      properties: {
        query: { type: "string" },
        limit: { type: "integer", minimum: 1, maximum: 10, default: 5 }
      },
      required: ["query"],
      additionalProperties: false
    }
  }
] as const;

async function main() {
  const msg = await client.messages.create({
    model: "claude-3-5-sonnet-latest",
    max_tokens: 512,
    tools,
    messages: [
      {
        role: "user",
        content: "문서에서 'rate limit' 관련 항목을 찾아줘"
      }
    ]
  });

  console.log(JSON.stringify(msg, null, 2));
}

main().catch(console.error);

여기서 400이 난다면, 90%는 tools 정의(특히 input_schema)가 원인입니다.

5) Python에서 스키마를 사전 검증하기 (jsonschema)

운영에서 가장 좋은 해법은 “Claude에 보내기 전에” 스키마를 검증하는 것입니다. Python이라면 jsonschema로 최소한의 구조 검사를 할 수 있습니다.

from jsonschema import Draft202012Validator

TOOL_INPUT_SCHEMA = {
    "type": "object",
    "properties": {
        "query": {"type": "string"},
        "limit": {"type": "integer", "minimum": 1, "maximum": 10}
    },
    "required": ["query"],
    "additionalProperties": False,
}

# 스키마 자체가 유효한지(메타 스키마 기준) 확인
Draft202012Validator.check_schema(TOOL_INPUT_SCHEMA)
print("schema ok")

이 검증은 “스키마 문법이 JSON Schema로서 타당한가”를 확인합니다. 다만 Claude가 지원하는 subset/제약은 별개일 수 있으니, 아래 섹션의 가드레일도 같이 두는 게 좋습니다.

> Python 런타임/패키지 문제로 검증 코드가 깨질 때가 있는데, 특히 3.12에서 의존성이 꼬이면 원인 파악이 어려워집니다. 환경 이슈가 의심되면 Python 3.12에서 pkg_resources 에러 근본 해결처럼 먼저 툴체인부터 안정화하세요.

6) Claude가 싫어하는(혹은 위험한) 스키마 패턴과 대안

6.1 oneOf/anyOf/allOf 남용

고급 조합 스키마는 모델/서버 구현에 따라 예외가 생기기 쉽습니다. 가능하면 단순화하세요.

  • 나쁜 방향: oneOf로 복잡한 분기
  • 좋은 방향: type: object + mode enum + mode별 optional 필드 + 서버에서 추가 검증

6.2 깊게 중첩된 객체

중첩이 깊어질수록 모델이 생성하는 입력이 흔들리고, 스키마 불일치로 실패할 확률이 올라갑니다.

  • 대안: 1-depth 평탄화, 또는 문자열(JSON)로 받지 말고 구조를 단순화

6.3 날짜/시간 포맷을 format에만 의존

format: date-time을 넣더라도 모델이 항상 ISO-8601로 주지 않을 수 있습니다.

  • 대안: 스키마는 string으로 두고, 서버에서 엄격 파싱(예: datetime.fromisoformat) 후 에러 메시지를 명확히 반환

7) 400을 “디버깅 가능한 에러”로 바꾸는 로깅/테스트 전략

7.1 요청 바디에서 tools 정의를 그대로 남기기

개인정보/비밀정보를 제외하고, 최소한 아래는 로깅하세요.

  • model
  • tools[].name
  • tools[].input_schema (또는 해시 + 원문은 안전 저장소)

400은 재현성이 높기 때문에, 그 시점의 tool schema 스냅샷이 있으면 해결 시간이 급감합니다.

7.2 CI에서 “스키마 린트”를 돌리기

추천 체크리스트:

  • 모든 tool에 name/description/input_schema 존재
  • input_schema.type == object
  • required는 배열이며, 모든 항목이 properties에 존재
  • additionalProperties 정책이 팀 컨벤션과 일치

간단한 Typescript 런타임 체크 예:

type JSONSchema = any;

function assertToolSchema(schema: JSONSchema) {
  if (schema?.type !== "object") throw new Error("input_schema.type must be object");
  if (schema?.required && !Array.isArray(schema.required)) {
    throw new Error("input_schema.required must be an array");
  }
  if (Array.isArray(schema?.required)) {
    for (const k of schema.required) {
      if (!schema.properties?.[k]) throw new Error(`required key not in properties: ${k}`);
    }
  }
}

7.3 “모델이 만든 tool input”도 별도 검증

요청 스키마가 정상이어도, 모델이 생성한 tool input이 스키마를 어길 수 있습니다. 이건 400이 아니라 tool 실행 단계에서 터지므로, 서버에서 입력 검증을 반드시 하세요.

  • JSON Schema validator로 tool input 검증
  • 실패 시: tool_result로 명확한 에러 메시지 + 기대 형태를 반환

8) 운영 팁: 실패를 줄이는 스키마 설계 규칙

  • 필드는 적을수록 좋다(최소 입력)
  • 숫자는 minimum/maximum으로 범위를 좁힌다
  • 문자열은 가능하면 enum으로 제한한다
  • additionalProperties: false를 쓸 거면, optional 필드도 properties에 모두 선언한다
  • 에러 메시지는 “어떤 필드가 왜 잘못됐는지”를 한 줄로 끝내지 말고 예시를 포함한다

이 규칙은 분산 시스템의 “입력 계약”을 단단히 하는 접근과 비슷합니다. 실제로 MSA에서 중복 실행/보상 처리 버그를 막는 것처럼, 입력/상태 계약을 명확히 해야 장애가 줄어듭니다. 관련해서는 MSA 사가(Saga) 패턴 - 중복 실행·보상처리 버그 해결 글의 ‘계약/보상 설계’ 관점이 참고가 됩니다.

9) 체크리스트: Claude Tool Use 400이 나면 이것부터

  • tools[].input_schematype: object가 있는가?
  • required는 문자열 배열인가?
  • required의 모든 키가 properties에 존재하는가?
  • properties의 각 값이 { type: ... } 형태의 스키마 객체인가?
  • 배열이면 items가 있는가?
  • enum 타입이 type과 일치하는가?
  • additionalProperties 정책이 일관적인가?
  • tool name이 중복/규칙 위반이 아닌가?

마무리

Claude Tool Use의 400 에러는 대부분 “모델이 이상하다”가 아니라 “내가 보낸 tool schema가 계약을 어겼다”에 가깝습니다. 해결의 핵심은:

  1. input_schema를 단순하고 엄격하게 작성
  2. 전송 전 스키마 자체를 검증
  3. CI/런타임 가드레일로 재발을 차단

이 3가지만 갖춰도, 400 때문에 개발 흐름이 끊기는 일이 눈에 띄게 줄어듭니다.