- Published on
Claude Tool Use 400 오류 - JSON Schema 교정
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 Claude Tool Use를 붙이다 보면, 모델 응답이 아니라 API가 곧바로 400 Bad Request를 내는 순간이 있습니다. 이때 로그를 보면 대개 tools 정의나 input_schema가 JSON Schema 규칙을 어겼거나, 모델이 반환한 tool_input이 스키마와 불일치한 경우가 많습니다.
이 글은 “왜 400이 나는지”를 감으로 때려 맞추는 대신, JSON Schema를 교정하는 체크리스트와 실전용 스키마 템플릿, 그리고 로컬에서 선검증하는 코드까지 한 번에 정리합니다.
또한 운영 환경에서는 400이 연쇄적으로 재시도를 유발해 장애처럼 보일 수 있으니, 재시도 정책도 함께 점검하는 편이 좋습니다. 비슷한 맥락으로 재시도 전략을 다룬 글로는 Gemini API 429 쿼터 초과 대응 - 재시도·백오프도 참고할 만합니다.
400 오류가 나는 지점 2가지
Claude Tool Use에서 400은 크게 두 갈래로 발생합니다.
요청 시점:
tools배열의 정의가 잘못되었거나input_schema가 유효한 JSON Schema가 아닌 경우실행 시점: 모델이 반환한
tool_use의input이 스키마를 만족하지 못하는데, 클라이언트가 그 입력을 그대로 실행하거나 후속 요청에 포함시키는 경우
둘 다 “스키마를 더 엄격하게”가 아니라, 스키마를 정확하게 만들고 검증을 자동화하면 해결됩니다.
가장 흔한 스키마 실수 체크리스트
아래는 현장에서 400을 가장 많이 만든 패턴들입니다.
1) type 누락 또는 잘못된 타입
JSON Schema는 properties만 적어두면 알아서 객체로 해석해주지 않습니다. 최상단에 type: "object"가 빠지면 실패할 수 있습니다.
또한 integer와 number, boolean과 string 같은 타입 불일치도 흔합니다.
2) required 누락으로 인한 애매함
모델은 “있으면 좋은 값”과 “반드시 필요한 값”을 구분하지 못하면 종종 생략합니다. 그 결과 서버에서 필수 값이 없어서 실패하거나, 후속 로직에서 NPE가 납니다.
따라서 정말 필요한 값은 required에 올려서 모델이 반드시 채우도록 유도해야 합니다.
3) additionalProperties 기본값 방치
JSON Schema의 additionalProperties 기본은 true입니다. 즉, 모델이 엉뚱한 키를 추가해도 통과합니다.
운영에서는 예상치 못한 키로 인해 로깅/DB 저장/캐시 키 생성 등이 꼬일 수 있으니, 가능하면 additionalProperties: false로 잠그고 시작하는 편이 안전합니다.
4) oneOf/anyOf 남발
유연해 보이지만 모델에게는 오히려 선택지가 많아져서 잘못된 형태로 반환할 확률이 올라갑니다. 정말 필요할 때만 쓰고, 가능하면 type과 enum으로 단순화하세요.
5) 배열 아이템 스키마 누락
type: "array"만 있고 items가 없으면, 모델이 문자열 배열인지 객체 배열인지 혼동합니다. items는 반드시 명시하세요.
6) 날짜/시간 포맷을 말로만 설명
설명(description)에 “ISO8601로 주세요”라고 써도 모델이 흔들립니다. format: "date-time" 등 포맷 힌트를 함께 주고, 예시를 description에 포함하세요.
문제 재현: 잘못된 Tool Schema 예시
아래는 얼핏 그럴듯하지만, 실제로는 400을 유발하거나 모델 출력이 자주 깨지는 스키마 예시입니다.
{
"name": "create_event",
"description": "캘린더 이벤트를 생성합니다.",
"input_schema": {
"properties": {
"title": { "type": "string" },
"startAt": { "type": "string" },
"durationMinutes": { "type": "number" }
}
}
}
문제점은 다음과 같습니다.
- 최상단
type: "object"가 없음 required가 없어 모델이title이나startAt을 생략할 수 있음startAt에format힌트가 없음durationMinutes는 보통 정수인데number로 열려 있음additionalProperties가 기본true라서 엉뚱한 키가 들어와도 통과
교정된 JSON Schema 템플릿
아래는 같은 목적을 “모델이 안정적으로 채울 수 있게” 교정한 버전입니다.
{
"name": "create_event",
"description": "캘린더 이벤트를 생성합니다.",
"input_schema": {
"type": "object",
"additionalProperties": false,
"properties": {
"title": {
"type": "string",
"minLength": 1,
"description": "이벤트 제목"
},
"startAt": {
"type": "string",
"format": "date-time",
"description": "이벤트 시작 시각. ISO 8601 예: 2026-02-24T09:30:00+09:00"
},
"durationMinutes": {
"type": "integer",
"minimum": 1,
"maximum": 1440,
"description": "이벤트 지속 시간(분)"
},
"timezone": {
"type": "string",
"description": "IANA 타임존 예: Asia/Seoul"
}
},
"required": ["title", "startAt", "durationMinutes"]
}
}
핵심은 4가지입니다.
type을 명시해 객체 스키마임을 확정required로 모델이 반드시 채워야 할 값을 고정additionalProperties: false로 키를 잠금format,minimum,enum같은 제약으로 출력 공간을 줄임
400을 줄이는 도구 설계 규칙
스키마는 “데이터 계약”이고, description은 “힌트”다
description은 모델에게 참고일 뿐이고, 실제 검증은 스키마가 합니다. 따라서 “문장으로 제약을 쓰는 것”보다 “스키마로 제약을 박는 것”이 더 중요합니다.
예를 들어 상태값은 다음처럼 enum으로 못 박는 편이 안전합니다.
{
"type": "string",
"enum": ["todo", "doing", "done"],
"description": "작업 상태"
}
숫자/통화/퍼센트는 단위를 스키마에 녹여라
모델이 price: 100을 반환했을 때, 원인지 달러인지 알 수 없으면 후속 로직이 흔들립니다. currency 필드를 분리하거나, description에 단위를 강제하고 minimum 같은 제약을 추가하세요.
배열은 items를 객체로 두고 minItems를 걸어라
{
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": { "type": "string", "minLength": 1 },
"qty": { "type": "integer", "minimum": 1 }
},
"required": ["id", "qty"]
}
}
이렇게 해야 모델이 items를 문자열로 뱉거나 빈 배열을 뱉는 경우를 줄일 수 있습니다.
로컬에서 선검증: Ajv로 스키마와 입력 검증하기
운영에서 400을 맞고 나서 고치는 것은 늦습니다. 배포 전에 아래처럼 스키마 자체와 모델이 준 tool input을 검증하는 테스트를 넣는 게 좋습니다.
Node.js 환경에서 Ajv를 쓰는 예시입니다.
import Ajv from "ajv";
import addFormats from "ajv-formats";
const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);
const inputSchema = {
type: "object",
additionalProperties: false,
properties: {
title: { type: "string", minLength: 1 },
startAt: { type: "string", format: "date-time" },
durationMinutes: { type: "integer", minimum: 1, maximum: 1440 }
},
required: ["title", "startAt", "durationMinutes"]
} as const;
const validate = ajv.compile(inputSchema);
const toolInput = {
title: "팀 주간 회의",
startAt: "2026-02-24T09:30:00+09:00",
durationMinutes: 60
};
if (!validate(toolInput)) {
console.error(validate.errors);
throw new Error("tool input schema validation failed");
}
이 검증을 통과한 입력만 실제 비즈니스 로직으로 넘기면, “모델 출력이 살짝 흔들려서 장애로 번지는” 상황을 크게 줄일 수 있습니다.
서버에서의 방어: 400은 재시도하지 말 것
400은 대부분 “내 요청이 잘못됐다”는 의미라서, 재시도해도 성공하지 않습니다. 그런데 일부 HTTP 클라이언트/큐 워커는 4xx도 무지성 재시도해서 로그와 트래픽을 폭발시킵니다.
400,401,403,404는 기본적으로 재시도 금지429,500,502,503,504만 지수 백오프로 재시도
이런 정책이 없으면 장애가 “무한 재시작/무한 리트라이” 형태로 보일 수 있습니다. 비슷한 패턴의 운영 진단은 systemd 서비스 자동 재시작 무한루프 진단 가이드도 참고하면 좋습니다.
실전 디버깅 루틴: 무엇을 로그로 남길까
400을 빠르게 고치려면, 아래 3가지를 한 세트로 남겨야 합니다.
- 전송한
tools정의 전체(JSON) - 문제가 된 tool의
input_schema - 모델이 반환한
tool_use.input원문(JSON)
그리고 “문제가 스키마인지, 입력인지”를 즉시 구분하기 위해 로컬 Ajv 검증을 같은 로그 컨텍스트에서 돌려보면 좋습니다.
추가로 프론트/서버가 Next.js 기반이라면, RSC 캐시나 라우트 핸들러 캐싱 때문에 “옛날 tool schema가 남아있는 것처럼” 보일 때도 있습니다. 이런 경우는 Next.js 15 RSC 캐시로 stale UI 뜰 때 해결법처럼 캐시 계층을 점검해 원인을 분리하세요.
자주 쓰는 교정 패턴 모음
패턴 1: 문자열이지만 사실상 ID
{
"type": "string",
"minLength": 1,
"pattern": "^[A-Za-z0-9_-]{6,64}$",
"description": "리소스 ID"
}
패턴 2: 페이지네이션
{
"type": "object",
"additionalProperties": false,
"properties": {
"cursor": { "type": "string" },
"limit": { "type": "integer", "minimum": 1, "maximum": 100 }
},
"required": ["limit"]
}
패턴 3: 정규화된 정렬 옵션
{
"type": "string",
"enum": ["createdAt_desc", "createdAt_asc", "name_asc", "name_desc"],
"description": "정렬 키"
}
마무리
Claude Tool Use의 400 오류는 “모델이 멍청해서”가 아니라, 대부분 스키마가 애매하거나 검증 체계가 없는 상태에서 운영으로 들어갔기 때문입니다.
- 스키마 최상단
type: "object"부터 확정 required와additionalProperties: false로 계약을 닫기enum,format,minimum등으로 출력 공간을 줄이기- Ajv 같은 검증기로 배포 전/런타임에 tool input을 선검증
- 400은 재시도하지 않도록 정책 분리
이 루틴을 적용하면, Tool Use 기반 에이전트의 안정성이 눈에 띄게 올라가고 “가끔 터지는” 운영 이슈가 대부분 사라집니다.