사용자가 제출한 청구서에서 구조화된 데이터를 추출하기 위해 GPT-4를 호출하는 기능을 출시했다고 해봅시다.
개발 환경에서는 완벽하게 동작합니다 — 매번 깨끗한 JSON 객체를 반환하죠. 그러다 프로덕션, 새벽 2시에 Sentry 알람이 뜹니다:
JSON.parse: unexpected token. 모델이 실제 페이로드 앞에 "물론이죠! 요청하신 JSON은 다음과 같습니다:"
같은 말을 먼저 붙이기로 결정한 겁니다. 일주일 뒤, 같은 기능에 다른 버그가 납니다: 모델이 total_amount 대신
totalAmount를 반환해서, 다운스트림 DB 쓰기가 조용히 필드를 버립니다. LLM 출력의 신뢰성을 프롬프트 엔지니어링으로
돌파해 왔다면,
OpenAI Structured Outputs이
기다려 온 해결책입니다.
Structured Outputs는 OpenAI가 2024년 8월에 출시한 기능으로,
JSON Schema를
response_format 파라미터로 넘기면 그 스키마에 정확히 맞는 반드시 유효한 응답을 돌려줍니다.
이것은 출력이 유효한 JSON임만 보장했던 — 특정 형태에 맞는지는 보장하지 않았던 — 구 JSON 모드({"type": "json_object"})와
다릅니다. 모델 출력을 도구 호출로 라우팅하지만 자체적인 의식 절차를 덧붙이는 function calling과도 구별됩니다.
Structured Outputs가 가장 깔끔한 길입니다: 원하는 형태를 기술하면, 매번 정확히 그 형태를 돌려받습니다.
내부적으로 OpenAI는 제약 디코딩(constrained decoding)을 사용합니다 — 모델의 토큰 샘플링이 여러분의 스키마에 의해 안내되므로
말 그대로 유효하지 않은 응답을 만들 수 없습니다.
첫 번째 Structured Output
from openai import OpenAI
import json
client = OpenAI()
response = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[
{
"role": "user",
"content": "Extract the vendor name, invoice number, and total amount from this text: "
"Invoice #INV-2024-0892 from Acme Supplies Ltd. Total due: $1,450.00"
}
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "invoice_extract",
"strict": True,
"schema": {
"type": "object",
"properties": {
"vendor_name": {"type": "string"},
"invoice_number": {"type": "string"},
"total_amount": {"type": "number"}
},
"required": ["vendor_name", "invoice_number", "total_amount"],
"additionalProperties": False
}
}
}
)
data = json.loads(response.choices[0].message.content)
print(data)
# {"vendor_name": "Acme Supplies Ltd", "invoice_number": "INV-2024-0892", "total_amount": 1450.0}여기서 세 가지 주목하세요. 첫째, 모델은 gpt-4o-2024-08-06입니다 — Structured Outputs는 이를 명시적으로 지원하는
모델(GPT-4o의 -2024-08-06 스냅샷 이후 또는 gpt-4o-mini)이 필요합니다. 둘째,
response_format.type은 "json_object"가 아니라 "json_schema"입니다. 셋째,
"strict": True가 보장을 제공합니다 — 이것이 없으면 다시 최선 노력(best-effort) 영역으로 돌아갑니다.
name 필드는 모델이 보는 레이블입니다. 파싱에는 영향이 없지만 API 로그를 읽기 쉽게 만들어줍니다.
JSON Schema 설계하기
제품 카탈로그 추출 작업을 위한 좀 더 현실적인 스키마입니다 — 비정형 제품 설명, 이커머스 리스팅, PDF 데이터시트에서 구조화된 데이터를 뽑아낼 때 쓸 법한 종류입니다. API 호출에 연결하기 전에 JSON Schema Generator로 스키마를 시각적으로 만들고 검증해 보세요.
{
"type": "object",
"properties": {
"product_name": {
"type": "string",
"description": "The full commercial name of the product"
},
"sku": {
"type": "string",
"description": "Stock keeping unit identifier"
},
"price_usd": {
"type": "number",
"description": "Price in US dollars, numeric only"
},
"in_stock": {
"type": "boolean"
},
"categories": {
"type": "array",
"items": { "type": "string" }
},
"dimensions": {
"type": "object",
"properties": {
"width_cm": { "type": "number" },
"height_cm": { "type": "number" },
"depth_cm": { "type": "number" }
},
"required": ["width_cm", "height_cm", "depth_cm"],
"additionalProperties": false
}
},
"required": [
"product_name", "sku", "price_usd",
"in_stock", "categories", "dimensions"
],
"additionalProperties": false
}- strict 모드에서는 모든 속성이
required에 있어야 합니다. 선택적 필드를 가질 수 없습니다. 원본 데이터에 필드가 없을 수도 있다면 유니온 타입을 쓰세요:{"type": ["string", "null"]}, 그리고 반드시required에 포함하세요. additionalProperties는 모든 객체 레벨에서false여야 합니다. 이는 재귀적으로 적용됩니다 — 중첩된 객체에도 필요하지 루트에만 두면 안 됩니다.- strict 모드에서 지원되는 타입:
string,number,integer,boolean,null,array,object. 타입 유니온(["string", "null"])이 허용됩니다. - strict 모드에서는
$ref나 재귀 스키마가 없습니다. 모든 것을 인라인해야 합니다. 재사용 가능한 정의가 필요하면 복사하세요. description필드를 아낌없이 추가하세요. 모델이 그것을 읽습니다. "미국 달러 가격, 숫자만 — 통화 기호 포함 금지"라고 쓰면 모델이 옳게 맞혀주길 바라는 것보다 더 깨끗한 출력을 얻습니다.- Enum은 동작합니다.
{"type": "string", "enum": ["pending", "shipped", "delivered"]}은 완전히 지원되며 모델은 이 세 값 중 하나만 내보냅니다.
Strict 모드 vs Non-Strict
# Strict mode — guaranteed conformance, tighter schema rules
response_format_strict = {
"type": "json_schema",
"json_schema": {
"name": "product_extract",
"strict": True, # <-- the key flag
"schema": product_schema
}
}
# Non-strict — more schema flexibility, best-effort conformance
response_format_lenient = {
"type": "json_schema",
"json_schema": {
"name": "product_extract",
"strict": False,
"schema": product_schema
}
}strict: True를 쓰면, OpenAI가 스키마를 처음 사용할 때 전처리해서 제약 디코더를 캐시합니다.
새 스키마로 하는 첫 호출은 약간 더 오래 걸리고, 같은 스키마로 하는 이후 호출은 빠릅니다. 그 대가로 얻는 것:
모델 출력이 구조적으로 보장됩니다 — 방어적 검사 없이 json.loads() 하고 바로 필드에 접근할 수 있습니다.
포기해야 하는 것: $ref, 구조적 변형에 걸친 anyOf, 재귀 스키마가 지원되지 않습니다.
Non-strict 모드는 더 넓은 범위의
JSON Schema
기능을 받아들이지만 최선 노력으로 대체됩니다 — 모델이 스키마를 따르려고 노력하지만 토큰 레벨에서 제약받지는 않습니다.
프로덕션 추출 파이프라인에서는 항상 strict 모드를 쓰세요. 스키마 제약은 일단 이해하면 관리할 만합니다.
중첩된 객체와 배열
중첩된 구조도 잘 돌아가지만, 모든 중첩 객체에는 자체 "additionalProperties": false와
모든 속성을 나열하는 자체 "required" 배열이 필요합니다. 흔한 실수는 루트 객체에 strict 규칙을 적용하고
자식에는 잊는 것입니다 — OpenAI가 검증 오류로 스키마를 거부합니다.
from openai import OpenAI
import json
client = OpenAI()
order_schema = {
"type": "object",
"properties": {
"order_id": {"type": "string"},
"customer": {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"}
},
"required": ["name", "email"],
"additionalProperties": False # required on nested objects too
},
"line_items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"description": {"type": "string"},
"quantity": {"type": "integer"},
"unit_price": {"type": "number"}
},
"required": ["description", "quantity", "unit_price"],
"additionalProperties": False # required on array item schemas too
}
},
"total_usd": {"type": "number"}
},
"required": ["order_id", "customer", "line_items", "total_usd"],
"additionalProperties": False
}
response = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[{
"role": "user",
"content": (
"Parse this order: Order #ORD-5531 for Jane Smith ([email protected]). "
"2x Wireless Keyboard at $49.99 each, 1x USB Hub at $29.99. Total: $129.97"
)
}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "order_extract",
"strict": True,
"schema": order_schema
}
}
)
order = json.loads(response.choices[0].message.content)
for item in order["line_items"]:
print(f"${item['unit_price']:.2f} x{item['quantity']} {item['description']}")거부(Refusal) 처리하기
Structured Outputs를 써도 모델은 응답을 거부할 수 있습니다 — 보통 프롬프트가 콘텐츠 정책을 건드릴 때입니다
(해로운 것에서 데이터를 추출하라고 요청하는 경우). 이런 일이 벌어지면 finish_reason은 "stop"이지만
message.content는 null이고 message.refusal에 거부 텍스트가 담깁니다.
이를 확인하지 않으면 json.loads(None)을 호출하려다 AttributeError가 납니다.
finish_reason == "length"도 주의하세요 — max_tokens 때문에 응답이 잘렸다면,
Structured Outputs와 상관없이 JSON이 불완전하고 파싱 불가능합니다.
import json
from openai import OpenAI
client = OpenAI()
def extract_invoice(raw_text: str) -> dict | None:
response = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[{"role": "user", "content": f"Extract invoice fields: {raw_text}"}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "invoice_extract",
"strict": True,
"schema": invoice_schema
}
},
max_tokens=1024
)
choice = response.choices[0]
if choice.finish_reason == "length":
raise ValueError("Response truncated — increase max_tokens or simplify your schema")
if choice.message.refusal:
# Model refused to answer — log and return None rather than crashing
print(f"Model refused: {choice.message.refusal}")
return None
return json.loads(choice.message.content)
result = extract_invoice("Invoice #2024-441 from BuildRight Inc., due $3,200 by Dec 15")
if result:
print(result["vendor_name"], result["total_amount"])같은 스키마를 검증에도 쓰기
잘 쓰이지 않는 패턴 하나: OpenAI에 넘기는
JSON Schema를
다른 소스에서 들어오는 데이터 — 웹훅, 파일 업로드, 서드파티 API — 검증에도 함께 쓰는 것입니다.
이렇게 하면 데이터 형태에 대한 단일 진실 공급원을 갖게 됩니다. Python에서는 jsonschema 라이브러리를,
Node.js에서는 Ajv를 쓰세요.
코드 없이 빠른 수동 점검을 하고 싶다면 스키마를 JSON Validator에 붙여넣어도 됩니다.
import json
import jsonschema
from jsonschema import validate, ValidationError
# The same schema used in your OpenAI call
invoice_schema = {
"type": "object",
"properties": {
"vendor_name": {"type": "string"},
"invoice_number": {"type": "string"},
"total_amount": {"type": "number"}
},
"required": ["vendor_name", "invoice_number", "total_amount"],
"additionalProperties": False
}
def validate_invoice(data: dict) -> bool:
try:
validate(instance=data, schema=invoice_schema)
return True
except ValidationError as e:
print(f"Validation failed: {e.message}")
print(f" Path: {' -> '.join(str(p) for p in e.path)}")
return False
# Validate a payload from a webhook — same schema, zero extra work
webhook_payload = json.loads(request_body)
if not validate_invoice(webhook_payload):
return HTTPResponse(status=400, body="Invalid invoice payload")
# Validate the OpenAI output too, for belt-and-suspenders safety
llm_output = json.loads(openai_response.choices[0].message.content)
assert validate_invoice(llm_output), "LLM output failed schema validation — check schema definition"
print(f"Processing invoice {llm_output['invoice_number']} for ${llm_output['total_amount']}")response_format에 복사하세요.JavaScript / Node.js 버전
OpenAI Node.js SDK는
Python API를 거의 그대로 미러링합니다. 주된 차이는 strict가 json_schema 객체 안에 같은 식으로 놓이고,
응답은 JSON.parse()로 파싱한다는 점입니다. Ajv를
사용한 검증은 Python의 jsonschema 라이브러리에 상응하는 Node.js 쪽입니다 — 더 빠르고 뛰어난 TypeScript 지원을 갖추고 있습니다.
import OpenAI from "openai";
import Ajv from "ajv";
const client = new OpenAI();
const ajv = new Ajv();
const invoiceSchema = {
type: "object",
properties: {
vendor_name: { type: "string" },
invoice_number: { type: "string" },
total_amount: { type: "number" },
line_items: {
type: "array",
items: {
type: "object",
properties: {
description: { type: "string" },
amount: { type: "number" }
},
required: ["description", "amount"],
additionalProperties: false
}
}
},
required: ["vendor_name", "invoice_number", "total_amount", "line_items"],
additionalProperties: false
};
const validateInvoice = ajv.compile(invoiceSchema);
async function extractInvoice(rawText) {
const response = await client.chat.completions.create({
model: "gpt-4o-2024-08-06",
messages: [{ role: "user", content: `Extract invoice fields: ${rawText}` }],
response_format: {
type: "json_schema",
json_schema: {
name: "invoice_extract",
strict: true,
schema: invoiceSchema
}
}
});
const choice = response.choices[0];
if (choice.message.refusal) {
throw new Error(`Model refused: ${choice.message.refusal}`);
}
const data = JSON.parse(choice.message.content);
// Validate even though strict mode guarantees structure —
// useful for catching schema drift between environments
if (!validateInvoice(data)) {
console.error("Schema validation errors:", validateInvoice.errors);
throw new Error("Output failed schema validation");
}
return data;
}
const invoice = await extractInvoice(
"Invoice #INV-881 from Nordic Parts AS. " +
"3x Brake Pads at $28.50 each. Total: $85.50"
);
console.log(`${invoice.vendor_name} — Invoice ${invoice.invoice_number}`);
invoice.line_items.forEach(item =>
console.log(` ${item.description}: $${item.amount}`)
);마무리
Structured Outputs는 프로덕션 버그의 한 카테고리 전체를 제거합니다 — 모델이 새벽 2시에 파서를 깨뜨리는
거의-맞는 JSON을 반환하는 종류 말이죠. 워크플로는 단순합니다: 스키마를 꼼꼼히 설계하고(모든 중첩 객체에
additionalProperties: false와 완전한 required 배열이 필요합니다), strict: true를 설정하고,
거부와 잘림을 명시적으로 처리하세요. 스키마가 자리 잡으면, OpenAI 호출, 웹훅 검증, 테스트 픽스처에 이르기까지
스택 전반에서 jsonschema(Python)나
Ajv(Node.js) 같은 라이브러리로 재사용할 수 있습니다.
스키마를 밑바닥부터 시작한다면, JSON Schema Generator가 샘플 페이로드에서
동작하는 기본 스키마를 얻는 가장 빠른 길입니다. 신뢰할 수 있는 JSON 출력을 위해 프롬프트 엔지니어링으로
헤매던 시절은 끝났습니다 — 그것을 위해 만들어진 도구를 쓰세요.