본문으로 건너뛰기

OpenAI Structured Outputs vs Function Calling: 출력 형식과 실행 흐름을 구분하는 법

8 분 소요OpenAI API

최종 UI 객체가 필요하면 Structured Outputs, 외부 데이터나 작업이 필요하면 Function Calling을 선택합니다. 둘을 함께 쓰는 것은 도구 결과 뒤에도 타입이 정해진 최종 응답이 필요할 때뿐입니다.

최종 구조화 응답, 도구 요청, 애플리케이션 실행, 도구 결과, 하이브리드 응답의 책임을 구분한 흐름도

Structured OutputsFunction Calling은 같은 기능의 두 이름이 아닙니다. 모델의 최종 응답을 정해진 객체로 받아야 하면 Structured Outputs(text.format)를, 모델이 애플리케이션에 조회·계산·변경을 요청해야 하면 Function Calling을 사용하세요. 외부 작업의 결과를 받은 뒤에도 타입이 정해진 최종 UI 객체가 필요할 때만 둘을 결합합니다.

먼저 다음 표에서 현재 요청을 고르세요. 이 구분을 하지 않으면 단순한 분류 결과를 얻기 위해 가짜 함수를 만들거나, 반대로 모델이 실제 주문 조회나 결제 취소까지 실행한다고 오해하기 쉽습니다.

지금 필요한 결과선택애플리케이션이 확인할 것여기서 멈출 때
이미 받은 문의를 category, priority, reply_needed 같은 UI 객체로 만들기Structured OutputsJSON Schema와 업무 규칙외부 상태를 읽거나 바꿀 이유가 없을 때
현재 주문 상태 조회, 사내 DB 검색, 티켓 생성처럼 외부 작업 요청Function Calling함수 선택, 인자, 권한, 실제 실행 결과반환 텍스트만으로 충분할 때
주문을 조회한 뒤 고객에게 보여 줄 고정 형태의 카드 만들기하이브리드도구 루프와 최종 Schema를 모두 검증도구 결과를 그대로 안전하게 표시할 수 있을 때
자유로운 설명·브레인스토밍일반 텍스트제품 수준의 안전·품질 기준기계가 파싱할 계약이 없을 때

차이는 “JSON을 받는가”가 아니라 누가 다음 일을 소유하는가입니다

OpenAI의 Structured Outputs 가이드는 Responses API의 최종 응답 Schema에 text.format 경로를 사용하도록 안내합니다. 이것은 모델이 반환할 응답의 모양을 계약하는 기능입니다. 분류 결과, 추출 결과, 화면 카드, 내부 워크플로의 다음 단계에 넘길 객체가 대표적인 대상입니다.

반면 Function Calling 가이드의 도구 호출은 모델이 “이 함수를 이 인자로 호출해 달라”고 만든 요청입니다. 모델이 함수 코드를 실행하지 않습니다. 서버가 function_call을 읽고, 인증·권한·검증을 거쳐 실제 코드를 실행한 다음, 같은 call_idfunction_call_output을 모델에 돌려보내야 합니다.

따라서 strict: true라는 공통점 때문에 둘을 합쳐 생각하면 안 됩니다. strict 도구는 함수 인자가 tool schema를 따르도록 돕지만, 그것은 최종 사용자 응답의 Schema와 다른 계약입니다. strict가 있어도 모델이 올바른 도구를 골랐는지, 주문 번호가 실제 존재하는지, 그 사용자가 취소 권한이 있는지, 취소가 한 번만 일어났는지는 애플리케이션의 책임입니다.

같은 주문 문의를 세 가지 경로로 나누기

예를 들어 고객이 “주문 8421은 언제 오나요?”라고 물었다고 하겠습니다.

  • 주문 정보가 이미 안전하게 프롬프트에 들어 있고 화면용 카드만 만들면 됩니다. text.format으로 {status, delivery_window, needs_human} 같은 최종 객체를 받으세요.
  • 최신 배송 상태를 사내 시스템에서 읽어야 합니다. 모델은 get_order_status 호출을 제안하고, 서버가 주문 소유권을 확인한 뒤 조회합니다.
  • 조회한 결과를 고객용 카드와 다음 조치로 다시 정리해야 합니다. 조회 결과를 function_call_output으로 돌려주고, 마지막 응답에만 Structured Outputs를 적용합니다.

외부 상태가 필요하지 않은 첫 번째 경우에 get_classification 같은 함수를 만드는 것은 도구 루프, 실패 처리, 왕복 시간을 늘릴 뿐입니다. 반대로 실제 배송 조회가 필요한 두 번째 경우에 Schema만 강제해도 최신 주문 정보는 생기지 않습니다.

최종 객체만 필요할 때: text.format

아래 예시는 이미 확보한 문의 본문을 담당 큐로 분류합니다. Schema를 통과해도 상담 큐가 실제로 존재하는지, refund가 이 조직의 허용된 업무인지까지 자동으로 검증되지는 않는다는 점이 중요합니다.

python
import json from openai import OpenAI client = OpenAI() ticket_schema = { "type": "object", "properties": { "queue": {"type": "string", "enum": ["delivery", "refund", "account"]}, "needs_human": {"type": "boolean"}, "reason": {"type": "string"}, }, "required": ["queue", "needs_human", "reason"], "additionalProperties": False, } response = client.responses.create( model="YOUR_CURRENT_TEXT_MODEL", input="문의: 결제는 됐는데 배송 조회가 되지 않습니다.", text={ "format": { "type": "json_schema", "name": "support_ticket", "strict": True, "schema": ticket_schema, } }, ) if response.status != "completed": raise RuntimeError(f"응답이 완료되지 않았습니다: {response.status}") refusal = next( ( part.refusal for item in response.output if item.type == "message" for part in item.content if part.type == "refusal" ), None, ) if refusal: raise RuntimeError(f"모델이 요청을 거절했습니다: {refusal}") if not response.output_text: raise RuntimeError("파싱할 최종 텍스트가 없습니다.") ticket = json.loads(response.output_text) allowed_queues = {"delivery", "refund", "account"} if ticket["queue"] not in allowed_queues or not ticket["reason"].strip(): raise ValueError("Schema는 맞지만 업무 의미 검증에 실패했습니다.") print(ticket)

required는 키가 반드시 존재한다는 뜻입니다. “아직 알 수 없음”이 가능한 값은 키를 빼는 대신 nullable type 또는 명시적인 상태 값으로 모델링하세요. 예를 들어 배송 예정일이 없을 수 있다면 delivery_date를 required로 두고 string 또는 null을 허용하는 방식이, 호출하는 쪽에서 누락 키를 추측하게 하는 방식보다 안전합니다.

또한 구조화 요청은 정상 객체 대신 refusal을 낼 수 있습니다. refusal을 JSONDecodeError로 뭉개거나 재시도만 하지 마세요. 응답 item에서 refusal을 먼저 판별하고, 사용자에게 가능한 다음 행동을 보여 주거나 사람 검토로 넘기세요. 파싱 성공은 transport와 형식에 관한 신호일 뿐, 답이 사실이거나 업무상 적절하다는 증거는 아닙니다.

외부 조회나 변경이 있으면: 애플리케이션 소유의 도구 루프

도구 호출은 최소 두 단계입니다. 모델의 호출 제안과 애플리케이션의 실제 실행을 분리해야 audit log, 권한 검사, timeout, 재시도, 중복 방지가 가능합니다. 다음 Python skeleton은 읽기 전용 주문 조회를 보여 줍니다. 실제 서비스에서는 get_order_status 내부에서 현재 사용자와 order_id의 소유 관계를 검사해야 합니다.

python
import json from openai import OpenAI client = OpenAI() tools = [{ "type": "function", "name": "get_order_status", "description": "현재 인증된 고객의 주문 배송 상태를 조회한다.", "strict": True, "parameters": { "type": "object", "properties": {"order_id": {"type": "string"}}, "required": ["order_id"], "additionalProperties": False, }, }] def get_order_status(order_id: str) -> dict: # 실제 코드: 인증된 사용자 범위에서 DB/API를 조회하고 timeout을 적용한다. return {"order_id": order_id, "state": "in_transit"} conversation = [{"role": "user", "content": "주문 8421의 현재 상태를 알려줘."}] first = client.responses.create( model="YOUR_CURRENT_TEXT_MODEL", input=conversation, tools=tools ) conversation += first.output tool_outputs = [] for item in first.output: if item.type != "function_call": continue args = json.loads(item.arguments) try: result = get_order_status(args["order_id"]) except Exception: # 내부 예외나 secret을 모델과 사용자에게 그대로 주지 않습니다. result = {"ok": False, "error": "lookup_unavailable"} tool_outputs.append({ "type": "function_call_output", "call_id": item.call_id, "output": json.dumps(result), }) if tool_outputs: final = client.responses.create( model="YOUR_CURRENT_TEXT_MODEL", input=conversation + tool_outputs, tools=tools, ) print(final.output_text)

이 코드는 실제 데이터베이스 연결 예제가 아니라 책임 경계를 보여 주는 skeleton입니다. function_call_output을 만들었다는 사실도 조회 성공의 증거는 아닙니다. HTTP 상태, 도구의 구조화된 성공·실패 결과, 감사 로그를 따로 확인하세요. 쓰기 도구라면 더 엄격해야 합니다. 결제 취소·메일 발송·레코드 삭제에는 서버가 만든 idempotency key, 사용자 확인, 권한 검사, 실행 결과 저장이 필요합니다. 같은 호출이 재전송되거나 모델이 유사한 호출을 여러 번 내도 side effect가 중복되지 않아야 합니다.

