
AI가 Markdown을 반환하고 TypeScript 앱은 JSON을 기대할 때
요약
LLM이 JSON 대신 Markdown 형식을 반환하여 발생하는 파싱 에러 문제를 다룹니다. 제약 생성(Constrained Generation)을 사용할 수 없는 환경에서 정규 표현식 등을 활용해 안정적인 JSON 추출기를 구현하는 전략을 제시합니다.
핵심 포인트
- LLM의 부연 설명으로 인해 JSON 파싱 에러가 빈번히 발생함
- 제약 생성 사용이 어려울 경우 별도의 추출기(Extractor) 구현이 필요함
- 펜스, 산문 포함, 잘림 등 5가지 주요 실패 패턴을 인지해야 함
- stop_reason을 확인하여 토큰 제한(Truncation) 문제를 우선 해결해야 함
- 도서: AI That Answers
- 시리즈: AI in TypeScript — 첫 LLM 호출부터 프로덕션 환경의 에이전트까지 다루는 5권의 도서 — 다섯 권 모두 여기에서 확인
- 내 프로젝트: Hermes IDE | GitHub — Claude Code 및 기타 AI 코딩 도구를 사용하여 작업하는 개발자를 위한 IDE
- 나: xgabriel.com | GitHub
당신은 JSON을 요청했습니다. "다른 텍스트 없이 JSON으로만 응답하세요"라고 말했습니다. 심지어 대문자로 강조까지 했죠. 그런데 가끔씩 다음과 같은 결과가 돌아옵니다:
물론입니다 — 추출된 데이터는 다음과 같습니다:
```json
{"total": 1240, "currency": "EUR"}
```
다른 도움이 필요하시면 말씀해 주세요.
JSON.parse는 첫 번째 글자에서 멈춰버립니다. 에러 메시지는 Unexpected token 'S'라고 출력되는데, 이는 전혀 유용한 정보를 주지 못하며, 프롬프트가 바뀌지 않았기 때문에 재시도(retry)를 해도 보통 똑같은 형태가 나타납니다.
가장 올바른 해결책은 제약 생성 (Constrained Generation)입니다. 하지만 항상 사용할 수 있는 것은 아닙니다. 이를 지원하지 않는 모델을 사용 중이거나, 변경할 수 없는 제공업체(provider)를 사용 중이거나, 도구 호출 (Tool-calling)이 적절하지 않은 경로일 수도 있습니다. 따라서 추출기 (Extractor)를 갖추는 것은 가치가 있으며, 그동안 쌓아온 정규 표현식 (Regex) 덩어리로 만들기보다는 제대로 작성할 가치가 있습니다.
실제로 마주하게 되는 다섯 가지 형태
1. 펜스 (Fenced). 가장 흔한 형태로, json 태그가 붙은 펜스이거나 태그가 없는 펜스 형태입니다.
2. 산문, 펜스, 다시 산문 (Prose then fence then prose). 양옆에 대화형 부연 설명이 붙어 있는 형태입니다.
3. 서문이 포함된 순수 JSON (Bare JSON with a preamble). 펜스 없이, 그냥 여기 있습니다: {"total": …}와 같은 형태입니다.
4. 잘림 (Truncated). 객체 중간에 max_tokens 제한에 걸린 경우입니다. 구조적으로 복구가 불가능하며, 중요한 점은 다른 실패 사례들과는 다른 종류의 실패라는 것입니다.
5. 유효한 JSON이지만 형식이 틀림 (Valid JSON, wrong shape). 파싱은 잘 되지만 스키마 (Schema) 검증에서 실패합니다. 이 글의 주제는 아니지만, 추출기는 이를 다른 실패 사례들과 혼동해서는 안 됩니다.
계층별로 추출하기, 비용이 적게 드는 것부터
export type Extract =
| { ok: true; value: unknown; how: "direct" | "fenced" | "scanned" }
| { ok: false; reason: "truncated" | "no-json" };
...
stop_reason을 가장 먼저 확인하는 과정은 흔히 생략되곤 합니다. 중단(Truncation)은 다른 모든 경우와는 다른 대응이 필요합니다. 재프롬프트(re-prompt)를 하는 것이 아니라, 더 큰 max_tokens를 설정하거나 요청 크기를 줄여야 합니다. 만약 여기서 이를 확인하지 않는다면, 실제로는 길이 문제임에도 불구하고 "잘못된 형식의 JSON (malformed JSON)" 문제를 디버깅하며 한참을 허비하게 될 것입니다.
how를 반환하는 것도 중요합니다. fenced 비율이 높아진다는 것은 프롬프트가 표류(drifting)하고 있다는 의미이며, 이는 불리언(boolean) 값만으로는 절대 알 수 없는 정보입니다.
정규식 지옥(regex soup) 없이 Fence 추출하기
function* fencedBlocks(text: string): Generator<string> {
const re = /```
(?:json|JSON)?\s*\n([\s\S]*?)\n```/g;
for (const m of text.matchAll(re)) yield m[1];
// 종료되지 않은 마지막 fence — 모델이 닫기 전에 실행이 중단됨
const last = text.lastIndexOf("```");
if (last !== -1 && (text.match(/```/g)?.length ?? 0) % 2 === 1) {
yield text.slice(last + 3).replace(/^(?:json|JSON)?\s*\n/, "");
}
}
홀수 개수 분기(odd-count branch)는 모델이 객체는 닫았지만 fence는 닫지 않은 경우를 처리합니다. 이는 8줄의 코드를 작성할 가치가 있을 만큼 빈번하게 발생합니다. 제너레이터(generator)를 사용하면 모든 블록을 추출하는 대신, 파싱에 성공한 첫 번째 블록에서 멈출 수 있습니다.
균형 잡힌 객체 스캔하기 (Scanning for a balanced object)
Fence가 전혀 없는 경우를 위한 최후의 수단입니다. 단순히 indexOf("{")부터 lastIndexOf("}")까지를 가져오는 방식은 산문(prose)에 중괄호가 포함되는 순간 깨지므로, 깊이(depth)를 추적하고 문자열(string)을 존중해야 합니다.
function scanBalanced(text: string): string | null {
const start = text.search(/[\{\[]/);
if (start === -1) return null;
...
inStr 추적은 문자열 값 안에 있는 중괄호가 카운트의 균형을 깨뜨리는 것을 방지합니다. 이 기능이 없는 버전은 테스트 피스처(test fixtures)에서는 잘 작동하겠지만, 설명(description)에 {가 포함된 첫 번째 레코드를 만나는 순간 실패하게 됩니다.

잘못된 JSON을 수정하지 마세요
유혹적인 다음 단계가 있습니다. 마지막 쉼표(trailing commas)를 제거하거나, 따옴표가 없는 키(unquoted keys)에 따옴표를 붙이거나, 작은따옴표를 큰따옴표로 변환하는 것입니다. 이를 위한 라이브러리들도 존재합니다.
하지만 데이터 경로(data path)에서는 이를 피해야 합니다. 수정을 가하는 것은 모델이 생성한 내용의 의미를 소리 없이 변화시키며, 저장된 값이 모델이 실제로 생성한 것인지 더 이상 확인할 수 없게 만듭니다. {"total": 1240,}가 {"total": 1240}이 되는 것은 무해하지만, {"items": [1, 2,가 {"items": [1, 2]}가 되는 것은 잘린 리스트로부터 완전히 새로운 리스트를 만들어내는 행위입니다.
텍스트가 유효한 JSON이 아니라면, 그것은 하나의 신호입니다. 이를 다시 피드백으로 제공하세요:
const ex = extractJson(text, res.stop_reason);
if (!ex.ok) {
if (ex.reason === "truncated") throw new ResponseTruncated(res.usage);
...

어떤 레이어가 작동했는지 (Instrument which layer fired)
metrics.increment(`json.extract.${ex.ok ? ex.how : ex.reason}`);
direct가 우세해야 합니다. fenced의 상승은 프롬프트 드리프트(prompt drift)를 의미합니다. 즉, 무언가가 변경되어 모델이 감싸는 형태로 응답하기 시작했다는 뜻입니다. scanned의 상승은 펜싱(fencing)도 중단되었음을 의미하며, 이는 일반적으로 증가하는 프롬프트에 의해 지침(instruction)이 묻혔다는 것을 나타냅니다. truncated의 상승은 입력 크기가 커졌다는 것을 의미합니다.
이 세 가지 각각에는 다른 해결책이 있으며, 카운터가 이를 구별해 줍니다. 이것 없이는 이 세 가지 모두
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기