CodeGym /행동 /ChatGPT Apps /위젯에서 도구 결과 처리: ToolOutput → UI

위젯에서 도구 결과 처리: ToolOutput → UI

ChatGPT Apps
레벨 4 , 레슨 3
사용 가능

1. ToolOutput에서 React‑컴포넌트까지: 전체 데이터 흐름

지난 강의에서는 서버 측 도구가 ToolOutput — 모델과 위젯을 위한 구조화된 응답 — 을 어떻게 형성하는지 살펴봤습니다. 이제 그 경로의 후반부, 즉 이 ToolOutput이 위젯으로 들어와 UI로 바뀌는 과정을 보겠습니다.

마법처럼 느끼지 않도록, 사용자에서 여러분의 위젯까지 데이터가 이동하는 경로를 다시 한 번 정리해 봅시다. 단순화하면 다음과 같습니다:

  1. 사용자가 채팅에서 질문을 보낸다.
  2. GPT가 요청을 분석하고 도구 목록을 확인한 뒤, “지금은 suggest_gifts가 도움이 되겠다”라고 결정한다.
  3. GPT는 이름과 인자(ToolInput)가 포함된 도구 호출을 구성하고, 이를 여러분의 서버(MCP 또는 백엔드)로 보낸다.
  4. 서버는 도구 로직을 실행하고 결과를 ToolOutput 형태로 반환한다 — 데이터가 담긴 구조화된 JSON과 모델을 위한 텍스트 요약이 포함된다.
  5. ChatGPT는 ToolOutput을 받아서 모델(대화를 이어가기)과 여러분의 위젯으로 Apps SDK를 통해 전달한다(window.openai.toolOutput 또는 훅).
  6. 여러분의 위젯 — 일반적인 React‑컴포넌트 — 은 toolOutput을 읽고 UI를 렌더링한다.

도식으로 표현하면 다음과 같습니다:

flowchart TD
  U[사용자] -->|채팅 요청| GPT[GPT]
  GPT -->|callTool: suggest_gifts| B[Backend/MCP]
  B -->|"ToolOutput (JSON)"| GPT
  GPT -->|toolOutput 전달| W["위젯 (React)"]
  W -->|카드, 목록| U

중요한 점: ToolOutput은 단순한 “서버의 응답”이 아닙니다. 동시에 위젯을 위한 렌더링 명령이자 모델을 위한 컨텍스트입니다. 좋은 App이란 이 JSON을 DevTools에서 개발자가 스크롤해 넘기는 것이 아니라, 편리한 인터페이스로 바꾸는 것입니다.

2. ToolOutput 해부: 내부에 무엇이 있는가

Apps SDK의 도구 결과 형식은 세 가지 논리 블록으로 나뉩니다: structuredContent, content, _meta(위젯에서는 toolResponseMetadata라는 이름으로 접근).

개략적으로는 다음과 같습니다:

{
  "structuredContent": { /* UI + 모델을 위한 데이터 */ },
  "content": "모델과 사용자를 위한 짧은 텍스트 요약",
  "_meta": { /* 위젯 전용의 서비스 데이터 */ }
}

누가 무엇을 볼 수 있는지 표로 정리하면 다음과 같습니다:

필드 누가 볼 수 있나 용도
structuredContent
모델 + 위젯 핵심 구조화 데이터(목록, 객체, 파라미터)
content
모델 + 사용자(텍스트에 표시) GPT가 자신의 답변에 삽입할 수 있는 짧은 요약
_meta
위젯만 모델에 필요하지 않은 서비스 데이터(ID, 버전, 키 등)

Apps SDK 문서는 structuredContent / content 쌍이 모델로 전달되며 이후 응답에서 사용될 수 있음을 강조합니다. 반면 _meta 필드는 숨겨져 있으며, 위젯 내부에서 toolResponseMetadata로만 접근할 수 있습니다.

GiftGenius용 ToolOutput 예시

서버의 suggest_gifts 도구가 대략 다음과 같은 본문을 반환한다고 가정해 봅시다:

{
  "structuredContent": {
    "items": [
      {
        "id": "boardgame-cozy-strategy",
        "title": "Cozy Strategy Board Game",
        "price": 39.99,
        "currency": "USD",
        "score": 0.92,
        "tags": ["board_game","strategy","2-4_players"]
      }
    ]
  },
  "content": "선물 아이디어 몇 가지를 찾았습니다. 아래에서 위젯이 카드 형태로 보여줍니다.",
  "_meta": {
    "giftGenius": {
      "catalogVersion": "2025-10-01",
      "experimentBucket": "A"
    }
  }
}

