- Published on
Claude Tool Use JSON 스키마 불일치 오류 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Claude Tool Use를 붙이다 보면 가장 당황스러운 순간이 있습니다. 모델이 툴을 “호출하긴 했는데”, 실제로 내려온 JSON이 내가 정의한 스키마와 미묘하게 어긋나서 파서가 터지는 경우입니다. 특히 프로덕션에서는 이 문제가 단발성으로 끝나지 않고, 특정 프롬프트/특정 입력에서만 재현되는 식으로 나타나 디버깅 시간을 크게 잡아먹습니다.
이 글은 Claude Tool Use에서 발생하는 JSON 스키마 불일치 오류를 대표적인 패턴으로 나누고, 각 패턴을 스키마 설계 단계에서 예방하는 방법과 런타임에서 방어적으로 처리하는 방법을 함께 정리합니다.
오류가 발생하는 지점 정리
Tool Use 흐름을 단순화하면 아래처럼 볼 수 있습니다.
- 개발자가 tool 정의(이름, 설명, input_schema)를 모델에 전달
- 모델이 tool 호출을 결정하고 tool input JSON을 생성
- 애플리케이션이 JSON을 파싱하고 스키마 검증
- 검증 성공 시 실제 함수 실행
스키마 불일치 오류는 대개 3번에서 발생합니다. 원인은 크게 두 갈래입니다.
- 스키마가 모델에게 “애매하게” 전달되어 모델이 다르게 해석
- 스키마는 명확하지만, 모델 출력이 “형식적으로” 깨짐
후자는 리트라이로 완화할 수 있지만, 전자는 구조를 바꾸지 않으면 계속 재발합니다.
대표적인 불일치 유형 8가지와 해결법
1) 필수 필드 누락: required를 믿지 못하는 케이스
증상
- 스키마에
required로 지정했는데도 모델이 해당 키를 빼먹음
해결
- 스키마에서
required를 지정하는 것만으로는 부족할 때가 있습니다. 특히 모델이 “없어도 되는 값”으로 판단하면 생략하는 경향이 있습니다. - 해결은 두 단계로 합니다.
- 스키마: 필수 필드는
required+description에서 없을 때의 처리 규칙을 명시 - 런타임: 누락 시 수정 리트라이를 하거나 기본값을 서버에서 채우기
- 스키마: 필수 필드는
수정 리트라이 프롬프트 예시(개념)
const repairInstruction = {
role: "user",
content: "도구 입력 JSON이 스키마와 불일치합니다. 누락된 필수 필드를 채워서 JSON만 다시 출력하세요."
};
2) 타입 불일치: 숫자가 문자열로 오는 문제
증상
integer인데"3"같은 문자열이 옴boolean인데"true"로 옴
해결
- 모델은 자연어 생성기라서 “보이는 값”을 우선합니다. 따라서 스키마의 타입을 엄격히 강제하려면 다음이 효과적입니다.
description에서 예시를 타입에 맞게 제시- 가능하면 숫자/불리언은 문자열로 받지 말고 서버에서 변환하지 않도록 강제
- 그래도 흔들린다면, 서버에서 **coercion(강제 변환)**을 허용하는 검증기를 사용
Node.js에서 zod로 coercion을 적용하는 예시
import { z } from "zod";
const ToolInputSchema = z.object({
userId: z.coerce.number().int(),
includeInactive: z.coerce.boolean().default(false),
});
type ToolInput = z.infer<typeof ToolInputSchema>;
export function parseToolInput(input: unknown): ToolInput {
return ToolInputSchema.parse(input);
}
주의
- coercion은 편하지만, 잘못된 값도 통과시키는 부작용이 있습니다. 로그에 원본 값을 남기고, 일정 비율 이상이면 프롬프트/스키마 개선으로 되돌아가야 합니다.
3) additionalProperties 미설정으로 인한 “쓸데없는 키” 폭주
증상
- 모델이 친절하게
reason,notes,debug같은 키를 추가 - 검증기가 엄격하면 실패
해결
- 엄격 검증을 원한다면 스키마에
additionalProperties: false를 명시하고, 모델에게도 “정의된 키만” 쓰라고 적어야 합니다. - 반대로 확장 가능성을 열어두고 싶다면
additionalProperties: true로 두되, 서버에서 사용하지 않는 키를 무시하도록 설계합니다.
스키마 예시
{
"type": "object",
"properties": {
"query": { "type": "string" },
"limit": { "type": "integer", "minimum": 1, "maximum": 50 }
},
"required": ["query"],
"additionalProperties": false
}
4) oneOf / anyOf에서 모델이 중간 형태를 생성
증상
- A 또는 B여야 하는데, A와 B를 섞어 “둘 다 비슷하게” 만들어 버림
해결
- 복잡한 조합 스키마는 모델에게 매우 어렵습니다. 가능하면 아래 전략 중 하나를 택합니다.
- 툴을 둘로 쪼개기(각각 단일 스키마)
mode같은 discriminator 필드를 두고,mode값에 따라 나머지 필드가 결정되도록 단순화
discriminator 패턴 예시
{
"type": "object",
"properties": {
"mode": { "type": "string", "enum": ["by_id", "by_email"] },
"id": { "type": "integer" },
"email": { "type": "string" }
},
"required": ["mode"],
"additionalProperties": false
}
런타임에서는 mode를 보고 id 또는 email 필수 검증을 추가로 수행합니다.
5) 배열/객체 중첩에서 구조가 한 단계 밀리는 문제
증상
items가 배열이어야 하는데 객체로 옴{ items: [...] }여야 하는데 그냥[...]만 옴
해결
- 중첩이 깊을수록 모델이 구조를 놓칩니다. 해결책은 “중첩을 줄이고, 이름을 더 직관적으로” 만드는 것입니다.
- 또한
description에 완전한 JSON 예시를 넣는 것이 효과가 큽니다.
예시를 포함한 스키마 설계 팁
description에 “반드시 최상위는 객체이며 키는items하나만 포함” 같은 강한 제약을 문장으로도 반복
6) 날짜/시간 포맷 불일치: ISO 8601 강제 실패
증상
"2026-2-5","tomorrow","2026/02/05"처럼 다양한 포맷
해결
- 스키마에서
format: "date-time"만으로는 부족할 수 있습니다. - 실무에서는 다음 중 하나로 갑니다.
- 서버에서 파싱 가능한 포맷을 넓게 받고 정규화
- 또는 입력 자체를
timestamp_ms같은 정수로 바꿔서 포맷 문제를 제거
정수 타임스탬프 스키마 예시
{
"type": "object",
"properties": {
"timestamp_ms": { "type": "integer", "minimum": 0 }
},
"required": ["timestamp_ms"],
"additionalProperties": false
}
7) null 처리: optional과 nullable을 혼동
증상
- 선택 필드인데
null로 내려오거나, 반대로null을 허용해야 하는데 누락으로 옴
해결
- “없음”을 표현하는 방식을 한 가지로 통일합니다.
- 선택 필드는 아예 키를 생략
null은 특별한 의미가 있을 때만 허용
- JSON Schema에서
null허용은 보통type을 배열로 둡니다.
예시
{
"type": "object",
"properties": {
"nickname": { "type": ["string", "null"] }
},
"additionalProperties": false
}
8) 문자열 enum 오타: 대소문자/언더스코어 흔들림
증상
"IN_PROGRESS"여야 하는데"in progress"로 옴
해결
- enum은 모델이 자주 틀리는 영역입니다.
- 다음을 추천합니다.
- enum 값을 짧고 단순하게(
"in_progress"같은 snake_case) description에 “반드시 아래 enum 중 하나만”을 반복- 서버에서
toLowerCase같은 보정은 가능하지만, 의미가 바뀔 수 있어 신중
- enum 값을 짧고 단순하게(
스키마 설계 체크리스트
다음 체크리스트는 “한 번에 맞는 JSON” 확률을 크게 올립니다.
- 최상위는 항상
object로 고정 additionalProperties: false를 기본으로 고려required는 최소화하되, 필수 값은 설명에서 “없으면 안 되는 이유”까지 명시oneOf/anyOf대신 discriminator 패턴 고려- 날짜는 문자열 포맷 대신 정수 타임스탬프 고려
- enum은 짧고 규칙적인 토큰 사용
- 중첩을 줄이고, 배열/객체 구조를 단순화
스키마 문제는 본질적으로 “계약(contract)” 문제라서, HTTP 레이어에서 미디어 타입이 어긋나면 바로 깨지는 것과 유사합니다. API 계약을 엄격히 맞추는 관점은 아래 글과도 결이 같습니다.
런타임 방어: 검증, 로깅, 리트라이 전략
스키마를 잘 만들어도 100%는 아닙니다. 그래서 런타임 방어가 필요합니다.
1) 검증기는 “에러 메시지 품질”이 중요
- 어떤 키가 누락/오타/타입 불일치인지 사람이 바로 알 수 있어야 합니다.
- Node.js라면
ajv(JSON Schema) 또는zod(TS 친화)를 많이 씁니다.
ajv 예시
import Ajv from "ajv";
const ajv = new Ajv({ allErrors: true, strict: true });
const schema = {
type: "object",
properties: {
query: { type: "string" },
limit: { type: "integer", minimum: 1, maximum: 50 }
},
required: ["query"],
additionalProperties: false
} as const;
const validate = ajv.compile(schema);
export function assertToolInput(input: unknown) {
const ok = validate(input);
if (!ok) {
const details = validate.errors?.map(e => ({
instancePath: e.instancePath,
message: e.message,
keyword: e.keyword
}));
throw new Error(`Tool input schema mismatch: ${JSON.stringify(details)}`);
}
}
2) “수정 리트라이”는 단순 재시도와 다르게 설계
단순 재시도는 같은 실수를 반복할 수 있습니다. 수정 리트라이는 아래 정보를 포함해야 합니다.
- 어떤 스키마를 기대했는지(요약)
- 어떤 점이 틀렸는지(검증 에러)
- 출력은 “JSON만” 허용
주의
- 이때도 본문 텍스트에서 부등호가 섞인 표현을 쓰기 쉬운데, 운영 문서/프롬프트를 MDX로 관리한다면
->같은 기호도 인라인 코드로 감싸는 습관이 안전합니다.
3) 관측 가능성: 원본 tool input을 반드시 저장
- 실패 케이스에서 원본 JSON, 검증 에러, 사용자 입력, 모델 설정(temperature 등)을 함께 남겨야 재현이 됩니다.
- 단, 개인정보/비밀정보는 마스킹합니다.
4) 스키마 변경은 버전으로 관리
- 스키마를 바꾸면 이전 대화/캐시/리플레이에서 깨질 수 있습니다.
tool_name_v2처럼 이름에 버전을 붙이거나, 내부적으로 버전 필드를 둡니다.
실전 예시: “검색 툴” 스키마를 불일치에 강하게 만들기
문제 상황
- 모델이
limit를 문자열로 주거나,filters에 예상치 못한 키를 추가
개선 목표
limit는 coercion으로 수용(서버)filters는 허용된 키만
스키마(개념)
{
"type": "object",
"properties": {
"query": { "type": "string", "minLength": 1 },
"limit": { "type": "integer", "minimum": 1, "maximum": 20 },
"filters": {
"type": "object",
"properties": {
"category": { "type": "string" },
"published": { "type": "boolean" }
},
"additionalProperties": false
}
},
"required": ["query"],
"additionalProperties": false
}
서버 파서(zod로 현실 타협)
import { z } from "zod";
const SearchInput = z.object({
query: z.string().min(1),
limit: z.coerce.number().int().min(1).max(20).default(10),
filters: z
.object({
category: z.string().optional(),
published: z.coerce.boolean().optional(),
})
.strict()
.optional(),
}).strict();
export type SearchInputType = z.infer<typeof SearchInput>;
export function parseSearchInput(input: unknown): SearchInputType {
return SearchInput.parse(input);
}
이 조합의 요지는 “모델이 흔히 틀리는 부분만 제한적으로 관대하게 받고, 나머지는 엄격하게 막는 것”입니다.
디버깅 관점: 스키마 불일치를 ‘데이터 품질 문제’로 다루기
이 문제는 단순히 LLM이 말을 잘못한 게 아니라, 데이터 파이프라인에서 dtype이 섞여 터지는 상황과 유사합니다. 즉, 입력이 불규칙할수록 검증/정규화 계층이 필요합니다.
또한 스키마가 복잡해질수록 “한 번에 완벽한 구조”를 기대하기보다, 단계적으로 단순화하고 관측하면서 개선하는 게 빠릅니다.
마무리: 재발 방지의 핵심은 3가지
- 스키마 단순화:
oneOf같은 복잡도를 줄이고 discriminator/툴 분리로 정리 - 엄격함의 위치 선택: 모델 출력은 부분적으로 관대하게, 비즈니스 로직 직전은 엄격하게
- 수정 리트라이 + 로깅: 검증 에러를 근거로 JSON만 다시 생성하게 하고, 원본을 남겨 패턴을 학습
위 3가지를 적용하면 “가끔 깨지는” 수준에서 “거의 안 깨지는” 수준까지 안정화할 수 있습니다. Tool Use는 결국 계약 기반 통합이므로, 스키마를 제품의 API 계약서처럼 다루는 순간부터 오류가 눈에 띄게 줄어듭니다.