
LLM의 JSON 출력은 실무에서 깨진다 — 추출→수복→검증의 3단계 대응법
요약
LLM의 JSON 출력 오류를 해결하기 위한 3단계(추출, 수복, 검증) 대응 전략을 소개합니다. 실무 환경에서 발생하는 불필요한 텍스트, 마지막 쉼표, 괄호 누락 등의 문제를 견고하게 처리하는 방법을 다룹니다.
핵심 포인트
- LLM 출력의 불완전성을 인정하고 받는 쪽을 견고하게 설계해야 함
- 추출(Extract): 코드 페이스 및 불필요한 설명문 제거
- 수복(Repair): 마지막 쉼표나 닫히지 않은 괄호 등 전형적 오류 수정
- 검증(Validate): Pydantic 등을 활용한 스키마 타입 검증
- 단계별 통과 여부를 측정하여 모델/프롬프트 변경 시 성능 저하 감지
결론 / TL;DR
LLM에게 "JSON으로 응답해줘"라고 요청해도, 실무(Production)에서는 상당한 빈도로 깨진 JSON이 반환됩니다. 코드 페이스(Code fence)가 붙거나, 앞뒤에 설명문이 섞이거나, 마지막 쉼표(Trailing comma)가 있거나, 출력이 중간에 끊기는 등의 문제가 발생합니다. json.loads()
한 번의 시도에만 의존하면, 어느 날 갑자기 실무 환경에서 예외(Exception)가 발생하며 시스템이 중단됩니다.
대책은 **단계적인 폴백(Fallback, 3단계 대응)**으로 받아내는 것입니다.
추출 (extract): 불필요한 텍스트나 코드 페이스를 제거하고, JSON 본체만 추출합니다.
수복 (repair): 마지막 쉼표나 닫히지 않은 괄호 등 "전형적인 오류"를 수정합니다.
검증 (validate): 스키마 (Pydantic)로 타입을 검증하고, 통과한 데이터만 실무로 흘려보냅니다.
나아가 "어느 단계에서 통과했는지"를 측정해 두면, 모델이나 프롬프트를 변경했을 때 발생하는 성능 저하를 즉시 알아차릴 수 있습니다. 바로 복사해서 사용할 수 있는 형태로 모두 공개합니다.
배경 · 무엇이 문제였는가
LLM의 출력을 후속 처리로 넘기기 위해, 구조화된 데이터 (JSON) 형태로 받고 싶은 상황은 많습니다. 프롬프트에 "다음 형식의 JSON으로 응답해줘"라고 적으면, 대부분은 올바른 JSON이 반환됩니다.
문제는 **"대부분"**이라는 부분입니다. 100번 중 몇 번은 다음과 같은 것이 반환됩니다.
네, 결과는 다음과 같습니다:
```json
{
"title": "샘플",
"tags": ["a", "b",], ← 마지막 쉼표
}
이상입니다. 조정이 필요하시면 말씀해 주세요.
`json.loads()`
에 이것을 그대로 전달하면 당연히 실패합니다. 앞뒤의 설명문, 코드 페이스, 마지막 쉼표 — 인간에게는 사소한 것이지만, 파서(Parser)는 가차 없이 중단됩니다. 게다가 까다로운 점은, **개발 중에는 거의 재현되지 않는다**는 것입니다. 온도(Temperature)나 미세한 입력 차이에 따라 확률적으로 발생하기 때문에, 실무 환경에서 처음 맞닥뜨리게 됩니다.
"LLM 측의 프롬프트를 완벽하게 만든다"는 것만으로는 문제를 0으로 만들 수 없습니다. 그래서 **받는 쪽을 견고하게(Robust) 만든다**는 방침을 세웠습니다.
## 수행한 내용
### 오류의 종류를 먼저 파악하기
대책을 세우기 전에, 실제로 발생했던 오류들을 분류해 두면 설계가 쉬워집니다.
| 오류 유형 | 예시 | 처리 가능한 단계 |
|---|---|---|
| 코드 페이스 포함 | ````json ... ```` | 추출 |
| 앞뒤 설명문 포함 | `네, 다음과 같습니다~` / `이상입니다` | 추출 |
| 마지막 쉼표 | `[a, b,]` | 수복 |
| 닫는 괄호 부족 | 출력이 중간에 끊김 (`max_tokens` 도달) | 수복 (일부) |
| 애초에 JSON 없음 | 전체 문장이 자연어임 | 처리 불가 → 실패로 측정 |
"처리할 수 없는 오류"도 있다는 점을 미리 인정하는 것이 중요합니다. 무리하게 고치려다 보면, **깨진 데이터를 "그럴듯하게" 통과시켜 버리는 것**이 더 위험하기 때문입니다.
### 1단계: 추출 (extract)
먼저 코드 페이스를 제거하고, 첫 번째 `{` 에서부터 **괄호의 대응이 맞는 위치**까지를 추출합니다. 문자열 리터럴 내부의 `{ }`를 잘못 세면 버그가 발생하므로, 문자열의 안팎을 추적하며 세는 것이 포인트입니다.
```python
import re
def extract_json_block(text: str) -> str:
# 코드 페이스 (```json ... ```)를 제거
text = re.sub(r"```(?:json)?", "", text).strip()
start = text.find("{")
if start == -1:
raise ValueError("JSON object not found")
depth = 0
in_str = False
esc = False
for i in range(start, len(text)):
c = text[i]
if in_str:
if esc:
esc = False
elif c == "\\":
esc = True
elif c == '"':
in_str = False
else:
if c == '"':
in_str = True
elif c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return text[start : i + 1]
# 여기까지 오면 = 닫는 괄호가 부족함 (중간에 끊김) → 2단계로 넘김
return text[start:]
2단계: 수복 (repair)
...
import json
def try_load(s: str):
try:
return json.loads(s), None
except json.JSONDecodeError as e:
return None, e
def repair_json(s: str) -> str:
# 1) trailing comma: ,} 또는 ,] 를 } 또는 ] 로 변경
s = re.sub(r",\s*([\}\]])", r"\1", s)
# 2) 닫기 누락: 열기/닫기 개수 차이만큼 보충 (1단계에서 끊긴 경우 구제)
s += "}" * max(0, s.count("{") - s.count("}"))
s += "]" * max(0, s.count("[") - s.count("]"))
return s
싱글 쿼트(single quote)나 키(key)의 따옴표 누락까지 직접 수정하려고 하면, 값 내부의 아포스트로피(apostrophe)를 파괴하는 등의 사고가 발생합니다. 그 정도 수준까지 필요하다면, 순순히 json-repair 라이브러리를 사용하세요.
...
from enum import Enum
from pydantic import BaseModel
class ParseStage(str, Enum):
RAW = "raw" # 그대로 통과
EXTRACTED = "extracted" # 추출을 통해 통과
REPAIRED = "repaired" # 수복을 통해 통과
FAILED = "failed" # 모두 실패
def robust_parse(text: str):
# 1단계: 우선 그대로 시도
data, _ = try_load(text)
if data is not None:
return data, ParseStage.RAW
# 2단계: 추출 후 시도
block = extract_json_block(text)
data, _ = try_load(block)
if data is not None:
return data, ParseStage.EXTRACTED
# 3단계: 수복 후 시도
data, err = try_load(repair_json(block))
if data is not None:
return data, ParseStage.REPAIRED
return None, ParseStage.FAILED
def parse_and_validate(text: str, model: type[BaseModel]) -> BaseModel:
data, stage = robust_parse(text)
metrics_increment(f"llm_json.{stage.value}") # ← 붕괴 유형을 측정
if data is None:
raise ValueError("LLM did not return parseable JSON")
return model.model_validate(data) # 스키마 검증 (타입이 다르면 여기서 예외 발생)
metrics_increment()
...
class Article(BaseModel):
title: str
tags: list[str]
score: float
article = parse_and_validate(llm_response_text, Article)
결과
...
llm_json.raw 92.0%
llm_json.extracted 6.5%
llm_json.repaired 1.3%
llm_json.failed 0.2%
이로써 **"모델을 교체했더니 repaired가 급증했다", "프롬프트를 바꿨더니 failed가 튀었다"**와 같은 성능 저하를 수치로 확인할 수 있게 되었습니다. 이전에는 "왠지 요즘 좀 이상한 것 같다"로 끝났을 이야기가, 판단 가능한 지표로 바뀐 것이 가장 큰 수확입니다.
주의할 점 (ハマりどころ)
- truncation (중간에 끊김)은 수복만으로 완전히 고칠 수 없다. 2단계의 닫기 괄호 보충은 "마지막 값이 누락된 상태"로 통과시켜 버릴 수 있습니다.
failed가 아니라repaired로 통과되어 버리면 오히려 위험하므로, 끊긴 의심이 있는 것은 실패(failed)로 처리하는 판단도 필요합니다. 근본적인 해결책은max_tokens를 높이거나 출력을 분할하는 것입니다. - 문자열 내부의
{또는}. 1단계에서 문자열 리터럴의 내외부를 추적하는 이유가 바로 이것 때문입니다. 단순하게count("{")를 사용하여 개수를 세면 버그가 발생할 수 있습니다.
단순한 구현만으로는 값이 중괄호({, })를 포함하는 데이터에서 깨지게 됩니다. -
싱글 쿼트(Single Quote) 일괄 치환은 사고를 유발합니다. '를 "로 치환하는 식의 복구 작업은 값 내부에 포함된 아포스트로피(it's 등)를 파괴합니다. 직접 구현하며 고생하기보다, 검증된 라이브러리를 활용하세요. -
추론(thinking) 모드에서 빈 JSON이 되는 경우가 있습니다. 내부 추론이 출력 예산(output budget)을 모두 소모하여 본체가 나오지 않는 케이스입니다. 이 또한 failed로 측정해 두면 "특정 모델·특정 설정에서만 발생한다"는 점을 인지할 수 있습니다. -
가장 효과적인 방법은 "애초에 깨지지 않게 하는 것"입니다. 많은 LLM API에는 JSON 출력을 강제하는 모드(JSON mode / structured outputs)나, 스키마를 전달하는 도구 사용(tool use / function calling) 기능이 있습니다. 이를 사용하면 2단계와 3단계의 필요성이 급격히 줄어듭니다. 3단계 대응법은 "그럼에도 불구하고 새어 나오는 부분"을 위한 보험이며, 1차 방어는 프롬프트 측면의 구조화 강제입니다.
요약
LLM의 JSON 출력은 확률적으로 깨질 수 있다는 전제하에 수신 측을 설계해야 안정적입니다.
JSON mode / structured outputs로 1차 방어 (애초에 깨지지 않게 함)
추출 → 복구 → 검증의 3단계 대응으로 누락된 부분을 수용
어느 단계에서 통과했는지 측정하여, 모델이나 프롬프트 변경 시 발생하는 성능 저하를 수치로 포착
"가끔 실패한다"를 "실패하지 않고 가시화된다"로 바꾸는 것만으로도, LLM을 프로덕션 환경에 올리는 심리적 장벽이 크게 낮아집니다.
LLM 활용 및 개인 개발 팁은 X(@box222box)에서도 발신하고 있습니다.
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기