여기서 structuredContent.items는 React‑위젯이 렌더링할 대상이고, content는 모델이 현재 상황을 사용자에게 설명할 때 사용할 수 있습니다. _meta.giftGenius는 오직 UI나 분석에만 필요한 내부 정보입니다(예: 링크에 어떤 카탈로그 버전을 사용할지).

바로 이 structuredContent가 서버의 임의 JSON을 직접 파싱하는 대신, JSX에서 바라보게 될 객체입니다.

3. 위젯에서 ToolOutput 받기: window.openai와 훅

이제 JSON 이야기를 코드로 옮겨 봅시다. 이 ToolOutput은 어떻게 여러분의 React‑컴포넌트로 들어올까요?

Apps SDK 템플릿은 두 가지 기본 방식을 제공합니다. 하나는 window.openai.toolOutput에 직접 접근하는 방법이고, 다른 하나는 더 권장되는 방식으로, 준비된 React 훅(useWidgetProps, useToolOutput 등)을 사용하는 것입니다. 훅을 사용하면 window.openai를 직접 만지지 않아도 되고, 더 안전하고 테스트하기 쉬운 코드를 만들 수 있습니다.

가장 단순한 방식: window.openai에서 직접 읽기

이해를 돕기 위해 “가장 날것”의 예를 보겠습니다:

'use client';

function RawToolOutputDebug() {
  const toolOutput = (window as any).openai?.toolOutput;
  return (
    <pre>{JSON.stringify(toolOutput, null, 2)}</pre>
  );
}

프로덕션에서는 이렇게 하면 안 되지만, 디버깅과 “처음에 눈으로 확인해 보기” 용도로는 충분합니다.

실전 버전: React‑훅 사용

window.openai 접근을 작은 훅으로 감싸서, 타입이 붙은 객체로 다루는 편이 훨씬 편합니다. 예컨대 SDK가 useWidgetProps라는 훅을 제공하고, 이 훅이 toolOutputtoolResponseMetadata를 돌려준다고 해 봅시다.

'use client';

import { useWidgetProps } from '@/lib/openai-widget';

export function GiftWidgetRoot() {
  const { toolOutput, toolResponseMetadata } = useWidgetProps();

  // 일단 선물 개수만 출력해 봅니다
  const items = toolOutput?.structuredContent?.items ?? [];

  return (
    <div>
      발견된 선물: {items.length}
    </div>
  );
}

실제 템플릿에서는 훅의 이름이 다를 수 있지만, 아이디어는 같습니다. SDK가 window.openai에서 데이터를 가져와서, 이를 여러분의 컴포넌트에 prop 또는 컨텍스트 형태로 넘겨줍니다. 매번 전역 객체를 뒤지는 것보다 훨씬 간단하고, 테스트에서도 쉽게 데이터 소스를 교체할 수 있습니다(예: toolOutput 픽스처 주입).

4. 선물 렌더링: structuredContent에서 JSX로

이제 본격적으로 structuredContent.items로 카드 UI를 그려 봅시다. 우리 위젯은 Next.js의 일반적인 React 클라이언트 컴포넌트입니다(파일 상단의 'use client').

먼저 단일 선물의 타입을 정의합니다:

type GiftItem = {
  id: string;
  title: string;
  price: number;
  currency: string;
  tags?: string[];
};

이제 작은 카드 컴포넌트를 작성합니다:

function GiftCard({ gift }: { gift: GiftItem }) {
  return (
    <div className="gift-card">
      <div className="gift-title">{gift.title}</div>
      <div className="gift-price">
        {gift.price} {gift.currency}
      </div>
    </div>
  );
}

그리고 toolOutput에서 데이터를 읽는 목록 컴포넌트:

'use client';

import { useWidgetProps } from '@/lib/openai-widget';

export function GiftList() {
  const { toolOutput } = useWidgetProps();
  const items = (toolOutput?.structuredContent?.items ?? []) as GiftItem[];

  return (
    <div className="gift-list">
      {items.map(gift => (
        <GiftCard key={gift.id} gift={gift} />
      ))}
    </div>
  );
}

보시다시피, 매우 일반적인 React 코드와 닮아 있습니다. 유일한 “마법”은 데이터 소스입니다. propsfetch 대신, ChatGPT 컨테이너에서 toolOutput을 읽어온다는 점이 다릅니다.

