
아빠(LLM)에게 받은 JSON이 깨져서 항목이 나오지 않아. 어떡하지, 어떡하지: Pydantic으로 구문·타입·의미를 검증하기
요약
LLM이 생성한 JSON 데이터의 문법적 오류뿐만 아니라 타입, 범위, 비즈니스 로직의 모순을 검증하는 방법을 다룹니다. Python의 Pydantic v2를 활용하여 구조화된 출력을 프로덕션 환경에서 안정적으로 처리하는 가이드를 제공합니다.
핵심 포인트
- json.loads()는 문법만 검증하며 타입 및 값의 유효성은 검증하지 못함
- Pydantic v2를 사용하여 데이터 타입, Enum, 수치 범위 등을 엄격히 검증 가능
- model_validator를 통해 필드 간의 논리적 모순(의미적 불일치) 해결 가능
- LLM의 비결정론적 출력을 프로덕션 시스템에 통합할 때 필수적인 안정성 확보 전략
아빠(LLM)에게 받은 JSON이 깨져서 원하는 항목이 나오지 않는다.
그런 경험은 없는가.
생성 AI에게 "JSON으로 응답해줘"라고 부탁했는데, 돌아온 JSON이 깨져 있었다——LLM을 사용한 시스템을 운용하다 보면 한 번쯤 겪게 되는 트러블이다.
{
"title": "개인정보 유출",
"severity": "severe",
...
언뜻 보기에는 JSON처럼 보인다.
하지만 시스템이 기대하는 사양이 다음과 같은 경우, 이 출력은 그대로 사용할 수 없다.
severity
은
critical
,
high
,
medium
,
low
중 하나 -
confidence
은 0.0부터 1.0까지의 수치 -
blocking
은 불리언 (Boolean) -
title
은 빈 문자열 불가
더욱 곤란한 것은, JSON으로서는 올바르더라도 시스템상으로는 모순되는 케이스이다.
{
"title": "표기 불일치",
"severity": "low",
...
이 JSON은 문법적으로나 타입상으로도 올바르지만,
경미한 문제임에도 불구하고 개발을 중단해야 하는 Blocking Issue가 되어 있다
라는 의미상의 불일치가 있다.
이 기사에서는 LLM이 생성한 JSON을 위 그림의 3단계로 나누어 검증한다.
Python의 json 모듈과 Pydantic v2를 사용한다.
대상 독자
- LLM의 구조화된 출력 (JSON)을 프로덕션 시스템에 통합하고 있거나, 통합하려고 하는 분
- "JSON Schema를 지정했으니 괜찮겠지"라고 생각했다가 큰코다친 분
- Pydantic v2의
model_validator나 strict 모드 등, 실전적인 사용법을 알고 싶은 분
json.loads()만으로는 부족한가
왜 Python으로 JSON을 읽기만 한다면, 다음 코드로 충분하다.
import json
raw_json = """
{
...
이 JSON에는 구문 에러가 없기 때문에, json.loads()는 성공한다. 하지만 다음과 같은 문제는 검출할 수 없다.
severity가 허용되지 않은 값confidence가 수치가 아님- 필수 필드가 누락됨
- 수치가 허용 범위를 벗어남
- 값의 조합이 업무 규칙에 위반됨
json.loads()가 확인하는 것은 어디까지나 JSON의 문법이다. 그래서 구조와 타입의 검증에 Pydantic을 사용한다.
환경을 준비한다
Python 3.11 이후를 상정.
pip install "pydantic>=2,<3"
사용 중인 버전을 확인한다.
python -c "import pydantic; print(pydantic.__version__)"
검증하고 싶은 JSON의 사양
이번에는 AI가 문서 내의 문제를 검출하여 반환하는 상황을 상정한다. 기대하는 JSON은 다음 형식이다.
{
"title": "개인정보 보관 기간이 미정의됨",
"severity": "high",
...
사양은 다음과 같다.
| 항목 | 타입 | 제약 |
|---|---|---|
title | 문자열 | 1글자 이상 |
severity | enum | critical, high, medium, low |
blocking | 불리언 (Boolean) | 필수 |
confidence | 부동 소수점 (Floating point) | 0.0 이상, 1.0 이하 |
tags | 문자열 배열 | 생략 가능 |
Pydantic 모델을 정의한다
Pydantic v2로 모델을 만든다.
from enum import StrEnum
from pydantic import BaseModel, Field
class Severity(StrEnum):
...
이것만으로 다음 검증이 가능하다.
- 필수 항목
- 데이터 타입
- enum
- 문자열 길이
- 수치 범위
- 배열 요소의 타입
올바른 JSON을 검증
valid_json = """
{
"title": "개인정보 보관 기간이 미정의됨",
...
출력 예시이다.
title='개인정보 보관 기간이 미정의됨' severity=<Severity.HIGH: 'high'> blocking=True confidence=0.92 tags=['privacy', 'data-retention']
high
0.92
model_validate_json()
은(는) JSON 문자열의 읽기와 Pydantic을 통한 검증을 한꺼번에 수행한다.
enum에 존재하지 않는 값을 검출
LLM이 high가 아니라, 그럴듯하게 severe라고 반환한 케이스이다.
invalid_severity_json = """
{
"title": "개인정보 유출",
...
검증한다.
from pydantic import ValidationError
try:
Finding.model_validate_json(invalid_severity_json)
...
출력에는 허용되는 값이 표시된다.
severity
Input should be 'critical', 'high', 'medium' or 'low'
LLM은 지정한 enum과 유사한 값을 임의로 만들어낼 때가 있다. 예를 들어 다음과 같은 값들이다.
severe
warning
important
very_high
HIGH
重大 (중대)
의미는 추측할 수 있어도, 시스템에 그대로 투입해서는 안 된다.
문자열로 반환된 숫자를 어떻게 다룰 것인가
다음 JSON에서는 confidence가 문자열로 되어 있다.
{
"title": "인증 방식이 미정의됨",
"severity": "high",
...
Pydantic은 기본 설정에서 변환 가능한 문자열을 숫자로 변환한다.
result = Finding.model_validate_json(
"""
{
...
출력은 다음과 같다.
0.91
<class 'float'>
편리하지만, LLM 출력을 엄격하게 감사(audit)하고 싶을 때는 암시적 변환(implicit conversion)을 허용하고 싶지 않을 수도 있다.
그 경우에는 strict 모드를 사용해야 할 것이다.
from pyd import BaseModel, ConfigDict, Field
class StrictFinding(BaseModel):
model_config = ConfigDict(strict=True)
...
이렇게 하면 문자열인 "0.91"은 에러가 된다.
try:
StrictFinding.model_validate_json(
"""
...
LLM 출력 검사에서는 다음 중 어느 쪽을 택할지 결정해 두어야 한다.
- 안전하게 변환할 수 있는 값은 받아들인다
- 타입이 완전히 일치하지 않으면 거부한다
이는 기술적인 문제라기보다, 시스템의 수용 방침이다.
숫자 범위를 검증하기
confidence는 0.0부터 1.0까지로 정의했다. 따라서 다음 JSON은 부적절하다.
{
"title": "로그 보관 방침이 불명확",
"severity": "medium",
...
try:
Finding.model_validate_json(
"""
...
에러 예시.
confidence
Input should be less than or equal to 1
LLM이 확신도(confidence)를 다음과 같이 서로 다른 척도로 반환하는 것은 드문 일이 아니다.
0.92
92
"92%"
"high"
"かなり高い" (꽤 높음)
Pydantic으로 허용 범위를 고정해 두면 척도의 혼입을 검출할 수 있다.
필수 항목의 누락을 검출
confidence가 존재하지 않는 JSON을 검증한다.
{
"title": "로그 보관 방침이 불명확",
"severity": "medium",
...
try:
Finding.model_validate_json(
"""
...
출력 예시이다.
confidence
Field required
LLM이 항목을 생략하는 원인으로는 다음과 같은 것들을 생각할 수 있다.
- 해당 값을 판단할 수 없었음
- 긴 출력 도중에 항목을 놓침
- JSON Schema를 충분히 지키지 않음
null
키 자체를 생략함 - 출력 토큰 제한에 도달함
판단 불가능을 허용하고 싶다면, 명시적으로 None을 허용하는 것이 좋다.
class NullableFinding(BaseModel):
title: str = Field(min_length=1)
severity: Severity
...
단, 모든 필수 값을 None으로 가능하게 만들면, 깨진 JSON을 정상으로 취급하기 쉬워진다.
'판단 불가능'과 '항목 출력 누락'은 가급적 분리해야 한다.
예를 들어, 다음과 같이 상태를 추가할 수 있다.
class ConfidenceStatus(StrEnum):
DETERMINED = "determined"
UNABLE_TO_DETERMINE = "unable_to_determine"
...
JSON 구문 에러를 분리하여 처리하기
지금까지는 주로 JSON으로 읽어들일 수 있는 출력을 검증해 왔다.
하지만, LLM은 JSON 자체를 깨뜨리는 경우도 있다.
{
"title": "개인정보 보관 기간이 미정의됨",
"severity": "high",
...
끝에 불필요한 쉼표(comma)가 있어 올바른 JSON이 아니다. 구문 에러(syntax error)와 스키마 에러(schema error)를 나누어 처리하는 함수를 만들기로 한다.
import json
from dataclasses import dataclass
from typing import Any
...
깨진 JSON을 전달한다.
broken_json = """
{
"title": "개인정보 보관 기간이 미정의됨",
...
출력 예시.
{
"success": false,
"error_type": "syntax",
"errors": [{'message': 'Expecting property name enclosed in double quotes', 'line': 7, 'column': 1, 'position': 130}]
}
이로써 적어도 다음은 구분할 수 있다.
syntax: JSON 문법 에러schema: 타입, 필수 항목, enum, 범위 에러completed: 검증 성공
Markdown 코드 펜스(Code Fence) 제거하기
LLM에게 JSON만 요구해도 다음과 같은 출력이 반환될 때가 있다.
```json
{
"title": "개인정보 보관 기간이 미정의됨",
"severity": "high",
"blocking": true,
"confidence": 0.92
}
사람에게는 친절하지만, 이것은 JSON이 아니다. 꽤 자주 발생하는 함정이다. 이러한 함정에 대비하여 간단한 코드 펜스 제거 함수를 준비한다.
```python
import re
JSON_FENCE_PATTERN = re.compile(
r"\A\s*```(?:json)?\s*(.*?)\s*```\s*\Z",
flags=re.DOTALL | re.IGNORECASE,
)
def strip_json_code_fence(text: str) -> str:
...
검증 함수의 시작 부분에서 호출한다.
def check_finding_json(raw_json: str) -> CheckResult:
normalized_json = strip_json_code_fence(raw_json)
try:
...
코드 펜스 제거는 비교적 안전한 정규화(normalization)이다.
반면, 깨진 JSON에 억지로 쉼표나 따옴표를 추가하는 처리는 값의 의미를 바꿀 가능성이 있다. 자동 복구(auto-repair)할 범위는 신중하게 결정해야 한다.
타입이 올바르더라도 의미가 올바르지 않을 수 있다
여기서부터가 Pydantic을 사용하면 재미있어지는 부분이다.
예를 들어, 다음 JSON은 구문도 타입도 올바르다.
{
"title": "표기 불일치",
"severity": "low",
...
하지만, 이번 시스템에서 low 문제는 Blocking으로 간주하지 않기로 했다면 어떨까? 이러한 필드 간의 관계는 model_validator로 검증할 수 있다.
from typing import Self
from pydantic import BaseModel, Field, model_validator
class ValidatedFinding(BaseModel):
...
검증.
try:
ValidatedFinding.model_validate_json(
"""
...
"""
)
출력 예시.
Value error, low severity의 Issue를 blocking=true로 설정할 수 없습니다.
이로써 검증을 3단계로 나눌 수 있었다.
| 단계 | 검증 내용 | 사용하는 것 |
|---|---|---|
| 구문 | JSON으로 읽을 수 있는가 | json.loads() |
| ... |
의미 검증을 늘리기
조금 더 업무 규칙 (Business Rule)을 추가해 보기로 하자. 이번 경우에는 다음 조건을 설정한다.
critical
은 반드시 Blocking
low
은 Blocking으로 설정할 수 없음
확신도 (Confidence)가 0.5 미만인 경우, 자동 Blocking을 할 수 없음
class BusinessValidatedFinding(BaseModel):
title: str = Field(min_length=1)
severity: Severity
...
JSON Schema만으로는 표현하기 어려운 업무 규칙도 Python으로 검증할 수 있었다.
에러를 기계 판독 가능한 형식으로 변환하기
CLI나 API에 통합하는 경우에는 Pydantic의 에러를 그대로 문자열로 표시하기보다 구조화하는 것이 사용하기 편리하다.
from typing import Any
def format_validation_errors(
error: ValidationError,
...
):
예를 들어, 다음의 잘못된 JSON을 검증한다고 가정해 보자.
invalid_json = """
{
"title": "",
...
"""
try:
Finding.model_validate_json(invalid_json)
except ValidationError as exc:
...
출력 예시.
{
"path": "$.title",
"type": "string_too_short",
...
}
이 형식이라면 UI에서 해당 항목을 강조 표시하거나, LLM에게 수정 지시를 보낼 수 있다.
자동 수복해도 되는 것, 안 되는 것
망가진 LLM 출력을 발견하면, 전부 자동으로 수복하고 싶어지지 않을까?
비교적 안전한 수복
- Markdown 코드 페이전스 (Code Fence) 제거
- 앞뒤 공백 제거
- UTF-8 BOM 제거
- 명확한 문자열 숫자의 타입 변환
- 대소문자만 다른 Enum의 정규화
신중하게 다뤄야 할 수복
- 존재하지 않는 필수 항목의 보완
severity의 추측blocking의 자동 변경- 수치 척도 (Scale)의 추측
- 망가진 문자열의 중간 부분 보완
- JSON의 누락된 부분을 LLM이 상상하게 만들기
예를 들어, confidence가 92인 경우, 0.92로 고칠 수 있을 것처럼 보인다.
하지만 모델이 정말로 100점 만점에 92를 반환한 것인지, 0부터 1000까지의 척도인지는 알 수 없다. 수복하는 대신, 다음과 같이 에러로 반환하는 것이 더 안전한 상황도 있다.
{
"valid": false,
"stage": "schema",
...
}
LLM에게 재생성 요청하기
검증에 실패했을 경우, 에러 내용을 LLM에게 전달하여 재생성하게 하는 방법도 있다.
수정 지시의 예시이다.
이전 JSON은 검증에 실패했습니다.
다음 에러를 수정하여 JSON 객체만 다시 출력해 주세요.
- $.severity: ...
배열 형식의 LLM 출력 검증하기
실제 시스템에서는 문제를 1건이 아니라 여러 건 반환하는 경우가 많을 것이다.
Pydantic v2에서는 RootModel을 사용할 수 있다.
from pydantic import RootModel
class FindingList(RootModel[list[BusinessValidatedFinding]]):
pass
입력 예시.
[
{
"title": "보관 기간이 정의되지 않음",
...
검증해 보자.
findings = FindingList.model_validate_json(
"""
[
...
"""
)
배열 내의 특정 요소가 망가진 경우에는 에러 위치에 배열 번호도 포함된다.
$[1].severity
대량의 구조화된 출력을 다룰 때, 어떤 항목이 망가졌는지 추적하기가 쉬워진다.
불필요한 필드 거부하기
LLM이 스키마에 존재하지 않는 설명 항목을 멋대로 추가하는 경우도 자주 발생한다.
{
"title": "보관 기간이 정의되지 않음",
"severity": "high",
...
불필요한 항목을 거부하려면 extra="forbid"를 지정한다.
from pydantic import ConfigDict
class StrictSchemaFinding(BaseModel):
model_config = ConfigDict(extra="forbid")
...
이를 통해 스키마에 없는 항목은 에러가 된다.
LLM 출력을 후속 시스템으로 전달할 경우, 불필요한 필드를 묵묵히 무시하는 것보다 이상(anomaly)으로 검출하는 것이 더 안전할 때가 있다.
지금까지 등장한 모델 정리
완성판으로 넘어가기 전에, 지금까지 등장한 모델들의 차이점을 정리해 둔다.
| 모델명 | 추가된 특징 |
|---|---|
Finding | 타입(type)・enum・범위・필수 항목의 기본 검증 |
StrictFinding | strict=True로 암묵적 타입 변환(implicit type conversion)을 금지 |
NullableFinding | confidence의 None 허용 |
FindingWithConfidenceStatus | "판단 불가능"을 명시하는 상태(status)를 추가 |
ValidatedFinding | blocking × severity의 정합성 체크 |
BusinessValidatedFinding | 비즈니스 규칙을 여러 개 추가 (critical/low/confidence) |
StrictSchemaFinding | extra="forbid"로 불필요한 필드 거부 |
완성판에서는 이 중 strict 모드·비즈니스 규칙 검증·불필요한 필드 거부를 하나의 모델로 통합하기로 했다.
완성판 체커
지금까지의 처리를 하나로 모은다.
from __future__ import annotations
import json
import re
...
(?:json)?\s*(.?)\s```\s*\Z",
flags=re.DOTALL | re.IGNORECASE,
)
def strip_json_code_fence(text: str) -> str:
...
실행 예시.
```python
raw_output = """
```json
{
"title": "표기 불일치",
"severity": "low",
"blocking": true,
"confidence": 0.98
}
"""
result = check_finding_json(raw_output)
print(result)
이 경우, 코드 펜스(code fence)는 제거되지만 의미 검증(semantic validation)에서 실패한다.
## 요약
LLM이 반환한 JSON은 단순히 JSON처럼 보이는 것만으로는 불충분하다.
최소한 다음의 3단계로 검증할 필요가 있다.
### 1. 구문 검증 (Syntax Validation)
JSON으로서 읽어들일 수 있는지 확인한다.
```python
json.loads(raw_json)
2. 스키마 검증 (Schema Validation)
타입, 필수 항목, enum, 숫자 범위, 불필요한 필드를 확인한다.
Finding.model_validate(parsed)
3. 의미 검증 (Semantic Validation)
필드 간의 관계나 비즈니스 규칙과의 정합성을 확인한다.
@model_validator(mode="after")
LLM은 유창한 문장뿐만 아니라 유창하게 망가진 JSON도 반환한다. 그리고 까다로운 점은, 완전히 망가진 JSON보다
문법적으로는 올바르고 값도 그럴싸하지만, 시스템상의 의미만 틀린 JSON
이라는 점이다.
Pydantic은 JSON을 단순히 읽기 위한 도구가 아니다. 외부에서 온 데이터를
이 시스템이 받아들여도 좋은 상태인가
확인하기 위한 경계(boundary)로 사용할 수 있다. 아빠에게 받은 JSON에 나오지 않는 항목이 있어도 괜찮다.
우선은 그냥 넘어가기보다, 검증하라.
Discussion

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