- Published on
OpenAI JSON Schema 응답 깨짐, strict 모드로 막기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 LLM 응답을 JSON으로 파싱하는 순간부터 장애는 시작됩니다. 개발자는 분명히 “JSON으로만 답해”라고 지시했는데, 모델이 설명 문장을 섞거나, 따옴표를 빠뜨리거나, 스키마에 없는 필드를 추가해서 파서가 터집니다. 더 난감한 건 재현이 어렵다는 점입니다. 동일 프롬프트에서도 토큰 경계, 안전 정책, 컨텍스트 길이, 스트리밍 중간 끊김 같은 변수로 응답이 흔들립니다.
이 글은 OpenAI의 JSON Schema 기반 응답(Structured Outputs)을 쓸 때 자주 겪는 “응답 깨짐” 유형을 분류하고, strict 모드로 구조적 일관성을 강제하는 방법을 중심으로 정리합니다. Node.js와 Python 예제를 포함해, 운영에서 파싱 실패를 줄이는 방어 코드를 함께 다룹니다.
왜 JSON Schema인데도 응답이 깨질까
JSON Schema를 지정하면 모델이 구조를 따르도록 “유도”할 수는 있지만, 다음 상황에서는 여전히 깨질 수 있습니다.
1) 모델이 JSON 바깥 텍스트를 섞는 경우
예를 들어 응답이 아래처럼 오면 JSON 파서가 즉시 실패합니다.
- JSON 앞뒤로 설명 문장 추가
- 코드펜스(예:
```json)로 감싸기 - “아래는 요청하신 JSON입니다” 같은 접두사
클라이언트에서 response_format을 제대로 쓰지 않았거나, strict를 켜지 않았거나, 혹은 스트리밍 중간에 일부가 손실되면 이런 형태가 빈번해집니다.
2) 스키마와 다른 타입, 누락, 추가 필드
- 숫자여야 하는데 문자열로 옴
- 필수 필드 누락
additionalProperties가 사실상 허용된 것처럼 임의 필드가 생김
이 경우 “JSON은 유효하지만 스키마 검증이 실패”합니다. 운영에서는 이쪽이 더 위험합니다. 파서는 통과했는데 downstream 로직에서 예외가 발생하거나, 더 나쁘게는 잘못된 값으로 비즈니스 로직이 실행될 수 있습니다.
3) 유니코드, 인코딩, 이스케이프 이슈
LLM 출력에는 다양한 유니코드 문자가 섞입니다. 특히 로그 수집이나 메시지 큐를 거치면서 인코딩이 꼬이면 JSON이 깨진 것처럼 보일 수 있습니다. 예를 들어 Windows 계열 기본 인코딩, 잘못된 바이트 시퀀스, 컨테이너 로케일 문제 등이 겹치면 UnicodeDecodeError류가 터집니다.
관련해서 파이썬에서 자주 보는 이슈는 아래 글도 참고할 만합니다.
4) 스트리밍 중간 실패로 잘린 JSON
스트리밍을 켜면 네트워크 끊김, 타임아웃, 프록시 버퍼링 등으로 응답이 중간에 잘릴 수 있습니다. 이때는 마지막 중괄호가 없거나 문자열이 닫히지 않아 파싱이 실패합니다.
MSA 환경에서 타임아웃 설계가 부실하면 이런 문제가 연쇄로 번질 수 있습니다. 타임아웃과 재시도 설계 관점은 아래 글도 결이 비슷합니다.
strict 모드가 해결하는 것과 한계
OpenAI Structured Outputs에서 strict를 켜면, 모델 출력이 지정한 JSON Schema에 “정확히” 맞도록 강제하는 쪽에 가깝게 동작합니다. 기대 효과는 다음과 같습니다.
- JSON 바깥 텍스트를 섞는 확률이 크게 감소
- 필수 필드 누락, 타입 불일치, 스키마 외 필드 생성이 줄어듦
- 서버에서 “파싱 성공률”이 올라가고, 검증 실패 재시도 로직이 단순해짐
하지만 strict가 만능은 아닙니다.
- 스트리밍 중간 손실, 네트워크 장애로 인한 잘림은 여전히 발생 가능
- 스키마가 과도하게 복잡하면 모델이 채우기 어려워 실패하거나 품질이 떨어질 수 있음
- 스키마 설계가 애매하면 “형식은 맞는데 의미는 틀린” 값이 들어올 수 있음
즉 strict는 “형식적 안정성”을 올리는 도구이고, “의미적 정합성”은 별도의 검증 로직이 필요합니다.
실무에서 추천하는 스키마 설계 원칙
1) additionalProperties를 명시적으로 막기
스키마가 느슨하면 모델이 필드를 더 만들어도 통과할 수 있습니다. 가능하면 객체 타입에는 additionalProperties: false를 붙여 “정해진 키만 허용”하는 편이 안전합니다.
2) 문자열 enum을 적극 사용하기
상태값, 카테고리, 액션 타입은 문자열 enum으로 제한하세요. 숫자 코드보다 디버깅도 쉽고, 모델도 더 잘 맞춥니다.
3) 날짜/시간은 포맷을 강제
예: ISO 8601을 쓰고, 서버에서 추가 검증을 합니다. 모델이 “내일 오후”처럼 자연어로 내보내는 것을 막아야 합니다.
4) 중첩 구조는 얕게
중첩이 깊을수록 모델이 실수할 확률이 올라갑니다. 꼭 필요한 깊이만 유지하고, 리스트 아이템 스키마도 단순하게 유지합니다.
Node.js 예제: strict + Zod 이중 방어
아래는 JSON Schema로 구조를 강제하고, 애플리케이션 레벨에서 Zod로 의미 검증까지 하는 패턴입니다. 핵심은 strict: true와, 실패 시 재시도 또는 폴백을 넣는 것입니다.
import OpenAI from "openai";
import { z } from "zod";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
// 애플리케이션 레벨 검증(의미 검증)
const OutputZ = z.object({
status: z.enum(["ok", "needs_clarification", "error"]),
title: z.string().min(1).max(80),
tags: z.array(z.string().min(1)).max(10),
summary: z.string().min(1).max(300),
});
type Output = z.infer<typeof OutputZ>;
const schema = {
name: "blog_metadata",
strict: true,
schema: {
type: "object",
additionalProperties: false,
properties: {
status: { type: "string", enum: ["ok", "needs_clarification", "error"] },
title: { type: "string", minLength: 1, maxLength: 80 },
tags: {
type: "array",
maxItems: 10,
items: { type: "string", minLength: 1 },
},
summary: { type: "string", minLength: 1, maxLength: 300 },
},
required: ["status", "title", "tags", "summary"],
},
} as const;
export async function generateMeta(input: string): Promise<Output> {
const res = await client.responses.create({
model: "gpt-4.1-mini",
input: [
{
role: "system",
content:
"You must output JSON that matches the provided JSON Schema. Do not include any extra keys.",
},
{ role: "user", content: input },
],
response_format: {
type: "json_schema",
json_schema: schema,
},
});
// SDK가 파싱된 결과를 제공하는 형태라면 그 값을 우선 사용
// (환경에 따라 raw text만 받는 경우가 있으니, 둘 다 대비)
const raw = (res as any).output_parsed ?? (res as any).output_text;
// raw가 문자열이면 JSON.parse, 객체면 그대로
const obj = typeof raw === "string" ? JSON.parse(raw) : raw;
// 의미 검증
return OutputZ.parse(obj);
}
운영 팁:
- 파싱 실패(
JSON.parse)와 스키마/의미 검증 실패(Zod)를 분리해서 로깅하세요. - 실패 유형별로 재시도 전략을 달리하는 것이 좋습니다. 예를 들어 파싱 실패는 동일 프롬프트 재시도, 의미 검증 실패는 프롬프트에 “제약 조건 위반”을 명시하고 재시도하는 식입니다.
Python 예제: strict + Pydantic 검증
파이썬에서는 Pydantic으로 2차 검증을 걸면 편합니다.
from pydantic import BaseModel, Field, ValidationError
from typing import Literal, List
from openai import OpenAI
client = OpenAI()
class Output(BaseModel):
status: Literal["ok", "needs_clarification", "error"]
title: str = Field(min_length=1, max_length=80)
tags: List[str] = Field(max_length=10)
summary: str = Field(min_length=1, max_length=300)
schema = {
"name": "blog_metadata",
"strict": True,
"schema": {
"type": "object",
"additionalProperties": False,
"properties": {
"status": {"type": "string", "enum": ["ok", "needs_clarification", "error"]},
"title": {"type": "string", "minLength": 1, "maxLength": 80},
"tags": {
"type": "array",
"maxItems": 10,
"items": {"type": "string", "minLength": 1},
},
"summary": {"type": "string", "minLength": 1, "maxLength": 300},
},
"required": ["status", "title", "tags", "summary"],
},
}
def generate_meta(text: str) -> Output:
res = client.responses.create(
model="gpt-4.1-mini",
input=[
{"role": "system", "content": "Return only JSON matching the JSON Schema."},
{"role": "user", "content": text},
],
response_format={"type": "json_schema", "json_schema": schema},
)
raw = getattr(res, "output_parsed", None) or getattr(res, "output_text", None)
obj = raw if isinstance(raw, dict) else __import__("json").loads(raw)
try:
return Output.model_validate(obj)
except ValidationError as e:
# 여기서 실패 유형/필드를 로깅하고 재시도 정책을 적용
raise
장애로 이어지는 패턴과 방지 체크리스트
1) “파싱 실패를 재시도”가 무한 루프가 되는 경우
재시도를 넣되, 반드시 상한을 두고 폴백을 준비해야 합니다.
- 최대 재시도 횟수(예: 2회)
- 재시도 시 프롬프트에 “이전 출력이 스키마 위반이었다”를 명확히 추가
- 최종 실패 시
status: error같은 안전한 기본 응답으로 전환
2) 스키마는 맞는데 값이 위험한 경우
예를 들어 url 필드가 스키마상 문자열이라 통과했지만, 내부망 주소나 의도치 않은 스킴이 들어오면 SSRF 같은 보안 이슈로 이어질 수 있습니다. 이건 strict로 못 막습니다.
- 허용 스킴 제한(예:
https만) - 도메인 allowlist
- 길이 제한, 정규식 검증
3) 로그/관측성 부재로 “가끔 깨짐”이 영원히 남는 경우
LLM 장애는 재현이 어렵기 때문에 관측성이 핵심입니다.
- 원문 응답(raw) 샘플링 저장(개인정보 마스킹 포함)
- 실패 유형별 카운트(파싱 실패, 스키마 실패, 의미 검증 실패)
- 모델 버전, 프롬프트 버전, 토큰 수, 지연시간, 재시도 횟수 태깅
CI에서 이런 류의 “가끔 실패”를 잡아내려면 캐시/재현성 관리도 중요합니다. 아래 글은 맥락은 다르지만, 파이프라인 디버깅 체크리스트 관점에서 참고가 됩니다.
strict 모드 적용 시 자주 묻는 질문
Q1. strict면 프롬프트에서 “JSON으로만”을 빼도 되나
빼도 되는 경우가 많지만, 운영에서는 남겨두는 편이 안전합니다. 스키마 강제는 형식을 잡아주고, 시스템 메시지는 모델의 “태도”를 고정합니다. 둘은 중복이 아니라 방어층입니다.
Q2. 스키마가 너무 빡빡하면 품질이 떨어지나
가능합니다. 특히 긴 자유서술을 억지로 구조화하면 모델이 의미를 희생하고 형식만 맞추려 할 수 있습니다. 이럴 땐 다음처럼 타협합니다.
- 구조화가 필요한 최소 필드만 스키마로 강제
- 긴 본문은 별도 필드로 두되 길이 제한만 걸기
- 분류 문제는 enum으로, 생성 문제는 문자열로 분리
Q3. 스트리밍을 써도 안전한가
스트리밍은 “UX”에는 좋지만 “정합성”에는 불리합니다. JSON 한 덩어리를 안전하게 받고 싶다면 비스트리밍이 더 단순합니다. 스트리밍을 유지해야 한다면, 완결성을 확인한 뒤에만 파싱하고, 중간 실패 시 재요청하는 흐름이 필요합니다.
결론: strict는 시작점, 검증과 관측성이 완성
OpenAI JSON Schema 기반 응답이 깨지는 문제는 대부분 “형식이 흔들리는 것”과 “운영 환경에서 응답이 손실되는 것”의 조합입니다. strict 모드는 형식 흔들림을 크게 줄여주지만, 다음이 함께 있어야 운영에서 안정화됩니다.
- 스키마 설계에서
additionalProperties차단, enum 적극 활용 - 애플리케이션 레벨의 2차 검증(Zod, Pydantic)
- 실패 유형별 재시도와 폴백
- 원문 샘플링, 실패 카운팅, 프롬프트/모델 버전 태깅
이 조합을 갖추면 “가끔 깨져서 알람만 울리는” 단계에서 벗어나, 예측 가능한 실패 처리와 안정적인 JSON 파이프라인을 만들 수 있습니다.