그리고 처음에는 as GiftItem[]을 조금 남발해도 괜찮습니다. 나중에는 백엔드와 공통 타입(Zod / JSON Schema → TS 타입)을 통해 structuredContent를 더 정밀하게 타입화하면 됩니다. 데모 수준에는 이 정도로 충분합니다.

5. ToolOutput 주변 UI 상태: 로딩, 비어 있음, 오류

성공했을 때 카드만 보여 주고 나머지 상황에서는 아무것도 안 보이는 앱은 친절하지 않습니다. 최소한 네 가지 상태는 명시적으로 처리해야 합니다. 도구가 실행 중일 때, 아직 데이터가 없을 때, 결과가 있을 때, 문제가 생겼을 때입니다.

Apps SDK는 보통 도구 호출 상태에 대한 정보를 제공합니다. 도구 호출 목록(useToolInvocations)이나 toolOutput 관련 플래그 형태로요. 이 강의에서는 단순한 모델만 사용해 보겠습니다. toolOutput이 아직 없으면 “로딩”, 있긴 한데 목록이 비어 있으면 “비어 있음”, 오류가 오면 “오류”입니다.

간단히 하려고, 서버가 오류 시 structuredContenterror 필드를 넣고, toolOutput 루트의 ok 플래그는 false라고 가정합니다. 이 스키마는 이전 서버 구현 강의에서 도구 응답 계약을 설계하며 이미 다뤘습니다.

type ToolOutput = {
  ok: boolean;
  structuredContent?: {
    items?: GiftItem[];
    error?: { code: string; message: string };
  };
};

이제 목록 컴포넌트를 업데이트합니다:

'use client';

import { useWidgetProps } from '@/lib/openai-widget';

export function GiftListWithStates() {
  const { toolOutput } = useWidgetProps() as { toolOutput?: ToolOutput };

  if (!toolOutput) {
    return <div>선물을 고르는 중…</div>;
  }

  if (!toolOutput.ok) {
    const msg = toolOutput.structuredContent?.error?.message
      ?? '추천을 가져오지 못했습니다.';
    return <div>오류: {msg}</div>;
  }

  const items = toolOutput.structuredContent?.items ?? [];

  if (items.length === 0) {
    return <div>조건에 맞는 선물을 찾지 못했습니다. 매개변수를 변경해 보세요.</div>;
  }

  return (
    <div className="gift-list">
      {items.map(gift => (
        <GiftCard key={gift.id} gift={gift} />
      ))}
    </div>
  );
}

이제 사용자 경험이 한결 좋아집니다:

  • 도구가 실행 중일 때, 무언가 진행 중임을 볼 수 있습니다.
  • 문제가 생기면 빈 화면 대신 이해 가능한 메시지가 보입니다.
  • 아무것도 없을 때도 정상인 척하지 않고, 상황을 분명히 알려 줍니다.

프로덕션에서는 “선물을 고르는 중…” 같은 텍스트를 작은 스켈레톤이나 스피너로 대체하겠죠. 복잡한 오류는 GPT에게 사람 친화적인 설명을 생성하도록 맡길 수도 있습니다. 하지만 컴포넌트의 기본 구조는 그대로 유지됩니다.

6. UI에서 _meta와 toolResponseMetadata 사용하기

이미 structuredContent에서 핵심 데이터를 렌더링하고, loading/empty/error 같은 기본 상태를 처리하는 법을 배웠습니다. 모델이 쓰지 않는 ToolOutput의 또 다른 중요한 조각 — _meta — 가 남았습니다.

_meta로 돌아가 봅시다. 이 필드는 모델에게는 보이지 않지만, 위젯에서는 toolResponseMetadata로 전달됩니다(이름은 다를 수 있으나 의미는 같습니다).

GPT의 추론에 영향을 주지 않지만 UI에 중요한 것들을 두기에 아주 좋은 장소입니다:

  • 카탈로그나 설정의 버전;
  • A/B 실험이나 캠페인의 내부 ID;
  • 사용자에게 어떤 “버튼”을 보여줄지에 대한 플래그;
  • 도메인 데이터와 섞고 싶지 않은 각종 기술적 정보.

예를 들어, 서버가 다음과 같은 _meta를 반환할 수 있습니다:

"_meta": {
  "giftGenius": {
    "catalogVersion": "2025-10-01",
    "showExperimentalBadges": true
  }
}

위젯은 이를 읽어서 일부 카드에 “새로운 아이디어” 배지를 그릴 수 있습니다.

type GiftMeta = {
  giftGenius?: {
    catalogVersion: string;
    showExperimentalBadges?: boolean;
  };
};

export function GiftListWithMeta() {
  const { toolOutput, toolResponseMetadata } = useWidgetProps() as {
    toolOutput?: ToolOutput;
    toolResponseMetadata?: GiftMeta;
  };

  const meta = toolResponseMetadata?.giftGenius;
  const items = toolOutput?.structuredContent?.items ?? [];

  return (
    <div>
      {meta && (
        <div className="catalog-version">
          카탈로그 기준 {meta.catalogVersion}
        </div>
      )}
      <div className="gift-list">
        {items.map(gift => (
          <GiftCard
            key={gift.id}
            gift={gift}
          />
        ))}
      </div>
    </div>
  );
}

여기서는 모델이 전혀 관여하지 않습니다. 모델은 catalogVersion이나 showExperimentalBadges를 알지 못하지만, 여러분의 UI는 이를 원하는 대로 활용할 수 있습니다.

문서는 이런 분리를 강조합니다. 대화와 모델의 추론에 중요한 데이터는 structuredContentcontent에 넣고, 순수 UI 기술 데이터는 _meta / toolResponseMetadata에 둡니다.

7. ToolInvocation 상태와 “X 수행 중…”

도구가 실행되는 동안, ChatGPT는 상단에 “GiftGenius 수행 중…” 혹은 “외부 애플리케이션에 요청 중” 같은 상태를 자동으로 보여 줍니다. 이는 여러분이 직접 출력하는 문구가 아니라, 도구 호출 메타데이터에 반응하는 ChatGPT의 호스트 환경이 표시하는 것입니다.

내부적으로는 _meta["openai/toolInvocation/invoking"]_meta["openai/toolInvocation/invoked"] 같은 서비스 키로 표현됩니다. 이 키들은 동작이 실행 중인지, 완료되었는지를 신호하며, 보통은 신경 쓸 필요가 없습니다. SDK가 서버 측에서 알아서 처리합니다.

UX 측면의 보너스는 이렇습니다. 위젯이 아직 스켈레톤을 그리지 못했더라도 사용자는 시스템이 무언가 하고 있음을 이미 봅니다. 여러분은 위에서 만든 “선물을 고르는 중…” 같은 로컬 상태와 스켈레톤으로 이 글로벌 상태를 보완해 주면 됩니다.

8. 데이터 크기와 성능: structuredContent에 모든 것을 담지 않기

structuredContent에 “얼마나 많이” 담아도 되는지에 대해 짚고 넘어가겠습니다. “카탈로그 전체가 있는데, 전부 주고 위젯이 필터링하게 하자”는 유혹이 들 수 있지만, 실무에서는 권장되지 않습니다.

첫째, structuredContent는 모델(LLM)의 컨텍스트로 들어가며, 전체 토큰 용량은 제한됩니다. 문서와 베스트 프랙티스는 용량을 절제하라고 권고합니다. 이곳은 데이터 저장소가 아니라 “한 번의 동작 결과”입니다.

둘째, payload가 클수록 응답이 느려지고, 제한에 걸리거나 예상치 못한 잘림/오류가 날 확률이 높아집니다.

권장 접근:

  • 백엔드에서 미리 필터/정렬하여 해당 단계에 필요한 정확한 데이터만 반환합니다. 예: 상위 10–20개의 선물.
  • 다음 페이지가 필요하면 별도의 동작(새로운 도구 호출, 새로운 ToolOutput)로 처리합니다.
  • 순수 UI 용도(예: 필터용 가능한 태그 목록)는 _meta를 활용할 수 있지만, 이것도 과하지 않게 유지합니다.

상태 모듈에서 “백엔드는 소스 오브 트루스, 위젯은 캐시/뷰”라는 개념을 다뤘습니다. 여기서도 동일합니다. 도구 결과는 호출 시점의 깔끔한 “스냅샷”이지, 데이터베이스 전체 복사본이 아닙니다.

9. 위젯 상태와 이후 대화의 연계

이 강의의 주제는 ToolOutput → UI이지만, 바로 옆에 또 하나 중요한 축인 widgetState가 있습니다. 이것이 있어야 사용자의 선택을 렌더 사이에 기억하고, 단순 전시용이 아닌 진짜 마법사/“선물 구성기” 같은 위젯을 만들 수 있습니다.

일반적인 시나리오:

  1. 첫 번째 ToolOutput이 선물 목록을 가져옵니다.
  2. 사용자가 카드 중 하나를 클릭합니다.
  3. 위젯은 widgetState에 어떤 선물이 선택되었는지 저장하고, 필요하면 후속 질문이나 상세를 위한 새로운 도구 호출을 보냅니다.
  4. 다음 ToolOutput들은 이 선택에 기반합니다.

