LLM API를 제품에 붙일 때 가장 흔한 장애는 모델이 똑똑하지 않아서가 아니라, 응답 형식이 흔들려서 생깁니다. 필수 키가 빠지고, enum에 없는 값이 들어오고, 배열이어야 할 필드가 문자열로 오고, 안전 거절을 일반 응답처럼 처리해서 후속 로직이 깨집니다. 이런 문제를 프롬프트로만 막으려 하면 결국 “반드시 JSON으로 답해”, “절대 다른 말 하지 마” 같은 문장이 계속 늘어납니다.
OpenAI Structured Outputs는 이 문제를 JSON Schema 기반으로 다루는 방식입니다. 공식 문서 기준 Structured Outputs는 모델 응답이 제공한 JSON Schema를 따르도록 보장하고, required key 누락이나 잘못된 enum 값 같은 문제를 줄입니다. Python SDK에서는 Pydantic, JavaScript SDK에서는 Zod와 함께 쓰기 쉽습니다. 이 글은 “Structured Outputs를 언제 쓰고, JSON mode와 어떻게 다르며, 실무에서 어떤 스키마 설계가 안전한가”를 정리합니다.
JSON mode는 “유효한 JSON을 출력한다”에 초점이 있습니다. 이것만으로도 일반 텍스트 파싱보다는 낫습니다. 하지만 제품 코드에서 필요한 건 유효한 JSON이 아니라 “우리 코드가 기대하는 구조”입니다.
예를 들어 고객 문의를 분류하는 응답이 필요하다고 해봅시다.
{
"category": "billing",
"priority": "high",
"summary": "환불 요청"
}
JSON mode에서는 다음 응답도 문법적으로는 유효합니다.
{
"category": "payment issue",
"urgent": true,
"summary": "환불 요청",
"confidence": "높음"
}
사람이 보기에는 비슷하지만 코드에는 다릅니다. priority가 없고, category가 enum이 아니며, confidence 타입도 애매합니다. 이 상태에서 후속 DB 저장, 라우팅, 알림 로직이 붙으면 장애가 납니다.
Structured Outputs는 “valid JSON”보다 한 단계 더 나아가 “schema-adherent JSON”을 목표로 합니다. 즉, 필드 이름, 타입, required 여부, enum을 코드 계약처럼 다룹니다.
모든 LLM 호출에 Structured Outputs가 필요한 건 아닙니다. 자유로운 글쓰기, 브레인스토밍, 요약 초안처럼 사람이 최종 소비하는 작업은 일반 텍스트가 더 낫습니다. 반대로 다음 작업은 Structured Outputs가 잘 맞습니다.
공통점은 “응답을 사람이 읽기 전에 코드가 먼저 읽는다”입니다. 코드가 먼저 읽는다면 프롬프트보다 스키마가 안전합니다.
OpenAI 문서에서는 Structured Outputs를 크게 두 경로로 설명합니다. 하나는 function calling에서 도구 호출 인자를 구조화하는 방식이고, 다른 하나는 모델의 최종 응답을 text.format 또는 JSON Schema response format으로 구조화하는 방식입니다.
기준은 간단합니다.
모델이 애플리케이션 기능을 호출해야 한다면 function calling을 씁니다. 예를 들어 주문 조회 함수, DB 검색 함수, 캘린더 생성 함수, 내부 API 호출 함수가 있다면 모델은 “어떤 함수를 어떤 인자로 호출할지” 결정해야 합니다. 이때 함수 인자 스키마가 중요합니다.
반대로 모델이 사용자에게 줄 응답 자체를 구조화해야 한다면 text format을 씁니다. 예를 들어 수학 풀이를 steps 배열과 final_answer로 받거나, 문서 추출 결과를 company_name, contract_date, risk_items로 받고 싶다면 최종 출력 스키마가 맞습니다.
실무에서는 두 가지를 섞습니다. 예를 들어 고객 지원 자동화에서는 먼저 function calling으로 주문 정보를 조회하고, 마지막에는 Structured Outputs로 상담원 화면에 표시할 요약 객체를 반환하게 할 수 있습니다.
Structured Outputs에서 가장 흔한 실수는 스키마를 너무 느슨하게 만드는 것입니다. “혹시 모르니 문자열로 받자”라고 하면 나중에 코드가 다시 파싱해야 합니다. 스키마는 모델 편의가 아니라 후속 코드의 안정성을 기준으로 설계해야 합니다.
좋은 스키마 원칙은 다음과 같습니다.
unknown enum을 둡니다.needs_review 같은 boolean을 둡니다.예를 들어 문의 분류라면 category를 자유 문자열로 두지 말고 billing, technical, account, sales, other처럼 제한합니다. 모델이 새 카테고리를 만들고 싶어도 만들 수 없게 해야 운영 대시보드가 안정됩니다.
Structured Outputs의 장점 중 하나는 safety-based refusal을 프로그램적으로 감지할 수 있다는 점입니다. 실무에서는 이 처리가 중요합니다. 모델이 안전 정책상 답할 수 없는 요청을 받았는데, 후속 로직이 정상 객체라고 가정하면 이상한 상태가 됩니다.
예외 처리는 최소 세 단계로 나누는 게 좋습니다.
여기서 “재시도”도 조심해야 합니다. 스키마가 너무 복잡해서 계속 실패하는 경우 프롬프트를 세게 쓰는 것보다 스키마를 나누는 게 낫습니다. 예를 들어 계약서 전체를 한 번에 추출하지 말고, 당사자 정보, 금액 조건, 해지 조항, 위험 조항을 별도 호출로 나눌 수 있습니다.
Python에서는 Pydantic 모델을 기준으로 응답을 파싱하면 애플리케이션 타입과 LLM 출력 타입을 맞추기 쉽습니다. TypeScript에서는 Zod 스키마를 쓰면 런타임 검증과 타입 추론을 같이 얻을 수 있습니다.
운영 패턴은 다음과 같습니다.
raw_evidence나 reason_code처럼 검토용 필드를 둘 수 있습니다.특히 스키마 버전 관리는 중요합니다. 오늘 priority가 low, medium, high였는데 다음 달 urgent를 추가하면 기존 데이터 분석과 대시보드가 영향을 받습니다. schema_version 필드를 응답에 넣거나, 호출 코드에서 버전을 로그에 남기는 방식이 안전합니다.
Structured Outputs는 응답 안정성을 높이지만 만능은 아닙니다. 스키마가 길고 복잡하면 프롬프트 토큰이 늘고, 모델이 따라야 할 제약이 많아집니다. 너무 깊은 nested object는 응답 지연과 실패율을 높일 수 있습니다.
따라서 처음부터 거대한 스키마를 만들지 마세요. 제품에서 바로 필요한 필드부터 시작하고, 사람이 실제로 쓰는 필드만 남기는 게 좋습니다. “언젠가 필요할 것 같은 필드”는 대부분 운영 복잡도만 늘립니다.
추천 기준은 이렇습니다.
마지막으로 바로 적용 가능한 체크리스트입니다.
정리하면 Structured Outputs는 “모델에게 JSON을 예쁘게 쓰게 하는 기능”이 아닙니다. LLM 응답을 제품 코드의 계약으로 바꾸는 기능입니다. JSON mode는 문법을 보장하고, Structured Outputs는 구조를 보장합니다. 운영 서비스라면 차이는 큽니다. 특히 분류, 추출, 라우팅, 자동화처럼 후속 코드가 붙는 작업에서는 프롬프트 문장보다 스키마가 더 강한 안전장치입니다.