# OpenAI Structured Outputs와 Function Calling 차이: JSON 응답과 도구 실행 선택법

> 최종 객체의 형식을 정하려면 Structured Outputs를, 앱의 조회나 작업을 요청하려면 Function Calling을 사용합니다. 최신 정보를 조회한 뒤 고정된 화면 객체까지 필요하다면 두 기능을 함께 쓰고, 형식·의미·실행 결과를 각각 확인해야 합니다.

- URL: https://blog.laozhang.ai/ko/posts/structured-outputs-vs-function-calling
- Published: 2026-07-28
- Updated: 2026-10-07
- Author: LaoZhang AI Team (https://blog.laozhang.ai/ko/about)
- Topic: ChatGPT·OpenAI
- Tags: Structured Outputs, Function Calling, Responses API, JSON Schema, Python

---
**최종 답변을 정해진 JSON 객체로 받아야 하면 Structured Outputs를, 모델이 앱에 조회·계산·변경을 요청해야 하면 Function Calling을 선택하세요.** 최신 재고를 조회한 뒤 화면에 표시할 객체까지 정해져 있다면 둘을 함께 사용합니다. 함수 인자가 JSON이라는 이유만으로 두 기능의 목적이 같아지는 것은 아닙니다.

Responses API에서 최종 응답 형식은 `text.format`으로 지정합니다. 함수 호출은 `tools`에 정의하고, 모델이 반환한 `function_call`을 앱이 실행한 다음 같은 `call_id`로 결과를 돌려줍니다. 모델의 호출 요청, 앱의 실행 결과, 사용자에게 보여 줄 최종 객체를 구분하면 검증할 지점도 분명해집니다. [OpenAI Structured Outputs 가이드](https://developers.openai.com/api/docs/guides/structured-outputs), [Function Calling 가이드](https://developers.openai.com/api/docs/guides/function-calling)

## 어떤 결과가 필요한지부터 선택하세요

| 필요한 결과 | 먼저 선택할 기능 | 앱이 맡을 일 |
| --- | --- | --- |
| 이미 받은 문의를 분류하거나 문서에서 값을 추출하기 | Structured Outputs | 최종 schema 검증과 업무 규칙 검사 |
| 현재 재고, 주문 상태, 검색 결과처럼 외부 정보를 가져오기 | Function Calling | 인자·조회 범위 확인, 조회 실행, 결과 반환 |
| 티켓 생성이나 주문 변경처럼 상태를 바꾸기 | Function Calling | 권한·승인 확인, 중복 방지, 실제 반영 확인 |
| 재고를 조회한 뒤 고정된 상품 카드로 표시하기 | 두 기능의 조합 | 도구 실행 검증과 최종 객체 검증 |
| 프로그램이 파싱할 필요 없는 설명 | 일반 텍스트 | 답변 내용과 서비스 품질 확인 |

예를 들어 “M-17 재고가 있나요?”라는 질문을 받았다고 하겠습니다. 이미 확보한 재고 스냅샷을 정리하기만 한다면 Structured Outputs로 충분합니다. 현재 수량을 알아야 한다면 앱의 재고 조회 함수를 호출해야 합니다. 조회 결과를 그대로 렌더링할 수 있다면 앱이 직접 카드를 만들 수도 있습니다. 여러 조회 결과를 요약하거나 사용자 질문에 맞춰 설명까지 구성해야 할 때 최종 Structured Outputs를 추가하세요.

단순 분류에 가짜 조회 함수를 붙이면 함수 정의, 결과 반환, 오류 처리까지 불필요하게 늘어납니다. 반대로 최종 JSON에 `"available": true`를 넣게 한다고 실제 재고 조회가 이루어지지는 않습니다.

## 같은 schema라도 최종 답변과 함수 인자는 다른 계약입니다

Structured Outputs의 최종 schema는 **앱이 받을 답변의 모양**을 정합니다. Function Calling의 `parameters`는 **앱에 요청할 함수 인자의 모양**을 정합니다. strict 함수 호출은 내부적으로 Structured Outputs를 활용하지만, 함수명과 인자가 맞는다는 사실은 실행 허가나 업무 완료를 뜻하지 않습니다. [strict mode 설명](https://developers.openai.com/api/docs/guides/function-calling#strict-mode)

재고 카드에서 상품 코드, 상태, 수량, 메시지를 받는다고 하겠습니다. 형식 검사는 `available_units`가 정수 또는 `null`인지 확인합니다. 의미 검사는 조회 결과가 6개일 때 최종 객체도 6개라고 말하는지 확인합니다. 실행 결과 검사는 앱이 허용된 상품을 실제로 조회했고, 그 시점의 재고 값을 받았는지 확인합니다.

형식이 맞아도 수량이 틀릴 수 있습니다. 모델이 “조회했습니다”라고 말해도 도구 결과가 없으면 조회 증거가 없습니다. 최종 출력의 `success` 같은 필드를 실행 완료의 유일한 근거로 쓰지 마세요.

## `strict`, `required`, `null`을 설계하는 기준

strict schema는 루트가 object여야 하며, 각 object에 `additionalProperties: false`를 지정하고 모든 property를 `required`에 넣어야 합니다. 선택적인 업무 값은 키를 생략하기보다 nullable 값으로 표현합니다. `required`는 값이 알려져 있다는 뜻이 아니라 **키가 존재해야 한다는 뜻**입니다. [지원 schema 범위](https://developers.openai.com/api/docs/guides/structured-outputs#supported-schemas)

enum과 nullable type을 함께 쓰면 두 조건을 모두 만족해야 합니다. 아래처럼 `null`을 enum에도 넣어야 실제로 허용됩니다.

```json
{
  "type": ["string", "null"],
  "enum": ["available", "out_of_stock", null]
}
```

상태 자체가 불명확한 경우에는 무조건 `null`로 합치기보다 `needs_input`, `unavailable`처럼 원인을 나눌 수도 있습니다. 다음 구현은 정보 부족과 조회 실패를 구분합니다. 이는 업무 설계이며, API가 자동으로 올바른 상태를 판단해 준다는 보장은 아닙니다.

현재 Structured Outputs는 JSON Schema 전체가 아닌 부분집합을 지원합니다. 루트 `anyOf`, `allOf`, `not`, `if`·`then`·`else` 등을 일반 validator가 받아들여도 공급자 API가 받아들이는 것은 아닙니다. 현재 기본 모델에 지원되는 일부 문자열·숫자·배열 제약도 fine-tuned 모델에는 제한이 있습니다. 사용할 모델과 schema를 함께 확인하세요. [지원 범위와 모델별 제한](https://developers.openai.com/api/docs/guides/structured-outputs#supported-schemas)

함수의 `strict`는 명시하는 편이 판단하기 쉽습니다. **Responses API에서 생략하면 가능한 경우 strict에 맞게 정규화하지만, 호환되게 만들 수 없으면 비엄격한 best-effort 호출로 돌아갑니다.** 이 경우 반환된 도구 정의에는 `strict: false`가 표시됩니다. Chat Completions는 기본적으로 비엄격합니다. `strict: true`인데 schema가 요구 조건을 만족하지 않으면 요청이 거부됩니다. 오류를 없애려고 strict만 끄면 앱이 기대하던 인자 계약도 약해집니다. [strict 기본 동작](https://developers.openai.com/api/docs/guides/function-calling#strict-mode)

![조회 정보가 있는 카드와 상품 코드가 없어 null 및 needs_input을 사용하는 카드의 비교](https://blog.laozhang.ai/posts/ko/structured-outputs-vs-function-calling/img/nullable-values.webp)

## Python 예제: 최종 객체와 도구 실행을 끝까지 연결하기

아래 코드는 Python 표준 라이브러리만 사용하는 **오프라인 실행 예제**입니다. 가상의 재고 데이터와 합성 응답으로 실행 순서·실패 분기·최종 검증을 확인합니다. 실제 OpenAI SDK나 재고 서비스에는 연결하지 않으며, 출력은 실제 모델의 응답이나 재고 조회 기록이 아닙니다.

`card_request`와 `tool_request`가 Responses API에 전달할 요청의 핵심 구조입니다. 실제 앱에서는 `fake_send` 대신 서버의 API 호출 함수를 연결하고 `lookup_stock`을 허용된 업무 조회로 교체해야 합니다. `YOUR_RESPONSES_MODEL`은 Structured Outputs와 함수 호출을 지원하는 실제 모델 ID로 바꿀 자리입니다. 모델마다 API 지원 조건을 확인하세요. 예를 들어 현재 GPT-6 Astra와 GPT-6.1 Sol의 도구 호출은 Responses API가 필요합니다. [모델과 함수 호출 조건](https://developers.openai.com/api/docs/guides/function-calling)

먼저 schema와 최종 응답 검사를 정의합니다. 아래의 `check_shape`는 이 예제에서 사용하는 object·기본 타입·enum만 검사하며, 전체 JSON Schema validator가 아닙니다.

```python
import copy
import json

MODEL = "YOUR_RESPONSES_MODEL"
CARD_SCHEMA = {
    "type": "object",
    "properties": {
        "sku": {"type": ["string", "null"]},
        "state": {
            "type": "string",
            "enum": ["available", "out_of_stock", "needs_input", "unavailable"],
        },
        "available_units": {"type": ["integer", "null"]},
        "message": {"type": "string"},
        "needs_input": {"type": "boolean"},
    },
    "required": ["sku", "state", "available_units", "message", "needs_input"],
    "additionalProperties": False,
}
TOOLS = [{
    "type": "function", "name": "lookup_stock",
    "description": "앱에서 허용한 상품 코드의 현재 재고를 조회한다.",
    "strict": True,
    "parameters": {
        "type": "object",
        "properties": {"sku": {"type": "string"}},
        "required": ["sku"], "additionalProperties": False,
    },
}]

class ResponseProblem(Exception):
    pass

def check_shape(value, schema):
    kinds = schema["type"]
    kinds = kinds if isinstance(kinds, list) else [kinds]
    checks = {
        "null": value is None,
        "string": isinstance(value, str),
        "integer": type(value) is int,
        "boolean": type(value) is bool,
        "object": isinstance(value, dict),
    }
    if not any(checks[kind] for kind in kinds):
        raise ValueError("타입 불일치")
    if "enum" in schema and value not in schema["enum"]:
        raise ValueError("enum 불일치")
    if isinstance(value, dict) and "properties" in schema:
        if set(value) != set(schema["required"]):
            raise ValueError("필드 누락 또는 추가")
        for key, rule in schema["properties"].items():
            check_shape(value[key], rule)

def require_completed(response):
    if response.get("status") != "completed":
        details = response.get("incomplete_details") or {}
        raise ResponseProblem(details.get("reason", response.get("status")))
    for item in response.get("output", []):
        if item.get("type") == "message":
            for part in item.get("content", []):
                if part.get("type") == "refusal":
                    raise ResponseProblem("refusal")

def card_request(history):
    return {
        "model": MODEL, "input": history,
        "instructions": (
            "제공된 재고 결과만 사용한다. 상품 코드가 없으면 needs_input과 "
            "null을 사용한다. 조회 실패는 unavailable로 표시한다. 추측하지 않는다."
        ),
        "text": {"format": {
            "type": "json_schema", "name": "stock_card",
            "strict": True, "schema": CARD_SCHEMA,
        }},
    }

def read_card(response, record):
    require_completed(response)
    texts = [
        part["text"]
        for item in response.get("output", [])
        if item.get("type") == "message"
        for part in item.get("content", [])
        if part.get("type") == "output_text"
    ]
    if not texts:
        raise ResponseProblem("최종 텍스트 없음")
    card = json.loads("".join(texts))
    check_shape(card, CARD_SCHEMA)
    if not card["message"].strip():
        raise ValueError("빈 메시지")
    if card["state"] == "needs_input":
        if (card["sku"] is not None or card["available_units"] is not None
                or card["needs_input"] is not True):
            raise ValueError("추가 입력 상태 불일치")
    elif record is None:
        raise ValueError("조회 근거 없음")
    elif not record["ok"]:
        if (card["state"] != "unavailable" or card["sku"] != record["sku"]
                or card["available_units"] is not None or card["needs_input"]):
            raise ValueError("조회 실패를 성공으로 표시함")
    else:
        quantity = record["available_units"]
        state = "available" if quantity > 0 else "out_of_stock"
        if (card["sku"] != record["sku"] or card["state"] != state
                or card["available_units"] != quantity or card["needs_input"]):
            raise ValueError("최종 객체와 재고 결과 불일치")
    return card
```

`message`의 내용 전체가 사실인지까지 이 비교만으로 확인되지는 않습니다. 실제 화면에서 “예약 완료”처럼 행동을 확정하는 문장을 보여 주려면 별도 실행 영수증과 맞춰야 합니다. 수량처럼 중요한 사실은 검증한 필드에서 직접 렌더링하면 자유 문장과의 불일치를 줄일 수 있습니다.

다음 코드를 같은 파일에 이어 붙이면 도구 요청, 실행, 결과 반환, 최종 객체까지 실행할 수 있습니다. 사용자에게서 받은 상품 코드를 허용된 조회 범위와 대조하고, 잘못된 함수명·인자·권한·타임아웃을 실패 결과로 돌려줍니다. 이 예제의 조회 범위는 가상의 앱 문맥이며 실제 인증이나 권한 시스템을 구현한 것은 아닙니다. 실제 앱에서는 모델이 선택한 상품 코드가 사용자의 조회 의도와도 일치하는지 검사해야 합니다.

```python
STOCK = {"M-17": 6, "M-00": 0}
ALLOWED = {"M-17", "M-00", "T-00"}

def lookup_stock(sku):
    if sku not in ALLOWED:
        raise PermissionError()
    if sku == "T-00":
        raise TimeoutError()
    return {"ok": True, "sku": sku, "available_units": STOCK[sku]}

def execute_call(call):
    try:
        if call["name"] != "lookup_stock":
            raise ValueError()
        args = json.loads(call["arguments"])
        check_shape(args, TOOLS[0]["parameters"])
        sku = args["sku"]
        if not sku.strip():
            raise ValueError()
        result = lookup_stock(sku)
    except PermissionError:
        result = {"ok": False, "sku": sku, "error": "not_allowed"}
    except TimeoutError:
        result = {"ok": False, "sku": sku, "error": "lookup_timeout"}
    except (ValueError, KeyError, TypeError):
        result = {"ok": False, "sku": None, "error": "invalid_call"}
    return result

def tool_request(history):
    return {
        "model": MODEL, "input": history, "tools": TOOLS,
        "tool_choice": "auto", "parallel_tool_calls": False,
        "instructions": "상품 코드가 있으면 재고를 조회하고, 없으면 물어본다.",
    }

def run_tools(send, history):
    history = copy.deepcopy(history)
    seen = {}
    records = []
    for _ in range(3):
        response = send(tool_request(history))
        require_completed(response)
        items = response.get("output", [])
        history.extend(items)  # reasoning을 포함한 전체 output을 보존한다.
        calls = [item for item in items if item.get("type") == "function_call"]
        if not calls:
            return history, records
        for call in calls:
            call_id = call.get("call_id")
            if not isinstance(call_id, str) or not call_id:
                raise ResponseProblem("call_id 없음")
            signature = (call.get("name"), call.get("arguments"))
            if call_id in seen:
                if seen[call_id][0] != signature:
                    raise ResponseProblem("같은 call_id의 내용이 바뀜")
                result = seen[call_id][1]
            else:
                result = execute_call(call)
                seen[call_id] = (signature, result)
                records.append(result)
            history.append({
                "type": "function_call_output", "call_id": call_id,
                "output": json.dumps(result, ensure_ascii=False),
            })
    raise ResponseProblem("도구 호출 횟수 초과")

def message_response(card):
    return {"status": "completed", "output": [{
        "type": "message", "role": "assistant", "content": [{
            "type": "output_text", "text": json.dumps(card, ensure_ascii=False),
        }],
    }]}

def fake_send(payload):
    # 합성 응답이며 실제 모델의 판단을 재현하지 않는다.
    outputs = [item for item in payload["input"]
               if item.get("type") == "function_call_output"]
    if "text" in payload:
        record = (json.loads(outputs[-1]["output"]) if outputs else
                  json.loads(payload["input"][-1]["content"]))
        if record["ok"]:
            quantity = record["available_units"]
            card = {
                "sku": record["sku"],
                "state": "available" if quantity > 0 else "out_of_stock",
                "available_units": quantity,
                "message": "조회 결과를 확인하세요.", "needs_input": False,
            }
        else:
            card = {"sku": record["sku"], "state": "unavailable",
                    "available_units": None, "message": "잠시 후 다시 조회하세요.",
                    "needs_input": False}
        return message_response(card)
    if outputs:
        return {"status": "completed", "output": []}
    return {"status": "completed", "output": [
        {"type": "reasoning", "id": "rs_demo", "summary": []},
        {"type": "function_call", "id": "fc_demo", "call_id": "call_demo",
         "name": "lookup_stock", "arguments": json.dumps({"sku": "M-17"})},
    ]}

STOCK_RECORD = {"ok": True, "sku": "M-17", "available_units": 6}

if __name__ == "__main__":
    # 경로 1: 이미 확보한 스냅샷에서 최종 객체만 만든다.
    direct_history = [{"role": "user", "content": json.dumps(STOCK_RECORD)}]
    direct = read_card(fake_send(card_request(direct_history)), STOCK_RECORD)
    print("직접 응답:", direct)

    # 경로 2: 도구를 실행하고 결과에서 최종 객체를 만든다.
    history, records = run_tools(fake_send, [{
        "role": "user", "content": "M-17의 현재 재고를 알려 주세요.",
    }])
    final_request = card_request(history)
    final_request.update({"tools": TOOLS, "tool_choice": "none"})
    hybrid = read_card(fake_send(final_request), records[-1] if records else None)
    print("도구 조회 후 응답:", hybrid)
```

두 경로 모두 합성 데이터의 `sku: M-17`, `state: available`, `available_units: 6`, `needs_input: false`를 출력합니다. 첫 번째는 이미 받은 스냅샷의 형식을 정리하며 최신 재고를 새로 조회하지 않습니다. 두 번째는 합성 `function_call`에 따라 가상 handler를 실행하고, 결과를 반환한 뒤 최종 카드와 대조합니다.

`id`는 응답 item의 식별자이고 `call_id`는 함수 요청과 결과를 연결하는 값입니다. 위 코드처럼 모든 output item을 순회해야 호출이 없거나 여러 개인 경우도 다룰 수 있습니다. reasoning 모델에서 도구 호출과 함께 나온 reasoning item도 후속 입력에 보존해야 합니다. `output[0]`이 늘 함수 호출이라고 가정하면 안 됩니다. [함수 호출 처리](https://developers.openai.com/api/docs/guides/function-calling#handling-function-calls)

함수 호출만으로 충분한 앱은 도구 결과 뒤의 모델 응답을 텍스트로 표시하거나, 검증한 `records`로 화면을 직접 만들 수 있습니다. 이 예제의 추가 최종 요청은 **조회 결과 뒤에도 고정된 카드 형식이 필요한 경우**를 보여 줍니다. 마지막에 `tool_choice: "none"`을 지정해 카드 생성 단계에서 새 조회를 요청하지 않도록 했습니다.

![재고 조회 요청과 앱의 실행 결과를 같은 call_id로 연결하고 최종 카드의 수량을 대조하는 예시](https://blog.laozhang.ai/posts/ko/structured-outputs-vs-function-calling/img/call-result-card.webp)

이 그림은 위 가상 재고 예제의 흐름을 설명하며 실제 서비스의 실행 기록이 아닙니다.

## 거절·불완전 출력·정보 부족은 다른 분기입니다

`status: completed`여도 message 안에 `refusal`이 있을 수 있습니다. 거절은 JSON 파싱 실패와 구분해서 처리해야 합니다. `status: incomplete`이고 이유가 `max_output_tokens` 또는 `content_filter`라면 완성된 카드로 취급하지 마세요. 잘린 JSON을 이어 붙이거나 빠진 값을 임의로 채워 성공 상태를 만들면 형식과 업무 의미가 모두 흐려집니다. [Structured Outputs의 예외 처리](https://developers.openai.com/api/docs/guides/structured-outputs)

| 받은 결과 | 앱의 처리 |
| --- | --- |
| completed이며 refusal 없음 | 텍스트 파싱 → schema 검사 → 업무 값 대조 |
| refusal | 거절 안내 또는 허용 가능한 대안 제공; 같은 요청의 무한 재시도 중단 |
| incomplete / max_output_tokens | 출력 한도·작업 크기를 검토하고 제한된 재시도 적용 |
| incomplete / content_filter | 정상 객체로 복구했다고 가정하지 않고 별도 실패 안내 |
| 상품 코드가 없거나 입력이 관련 없음 | `needs_input`과 `null`을 사용해 필요한 정보를 질문 |
| 도구 권한 거부·조회 실패 | 실패 결과를 반환하고 재고 있음으로 표시하지 않음 |

이 예제에서는 다음 정보 부족 객체가 허용됩니다. 필드는 모두 존재하지만 수량을 모른다는 사실을 드러냅니다.

```json
{
  "sku": null,
  "state": "needs_input",
  "available_units": null,
  "message": "조회할 상품 코드를 알려 주세요.",
  "needs_input": true
}
```

출력에 `needs_input`을 넣는 것만으로 모델의 추측이 사라지는 것은 아닙니다. 입력이 해당 업무와 관련 있는지, 코드가 허용된 범위인지, 반환한 수량이 근거와 일치하는지 앱에서 확인해야 합니다. [관련 없는 입력과 내용 오류에 관한 공식 안내](https://developers.openai.com/api/docs/guides/structured-outputs)

## 도구를 강제해도 실행 성공은 별도로 확인해야 합니다

`tool_choice: "auto"`는 호출이 없거나 하나 또는 여러 개일 수 있습니다. `"required"`는 하나 이상 호출하게 하고, 특정 함수 지정은 그 함수를 선택하게 합니다. `parallel_tool_calls: false`는 한 턴의 호출을 **0개 또는 1개**로 제한하며, 반드시 한 번 호출하게 하는 옵션이 아닙니다. 정보가 부족한데 함수를 강제하면 잘못된 인자로 호출하도록 압박할 수 있습니다. [tool choice와 병렬 호출](https://developers.openai.com/api/docs/guides/function-calling#tool-choice)

fine-tuned 모델이 한 턴에 여러 함수를 호출하면 해당 호출에는 strict가 비활성화되는 문서상 예외도 있습니다. 모델·도구 설정을 바꿀 때는 입력 계약이 그대로 유지되는지 확인하세요. [병렬 호출의 조건](https://developers.openai.com/api/docs/guides/function-calling#parallel-function-calling)

코드의 `seen`은 한 프로세스 안에서 **같은 call_id와 같은 인자**가 다시 온 경우 재실행을 피하는 예입니다. 서로 다른 call_id로 같은 업무를 요청하거나 프로세스가 재시작되는 경우까지 막지는 못합니다. 주문 변경이나 티켓 생성처럼 쓰기 작업으로 확장한다면 서버가 승인된 업무 단위의 idempotency key를 만들고, 실행 결과를 저장한 뒤 재시도 시 기존 결과를 확인해야 합니다. 타임아웃은 “반영되지 않음”의 증거가 아니므로 무조건 다시 실행하면 중복 변경이 생길 수 있습니다.

읽기 도구도 실제 성공 판단이 필요합니다. 응답 코드뿐 아니라 조회 대상, 반환 시각, 업무 시스템의 성공·실패 값이 맞는지 확인하세요. 쓰기 도구라면 모델의 답변 대신 업무 시스템의 접수 번호, 저장된 결과, 후속 조회에서 확인한 상태를 근거로 완료를 표시해야 합니다. `required`, `strict`, `call_id` 어느 것도 이러한 영수증을 대신하지 않습니다.

## 적용 전에 확인할 실패 경로

위 코드는 오프라인에서 Python 구문, 예제 schema의 기본 형식, 합성 handler와 응답 분기를 확인했습니다. 실제 SDK 호환성, API의 schema 수용 여부, 모델의 판단 정확도, 업무 시스템의 실행 결과는 이 검사로 확인되지 않습니다.

서비스에 연결할 때는 정상 수량 6개뿐 아니라 재고 0개, 정보 부족, 권한 거부, 잘못된 함수명·인자, 거절, 출력 중단, 도구 반복, 타임아웃을 확인하세요. 최종 객체의 수량을 의도적으로 바꿔 거부되는지도 검사하면 “JSON이 맞으니 성공”이라는 잘못된 판정을 찾아낼 수 있습니다. 전송 실패·인증 오류·요청 한도는 API 호출 경계에서 별도로 처리하고, 쓰기 재시도는 저장된 실행 상태를 확인한 뒤 결정합니다.

Responses 연결과 프로젝트 키 설정이 먼저 필요하다면 [OpenAI API 키 발급과 첫 호출 절차](https://blog.laozhang.ai/ko/posts/openai-api-key-free-trial)를 참고하세요. 기존 Chat Completions 코드의 전체 호출 루프를 옮기는 작업은 [Responses API 함수 호출 이전 가이드](https://blog.laozhang.ai/ko/posts/chat-completions-to-responses-api-function-calling-migration)에서 이어갈 수 있습니다.

## 자주 묻는 질문

### Structured Outputs로 직접 작업 요청 JSON을 만들면 안 되나요?

가능합니다. 다만 앱이 그 JSON을 해석하는 자체 dispatcher를 만들었다면 함수 선택, 인자 검사, 권한, 실행, 결과 반환을 모두 직접 책임져야 합니다. JSON 자체가 실행되는 것은 아닙니다. 모델에 앱의 도구를 연결하는 목적이라면 공식 Function Calling 프로토콜을 사용하는 편이 요청과 결과를 구분하기 쉽습니다. [두 사용 방식의 구분](https://developers.openai.com/api/docs/guides/structured-outputs)

### `strict: true`면 잘못된 재고 수량도 막을 수 있나요?

schema에 맞는 타입과 필드로 받는 데 도움이 되지만 사실의 정확성을 보장하지는 않습니다. 6이라는 조회 결과를 60으로 바꿔도 integer 조건은 만족합니다. 중요한 값은 신뢰할 수 있는 입력이나 도구 결과와 대조해야 합니다. [Structured Outputs의 내용 오류](https://developers.openai.com/api/docs/guides/structured-outputs)

### JSON mode와 Structured Outputs 중 무엇을 써야 하나요?

모델과 사용 경로가 지원한다면 정해진 객체에는 Structured Outputs를 사용하세요. JSON mode는 특정 schema를 보장하지 않습니다. JSON mode를 사용해야 한다면 문맥에 JSON 출력을 명시하고, 앱에서 schema와 불완전 출력을 검사해야 합니다. JSON 지시가 없으면 공백 출력이 이어지거나 요청 오류가 발생할 수 있습니다. [JSON mode 안내](https://developers.openai.com/api/docs/guides/structured-outputs#json-mode)

### `required`이면 정보가 없어도 값이 꼭 있어야 하나요?

키는 있어야 하지만 값은 schema가 허용하면 `null`일 수 있습니다. enum도 함께 사용한다면 enum에 `null`을 포함하세요. 정보 부족과 조회 실패를 구분해야 하는 앱에서는 nullable 값과 상태 필드를 함께 설계하고, 상태 사이의 업무 규칙을 별도로 검사하는 것이 좋습니다. [필수 필드와 nullable 값](https://developers.openai.com/api/docs/guides/structured-outputs#supported-schemas)

### 조회가 끝나면 반드시 두 번째 모델 호출을 해야 하나요?

아닙니다. 앱이 도구 결과를 바로 안전하게 표시할 수 있으면 직접 렌더링해도 됩니다. 모델이 여러 결과를 해석하거나 고정된 최종 객체를 만들어야 한다면 결과를 반환한 뒤 Structured Outputs를 적용하세요. 도구 루프 도중 추가 호출이 올 수도 있으므로, 고정된 “두 번”보다 완료 조건과 최대 반복 횟수를 정하는 편이 정확합니다. [도구 결과를 이어서 처리하는 흐름](https://developers.openai.com/api/docs/guides/function-calling)

## 참고 자료

이 글이 링크한 외부 페이지를 본문에 나온 순서대로 정리했습니다. 마지막 업데이트: 2026-10-07.

- [OpenAI Structured Outputs 가이드](https://developers.openai.com/api/docs/guides/structured-outputs) (developers.openai.com)
- [Function Calling 가이드](https://developers.openai.com/api/docs/guides/function-calling) (developers.openai.com)
