Published on

Claude Tool Use JSON 스키마 불일치 오류 해결

Authors

Claude Tool Use를 붙이다 보면 가장 당황스러운 순간이 있습니다. 모델이 툴을 “호출하긴 했는데”, 실제로 내려온 JSON이 내가 정의한 스키마와 미묘하게 어긋나서 파서가 터지는 경우입니다. 특히 프로덕션에서는 이 문제가 단발성으로 끝나지 않고, 특정 프롬프트/특정 입력에서만 재현되는 식으로 나타나 디버깅 시간을 크게 잡아먹습니다.

이 글은 Claude Tool Use에서 발생하는 JSON 스키마 불일치 오류를 대표적인 패턴으로 나누고, 각 패턴을 스키마 설계 단계에서 예방하는 방법과 런타임에서 방어적으로 처리하는 방법을 함께 정리합니다.

오류가 발생하는 지점 정리

Tool Use 흐름을 단순화하면 아래처럼 볼 수 있습니다.

  1. 개발자가 tool 정의(이름, 설명, input_schema)를 모델에 전달
  2. 모델이 tool 호출을 결정하고 tool input JSON을 생성
  3. 애플리케이션이 JSON을 파싱하고 스키마 검증
  4. 검증 성공 시 실제 함수 실행

스키마 불일치 오류는 대개 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 같은 보정은 가능하지만, 의미가 바뀔 수 있어 신중

스키마 설계 체크리스트

다음 체크리스트는 “한 번에 맞는 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가지

  1. 스키마 단순화: oneOf 같은 복잡도를 줄이고 discriminator/툴 분리로 정리
  2. 엄격함의 위치 선택: 모델 출력은 부분적으로 관대하게, 비즈니스 로직 직전은 엄격하게
  3. 수정 리트라이 + 로깅: 검증 에러를 근거로 JSON만 다시 생성하게 하고, 원본을 남겨 패턴을 학습

위 3가지를 적용하면 “가끔 깨지는” 수준에서 “거의 안 깨지는” 수준까지 안정화할 수 있습니다. Tool Use는 결국 계약 기반 통합이므로, 스키마를 제품의 API 계약서처럼 다루는 순간부터 오류가 눈에 띄게 줄어듭니다.