하이브리드는 “도구 + 예쁜 JSON”이 아니라 두 계약을 모두 검증하는 흐름입니다

도구 결과를 받은 뒤 앱이 바로 렌더링할 수 있고 별도 형태 변환이 필요 없다면 두 번째 모델 호출을 생략할 수 있습니다. 그 결과가 신뢰할 수 있는 서버 데이터인지, 사용자에게 노출해도 되는 필드인지 검증한 뒤 앱이 렌더링하면 됩니다.

반대로 여러 도구 결과를 묶어 고객용 조치 카드로 바꿔야 한다면, 도구 루프가 끝난 뒤 마지막 Responses 요청에 text.format을 붙입니다. 이때 최종 Schema에는 예를 들어 summary, next_action, escalate만 넣고, 권한 결정이나 실제 환불 실행을 모델이 출력한 문자열에 맡기지 마세요. 하이브리드는 외부 작업과 최종 타입 객체가 둘 다 필요한 경우에만 가치가 있습니다.

strict와 JSON mode의 정확한 경계

OpenAI의 strict mode는 함수 인자가 도구 Schema를 따르게 하는 Structured Outputs 사용 방식입니다. strict 도구 Schema에서는 각 object에 additionalProperties: false가 필요하고, 모든 property를 required에 포함해야 합니다. 그래서 “선택적 business 값”은 키를 생략하는 문제가 아니라 null 또는 상태 enum을 어떻게 표현할지의 문제입니다.

그러나 strict는 다음을 보장하지 않습니다.

  • order_id가 실제 주문인지
  • 모델이 가장 적절한 도구를 골랐는지
  • 현재 사용자가 그 주문을 읽거나 변경할 권한이 있는지
  • 도구가 성공했고 외부 시스템에 한 번만 반영됐는지

JSON mode는 유효한 JSON을 만드는 데는 유용하지만 특정 Schema adherence를 보장하지 않습니다. 현재 경로에서 Structured Outputs를 지원한다면 JSON mode를 최종 계약의 대체물로 쓰지 마세요. 다만 오래된 호환 경로나 지원하지 않는 모델을 다루는 경우에는 애플리케이션 JSON validation, incomplete output 처리, 재시도 정책을 별도로 구현해야 합니다.

배포 전에는 네 층을 따로 통과시키세요

“200 OK였고 JSON parse도 됐다”로 테스트를 끝내면 안 됩니다. 이 순서로 happy path와 실패 경로를 테스트하세요.

  1. transport: timeout, 인증 오류, rate limit, request ID를 기록하고 API 호출이 끝났는지 확인합니다.
  2. shape: refusal을 별도로 처리한 뒤 JSON/Schema가 계약에 맞는지 확인합니다.
  3. meaning: enum, 날짜, 금액, 사용자 입력과 도구 인자가 업무 규칙·권한·현재 데이터와 맞는지 검증합니다.
  4. external result: 읽기 결과의 freshness, 쓰기 결과의 idempotency·감사 로그·실제 반영을 확인합니다.

테스트 케이스에는 최소한 unrelated input에 대한 refusal 또는 안전한 불가 응답, 누락·무효 인자, tool timeout, 권한 거부, 도구가 실패한 결과, 같은 쓰기 요청의 중복 전송을 넣으세요. 모델이 훌륭한 JSON을 냈다는 것은 2단계의 일부만 통과한 것입니다.

구현 체크리스트와 다음 단계

  • 최종 객체만 필요하면 text.format부터 시작합니다. fake function을 만들지 않습니다.
  • 외부 상태를 읽거나 바꾸면 함수 호출을 사용하고, 실행·권한·재시도는 서버가 소유합니다.
  • tool schema의 strict와 최종 response schema를 서로 다른 계약으로 리뷰합니다.
  • refusal, semantic validation, tool error, duplicate side effect를 각기 다른 분기로 둡니다.
  • SDK helper, text.format 세부 문법, 지원 JSON Schema subset, parallel tool 동작은 배포 직전에 최신 OpenAI 공식 문서를 다시 확인합니다.

Responses API를 처음 연결하거나 project key·Billing·첫 호출을 먼저 확인해야 한다면 OpenAI API 키 발급과 첫 Responses 호출 가이드부터 진행하세요. 이미 Chat Completions의 함수를 Responses API로 옮기는 중이라면 함수 호출 마이그레이션 가이드에서 call_id와 반환 루프를 함께 점검하는 편이 안전합니다.

#OpenAI Responses API#Structured Outputs#Function Calling#JSON Schema#AI 에이전트
Share: