- Published on
OpenAI Structured Outputs 400 해결 - JSON Schema
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
OpenAI Structured Outputs(JSON Schema 기반 강제 출력)를 붙이면, 모델 출력의 일관성이 크게 좋아집니다. 그런데 도입 초기에 가장 많이 부딪히는 게 400 Bad Request입니다. 메시지는 대개 Invalid schema, Schema is not valid, Unsupported keyword, response_format is invalid 같은 형태로 뭉뚱그려져 있고, 원인은 스키마의 아주 사소한 디테일인 경우가 많습니다.
이 글은 “왜 400이 나는지”를 JSON Schema 호환성 관점에서 분해하고, 실무에서 바로 적용 가능한 스키마 작성 규칙, 금지/주의 패턴, 검증 및 디버깅 루틴, 그리고 동작하는 코드 예제까지 한 번에 정리합니다.
> Structured Outputs로 환각을 줄이는 맥락과 스키마 강제 출력 전략은 아래 글도 함께 보면 좋습니다: RAG 환각을 줄이는 JSON Schema 강제 출력법
Structured Outputs에서의 “JSON Schema”는 완전한 표준이 아니다
핵심은 이겁니다.
- OpenAI가 받는 스키마는 JSON Schema 표준을 그대로 100% 구현한 것이 아니라,
- 지원하는 서브셋(subset) 이 있고,
- 그 서브셋에서 벗어나면 모델 호출 전에 요청 자체가 400으로 거절됩니다.
따라서 “내 스키마가 Draft-07/2020-12에서 합법”이어도, OpenAI Structured Outputs에서 지원하지 않는 키워드/구성이면 400이 납니다.
400의 대표 원인 10가지 (실전에서 많이 터지는 순)
1) 루트 타입이 object가 아닌 경우
Structured Outputs는 대개 루트가 object인 스키마를 기대합니다.
- 실패 예: 루트가
type: "array" - 해결: 루트를 object로 두고 배열은 프로퍼티로 감싸기
{
"type": "object",
"properties": {
"items": {
"type": "array",
"items": { "type": "string" }
}
},
"required": ["items"],
"additionalProperties": false
}
2) additionalProperties 미지정(또는 true)로 인한 strict 위반
Structured Outputs의 강제성을 제대로 얻으려면 보통
additionalProperties: falserequired명시
를 함께 씁니다. 일부 환경/설정에서는 이를 강하게 요구하거나, 스키마가 느슨하면 예상치 못한 오류/거절을 유발하기도 합니다.
권장 패턴:
{
"type": "object",
"properties": {
"id": { "type": "string" },
"score": { "type": "number" }
},
"required": ["id", "score"],
"additionalProperties": false
}
3) nullable 표현을 type: ["string", "null"]로 쓴 경우
표준 JSON Schema에서는 흔하지만, 일부 구현체/서브셋에서는 type 배열을 제한합니다.
대안은 보통 anyOf를 사용합니다.
{
"type": "object",
"properties": {
"nickname": {
"anyOf": [
{ "type": "string" },
{ "type": "null" }
]
}
},
"required": ["nickname"],
"additionalProperties": false
}
4) oneOf/anyOf/allOf의 과도한 중첩
Union을 복잡하게 만들면 스키마 파서가 거절하거나, 모델이 생성 과정에서 충돌을 일으킵니다. 특히 oneOf 중첩 + required 조합이 복잡해질수록 실패 확률이 올라갑니다.
실무 팁:
- union이 필요하면 최상위 1단에서 단순하게
- discriminator 역할을 하는
type필드(예:kind)를 넣고 분기
{
"type": "object",
"properties": {
"kind": { "type": "string", "enum": ["A", "B"] },
"payload": {
"anyOf": [
{
"type": "object",
"properties": {
"kind": { "const": "A" },
"value": { "type": "string" }
},
"required": ["kind", "value"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"kind": { "const": "B" },
"count": { "type": "integer" }
},
"required": ["kind", "count"],
"additionalProperties": false
}
]
}
},
"required": ["kind", "payload"],
"additionalProperties": false
}
5) format, patternProperties, propertyNames, dependencies 등 “지원 여부가 애매한 키워드” 사용
표준에서는 합법이지만, Structured Outputs에서는 제한될 수 있습니다.
실무적으로는:
format: "email"같은 건 프롬프트/후처리로 대체하거나- 단순
pattern정도로만 제한적으로 사용
패턴은 가능하더라도 너무 복잡한 정규식은 피하는 게 안전합니다.
6) items 스키마 누락 (배열 타입인데 items 없음)
일부 구현체는 type: "array"일 때 items가 필수입니다.
{ "type": "array" }
위처럼 쓰면 400이 날 수 있습니다.
7) required에 존재하지 않는 프로퍼티 이름이 들어간 경우
타이핑 실수로 자주 납니다.
{
"type": "object",
"properties": {
"userId": { "type": "string" }
},
"required": ["user_id"],
"additionalProperties": false
}
userId vs user_id 같은 불일치가 있으면 스키마 검증에서 탈락합니다.
8) enum 타입 불일치
enum 값과 type이 맞지 않으면 거절됩니다.
{
"type": "integer",
"enum": ["1", "2"]
}
9) 숫자 제약(minimum/maximum)과 타입 충돌
minimum을 줬는데 type이 string이면 당연히 깨집니다. 스키마가 커질수록 이런 충돌이 숨어 들어갑니다.
10) 스키마가 너무 크거나(깊이/중첩 과다) 너무 모호한 경우
요청은 통과해도 모델이 생성 단계에서 실패하거나, 서버가 스키마를 처리하다 거절할 수 있습니다. 스키마는 “정확하지만 단순하게”가 정답입니다.
“동작하는” Structured Outputs 예제 (Node.js)
아래는 최소한의 안전한 패턴을 담은 예제입니다.
- 루트 object
- required 명시
- additionalProperties false
- nullable은 anyOf
import OpenAI from "openai";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const schema = {
name: "issue_triage",
strict: true,
schema: {
type: "object",
properties: {
category: {
type: "string",
enum: ["bug", "question", "incident"]
},
severity: {
type: "integer",
minimum: 1,
maximum: 5
},
title: { type: "string" },
owner: {
anyOf: [{ type: "string" }, { type: "null" }]
},
tags: {
type: "array",
items: { type: "string" }
}
},
required: ["category", "severity", "title", "owner", "tags"],
additionalProperties: false
}
};
const resp = await client.responses.create({
model: "gpt-4.1-mini",
input: "ALB에서 간헐적으로 504가 발생해. 원인 분류와 심각도, 담당자 후보를 정리해줘.",
response_format: { type: "json_schema", json_schema: schema }
});
console.log(resp.output_text);
이 형태를 베이스로 확장하면 400을 상당 부분 피할 수 있습니다.
400을 줄이는 스키마 작성 규칙 (체크리스트)
1) object는 항상 additionalProperties: false
스키마가 커질수록 “모델이 임의 필드를 추가”하는 일이 늘고, strict 모드에서는 그게 곧 실패로 이어집니다. object에는 기본적으로 additionalProperties: false를 습관화하세요.
2) required는 전부 명시하고, nullable이면 anyOf로
“없을 수도 있음”을 표현하려고 required에서 빼면, 모델이 필드를 누락하는 쪽으로 학습/유도되기 쉽습니다.
- 필드는 항상 존재
- 값이 없으면 null
이 패턴이 운영에서 가장 안정적입니다.
3) union은 단순하게, 가능하면 discriminator 사용
oneOf로 복잡한 다형성을 만들기보다, kind 같은 식별자를 두고 분기하세요.
4) 배열은 items 필수, 객체 배열이면 items를 object로 엄격히
배열이 “문자열 배열인지, 객체 배열인지”를 모호하게 두지 마세요.
5) 검증은 로컬에서 먼저 (Ajv 등)
OpenAI에 쏘기 전에, CI에서 JSON Schema 자체를 검증하면 400의 절반은 사라집니다.
Node.js Ajv 예:
import Ajv from "ajv";
const ajv = new Ajv({ allErrors: true, strict: false });
const validate = ajv.compile(schema.schema);
const ok = validate({
category: "bug",
severity: 3,
title: "intermittent 504",
owner: null,
tags: ["eks", "alb"]
});
if (!ok) {
console.error(validate.errors);
}
디버깅 루틴: 400이 나면 이렇게 깎아라
운영에서 가장 빠른 방법은 “스키마 다이어트”입니다.
- 최소 스키마(필드 1~2개, object/required/additionalProperties만)로 호출이 되는지 확인
- 필드를 하나씩 추가하면서 어디서 깨지는지 찾기
- 깨지는 지점의 키워드(
oneOf,type: [],format, 복잡한pattern)를 제거/대체 - nullable은 anyOf로 통일
- object마다 additionalProperties false 확인
이 방식은 Kubernetes 장애 분석처럼 “원인을 좁혀가는” 접근이 제일 빠릅니다. 인프라에서 5xx를 쪼개듯이(예: EKS ALB Ingress 504(5xx) 간헐 발생 원인·해결), 스키마도 최소 단위로 축소해 재현점을 찾는 게 효율적입니다.
실무 팁: 스키마는 ‘정합성’보다 ‘운영 안정성’이 우선
표준 JSON Schema의 모든 표현력을 끌어다 쓰면, 초기엔 우아해 보이지만 운영에서는 실패 지점이 늘어납니다. 특히 RAG/에이전트 파이프라인에서 Structured Outputs는 “모델을 묶는 마지막 안전벨트”라서, 스키마가 복잡할수록 안전벨트가 오히려 끊어질 수 있습니다.
권장 운영 패턴:
- 1차: Structured Outputs로 형태 강제
- 2차: 애플리케이션에서 비즈니스 규칙 검증(예: severity가 incident면 4 이상이어야 함)
- 3차: 실패 시 재시도/보정 프롬프트
사가/재처리처럼 실패를 전제로 설계하는 접근이 잘 맞습니다(관점 참고: Saga 보상 트랜잭션 실패 재처리 설계 가이드).
결론
OpenAI Structured Outputs의 400은 대부분 “모델 문제가 아니라 스키마 문제”입니다. 그리고 그 스키마 문제는 대개 다음 3가지로 수렴합니다.
- OpenAI가 지원하는 JSON Schema 서브셋을 벗어남
- strict 모드에서 추가 필드/누락 필드가 생길 여지가 있음
- union/nullable 표현이 과도하게 복잡하거나 호환되지 않음
루트 object + required + additionalProperties false + nullable은 anyOf. 이 4가지만 지켜도 400의 대부분을 예방할 수 있고, 나머지는 “최소 스키마로 축소 후 점진 확장” 디버깅 루틴으로 빠르게 해결할 수 있습니다.