이메일에서 JSON 추출하기: 스키마 유효성이 데이터의 진실성을 보장하지 못하는 이유와 자동화 보호 방법
JSON Schema와의 구문적 일치성이 추출된 데이터의 사실적 정확성을 보장하지 못하는 이유를 분석하고, 내부 프로덕션 시스템으로 값을 라우팅하기 전에 애플리케이션 레벨에서 구축해야 하는 2단계 검증 파이프라인 설계 및 구현 방안을 설명합니다.
목차

최신 LLM의 구조화된 출력(Structured Outputs) 모드는 엔지니어링 측면에서 중요한 문제를 해결합니다. 모델의 응답이 JSON Schema 계약이나 Pydantic 모델을 엄격하게 준수하도록 보장하기 때문입니다. 하지만 유효한 스키마가 보장하는 것은 문서의 구문적 형태(syntactic shape)일 뿐이며, 내용의 사실적 진실성(factual truth)까지 보장하지는 않습니다.
공식 Google Gemini API documentation on Structured Outputs에서도 출력이 구문적으로 올바르더라도 개발자가 애플리케이션 측에서 값을 직접 검증하고 의미론적 불일치(semantic discrepancies)를 처리해야 한다고 명시적으로 경고합니다. 스키마는 파서가 JSONDecodeError 예외를 일으키지 않도록 보호하지만, 환각(hallucination), 잘못된 추론, 조건부 표현이나 양태적 한정(modal qualifications)의 간과 앞에서는 무력합니다.
아래에서는 인입된 고객 지원 이메일을 처리하는 엔드투엔드 시나리오를 다룹니다. 원시 텍스트를 API에 전달하는 단계부터 애플리케이션 레벨의 의미론적 검증 및 안전한 필드 라우팅에 이르는 과정을 단계별로 살펴봅니다.
1. 입력 데이터: 모호한 고객 지원 이메일
자연어에서 흔히 발생하는 전형적인 모호성인 조건부 마감 기한과 검증되지 않은 가정을 의도적으로 포함한 가상의 고객 지원 이메일을 살펴보겠습니다.
email_text = """От: alex.m@partner-example.com
Тема: Проблема с выгрузкой отчетов и планы по релизу
Привет! У нас со вчерашнего дня сбоит генерация PDF в биллинге.
Мы хотели бы закрыть этот вопрос как можно быстрее, в идеале к концу недели,
но если ваш релиз 2.4 задерживается, то крайний срок сдвигается на следующий вторник.
Кстати, затронут ли европейский кластер платежей eu-central-1, мы пока точно не знаем — коллеги еще перепроверяют трейсы."""
이 텍스트에는 두 가지 핵심적인 뉘앙스가 담겨 있습니다:
- 조건부 마감 기한(Conditional deadline): 마감일은 외부 요인(2.4 릴리스 지연 여부)에 따라 달라지며, 절대적인 달력 날짜가 아닌 상대적인 표현(“следующий вторник”, 다음 주 화요일)으로 서술되어 있습니다.
- 확인되지 않은 사실(Unconfirmed fact):
eu-central-1클러스터는 트레이스 확인이 필요한 미결 질문이자 가설로서만 언급되었습니다. 이 문자열은 문장 내 줄바꿈 없이 구성되어 정확한 부분 문자열 일치(substring matching) 검사가 가능합니다.
2. Pydantic 스키마 정의 및 Interactions API 호출
Pydantic을 사용하여 데이터 추출 계약을 정의하고, 공식 Google GenAI SDK 인터페이스를 통해 모델을 호출합니다.
from typing import Optional
from google import genai
from pydantic import BaseModel, Field
class TicketExtraction(BaseModel):
issue_summary: str = Field(
description="Краткая суть проблемы."
)
deadline_iso: Optional[str] = Field(
default=None,
description="Однозначно зафиксированный срок в формате YYYY-MM-DD. Если дата не определена точно или зависит от условий, вернуть null."
)
deadline_context: Optional[str] = Field(
default=None,
description="Оговорки, условия или контекст, сопровождающие срок."
)
affected_cluster: Optional[str] = Field(
default=None,
description="Точно подтвержденный затронутый кластер или сервис. Если факт не подтвержден или выражено сомнение, вернуть null."
)
raw_quote: Optional[str] = Field(
default=None,
description="Точная цитата из текста, обосновывающая извлеченные факты."
)
client = genai.Client()
prompt = f"""Извлеки параметры инцидента из письма.
Строго следуй правилу: если автор выражает сомнение или значение зависит от условий,
записывай в соответствующее поле null, а контекст выноси в deadline_context.
Письмо:
{email_text}"""
interaction = client.interactions.create(
model="gemini-3.8-flash",
input=prompt,
response_format={
"type": "text",
"mime_type": "application/json",
"schema": TicketExtraction.model_json_schema(),
},
)
live_extraction = TicketExtraction.model_validate_json(interaction.output_text)
3. 실패 사례 예시: 구문적 성공과 의미론적 실패
아래 스니펫은 특정 API 호출에서 반드시 나타나는 출력이 아니라, 전형적인 논리적 실패를 보여주는 가상의 예시입니다. 모델이 프롬프트에 지정된 의미론적 제약 조건을 무시한 채 형식적으로만 유효한 JSON을 반환할 수 있음을 보여줍니다.
{
"issue_summary": "Сбой генерации PDF в биллинге",
"deadline_iso": "следующий вторник",
"deadline_context": "в идеале к концу недели, но если релиз 2.4 задерживается, то следующий вторник",
"affected_cluster": "eu-central-1",
"raw_quote": "затронут ли европейский кластер платежей eu-central-1, мы пока точно не знаем"
}
JSON Schema 유효성 검사기와 Pydantic 라이브러리의 관점에서 보면 이 객체는 완벽합니다. 모든 키가 존재하고 타입도 일치합니다. 하지만 다운스트림 자동화 관점에서는 치명적인 오류들이 포함되어 있습니다:
- 문자열 타입이 임의의 텍스트를 허용하는 이유:
생성된 스키마에서
Optional[str]필드는anyOf구조 또는 타입 유니온(string및null)으로 변환됩니다. 스키마 검증기 입장에서는"следующий вторник"이라는 값도 완전히 유효한 문자열입니다.description내부의 텍스트 지침은 파서 수준에서 아무런 제약을 가하지 못하므로, 비어 있지 않은 모든 문자열이 검증을 통과하게 됩니다. - 가설을 확정된 사실로 둔갑:
모델은 진술의 의문형 및 추측성 뉘앙스를 무시하고
eu-central-1식별자를 추출했습니다. - 지침 기반
null반환의 불안정성: 불확실할 경우null을 반환하라는 엄격한 프롬프트 지침이 있더라도, 원본 텍스트에 개체 이름이 명시적으로 등장하면 개체를 추출하려는 모델의 성향이 우선하는 경우가 많습니다.
4. 단순한 휴리스틱 검증의 한계: 인용문, 불용어 및 인프라 레지스트리
개발자들은 종종 휴리스틱을 활용하여 LLM의 의미론적 불안정성을 보완하려고 시도합니다. 그러나 이러한 기본적인 기법들이 신뢰할 수 있는 보장을 제공하지 못하는 이유는 다음과 같습니다:
- 인용문 포함 여부 검증 (
raw_quote in email_text): 이 예시에서"затронут ли европейский кластер платежей eu-central-1, мы пока точно не знаем"라는 구문은 이메일 원문의 연속된 부분 문자열과 정확히 일치하므로, 이 검사는True로 평가됩니다. 그러나 텍스트에 물리적으로 존재한다는 사실은 인용구가 환각되지 않았음만을 증명할 뿐입니다. 의미론적으로는 작성자가 장애 발생 여부를 확신하지 못하고 있다는 정반대의 사실을 명확히 보여줍니다. - 휴리스틱 불용어(Stop-word) 매칭:
특정 표지어(예:
"ли"와 같은 조사나"возможно","не знаем"등의 표현)를 검색하는 방식은 매우 취약합니다. 짧은 표지어는 다른 단어 내부에서 오탐을 일으키기 쉽고, 완전히 다른 양태적 의미를 지닌 문장에서도 잘못 걸러질 수 있습니다. - 인프라 허용 목록(Allowed Clusters) 대조 검증:
내부 서버 레지스트리에서
eu-central-1을 확인하는 것은 해당 클러스터가 실제로 존재한다는 점(개체 식별)만 확인할 뿐입니다. 해당 노드에서 실제로 장애나 인시던트가 발생했는지(장애 이벤트)를 증명하지는 못합니다. 외부의 신뢰할 수 있는 증거(모니터링 신호, 확인된 알림 등)가 없다면, 인입된 이메일에 언급된 클러스터는 검증되지 않은 가설에 불과합니다.
5. 애플리케이션 검증 파이프라인 및 안전한 라우팅
내부 다운스트림 시스템의 장애를 방지하려면 애플리케이션 레벨에서 2차 검증 레이어를 구현해야 합니다. 이 레이어는 날짜 형식을 독립적으로 검증하고, 의심스러운 개체를 걸러내며, 불완전하거나 모호한 데이터를 사람 검토자에게 라우팅합니다.
Python의 date.fromisoformat() 헬퍼 함수는 그 자체만으로는 YYYY-MM-DD 형식만을 엄격하게 준수하도록 보장하지 못합니다(최신 Python 버전에서는 확장된 ISO 문자열도 허용됨). 따라서 형식 검증 시에는 엄격한 정규 표현식과 달력 유효성 파싱을 결합해야 합니다.
이 검증기의 동작을 시연하기 위해, 가상의 잘못된 JSON으로 초기화된 별도의 demo_extraction 객체를 사용합니다.
import re
from datetime import date
from typing import Any, Optional
from pydantic import BaseModel, Field
class ValidationResult(BaseModel):
is_safe_for_automation: bool
confirmed_deadline: Optional[date] = None
confirmed_cluster: Optional[str] = None
review_reasons: list[str] = Field(default_factory=list)
downstream_payload: dict[str, Any] = Field(default_factory=dict)
def validate_and_route_ticket(
extracted: TicketExtraction,
source_text: str
) -> ValidationResult:
reasons: list[str] = []
if extracted.raw_quote and extracted.raw_quote not in source_text:
reasons.append("Указанная цитата отсутствует в исходном письме.")
parsed_date: Optional[date] = None
if extracted.deadline_iso:
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", extracted.deadline_iso):
reasons.append(
f"Значение deadline_iso не соответствует строгому формату YYYY-MM-DD: '{extracted.deadline_iso}'."
)
else:
try:
parsed_date = date.fromisoformat(extracted.deadline_iso)
except ValueError:
reasons.append(
f"Значение deadline_iso содержит недопустимую календарную дату: '{extracted.deadline_iso}'."
)
parsed_date = None
if parsed_date and extracted.deadline_context:
reasons.append(
f"Срок {parsed_date.isoformat()} сопровождается оговорками ('{extracted.deadline_context}') и требует ручного согласования."
)
parsed_date = None
validated_cluster: Optional[str] = None
if extracted.affected_cluster:
reasons.append(
f"Кластер '{extracted.affected_cluster}' упомянут в письме как предположение; без внешнего подтверждения из мониторинга требуется проверка оператором."
)
needs_manual_review = len(reasons) > 0 or parsed_date is None or validated_cluster is None
safe_payload = {
"summary": extracted.issue_summary,
"deadline": parsed_date.isoformat() if parsed_date else None,
"cluster": validated_cluster,
"routing_status": "MANUAL_REVIEW" if needs_manual_review else "AUTO_APPROVED",
}
return ValidationResult(
is_safe_for_automation=not needs_manual_review,
confirmed_deadline=parsed_date,
confirmed_cluster=validated_cluster,
review_reasons=reasons,
downstream_payload=safe_payload,
)
hypothetical_bad_json = """{
"issue_summary": "Сбой генерации PDF в биллинге",
"deadline_iso": "следующий вторник",
"deadline_context": "в идеале к концу недели, но если релиз 2.4 задерживается, то следующий вторник",
"affected_cluster": "eu-central-1",
"raw_quote": "затронут ли европейский кластер платежей eu-central-1, мы пока точно не знаем"
}"""
demo_extraction = TicketExtraction.model_validate_json(hypothetical_bad_json)
decision = validate_and_route_ticket(demo_extraction, email_text)
6. 최종 상태: 수동 검토 및 필드 격리
애플리케이션 검증이 실행된 후, decision 객체는 엄격하게 제어된 상태로 전환됩니다:
{
"is_safe_for_automation": false,
"confirmed_deadline": null,
"confirmed_cluster": null,
"review_reasons": [
"Значение deadline_iso не соответствует строгому формату YYYY-MM-DD: 'следующий вторник'.",
"Кластер 'eu-central-1' упомянут в письме как предположение; без внешнего подтверждения из мониторинга требуется проверка оператором."
],
"downstream_payload": {
"summary": "Сбой генерации PDF в биллинге",
"deadline": null,
"cluster": null,
"routing_status": "MANUAL_REVIEW"
}
}
신뢰 수준에 따른 필드 분류
| 필드 | 라우팅 상태 | 근거 |
|---|---|---|
summary | 티켓 초안 | 빌링 PDF 생성 문제는 명확하게 진술되어 있습니다. 이 필드는 이슈 트래커의 임시 티켓 설명으로 안전하게 채울 수 있습니다. |
cluster | 차단됨 (null) | 클러스터 언급은 작성자의 확인되지 않은 가정입니다. 오경보를 방지하기 위해 인프라 온콜(on-call) 로테이션으로의 자동 할당이 차단됩니다. |
deadline | 차단됨 (null) | 비구조화된 구문은 달력 날짜가 아니며 기한 자체도 조건부입니다. 엄격한 SLA에 자동으로 연동되는 것이 차단됩니다. |
routing_status | 운영자 대기열 | MANUAL_REVIEW 상태는 자동 트리거가 일시 중단되었음을 나타내며, 상세 검토 사유 목록과 함께 티켓을 1차 지원 담당자에게 전달합니다. |
SLA 계산, 우선순위 격상, 전문 엔지니어링 팀으로의 라우팅이 이루어지기 전에 반드시 사람의 검증을 거쳐야 합니다. 애플리케이션은 보고된 문제 요약을 포함하는 티켓 초안을 생성할 수는 있지만, 운영자가 명시적으로 확인할 때까지 주요 인시던트 속성은 할당되지 않은 상태로 유지됩니다.
요약
Structured Outputs는 스키마 수준의 포맷 안정성을 제공하여 다운스트림 코드를 예기치 않은 구문 오류로부터 보호합니다. 그러나 스키마 계약을 준수한다고 해서 데이터의 진실성이 보장되는 것은 아닙니다. LLM을 비즈니스 프로세스에 안전하게 통합하려면 API 경계에서의 구문적 강제, 애플리케이션 코드 내부의 엄격한 정규식 및 타입 검증, 그리고 불확실한 필드를 사람의 검토 대기열로 의무적으로 격리하는 2단계 아키텍처가 반드시 필요합니다.