왜 당신의 LLM은 계속해서 쓰레기 같은 JSON을 반환하는가 (그리고 이를 멈추는 방법)
요약
LLM이 JSON 형식을 제대로 반환하지 못해 발생하는 프로덕션 환경의 오류 원인과 해결책을 다룹니다. 구조화된 출력 API, 타입 검증, 복구 및 재시도라는 세 가지 계층적 접근 방식을 제안합니다.
핵심 포인트
- LLM의 토큰 생성 방식 때문에 발생하는 JSON 형식 오류 원인 분석
- 마크다운 코드 펜스, 불필요한 서두, 후행 쉼표 등 주요 실패 모드 정리
- 네이티브 구조화된 출력 API 활용의 중요성
- 타입화된 검증 및 복구/재시도 전략을 통한 안정적인 시스템 구축
원래 내 블로그에 게시되었습니다. 여기에는 정식 링크(canonical link)와 함께 교차 게시되었습니다.
오디오를 선호하시나요? Spotify 에피소드 · Telegram
LLM 호출을 연결합니다. 데모는 마법 같습니다. 제품을 출시합니다.
다음 날 아침, Sentry가 불타오릅니다. json.JSONDecodeError: Expecting value: line 1 column 1 (char 0). 실패한 페이로드(payload)를 열어보니 모델이 아주 정중하게 다음과 같이 반환했습니다:
물론이죠! 요청하신 JSON은 다음과 같습니다:
```json
{
"name": "Acme Corp",
"founded": 1998,
}
```
다른 것이 필요하시면 말씀해 주세요!
세 가지 문제가 동시에 발생했습니다: 수다스러운 서문(preamble), 마크다운 코드 펜스(markdown code fence), 그리고 마지막에 붙은 쉼표(trailing comma). LLM을 기반으로 구축하는 모든 팀은 프로덕션(production)에 적용한 첫 일주일 이내에 이 문제에 직면합니다. 해결책은 단 하나의 기술이 아닙니다. 이전 단계에서 놓친 것을 각각 잡아내는 세 가지 계층(layers)입니다.
이 포스트는 계층화된 플레이북(playbook)을 제시합니다: 첫째, 네이티브 구조화된 출력(structured-output) API, 둘째, 타입화된 검증(typed validation), 셋째, 복구 및 재시도(repair-and-retry)입니다. 이는 오늘날 저희의 프로덕션 코드에서도 동일하게 적용되는 패턴입니다.
애초에 왜 LLM은 JSON 작업에 실패하는가
언어 모델(language model)은 JSON을 _출력(output)_하는 것이 아닙니다. 모델은 가장 확률이 높은 다음 토큰(token)을 반복적으로 출력할 뿐입니다. JSON은 모델이 학습 과정에서 많이 본 특정 토큰 시퀀스(sequence)일 뿐입니다. 따라서 프롬프트(prompt)가 형식에 대해 조금이라도 모호하거나, 모델이 도움이 되고 대화하는 방식으로 미세 조정(fine-tuned)되어 있다면, 해당 토큰 확률은 자연어(natural language) 쪽으로 흐르게 됩니다.
빈도순으로 나열한 일반적인 실패 모드(failure modes)는 다음과 같습니다:
- 마크다운으로 감싸기 (Wrapped in markdown) — 훈련 데이터가 코드 블록으로 가득 차 있기 때문에
json ...형태로 출력됩니다. - 잡담 같은 서두 또는 후미의 잡담 (Chatty preamble or trailing chatter) — "여기에 JSON이 있습니다:", _"도움이 되길 바랍니다!"_와 같이 불필요한 문구가 붙습니다.
- 후행 쉼표 (Trailing commas) — JSON에서는 금지되어 있지만, JavaScript나 Python에서는 허용됩니다.
- 작은따옴표 사용 (Single quotes) — JSON처럼 보이지만 실제로는 아닙니다.
- 문자열 내부의 이스케이프되지 않은 따옴표 (Unescaped quotes inside strings) — _"그는 "안녕"이라고 말했습니다"_와 같이 오류가 발생합니다.
- 객체 중간 잘림 (Truncation mid-object) — 토큰 제한에 도달하여 마지막 중괄호(brace)가 누락됩니다.
- 환각된 필드 (Hallucinated fields) — 요청하지 않은 추가 키가 있거나, 필수 키가 빠집니다.
- 잘못된 타입 (Wrong types) — 정수(integer)를 요구했는데 `
Gemini — response_mime_type + response_schema
Gemini는 디코딩 (decoding) 과정에서 열거형 (enums)을 강제하는 데 특히 강력합니다. 만약 특정 필드가 고정된 유효 값 세트를 가진다면, 이를 Literal로 선언하세요. Gemini는 해당 세트 외부의 토큰을 생성하는 것을 물리적으로 거부할 것이며, 이를 통해 Layer-2 검증 실패의 한 부류를 발생하기도 전에 차단합니다.
from typing import Literal
from google import genai
from google.genai import types
...
경험 법칙 (Rule of thumb): 만약 API에 구조화된 출력 (structured-output) 모드가 있다면, 그것을 사용하세요. _"다른 텍스트 없이 유효한 JSON만 반환하라"_라고 말하는 프롬프트를 직접 만드는 방식은 피하세요. 그런 방식은 2023년에도 작동했고 2026년에도 어느 정도는 작동하겠지만, 제약된 디코딩 (constrained decoding) 방식보다 엄연히 성능이 떨어집니다.
Layer 2: Pydantic으로 검증하기 (Layer 1이 성공했더라도)
제약된 디코딩 (Constrained decoding)은 구문론적 (syntactic) JSON을 제공합니다. 하지만 그것이 의미론적 (semantic) 정확성을 보장하지는 않습니다. 모델은 여전히 다음과 같은 실수를 할 수 있습니다:
- 4자리 연도를 원했는데
founded: 1을 반환함. - 빈
name을 반환함. - 그럴듯하지만 틀린 산업군 (industry)을 반환함.
- 소스 텍스트에 실제 해당 필드가 포함되어 있지 않아 모든 값이 null로 반환됨.
Pydantic은 잘못된 데이터가 도메인 로직 (domain logic)에 진입하기 전, 경계 단계에서 이러한 문제들을 잡아냅니다:
from pydantic import BaseModel, Field, field_validator
from typing import Literal
...
여기서 발생하는 검증 실패는 단순한 에러가 아니라 _유용한 신호 (useful signal)_입니다. 만약 founded=1이 계속해서 검증기를 통과하지 못한다면, 프롬프트가 모호하다는 뜻입니다 — 프롬프트를 수정하세요. 만약 industry="other"가 너무 자주 나타난다면, 열거형 (enum) 범위가 너무 좁다는 뜻입니다 — 스키마 (schema)를 수정하세요.
패턴은 다음과 같습니다: 모든 LLM 호출은 Pydantic 모델로 반환되며, 코드베이스의 나머지 부분은 오직 검증된 객체만을 보게 됩니다. LLM을 신뢰할 수 없는 제3자 API처럼 취급하세요.
Layer 3: 위 두 레이어가 모두 실패할 때 복구 및 재시도
약 95%의 호출에서는 레이어 1과 2만으로 충분합니다. 나머지 5% — 긴 입력, 이상한 엣지 케이스 (edge cases), 모델 성능 저하, 다른 모델 버전으로 연결되는 속도 제한 (rate-limit) 재시도 등 — 는 여전히 실패합니다. 여러분에게는 폴백 (fallback)이 필요합니다.
거의 JSON에 가까운 경우를 위한 json_repair
json-repair는 마지막 쉼표 (trailing commas), 작은따옴표 (single quotes), 닫는 중괄호 누락 (missing closing braces), 마크다운 펜스 (markdown fences), JSON 주변의 설명 문구 (prose-around-JSON)와 같은 흔한 형태 오류를 수정해 주는 작은 라이브러리입니다.
from json_repair import repair_json
import json
...
이것은 마법이 아니라, 관대한 파서 (forgiving parser)입니다. 엄격한 JSON (strict JSON)이 거부하는 입력값에 대해서도 성공할 것이며, 그 어떤 단일 프롬프트 변경보다 더 많은 프로덕션 호출 (production calls)을 구해냈습니다.
검증 오류를 모델에 다시 피드백하여 재시도하기
repair_json이 실패하고 Pydantic 검증 (validation)마저 실패한다면, 다음 프롬프트에 오류 메시지를 포함하여 재시도하십시오. 모델은 무엇이 잘못되었는지 알려주면 자신의 실수를 수정하는 데 정말 탁월한 능력을 보여줍니다:
def call_with_repair(messages, schema_cls, max_retries=2):
for attempt in range(max_retries + 1):
resp = call_llm(messages) # 네이티브 구조화된 출력 (native structured-output) 호출
...
두 번의 재시도가 가장 적절한 지점 (sweet spot)입니다. 한 번은 고질적인 실패를 해결하기에 부족하며, 세 번은 성공률 상승은 거의 없으면서 토큰만 낭비하게 됩니다.
재시도는 토큰 비용 측면에서 이차 함수적으로 증가합니다 — 프롬프트 캐싱 (prompt caching)을 사용하여 비용 곡선을 완만하게 만드세요. 50K 토큰 프롬프트가 두 번 재시도되면, 캐싱을 하지 않을 경우 150K 토큰이 전체 가격으로 청구됩니다. OpenAI, Anthropic, Gemini 모두 2026년까지 프롬프트 캐싱을 출시할 예정입니다. 두 번째와 세 번째 시도는 캐시된 접두사 (cached prefix)를 사용하여 비용의 아주 일부(통상 10~25%)만 지불해야 합니다. 시스템 프롬프트와 소스 문서를 캐싱하고, 검증 오류 피드백 메시지만 변경하십시오.
실패 또한 구조화하기
모든 재시도가 소진되었을 때, 단순히 raise Exception("LLM failed")를 하지 마십시오. 호출자가 분기 처리할 수 있도록 타입이 지정된 예외 (typed exception)를 발생시키십시오:
class InsufficientDataError(Exception):
"""소스 자료에 요청된 필드가 실제로 포함되어 있지 않음."""
...
서로 다른 실패에는 서로 다른 처리가 필요합니다. SchemaViolation은 모델 또는 프롬프트의 문제이므로 로그를 남기고 알림을 보내십시오. InsufficientDataError는 데이터의 문제이므로, 사용자에게 500 에러를 보여주는 대신 _"이 문서에서 X를 추출할 수 없었습니다"_라고 노출하십시오.
종합하기
압축된 전체 패턴은 다음과 같습니다:
def extract_company(text: str) -> Company:
messages = [
{"role": "system", "content": "Extract structured company data from the text."},
...
세 개의 계층, 하나의 진입점. 호출자는 JSONDecodeError를 절대 보지 않습니다. 대신 Company 객체나 직접 처리 가능한 타입화된 예외(typed exception)를 받게 됩니다.
결정 매트릭스 (Decision Matrix)
| 호출 대상 | 사용 방법 |
|---|---|
| OpenAI GPT-4o 또는 최신 모델 | response_format=PydanticModel (.parse() API) |
| ... |
그리고 또 다른 축: 모델 크기
| 모델 클래스 | 전략 |
|---|---|
| Frontier (GPT-4o, Claude Opus 4.x, Gemini 2.5 Pro) | 계층 1 + 2만으로도 보통 충분합니다. 계층 3은 예외적인 케이스(long tail)를 잡아냅니다. |
| Small / edge (Gemini Flash, Llama 3.x 8B, Phi-4, Mistral 7B) | 계층 3이 필수적입니다. 작은 모델들은 중첩된 스키마 (nested schemas), 선택적 필드 (Optional fields), 그리고 긴 열거형 (long enums)에서 훨씬 더 자주 실수를 범합니다. 구조화된 출력 (structured outputs) 기능을 켜더라도 2~5%의 재시도율 (retry rate)을 예산에 반영하십시오. |
아무도 말해주지 않는 주의사항 (Gotchas)
Optional[T]가 포함된 스키마는 공격적으로None으로 채워집니다. 모델은 Null 허용이 가능할 때 _"모르겠습니다"_를 유효한 답변으로 취급합니다. 누락된 데이터에 대해 추출 결과가 정직하기를 원한다면, 대신 타입화된 예외 경로 (typed exception path)를 사용하십시오.- 값이 너무 많은 열거형 (Enums)은 "기타 (other)"로 퇴보합니다.
Literal[...]리스트를 짧게 유지하십시오. 만약 50개의 카테고리가 필요하다면, 2단계 파이프라인을 사용하십시오: 자유 텍스트 (free-text) → 임베딩 (embedding) → 가장 가까운 열거형 (nearest enum). additionalProperties: false는 중요합니다. 이 설정이 없으면 모델은 필드를 마음대로 만들어냅니다. Pydantic v2는 이를 기본적으로 생성하지만, JSON 스키마 (JSON Schema)를 직접 작성한다면 반드시 확인하십시오.- 스트리밍 (Streaming) + 구조화된 출력 (structured outputs)은 어디서나 반쯤 고장 나 있습니다. JSON을 스트리밍할 수는 있지만, 스트림이 완료될 때까지는 타입화된 파싱 (typed-parse)을 할 수 없습니다. 사용자에게 추출된 데이터에 대해 타자기 효과 (typewriter effect)를 약속하지 마십시오.
- JSON 모드는 공짜가 아닙니다. 제약된 디코딩 (Constrained decoding)은 대부분의 제공업체에서 10~30%의 지연 시간 (latency)을 추가합니다. 정확성을 위해서는 가치가 있지만, 이를 고려하여 예산을 잡으십시오.
- 프롬프트가 길 때 재시도 (Retries) 비용은 이차 함수적으로 증가합니다. 50K 토큰 프롬프트가 두 번 재시도되면 150K 토큰이 됩니다.
시스템 프롬프트 (System Prompt)를 공격적으로 캐싱(Cache)하세요.
결론
"소프트웨어 구성 요소로서의 LLM (LLM-as-software-component)" 문제는 더 똑똑한 모델을 사용한다고 해서 해결되지 않습니다. 이 문제는 모델을 다른 모든 신뢰할 수 없는 업스트림 서비스 (Upstream Service)처럼 취급함으로써 해결됩니다. 즉, 모델이 반환할 수 있는 것을 제한하고, 반환된 내용을 검증하며, 거의 맞았지만 틀린 것을 수정하고, 피드백과 함께 재시도하며, 모든 것이 실패했을 때는 타입화된 에러 (Typed Errors)를 통해 명확하게 실패를 알리는 것입니다.
세 가지 계층. 각 계층은 이전 계층이 놓친 것을 잡아냅니다. 이 방식을 적용하면 새벽 6시에 Sentry 인박스가 불타오르는 일은 멈출 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기