- Published on
Claude Tool Use JSON 파싱 오류 5분 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Claude Tool Use를 붙이다 보면 가장 자주 만나는 에러가 JSON parsing error 류입니다. 체감상 “모델이 JSON을 잘못 뱉었다”로 끝나기 쉬운데, 실제 원인은 꽤 정형화되어 있습니다. 특히 Tool Use는 모델 출력이 곧바로 실행되는 경로로 이어지기 때문에, 파싱 실패는 곧바로 기능 장애로 번집니다.
이 글은 다음을 목표로 합니다.
- 5분 안에 파싱 오류를 재현 가능한 형태로 고정
- 원인을 4가지 유형으로 분류해서 즉시 조치
- Node.js 기준으로 안전한 파싱 코드 패턴 제공
또한 MDX 렌더링 환경에서 흔한 “출력에 섞인 특수문자” 문제도 함께 다룹니다.
1) 증상부터 고정하기: 실패한 원문을 반드시 저장
파싱 오류는 재현이 중요합니다. 스트리밍이든 비스트리밍이든, 실패한 응답의 원문 텍스트를 그대로 저장해두면 원인 파악이 80% 끝납니다.
- 요청 파라미터(모델, temperature, max tokens, tool schema)
- 응답 원문(가능하면 raw)
- 파서가 실패한 위치(오프셋, 라인, 컬럼)
Node.js 예시입니다.
import fs from "node:fs";
export function dumpForensics(label: string, payload: unknown) {
const file = `./forensics-${label}-${Date.now()}.json`;
fs.writeFileSync(file, JSON.stringify(payload, null, 2), "utf8");
return file;
}
여기서 핵심은 “에러 로그만 남기지 말고, 파싱 대상 문자열 자체를 남기는 것”입니다.
2) 원인 유형 4가지: 이거면 대부분 끝납니다
Claude Tool Use JSON 파싱 오류는 대개 아래 4가지 중 하나입니다.
유형 A. 스트리밍 조각을 JSON으로 바로 파싱
스트리밍 응답을 받는 동안 chunk를 이어 붙이기 전에 JSON.parse() 를 돌리면 당연히 깨집니다.
- 증상:
Unexpected end of JSON input - 특징: 응답이 길수록 빈도 증가
해결은 간단합니다.
- 스트리밍에서는 “최종 완성된 tool arguments” 이벤트만 파싱
- 또는 버퍼에 쌓아 완성 이후 파싱
let buffer = "";
for await (const chunk of stream) {
// chunk.delta.text 같은 필드를 buffer에 누적
buffer += chunkText(chunk);
}
const data = JSON.parse(buffer);
만약 SDK가 tool call을 구조화해서 주는 경우, 그 구조화된 필드만 신뢰하는 것이 정답입니다. 모델이 생성한 자유 텍스트를 억지로 JSON으로 만들려고 하면 실패 확률이 올라갑니다.
유형 B. JSON이 아닌 텍스트가 섞임(출력 오염)
모델이 친절하게 설명을 섞거나, 코드펜스, 프리픽스 문자열을 붙이면 JSON 파서가 깨집니다.
- 증상:
Unexpected token(대개 첫 글자에서 터짐) - 예:
Here is the JSON:같은 문장이 앞에 붙음
대응 전략은 두 가지입니다.
- 가장 권장: Tool Use에서는 tool arguments를 별도 필드로 받도록 설계하기
- 불가피할 때만: 문자열에서 JSON만 추출
간단한 “JSON만 추출” 유틸입니다.
export function extractFirstJsonObject(input: string): string {
const start = input.indexOf("{");
if (start === -1) throw new Error("No JSON object start");
let depth = 0;
for (let i = start; i < input.length; i++) {
const ch = input[i];
if (ch === "{") depth++;
if (ch === "}") depth--;
if (depth === 0) return input.slice(start, i + 1);
}
throw new Error("Unclosed JSON object");
}
export function safeParsePossiblyDirtyJson(input: string) {
const json = extractFirstJsonObject(input);
return JSON.parse(json);
}
주의: 이 방식은 문자열 안의 중괄호, 이스케이프 등을 완벽히 처리하지 못할 수 있습니다. 따라서 “임시 응급처치”로만 쓰고, 장기적으로는 tool call 구조를 신뢰하는 쪽으로 바꾸는 게 좋습니다.
유형 C. 스키마 불일치(타입은 맞는데 구조가 다름)
파싱은 성공했는데, 이후 로직에서 실패하는 케이스도 흔합니다.
required필드 누락- 배열이어야 하는데 문자열
- enum 값이 다름
- 숫자여야 하는데 문자열 숫자
이건 JSON.parse() 문제가 아니라 검증 부재 문제입니다. 해결책은 “파싱 다음에 스키마 검증”을 강제하는 것입니다.
Zod로 빠르게 잡는 예시입니다.
import { z } from "zod";
const ToolArgsSchema = z.object({
query: z.string().min(1),
limit: z.number().int().min(1).max(50).default(10),
sort: z.enum(["relevance", "recent"]).default("relevance"),
});
export function parseToolArgs(raw: string) {
const parsed = JSON.parse(raw);
return ToolArgsSchema.parse(parsed);
}
이 패턴으로 바꾸면 “파싱 오류”처럼 보이던 문제의 상당수가 schema validation error 로 바뀌고, 원인과 수정 포인트가 훨씬 명확해집니다.
TypeScript 빌드/타입 관련 이슈로 이어지는 경우도 많습니다. 특히 선언 추론이 꼬이면 런타임에서 엉뚱한 구조를 기대하게 됩니다. 관련해서는 TS 5.5 isolatedDeclarations 오류 해결 가이드도 같이 참고하면 좋습니다.
유형 D. JSON은 맞는데 유니코드/이스케이프/개행 처리 문제
드물지만 아래도 실제로 터집니다.
- 제어문자(예:
\u0000) 포함 - 문자열에 줄바꿈이 그대로 들어가 이스케이프가 깨짐
- 로그/전송 과정에서 인코딩이 훼손
대응은 “입력 문자열 정규화”와 “전송 레이어 점검”입니다.
export function normalizeJsonText(s: string) {
// 가장 흔한 문제: BOM, 이상한 제어문자 제거
return s
.replace(/^\uFEFF/, "")
.replace(/[\u0000-\u001F]+/g, (m) => (m === "\n" || m === "\r" || m === "\t" ? m : ""));
}
export function parseNormalizedJson(raw: string) {
return JSON.parse(normalizeJsonText(raw));
}
3) 5분 해결 체크리스트
실무에서 바로 쓰는 순서입니다.
1분: 에러 메시지로 유형 분기
Unexpected end of JSON input이면 유형 A 가능성 매우 큼Unexpected token H같은 형태면 유형 B 가능성 큼- 파싱은 되는데 이후 오류면 유형 C
2분: 실패 원문을 파일로 떨구고 눈으로 확인
- 앞뒤에 설명이 붙었나
- 코드펜스가 있나
- 마지막 중괄호가 닫혔나
1분: “tool arguments만 파싱”으로 경로 변경
가능하다면 “모델 텍스트”가 아니라 SDK가 제공하는 tool call arguments를 사용하세요.
- 텍스트는 사람용
- tool arguments는 기계용
1분: 스키마 검증 추가
JSON.parse() 다음 줄에 Zod 같은 검증을 붙이면, 재발률이 크게 떨어집니다.
4) 권장 아키텍처: 파싱과 실행을 분리
Tool Use에서 가장 위험한 패턴은 “파싱 성공 = 실행”입니다. 파싱이 되더라도 의도치 않은 값이 들어오면 사고가 납니다.
권장 흐름:
- raw 응답 저장
- JSON 파싱
- 스키마 검증
- 비즈니스 룰 검증(권한, 범위 제한, allowlist)
- 실행
이 패턴은 Kubernetes나 Terraform 같은 운영 자동화에서도 동일하게 중요합니다. 예를 들어 자동화가 잘못된 인자를 먹으면 장애로 직결됩니다. 운영 자동화 트러블슈팅 관점은 Kubernetes CrashLoopBackOff 원인 12가지와 진단 같은 글의 접근법과도 통합니다. “재현 가능한 로그 확보 → 원인 분기 → 안전장치 추가”가 핵심입니다.
5) 실전 예제: Express에서 Claude Tool Use 결과 안전 처리
아래는 “문자열 기반 JSON”이 들어오더라도 최대한 안전하게 처리하는 예시입니다.
import express from "express";
import { z } from "zod";
const app = express();
app.use(express.json());
const ArgsSchema = z.object({
userId: z.string().min(1),
action: z.enum(["read", "write"]),
resource: z.string().min(1),
});
function tryParseJson(raw: string) {
try {
return { ok: true as const, value: JSON.parse(raw) };
} catch (e) {
return { ok: false as const, error: e };
}
}
app.post("/tool-result", (req, res) => {
const raw = String(req.body?.toolArguments ?? "");
const parsed = tryParseJson(raw);
if (!parsed.ok) {
return res.status(400).json({
message: "Invalid JSON in tool arguments",
rawPreview: raw.slice(0, 200),
});
}
const validated = ArgsSchema.safeParse(parsed.value);
if (!validated.success) {
return res.status(422).json({
message: "Tool arguments schema mismatch",
issues: validated.error.issues,
});
}
// 비즈니스 룰 검증 예시: write는 특정 리소스만 허용
if (validated.data.action === "write" && validated.data.resource !== "notes") {
return res.status(403).json({ message: "Write not allowed for this resource" });
}
return res.json({ ok: true });
});
app.listen(3000);
핵심 포인트:
- 파싱 실패는
400, 스키마 불일치는422로 분리 - 원문 일부를
rawPreview로 남기되, 전체를 그대로 노출하지 않음 - 검증 후에도 “업무 규칙” 단계에서 한 번 더 차단
6) 자주 묻는 함정 3가지
함정 1. “모델에게 JSON만 출력하라”로 해결하려고 함
프롬프트로 개선은 되지만, 100% 강제는 어렵습니다. 특히 대화 맥락이 길어지면 설명이 섞일 수 있습니다. Tool Use라면 구조화 필드를 우선 사용하세요.
함정 2. 파싱 에러를 재시도로만 덮음
재시도는 비용만 늘리고, 근본 원인을 숨깁니다. 최소한 “실패 원문 저장”과 “유형 분기”는 하세요.
함정 3. 로그에 민감정보가 섞임
tool arguments에 토큰, 쿠키, 개인정보가 섞일 수 있습니다. 원문 저장 시에는 마스킹을 고려하세요. 인증/인가 관련 이슈는 애플리케이션에서 특히 위험합니다. 비슷한 맥락으로 보안 컨텍스트가 간헐적으로 누락되는 케이스는 Spring Boot 3에서 가끔 401? SecurityContext 누락 해결도 참고할 만합니다.
결론: 파싱은 기술 문제가 아니라 파이프라인 문제
Claude Tool Use JSON 파싱 오류는 대부분 아래 3줄로 정리됩니다.
- 스트리밍이면 “완성된 arguments”만 파싱
- 텍스트 오염 가능성을 없애고, 구조화 필드를 우선 사용
JSON.parse()다음에 스키마 검증을 강제
이 3가지만 적용해도 “5분 해결”이 아니라 “재발 방지”까지 가능합니다.