코드 관점에서는 일반적인 React state + setWidgetState 호출과 같습니다. 차이점은 이 상태가 모델과 백엔드에도 보인다는 점이므로, 작고 간결하게 유지하고 비밀을 보관하지 않는 것이 중요합니다.

멀티스텝 워크플로와 후속 질문 모듈에서 자세히 다룰 예정입니다. 지금은 이렇게 생각해 두세요. ToolOutput은 서버로부터의 “데이터 스냅샷”이고, widgetState는 그 스냅샷을 둘러싼 사용자 선택의 컨텍스트입니다.

ToolOutput → UI 작업에서 흔한 실수

오류 #1: “UI가 사용자에 맞춘 가공 없이 원시 JSON 트리를 그대로 렌더링함”.
디버깅 중에는 <pre>{JSON.stringify(toolOutput)}</pre>로 끝내고 싶을 때가 있습니다. 개발 중엔 괜찮지만, 프로덕션에서는 사용자가 여러분이 자랑스러워하는 구조를 보더라도 이해하지 못합니다. 가능한 한 이른 시점에 structuredContent를 의미 있는 컴포넌트(목록, 카드, 테이블)로 감싸고, 사람에게 서버의 토큰화된 응답을 읽게 하지 마세요.

오류 #2: 도메인 데이터와 기술 메타데이터를 structuredContent에 섞어 넣음.
“모델과 사용자에게 보여야 하는 것”과 “UI/분석에만 필요한 것”을 나누면 코드가 훨씬 깔끔해집니다. 실험 플래그, 카탈로그 버전, idempotency key 같은 기술 필드는 _meta / toolResponseMetadata에 두세요. 이것들을 structuredContent에 뒤섞어 두면, 계약 진화와 모델 동작 테스트가 어려워집니다.

오류 #3: 로딩/빈 결과/오류 상태를 명시적으로 처리하지 않음.
“아무것도 없음”을 <div></div>로 대체하거나, “문제가 생김”을 숨겨 버리면 사용자는 “앱이 안 된다”고 판단합니다. 최소한의 텍스트 플레이스홀더와 간단한 스켈레톤만으로도 UX는 크게 좋아집니다. ChatGPT의 시스템 상태 “X 수행 중…”에만 의존하지 말고, 위젯도 자신에게 무슨 일이 일어나는지 알려야 합니다.

오류 #4: 하나의 ToolOutput에 “세상의 전부”를 넣으려는 시도.
상품 카탈로그 전체, 사용자 히스토리, 서버 로그까지 하나의 structuredContent에 넣는 것은 좋지 않습니다. 모델 제한에 걸리고, 응답이 느려지며, UI가 복잡해집니다. 현재 단계에 필요한 데이터만 정확히 반환하세요(목록 페이지, 선택 항목의 상세 등). 이후 단계는 별도의 도구 호출로 구성합니다.

오류 #5: 타입 없이 불안정한 응답 형태에 UI를 강하게 결합.
아무 필드 존재 여부도 확인하지 않고, 타입도 없이 여기저기서 toolOutput.structuredContent.items[0].whatever를 호출하면, 서버 스키마가 조금만 바뀌어도 위젯이 깨집니다. JSON Schema로 TS 타입을 생성해 동기화하거나, 최소한 수동으로 인터페이스(GiftItem, ToolOutput)를 정의하고 optional 필드를 주의 깊게 다루세요.

오류 #6: _meta를 무시하고 모델을 “불필요한” 필드로 과적재.
“어차피 JSON이니까 뭐든 넣자”는 유혹이 있습니다. 하지만 각 필드는 모델 컨텍스트를 늘리고, 모델에 불필요한 것들도 많습니다. GPT의 추론에 영향을 주지 않고 텍스트 응답에도 필요 없다면, _meta에 넣고 위젯에서만 사용하세요.

오류 #7: 여러 컴포넌트에서 window.openai를 직접 참조.
물론 window.openai.toolOutput은 동작합니다. 그러나 앱의 절반이 전역을 파기 시작하면, 디버깅과 테스트가 악몽이 됩니다. 한 번 훅/컨텍스트(useWidgetProps/useToolOutput)로 감싼 뒤, 나머지는 정상적인 props와 타입이 붙은 객체로 처리하는 편이 훨씬 낫습니다. 더 깔끔하고, Storybook/테스트에서 픽스처로 쉽게 대체할 수 있습니다.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION