Skip to main content
Summary

도구는 모델과 서버 사이의 계약이다

Apps SDK 앱을 만들 때 가장 먼저 다듬어야 할 것은 화면이 아니라 도구입니다. 사용자는 자연어로 말하지만, ChatGPT는 어느 순간 그 요청을 "어떤 도구를 어떤 인자로 호출할지"로 바꿔야 합니다. 이때 도구 정의가 흐리면 모델은 잘못된 도구를 고르거나, 필요한 인자를 빠뜨리거나, 조회만 해야 할 상황에서 변경 작업을 시도할 수 있습니다.

OpenAI Apps SDK 문서는 도구를 MCP 서버와 모델 사이의 계약으로 설명합니다. 도구는 "무엇을 할 수 있는지", "어떤 입력을 받아야 하는지", "어떤 결과를 돌려주는지"를 모델에게 알려주는 인터페이스입니다.1 좋은 도구 설계는 모델의 추측을 줄입니다. 반대로 나쁜 도구 설계는 위젯이 아무리 예뻐도 앱 전체를 불안정하게 만듭니다.

도구를 설계할 때는 세 가지 질문부터 던지면 됩니다.

  • 이 도구는 한 가지 일을 하는가?

  • 모델이 언제 이 도구를 호출해야 하는지 설명만 보고 알 수 있는가?

  • 입력과 출력이 다음 대화나 위젯 렌더링에 재사용하기 좋은 구조인가?

예를 들어 search_and_order_food 같은 도구는 편해 보이지만 위험합니다. 검색은 조회 작업이고 주문은 변경 작업입니다. 둘을 한 도구에 넣으면 모델은 "메뉴만 찾아줘"라는 요청에서도 주문 가능성이 있는 도구를 보게 됩니다. 더 나은 설계는 search_restaurants, get_menu_items, create_order_preview, place_order처럼 역할을 나누는 것입니다.

도구 설계와 JSON Schema 작성법

모델 친화적인 도구 설명 쓰기

도구 설명은 사람을 위한 API 문서가 아닙니다. 모델이 호출 여부를 판단하는 신호입니다. 그래서 설명에는 구현 방식보다 사용자 의도가 들어가야 합니다.

나쁜 설명은 보통 이렇게 씁니다.

{
  name: "get_items",
  description: "Fetch items from database"
}

이 설명은 개발자에게는 익숙하지만 모델에게는 부족합니다. 어떤 사용자의 요청에서 호출해야 하는지 알 수 없습니다. 더 나은 설명은 다음과 같습니다.

{
  name: "search_restaurants",
  description:
    "사용자가 지역, 음식 종류, 가격대, 영업 여부를 기준으로 식당을 찾고 싶어 할 때 사용합니다. 주문 생성이나 결제에는 사용하지 않습니다."
}

좋은 설명에는 보통 네 가지가 들어갑니다.

  1. 사용자 의도: "식당을 찾고 싶을 때", "배송 상태를 확인할 때"

  2. 도구의 범위: "조회만 수행", "예약을 실제로 생성"

  3. 호출하지 말아야 할 경우: "결제에는 사용하지 않음"

  4. 비슷한 도구와의 차이: "상세 정보가 아니라 목록 검색에 사용"

도구가 많아질수록 "호출하지 말아야 할 경우"가 중요해집니다. 모델은 여러 도구 중 가장 그럴듯한 것을 고릅니다. 설명이 모두 비슷하면 선택이 흔들립니다. search_products와 get_product_detail이 있다면 하나는 "여러 상품 후보를 찾을 때", 다른 하나는 "이미 특정 상품 ID를 알고 있을 때"라고 구분해야 합니다.

이름도 중요합니다. do_action, process, handle_user_request 같은 이름은 피하세요. 도구 이름은 짧고 구체적인 동사형이 좋습니다.

좋음: search_products, get_order_status, create_reservation
나쁨: run, process_data, submit, execute

JSON Schema로 입력을 좁히기

도구 설명이 "언제 호출할지"를 알려준다면, JSON Schema는 "어떻게 호출할지"를 알려줍니다. 스키마가 느슨하면 모델은 자유롭게 인자를 만들어냅니다. 스키마가 명확하면 모델은 필요한 값만 맞춰 전달합니다.

가장 기본 형태는 객체입니다.

inputSchema: {
  type: "object",
  properties: {
    query: {
      type: "string",
      description: "사용자가 찾고 싶은 음식, 식당명, 메뉴 키워드"
    },
    location: {
      type: "string",
      description: "검색 기준 위치. 예: 강남역, 서울 마포구"
    }
  },
  required: ["query", "location"],
  additionalProperties: false
}

여기서 핵심은 required와 additionalProperties입니다. required는 반드시 필요한 값을 명시합니다. 위치 없이는 검색 품질이 떨어진다면 location을 필수로 두는 편이 낫습니다. 그러면 모델은 위치가 없을 때 바로 추측하기보다 사용자에게 물어볼 가능성이 커집니다.

additionalProperties: false는 정의하지 않은 필드를 막는 역할을 합니다. 예를 들어 스키마에 없는 userMood, randomNote 같은 필드가 섞이는 일을 줄입니다. 실무에서는 이 옵션을 기본값처럼 쓰는 것이 좋습니다. 입력이 예측 가능해야 서버 검증과 로그 분석도 쉬워집니다.

enum은 모델의 선택지를 줄인다

문자열이라고 해서 모두 자유 입력으로 둘 필요는 없습니다. 가능한 값이 정해져 있다면 enum을 사용하세요.

inputSchema: {
  type: "object",
  properties: {
    priceLevel: {
      type: "string",
      enum: ["low", "medium", "high"],
      description: "사용자가 원하는 가격대"
    },
    sortBy: {
      type: "string",
      enum: ["distance", "rating", "delivery_time"],
      description: "검색 결과 정렬 기준"
    }
  },
  required: ["priceLevel"],
  additionalProperties: false
}

enum은 모델의 표현을 서버가 처리 가능한 값으로 맞춰줍니다. 사용자가 "가성비 좋은 곳"이라고 말해도 모델은 priceLevel: "low"처럼 정해진 값으로 바꿔 전달할 수 있습니다.

다만 enum 값을 너무 개발자 내부 용어로 만들지 마세요. A1, B2, SVC_PREM 같은 값은 모델에게 의미가 약합니다. 가능하면 standard, express, scheduled처럼 의미가 드러나는 값을 쓰는 편이 좋습니다.

nested object는 관련 값을 묶을 때 쓴다

입력값이 많아지면 평평한 구조보다 중첩 객체가 읽기 쉽습니다. 특히 날짜 범위, 필터, 좌표, 사용자 선택 옵션처럼 함께 움직이는 값은 묶어 주세요.

inputSchema: {
  type: "object",
  properties: {
    filters: {
      type: "object",
      description: "검색 결과를 좁히기 위한 조건",
      properties: {
        cuisine: {
          type: "string",
          description: "음식 종류. 예: 한식, 일식, 피자"
        },
        openNow: {
          type: "boolean",
          description: "현재 영업 중인 식당만 볼지 여부"
        },
        priceLevel: {
          type: "string",
          enum: ["low", "medium", "high"]
        }
      },
      required: [],
      additionalProperties: false
    },
    page: {
      type: "integer",
      minimum: 1,
      description: "검색 결과 페이지 번호"
    }
  },
  required: ["filters"],
  additionalProperties: false
}

중첩 객체를 쓸 때도 각 단계마다 additionalProperties: false를 붙이는 습관이 좋습니다. 바깥 객체만 막고 안쪽 객체를 열어 두면 예상하지 못한 필드가 안쪽으로 들어올 수 있습니다.

배열도 자주 씁니다. 예를 들어 여러 상품 ID를 한 번에 조회한다면 다음처럼 표현할 수 있습니다.

productIds: {
  type: "array",
  items: { type: "string" },
  minItems: 1,
  maxItems: 20,
  description: "상세 정보를 조회할 상품 ID 목록"
}

minItems, maxItems는 성능과 안전성에 도움이 됩니다. 사용자가 "전부 보여줘"라고 말해도 서버가 감당할 수 있는 범위 안에서 호출되도록 제한할 수 있습니다.

조회 도구와 변경 도구를 구분하기

Apps SDK 도구에는 주석을 붙여 모델에게 도구의 성격을 알려줄 수 있습니다. 대표적으로 readOnlyHint는 도구가 데이터를 변경하지 않는 조회성 도구임을 나타냅니다. Apps SDK 예제 저장소도 MCP 서버가 도구를 노출할 때 JSON Schema 계약과 함께 readOnlyHint 같은 선택적 주석을 제공할 수 있다고 설명합니다.2

server.registerTool(
  "get_order_status",
  {
    title: "주문 상태 조회",
    description: "사용자가 기존 주문의 배송 상태를 알고 싶어 할 때 사용합니다. 주문을 변경하지 않습니다.",
    inputSchema: {
      type: "object",
      properties: {
        orderId: { type: "string", description: "조회할 주문 ID" }
      },
      required: ["orderId"],
      additionalProperties: false
    },
    annotations: {
      readOnlyHint: true
    }
  },
  async ({ orderId }) => {
    const order = await getOrder(orderId);
    return {
      structuredContent: { order },
      content: [{ type: "text", text: `주문 ${orderId}의 현재 상태는 ${order.status}입니다.` }]
    };
  }
);

조회 도구에는 readOnlyHint: true를 붙이고, 생성·수정·삭제 도구에는 붙이지 않는 식으로 경계를 분명히 하세요. 이 구분은 단순한 문서화가 아닙니다. 모델이 안전한 도구를 우선 선택하고, 변경성 작업에서는 더 조심스러운 흐름을 만들도록 돕는 신호입니다.

변경 도구는 더 좁게 설계해야 합니다. update_user_profile처럼 모든 필드를 바꿀 수 있는 도구보다 update_delivery_address, cancel_reservation, confirm_order처럼 의도가 분명한 도구가 안전합니다. 특히 결제, 삭제, 외부 전송처럼 되돌리기 어려운 작업은 미리보기 도구와 확정 도구를 나누는 편이 좋습니다.

create_order_preview: 장바구니와 배송 정보를 검증하고 예상 금액을 반환
place_order: 사용자가 확인한 previewId로 실제 주문 생성

이렇게 나누면 모델은 먼저 미리보기를 만들고, 사용자가 확인한 뒤 확정 도구를 호출할 수 있습니다.

structuredContent와 _meta를 분리하기

도구 결과에는 모델이 읽을 데이터와 위젯만 사용할 데이터를 구분해서 담아야 합니다. 일반적으로 structuredContent는 모델과 위젯이 함께 이해할 수 있는 구조화 데이터입니다. 반면 _meta는 위젯 렌더링이나 클라이언트 동작에 필요한 부가 정보로, 모델에게 굳이 보여줄 필요가 없는 값을 담는 데 사용합니다. Apps SDK 예제들은 구조화된 페이로드와 메타데이터를 결합해 위젯을 렌더링하는 흐름을 보여줍니다.2

여기서 자주 생기는 혼동이 있습니다. ChatGPT Apps SDK에서 "이 도구가 어떤 위젯 템플릿을 띄울지"는 보통 도구 descriptor의 _meta["openai/outputTemplate"]에 둡니다. 반면 도구 실행 결과의 _meta에는 위젯이 추가로 참고할 보조 데이터, 추적 ID, 세션 식별자, 모델에게 노출할 필요가 없는 상세 맵 등을 담습니다. 즉, 템플릿 연결과 실행 결과의 보조 데이터는 분리해서 생각하는 편이 안전합니다.

예를 들어 식당 검색 도구는 다음처럼 정의할 수 있습니다.

const RESTAURANT_LIST_UI = "ui://widgets/restaurant-list.html";

server.registerTool(
  "search_restaurants",
  {
    title: "식당 검색",
    description: "사용자가 지역, 음식 종류, 가격대를 기준으로 식당 후보를 찾고 싶을 때 사용합니다. 주문이나 결제에는 사용하지 않습니다.",
    inputSchema: {
      type: "object",
      properties: {
        query: { type: "string", description: "음식 또는 식당 키워드" },
        location: { type: "string", description: "검색 기준 위치" }
      },
      required: ["query", "location"],
      additionalProperties: false
    },
    annotations: {
      readOnlyHint: true
    },
    _meta: {
      "openai/outputTemplate": RESTAURANT_LIST_UI,
      "openai/toolInvocation/invoking": "식당을 찾는 중입니다…",
      "openai/toolInvocation/invoked": "식당 후보를 찾았습니다."
    }
  },
  async ({ query, location }) => {
    const restaurants = await searchRestaurants({ query, location });

    return {
      structuredContent: {
        restaurants: restaurants.map((r) => ({
          id: r.id,
          name: r.name,
          rating: r.rating,
          deliveryTimeMinutes: r.deliveryTimeMinutes
        }))
      },
      content: [
        {
          type: "text",
          text: `${location} 근처에서 ${restaurants.length}개의 식당을 찾았습니다.`
        }
      ],
      _meta: {
        restaurantsById: Object.fromEntries(restaurants.map((r) => [r.id, r])),
        traceId: crypto.randomUUID()
      }
    };
  }
);

여기서 structuredContent에는 모델이 후속 대화에서 사용할 수 있는 요약된 식당 목록을 넣었습니다. 도구 descriptor의 _meta["openai/outputTemplate"]는 어떤 위젯 템플릿을 렌더링할지 알려줍니다. 도구 결과의 _meta에는 위젯이 상세 렌더링에 사용할 전체 객체 맵과 추적용 ID를 넣었습니다.

분리 원칙은 간단합니다.

  • 모델이 다음 답변이나 다음 도구 호출에 써야 하는 값은 structuredContent

  • 화면 렌더링에만 필요한 상세 데이터는 도구 결과의 _meta

  • ChatGPT가 어떤 위젯 템플릿을 렌더링해야 하는지는 도구 descriptor의 _meta["openai/outputTemplate"]

  • 사용자에게 설명할 자연어 요약은 content

단, _meta가 보안 저장소라는 뜻은 아닙니다. 위젯에서 접근할 수 있는 값이므로 민감 정보를 무심코 넣으면 안 됩니다. 장기 토큰, 결제 정보, 주민등록번호 같은 값은 애초에 도구 결과로 내려보내지 않는 편이 안전합니다.

실패 응답도 계약으로 설계하기

도구는 항상 성공하지 않습니다. 사용자가 잘못된 ID를 말할 수도 있고, 외부 API가 실패할 수도 있고, 인증이 만료될 수도 있습니다. 중요한 것은 실패했을 때도 모델이 사용자에게 설명할 수 있는 구조를 돌려주는 것입니다.

나쁜 실패 응답은 다음과 같습니다.

throw new Error("failed");

이렇게 하면 모델은 무엇이 실패했는지, 사용자가 무엇을 고쳐야 하는지 알기 어렵습니다. 더 나은 방식은 오류도 구조화하는 것입니다.

return {
  isError: true,
  structuredContent: {
    ok: false,
    error: {
      code: "ORDER_NOT_FOUND",
      message: "해당 주문을 찾을 수 없습니다.",
      retryable: false,
      userAction: "주문 ID를 다시 확인해 주세요."
    }
  },
  content: [
    {
      type: "text",
      text: "주문 ID가 일치하지 않아 주문 상태를 조회하지 못했습니다. 주문 번호를 다시 확인해 주세요."
    }
  ]
};

오류 구조에는 최소한 다음 필드를 두는 것이 좋습니다.

  • code: 서버와 로그에서 식별할 안정적인 오류 코드

  • message: 모델이 이해할 수 있는 짧은 설명

  • retryable: 같은 요청을 다시 시도할 가치가 있는지

  • userAction: 사용자가 고칠 수 있는 행동

일시적인 네트워크 오류라면 retryable: true로 표시하고, "잠시 후 다시 시도할 수 있다"는 메시지를 함께 줍니다. 입력값 문제라면 retryable: false에 가깝고, 사용자가 어떤 값을 다시 제공해야 하는지 알려줘야 합니다.

인증 실패도 같은 원칙입니다. 단순히 "권한 없음"이라고 끝내지 말고, 사용자가 연결을 다시 해야 하는지, 특정 권한이 부족한지, 세션이 만료되었는지 구분해야 합니다. 그래야 ChatGPT가 다음 대화에서 "계정을 다시 연결해 주세요"처럼 자연스럽게 안내할 수 있습니다.

도구 설계 체크리스트

도구를 등록하기 전에 아래 항목을 빠르게 점검해 보세요.

  • 도구 하나가 한 가지 책임만 갖는가?

  • 이름만 보고도 동작을 짐작할 수 있는가?

  • 설명에 호출해야 할 사용자 의도와 호출하지 말아야 할 경우가 들어 있는가?

  • 필수 입력은 required에 들어 있는가?

  • 가능한 값은 enum으로 좁혔는가?

  • 중첩 객체와 배열에 additionalProperties, minItems, maxItems 같은 제한을 두었는가?

  • 조회 도구에는 readOnlyHint를 붙였는가?

  • 변경 도구는 미리보기와 확정 단계를 나눌 필요가 없는가?

  • 모델이 볼 데이터와 위젯만 볼 데이터를 structuredContent와 도구 결과의 _meta로 분리했는가?

  • ChatGPT 위젯을 띄우는 도구에는 _meta["openai/outputTemplate"]가 올바른 UI 리소스를 가리키는가?

  • 실패 응답이 모델과 사용자 모두에게 설명 가능한가?

좋은 Apps SDK 앱은 화려한 UI보다 먼저 좋은 도구 설계에서 시작됩니다. 모델이 도구를 안정적으로 선택하고, 서버가 예측 가능한 입력을 받고, 위젯이 필요한 데이터만 받는 구조가 잡히면 그 위에 어떤 UI를 올려도 흔들리지 않습니다.

참고

1Define tools - Apps SDK | OpenAI Developers

2OpenAI Apps SDK Examples

책 목록

ChatGPT 앱을 만드는 법: OpenAI Apps SDK 실전 가이드

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기