AI JSON을 믿기 전에: 구조화 출력과 로컬 검증을 분리하는 법
AI 응답을 업무 데이터로 연결하는 개발자를 위한 실습입니다. 유료 호출 없이 작은 JSON 계약을 시험하고, 구조화 출력과 로컬 검사·내용 확인의 경계를 분리합니다.
AI 응답을 업무 프로그램에 연결할 때 중요한 것은 JSON처럼 보이는 문장을 받는 일이 아니라, 정해진 필드와 타입을 지킨 데이터만 다음 단계로 보내는 일입니다. OpenAI의 Structured Outputs로 출력 모양을 제한하고, Python 표준 라이브러리로 작은 업무 계약을 다시 확인하는 과정을 나누면 유료 API를 호출하기 전에도 오류 처리와 저장 경계를 시험할 수 있습니다.
세 가지 검증 경계
JSON 문법, 스키마 일치, 업무상 사실은 서로 다릅니다. json.loads()가 성공해도 필수 키가 없거나 숫자 자리에 문자열이 들어갈 수 있습니다. 스키마를 모두 지켜도 원문에 없는 담당자나 틀린 일정이 들어갈 수 있습니다. 구조화 출력은 형식 계약을 강하게 만드는 수단이지, 내용의 진실성과 실행 권한을 보장하는 장치가 아닙니다.
2026년 10월 3일 확인한 OpenAI 공식 가이드는 JSON mode와 Structured Outputs를 구분합니다. 전자는 유효한 JSON 출력에 초점을 맞추고 후자는 지원되는 JSON Schema에 맞는 출력을 제공합니다. Responses API에서는 text.format에 type: json_schema, strict: true, 스키마를 넣습니다. 최상위는 객체여야 하고 모든 필드는 required에 들어가며 객체에는 additionalProperties: false가 필요합니다. 선택적인 값은 지원 조건에 맞춰 null 허용 타입으로 표현할 수 있습니다.
이 글의 로컬 코드는 범용 JSON Schema 엔진이 아닙니다. 딱 세 필드의 계약과 추가 업무 규칙을 직접 검사합니다. Python 표준 라이브러리에 완전한 JSON Schema 검증기가 있다고 오해하지 않는 것이 출발점입니다. 여러 스키마·참조·조합 규칙을 운영하려면 별도 검증 라이브러리를 평가하고 그 버전까지 관리해야 합니다.
준비와 버전
Python 3와 터미널, 비어 있는 실습 폴더가 필요합니다. 외부 패키지 설치나 API 키는 로컬 실습에 필요하지 않습니다. macOS·Linux의 bash/zsh 기준이며 Windows에서는 코드 파일을 편집기로 저장한 뒤 py validate.py처럼 실행하면 됩니다. Python 설치 자체는 공식 배포판의 운영체제별 안내를 따릅니다.
mkdir structured-output-demo
cd structured-output-demo
python3 --version
이번 로컬 검증은 macOS의 Python 3.9.6에서 실행했습니다. 이 버전은 최신 지원 환경으로 권장하는 것이 아니라 실제 시험 환경을 밝힌 것입니다. 새 프로젝트는 지원 중인 Python 배포판을 선택하세요. 확인한 공식 json 문서는 Python 3.14.8 문서이며 아래 예제는 구버전에도 있는 기능만 사용합니다. 특정 최신 모델명이나 계정별 API 접근 가능 여부는 이 글에서 단정하지 않습니다.
최소 계약 만들기
실습 계약은 업무 항목의 제목, 우선순위, 예상 소요 시간입니다. 스키마를 schema.json으로 저장하고, 로컬 검사기는 이를 자동 해석하지 않고 같은 조건을 명시적으로 구현합니다. 따라서 스키마를 바꾸면 검사 코드와 테스트도 함께 바꿔야 합니다. 이 중복은 작은 실습에서는 이해하기 쉽지만 큰 서비스에서는 단일 정의에서 스키마와 검증기를 만드는 편이 유지보수에 유리합니다.
cat > schema.json <<'JSON'
{
"type": "object",
"properties": {
"title": {"type": "string"},
"priority": {"type": "string", "enum": ["low", "normal", "high"]},
"minutes": {"type": "integer"}
},
"required": ["title", "priority", "minutes"],
"additionalProperties": false
}
JSON
cat > validate.py <<'PY'
import json
import sys
from pathlib import Path
def pairs_to_object(pairs):
obj = {}
for key, value in pairs:
if key in obj:
raise ValueError("duplicate key: " + key)
obj[key] = value
return obj
def reject_constant(value):
raise ValueError("non-standard number: " + value)
def validate(text):
if len(text.encode("utf-8")) > 10000:
raise ValueError("input too large")
obj = json.loads(text, object_pairs_hook=pairs_to_object,
parse_constant=reject_constant)
if type(obj) is not dict:
raise ValueError("root must be object")
if set(obj) != {"title", "priority", "minutes"}:
raise ValueError("missing or extra keys")
if type(obj["title"]) is not str or not obj["title"].strip():
raise ValueError("title must be non-empty string")
if type(obj["priority"]) is not str or obj["priority"] not in {
"low", "normal", "high"
}:
raise ValueError("invalid priority")
if type(obj["minutes"]) is not int or not 1 <= obj["minutes"] <= 480:
raise ValueError("minutes must be integer in 1..480")
return obj
if __name__ == "__main__":
try:
with Path(sys.argv[1]).open("rb") as stream:
raw = stream.read(10001)
if len(raw) > 10000:
raise ValueError("input too large")
result = validate(raw.decode("utf-8"))
print(json.dumps(result, ensure_ascii=False, allow_nan=False))
except (ValueError, OSError, IndexError, RecursionError) as exc:
print("REJECT:", exc, file=sys.stderr)
sys.exit(1)
PY
printf '%s\n' '{"title":"검토 회의","priority":"normal","minutes":30}' > sample.json
python3 validate.py sample.json
성공하면 한 줄의 JSON과 종료 코드 0이 나옵니다. minutes의 1~480 범위와 빈 제목 금지는 API에 전달한 스키마보다 더 좁은 업무 규칙입니다. bool은 Python에서 int의 하위 타입이므로 isinstance(True, int)만으로 검사하면 안 됩니다. 예제의 type(...) is int는 true를 시간으로 잘못 받아들이는 문제를 막습니다.
실패도 실행하기
정상 응답 하나만 통과시키면 검사기의 의미가 약합니다. 필수 키 누락, 추가 필드, 잘못된 enum, 중복 키, NaN, 배열 루트가 모두 거절되는지 확인합니다. 다음 코드는 실패를 기대하는 테스트이므로 전체 테스트의 종료 코드가 0인 것이 정상입니다.
cat > test_validate.py <<'PY'
from validate import validate
good = '{"title":"검토","priority":"high","minutes":30}'
assert validate(good)["minutes"] == 30
bad = [
'{"title":"검토","priority":"high"}',
'{"title":"검토","priority":"high","minutes":30,"send":true}',
'{"title":"검토","priority":"urgent","minutes":30}',
'{"title":"검토","priority":"high","minutes":true}',
'{"title":"검토","title":"변경","priority":"high","minutes":30}',
'{"title":"검토","priority":"high","minutes":NaN}',
'[]',
]
for text in bad:
try:
validate(text)
except ValueError:
print("REJECT OK")
else:
raise AssertionError("unexpected acceptance")
print("PASS: 1 accepted, 7 rejected")
PY
python3 test_validate.py
직접 실행에서 정상 1건과 실패 7건을 확인했습니다. 이것은 외부 모델의 응답 품질 시험이 아니라 로컬 계약의 회귀 시험입니다. 입력 파일은 최대 10,001바이트까지만 먼저 읽어 무제한 파일 로드를 피합니다. JSON 중첩 깊이와 처리 시간을 완전히 제한하는 서버용 샌드박스는 아니므로, 공개 업로드 서비스에서는 요청 크기·시간·동시 처리 제한을 별도로 두어야 합니다.
API 요청의 모양
다음은 공식 문서에 근거해 API 요청 본문을 만드는 코드입니다. 실행해도 로컬 request.json만 만들며 네트워크 호출이나 비용이 발생하지 않습니다. OPENAI_MODEL에는 조직 계정에서 Structured Outputs와 Responses API 지원을 확인한 모델 식별자를 넣으세요. 임의로 모델명을 추정하거나 존재하지 않는 최신 버전을 고정하지 않습니다.
cat > build_request.py <<'PY'
import json
import os
from pathlib import Path
model = os.environ.get("OPENAI_MODEL")
if not model:
raise SystemExit("Set OPENAI_MODEL to a verified supported model ID")
schema = json.loads(Path("schema.json").read_text(encoding="utf-8"))
payload = {
"model": model,
"input": "업무 항목을 추출하세요: 검토 회의, 보통 우선순위, 30분",
"max_output_tokens": 300,
"store": False,
"text": {"format": {
"type": "json_schema", "name": "work_item",
"strict": True, "schema": schema
}}
}
Path("request.json").write_text(
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
)
print("WROTE request.json; no API request sent")
PY
python3 build_request.py
환경 변수를 아직 설정하지 않았다면 안내와 함께 멈추는 것이 정상입니다. 실제 제출은 승인된 클라이언트에서 POST https://api.openai.com/v1/responses로 수행하며 Bearer 인증과 JSON Content-Type이 필요합니다. 이 글에서는 실제 제출을 실행하지 않았고 유료 API 호출도 하지 않았습니다. 구조화 출력이 지원되지 않는 모델·스키마는 요청 단계에서 오류가 날 수 있습니다.
응답을 저장하기 전
API 연결 시 HTTP 성공만 확인하고 응답 전체를 validate()에 넣으면 안 됩니다. Responses API의 완료 상태를 확인하고 메시지 content에서 output_text를 찾아 그 텍스트에 검증기를 적용합니다. 안전상 거절인 refusal, 토큰 한도로 끝난 incomplete, 실패 상태는 업무 항목과 다른 흐름으로 분기해야 합니다. 출력이 여러 개인 경우 무엇을 사용할지 계약부터 정하고, 첫 배열 원소가 항상 답이라고 가정하지 않습니다.
검증 통과는 저장 후보가 되었다는 뜻입니다. 일정 등록, 이메일 발송, 결제 같은 부작용은 별도 승인과 권한 검사 뒤 실행합니다. 시간이나 제목이 실제 원문과 맞는지도 필요한 업무에서는 사람이 확인합니다. 실패 자료는 원문을 그대로 오류 로그에 남기기보다 오류 종류와 비식별 추적 ID를 남기는 편이 안전합니다.
비용과 데이터
로컬 예제는 네트워크를 사용하지 않습니다. API에 연결하면 입력·출력 토큰과 선택한 모델의 현재 요금이 비용을 결정하며, 재시도도 추가 비용이 됩니다. max_output_tokens는 출력 예산을 제한하지만 너무 작으면 불완전 응답이 늘 수 있습니다. 무조건 반복하는 재시도 대신 상한과 원인별 분기를 두세요.
API 요청의 입력은 외부 서비스로 전달됩니다. 주민번호·고객 비밀·접근 토큰을 실습 입력에 넣지 않습니다. store: false는 응답 저장 동작을 제어하는 옵션이지 모든 운영 로그와 보존 정책이 사라진다는 보증이 아닙니다. 조직의 데이터 정책과 서비스의 현재 보존 조건을 먼저 확인해야 합니다. 로컬 파일에도 같은 민감정보 경계를 적용합니다.
오류와 되돌리기
JSONDecodeError는 따옴표·쉼표·코드 펜스가 섞였는지 살피고, 키 오류는 계약 버전과 모델 입력을 대조합니다. 잘못된 값을 자동 보정해 성공으로 꾸미지 않습니다. 계약을 바꿀 때는 이전 테스트를 보존하고 필요한 새 테스트를 추가합니다. API 연결에서 400은 모델·스키마·본문, 401은 인증, 429는 한도와 Retry-After를 확인합니다. 이 상태별 안내는 문서 기준이며 실제 API 오류를 유발한 시험은 하지 않았습니다.
되돌리기는 먼저 API 호출 연결을 끄고, 이전 계약과 검사기 버전을 함께 복원하는 방식으로 합니다. 실습만 정리하려면 pwd와 파일 목록을 확인하고 아래 파일만 삭제합니다. 조직 데이터가 들어간 파일은 백업·보존 규칙부터 확인하세요.
pwd
ls
rm -i schema.json validate.py test_validate.py sample.json build_request.py
# request.json이 생성된 경우에만 별도로 확인 후 제거
rm -i request.json
unset OPENAI_MODEL