CodeGym /행동 /ChatGPT Apps /서버와의 상호작용: window.fetch와 openExternal

서버와의 상호작용: window.fetch와 openExternal

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

1. 바깥으로 나가는 두 가지 경로: 탐색과 데이터

일반적인 Next.js 개발자는 “서버에 다녀와야 한다”는 말을 들으면 자동으로 fetch나 즐겨 쓰는 HTTP 클라이언트를 떠올립니다. ChatGPT Apps 세계에서 이런 반사적 반응은 고통을 부릅니다.

ChatGPT Apps의 위젯 보안 파트에서는 이 오래된 반사를 처음부터 끊도록 제안합니다. 위젯은 자유로운 인터넷에서 살지 않습니다. 강력히 격리되어 있고, 그 네트워크 접근은 호스트의 정책에 의해 필터링되고 제한됩니다.

위젯이 바깥과 소통할 수 있는 기본 창은 단 세 가지뿐입니다:

  1. 탐색: 사용자를 외부로 보냅니다. 이를 위해 openExternal이 있습니다.
  2. 데이터 교환: JSON을 주고받고, 백엔드와 대화합니다. fetch로 가능하지만 제약이 큽니다.
  3. MCP tool call: 제한이 없는 도구 호출(MCP / 백엔드).

이 강의에서는 첫 번째이자 가장 안전한 경로(탐색)에 집중하고, 통제된 fetch를 조심스럽게 살펴봅니다. 다음 모듈에서는 서버와 진지하게 대화하는 주된 방법으로 MCP와 도구를 다룹니다.

2. openExternal: 사용자를 위한 안전한 “텔레포트”

그냥 window.open을 쓰면 안 되는 이유

일반 웹 애플리케이션이라면 아마 이렇게 썼을 겁니다:


window.open("https://example.com", "_blank");

ChatGPT 샌드박스에서는 이게 아예 동작하지 않거나 아주 이상하게 동작합니다. 위젯은 강력한 sandbox가 적용된 격리된 iframe이며, 브라우저 탭과 동일한 권한을 갖지 않습니다.

또한 ChatGPT 호스트는 여러분이 사용자를 언제 어디로 보내는지 통제하길 원합니다. 그 이유는 다음과 같습니다:

  • 은밀한 추적을 막기 위해;
  • 사용자에게 이해 가능한 확인 UI를 보여주기 위해(특히 모바일/데스크톱 클라이언트에서);
  • 서로 다른 환경(web, 데스크톱, 모바일 앱)에서 링크의 동작을 일관되게 보장하기 위해.

그래서 특별한 API openExternal이 고안되었고, window.openai 또는 더 편리한 React 훅 useOpenExternal을 통해 접근할 수 있습니다.

useOpenExternal은 어떻게 생겼나

공식 Apps SDK 예제에서 useOpenExternal 훅은 대략 이렇게 구현되어 있습니다:


export function useOpenExternal() {
  const openExternal = useCallback((href: string) => {
    if (typeof window === "undefined") return;

    if (window?.openai?.openExternal) {
      try {
        window.openai.openExternal({ href });
        return;
      } catch (error) {
        console.warn("openExternal failed, falling back to window.open", error);
      }
    }

    window.open(href, "_blank", "noopener,noreferrer");
  }, []);

  return openExternal;
}

핵심은 간단합니다. 먼저 ChatGPT의 네이티브 메커니즘 (window.openai.openExternal)을 시도합니다. 만약 위젯이 ChatGPT가 아닌 환경에서 렌더링된다면(예: 개발 중에 브라우저에서 그냥 열어본 경우), 안전하게 일반 window.open으로 폴백합니다.

여러분의 앱에서는 이 훅이 이미 템플릿에 포함되어 있을 것입니다(OpenAI의 표준 레포를 사용했다면). 그러니 이렇게 사용해야 합니다 — 직접 window.openai를 건드리지 마세요.

예시: GiftGenius의 “상점에서 보기” 버튼

우리 GiftGenius의 toolOutputproductUrl 필드가 있는 추천 결과가 온다고 가정해봅시다. 각 카드에 여러분 사이트에서 상품을 여는 버튼을 추가해보겠습니다:

import { useWidgetProps } from "../hooks/use-widget-props";
import { useOpenExternal } from "../hooks/use-open-external";

export function GiftListWidget() {
  const { toolOutput } = useWidgetProps<{
    recommendations: { id: string; title: string; price: string; url: string }[];
  }>();
  const openExternal = useOpenExternal();

  if (!toolOutput) return <p>추천이 아직 없어요…</p>;

  return (
    <div>
      {toolOutput.recommendations.map((gift) => (
        <div key={gift.id} className="flex justify-between gap-2">
          <div>
            <div>{gift.title}</div>
            <div className="text-sm text-muted-foreground">{gift.price}</div>
          </div>
          <button onClick={() => openExternal(gift.url)}>
            열기
          </button>
        </div>
      ))}
    </div>
  );
}

사용자 관점에서: 버튼을 누르면 ChatGPT가 “외부 사이트를 열까요?” 같은 시스템 창을 보여줄 수 있고, 그다음 새 탭이나 기본 브라우저에서 여러분의 페이지를 엽니다. 비밀, 토큰 등을 넘기지 않습니다. 그저 사용자를 “채팅 밖, 여러분의 사이트”로 보내는 것입니다.

3. 샌드박스의 window.fetch: 익숙한 그 fetch가 아니다

프런트엔드 개발자가 보통 기대하는 것

보통은 이렇게 생각합니다. “브라우저니까 CORS만 맞으면 어떤 URL이든 편하게 호출할 수 있어. 최악의 경우 에러가 나겠지만 시도는 할 수 있지.”

ChatGPT Apps 생태계에서는 위험한 착각입니다. 위젯을 둘러싼 샌드박스는 단순한 “사소한 트집”이 아니라 보안의 근본 요구사항입니다. 위젯이 사용자를 추적하거나 임의 도메인으로 요청하거나 로컬 네트워크를 스캔하거나, 요컨대 브라우저 안의 미니 브라우저처럼 행동하지 못하게 막기 위함입니다.

같은 맥락에서, Apps SDK의 위젯은 임의 네트워크 접근이 없거나 강하게 제한됩니다 — 버그가 아니라 의도된 아키텍처 결정입니다.

실제로는 이렇게 보입니다

일반적인 ChatGPT 환경에서는:

  • fetch가 가능하더라도 제한된 도메인 목록에서만 허용됩니다(보통 App이 돌아가는 여러분의 도메인, 그리고 명시적으로 허용한 몇 개의 API 정도);
  • 요청이 호스트의 특수 프록시를 거치며, 이 프록시는 헤더와 URL을 필터링합니다;
  • PUT, DELETE 같은 일부 메서드나 비표준 헤더는 보안 정책에 의해 차단될 수 있습니다.

그럼에도 편한 길은 하나 있습니다. 위젯과 백엔드가 같은 도메인에 있다면(Next.js 템플릿처럼 MCP 서버와 UI가 하나의 앱으로 서비스될 때), 내부 요청 fetch("/api/...") 은 보통 허용됩니다.

핵심은 — 위젯이 인터넷의 임의 api로 요청할 수 있다고 기대하지 말라는 것입니다. Stripe, Notion, CRM 등 외부 서비스와의 “무거운” 통신은 MCP/백엔드 쪽에서 이뤄져야 합니다. ChatGPT는 그쪽을 신뢰된 리소스로 다룹니다.

Insight

ChatGPT 위젯에서는 상대 경로를 즉시 잊고 절대 URL로 살아야 합니다. 이유는 간단합니다. 여러분의 HTML은 백엔드와 같은 도메인에서 동작하지 않습니다. ChatGPT는 여러분의 html을 읽어 자신의 호스트에 올린 뒤, 격리된 iframe 안에서 렌더링합니다. 그러니 "/api/...""/static/logo.png" 는 갑자기 여러분의 앱이 아니라 ChatGPT 도메인을 기준으로 해석되고 — 모든 것이 망가집니다.

<base>로는 거의 해결되지 않습니다. 실험상, widgetCSP가 설정되어 있지 않다면 다음처럼 <base href="https://my-app.dev/">를 지정하면: 리소스는 여러분의 도메인에서 불러와지지만, 샌드박스 규칙상 스크립트는 여전히 동작하지 않습니다. 그리고 이는 Dev Mode에서만 동작합니다.

정상적인 openai/widgetCSP를 지정하는 순간(프로덕션에서는 리뷰를 위해 어차피 지정해야 합니다) 플랫폼은 <base>를 무시합니다. 게임은 끝입니다. 리소스와 스크립트는 CSP에서 허용한 도메인에서만, 그것도 절대 링크로 로드됩니다.

권장사항: ChatGPT 위젯에서 밖으로 나가는 모든 것 — fetch, 이미지, CSS, openExternal로 여는 여러분의 페이지 — 은 항상 앱의 기준 도메인에서 시작하는 “완전한 URL”로 구성하세요. 이 도메인은 설정/ENV로 제어하고, 상대 경로나 <base>에 기대지 마세요.

4. 아키텍처: 얇은 UI, 두꺼운 백엔드

fetch의 제약과 샌드박스 전반에서 더 큰 아키텍처 원칙이 도출됩니다. 우리는 이미 여러 번 이 만트라를 말했지만, 지금이 각인할 때입니다. 위젯은 얇은 UI 레이어입니다. 백엔드(MCP/tools)가 준비한 것을 렌더링하고, 사용자 행동에 반응을 보여주며, 정말 필요할 때만 몇 가지 작은 공개 요청을 합니다.

인증, 개인 데이터 접근, 시크릿, 비평범한 비즈니스 로직은 모두 서버 쪽에 있어야 합니다. 본 강의의 보안 문서는 별도로 강조합니다. 프런트엔드(React 위젯)는 “public place”, 신뢰도 0의 영역이며, 시크릿은 여기에 있어선 안 됩니다.

이 주제에 대한 제 모든 리서치의 목표는 단호합니다. ChatGPT Apps에서 “두꺼운 클라이언트” 아이디어의 마지막 못을 박는 것. 위젯은 머리일 뿐이고, 몸과 두뇌는 MCP/백엔드입니다.

따라서:

  • openExternal — 사용자를 여러분의 “정상적인” 사이트로 보내는 용도. 그곳에서 익숙한 SPA, 마이페이지 등등을 돌리세요;
  • callTool(다음 모듈) — 모델이 여러분의 백엔드에 일을 맡기는 주된 방법;
  • 위젯의 fetch — 가능하면 여러분의 앱으로 향하는 보조적이고 안전하며 가급적 공개된 요청에서만 드물게 사용.

5. 실습: 우리 GiftGenius에 openExternal 넣기

openExternal을 학습용 App에 좀 더 깔끔하게 녹여 넣고 UX도 함께 생각해봅시다.

미니 UX 규칙

사용자를 외부로 보내는 경우, 다음이 유용합니다:

  • 어디로 가는지 명확히 알려주기;
  • 설명 없이 갑작스러운 “점프”를 만들지 않기(예: GPT가 “상점 사이트를 열겠습니다…”라고 말하거나, 버튼에 명확히 표기).

제목과 라벨 예시:

<button onClick={() => openExternal(gift.url)}>
  상점 사이트에서 열기
</button>

사용자는 지금 따뜻한 채팅방 밖, 장바구니와 결제가 있는 현실 세계로 넘어간다는 것을 이해합니다.

리스트 컴포넌트 소폭 리팩터링

앞서 간단한 GiftListWidget을 만들었습니다. 이전 강의에서 toolOutput을 기반으로 선물 목록을 보여주는 위젯을 구현했다고 가정하겠습니다. 이제 이를 조금 더 다듬어, Gift 타입에 url 필드를 추가하고 openExternal 버튼을 넣겠습니다.

type Gift = {
  id: string;
  title: string;
  priceLabel: string;
  url: string;
};

export function GiftListWidget() {
  const { toolOutput } = useWidgetProps<{ gifts: Gift[] }>();
  const openExternal = useOpenExternal();

  if (!toolOutput || toolOutput.gifts.length === 0) {
    return <p>아직 아무것도 찾지 못했어요. 검색어를 바꿔 보세요.</p>;
  }

  return (
    <div>
      {toolOutput.gifts.map((gift) => (
        <div key={gift.id} className="flex justify-between gap-2">
          <div>
            <div>{gift.title}</div>
            <div className="text-sm text-muted-foreground">
              {gift.priceLabel}
            </div>
          </div>
          <button onClick={() => openExternal(gift.url)}>
            보기
          </button>
        </div>
      ))}
    </div>
  );
}

여전히 window.openai에 직접 손대지 않고, 편리한 훅을 사용합니다 — 훅은 ChatGPT 환경이 아닐 때 window.open으로 폴백하는 로직을 이미 포함하고 있습니다. Gift의 구조는 예시이며, 실제 App에서는 여러분의 백엔드에 맞게 조정하면 됩니다.

6. 실습: 우리 백엔드에 안전하게 fetch 하기

이제 fetch를 살펴봅시다. 다시 한 번 강조합니다. 복잡하거나 민감한 작업은 도구/MCP를 통해 처리하는 것이 좋습니다. 하지만 가끔은 위젯에서 여러분의 서버로부터 가벼우면서 공개된 정보를 가져오고 싶을 때가 있습니다. 예를 들어, 인기 선물 카테고리 목록 같은 것 말이죠.

Next.js의 간단한 공개 API 라우트

우리 Next.js 프로젝트에 다음 핸들러를 추가합니다:

// app/api/public/popular-tags/route.ts
import { NextResponse } from "next/server";

const tags = ["어린이를 위한", "여행자를 위한", "게이머를 위한"];

export async function GET() {
  return NextResponse.json({ tags });
}

이 라우트는 사용자에 대해 아무것도 모르고, 토큰이 필요하지 않으며, 외부 서비스에도 접근하지 않습니다 — 그저 정적 배열을 반환합니다. 이런 코드는 프로덕션과 샌드박스 모두에 거의 위험 없이 가져갈 수 있습니다.

위젯에서 fetch로 이 라우트를 호출하기

이제 위젯 컴포넌트에서 이 태그들을 불러오겠습니다. 샌드박스 제약을 고려할 때, 절대 URL로 요청하는 것이 가장 편합니다. Dev Mode에서 터널을 통해 노출하고 ChatGPT에 등록해둔, 여러분의 App이 돌아가는 바로 그 도메인으로 요청하세요(이는 Dev Mode와 터널 모듈에서 설정했습니다).

중요: 위젯의 도메인은 https://genius.web-sandbox.oaiusercontent.com 같은 형태이므로, 데이터 로딩에 상대 경로를 사용하지 말고 오직 절대 경로만 사용하세요. 예시:

import { useEffect, useState } from "react";

export function PopularTags() {
  const [tags, setTags] = useState<string[] | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let cancelled = false;

    async function loadTags() {
      try {
        const res = await fetch("https://giftgenius.app/api/public/popular-tags");
        if (!res.ok) throw new Error("Bad status");
        const data: { tags: string[] } = await res.json();
        if (!cancelled) setTags(data.tags);
      } catch (e) {
        if (!cancelled) setError("인기 카테고리를 불러오지 못했습니다");
      }
    }

    loadTags();
    return () => {
      cancelled = true;
    };
  }, []);

  if (error) return <p>{error}</p>;
  if (!tags) return <p>인기 카테고리를 불러오는 중…</p>;

  return (
    <div className="flex flex-wrap gap-2 text-sm">
      {tags.map((tag) => (
        <span key={tag} className="rounded border px-2 py-1">
          {tag}
        </span>
      ))}
    </div>
  );
}

중요한 점:

  • 에러를 정중하게 처리하고 사용자에게 이해 가능한 메시지를 보여줍니다;
  • fetch가 “당연히 동작할 것”이라 가정하지 않습니다 — 샌드박스 정책은 도메인을 바꾸거나 수상한 요청을 시작하는 순간 접근을 막을 수 있습니다;
  • 토큰/시크릿은 절대 전달하지 않습니다. 인증이 필요하다면 — 그것은 MCP와 인증 모듈의 역할입니다.

7. openExternal vs fetch vs 도구(callTool): 역할 정리

헷갈리지 않도록, 다음과 같은 “역할 매트릭스”를 머릿속에 두면 편합니다:

시나리오 무엇을 사용 이렇게 하는 이유
랜딩/상품/마이페이지 열기 openExternal 호스트가 통제하는 명시적 사용자 이동
App에서 공개 데이터 가져오기 fetch("my.com/api/...") 가벼운 JSON, 동일 도메인, 시크릿 없음
사용자 데이터, DB 가져오기 callTool/MCP 인증, 로직, 안전한 백엔드가 필요
외부 API 호출(Stripe…) MCP/서버 프런트는 시크릿을 보지 않고, 정책을 준수

이 모듈에서 중요한 것은 도구를 의식적으로 선택하는 법을 익히는 것입니다. “위젯 = 프런트엔드니까 전부 fetch로 가능”이라는 사고에서 벗어나, “위젯 = LLM+MCP 백엔드 위의 관리되는 UI 레이어”라는 아키텍처로 전환해야 합니다.

Insight

ChatGPT App에서의 서버 상호작용은 논리적으로 두 층으로 나눌 수 있습니다:

  • ChatGPT ↔ MCP 서버: 모델이 MCP 도구를 호출합니다. 각 tool-call은 비즈니스 시나리오의 시작 또는 전환(선물 추천, 주문 생성, 비용 계산 등)입니다. 여기엔 “무거운” 로직, 데이터 작업, 외부 API, 인증이 존재합니다.
  • 위젯 ↔ 서버: 위젯은 자신의 백엔드에 가벼운 fetch() 요청을 하거나, 이미 활성화된 시나리오 안에서 callTool()로 동일한 MCP 도구를 트리거합니다. 이는 지역적 단계입니다. 보조 데이터를 불러오고, UI의 일부를 갱신하고, 상태를 보완 확인합니다.

즉, MCP 도구 = 비즈니스 프로세스의 시작/제어이고, 위젯에서의 fetch()/callTool()은 이미 선택된 시나리오 내부의 작은 작업들로, 대화의 전체 “스토리”를 바꾸려 들지 않습니다.

8. 작은 실습

주제를 실습으로 굳히기 위해 GiftGenius에 작은 기능을 추가해봅시다.

제안된 시나리오:

  1. 선물 목록에 “결제 페이지로 이동” 버튼을 추가하고, openExternal로 여러분의 dev 사이트의 결제 페이지를 엽니다.
  2. 선물 목록 위에 위에서 만든 PopularTags를 렌더링해 인기 카테고리를 보여줍니다. 로딩 실패 시에는 대체 텍스트를 보여주고, 위젯 전체가 망가지지 않게 합니다.
  3. UX에 유의하세요. GPT 응답 텍스트나 위젯 UI에서 “버튼을 누르면 상점 사이트를 새 탭에서 열겠습니다”라고 사용자에게 설명합니다.

이 기능은 두 채널을 축약해서 보여줍니다:

  • openExternal — 명시적 탐색용;
  • fetch — 여러분의 App 옆에 위치한 작은 공개 API용.

9. window.fetch와 openExternal을 사용할 때 흔한 실수

오류 №1: 위젯을 여러분의 모든 API에 붙는 완전한 SPA 클라이언트로 쓰려는 것.
오래된 습관은 “그냥 React에서 우리 REST/GraphQL을 바로 호출하자”로 쉽게 기울입니다. ChatGPT Apps에서는 샌드박스와 정면충돌합니다. 일부 요청은 그냥 통과하지 않고, 일부는 정책에 차단되며, 보안도 위태로워집니다. 복잡한 로직과 사용자 데이터 접근은 MCP/도구를 통해 가야지, 위젯에서 직접 가면 안 됩니다.

오류 №2: 시크릿과 토큰을 위젯 코드에 보관하는 것.
빠르게 프로토타이핑하고 싶어 프런트 코드에 어떤 서비스의 API 키를 적어 넣고 싶을 수 있습니다(“테스트만 하니까”). 일반 SPA에서도 나쁜 생각이고, ChatGPT Apps에서는 절대 금지입니다. 위젯은 공개 환경입니다. 시크릿은 서버 설정이나 시크릿 관리 시스템(Vercel env, KMS 등)에 있어야 합니다.

오류 №3: 아무 도메인으로의 fetch가 “그냥 동작할 것”이라고 생각하는 것.
Dev Mode에서 어떤 요청이 통과했다고 해도(예: 터널이 비표준으로 연결되어서), 프로덕션에서는 거의 확실히 깨집니다. ChatGPT는 아웃바운드 요청을 제한하며, 임의의 외부 도메인은 위젯에서 접근할 수 없습니다. 위젯은 자신의 도메인, 그리고 극히 작은 화이트리스트에만 신뢰성 있게 접근할 수 있다고 생각하세요.

오류 №4: openExternal 대신 window.open을 쓰는 것.
기술적으로는 가끔 window.open이 동작할 수 있고, 특히 브라우저 프리뷰에서는 “괜찮아 보이는” 착각을 줍니다. 하지만 실제 ChatGPT 환경, 특히 네이티브 클라이언트에서는 동작이 예측 불가합니다. 사용자가 아예 이동을 못 보거나 이상한 에러를 볼 수도 있습니다. 올바른 방법은 openExternal(useOpenExternal 훅을 통해)을 사용하는 것입니다. 현재 환경에서 링크를 올바르게 여는 법을 알고 있습니다.

오류 №5: fetch 에러를 처리하지 않고, 로딩 상태를 사용자에게 보여주지 않는 것.
샌드박스에서는 네트워크 에러가 예외가 아니라 일상입니다. 터널이 끊기거나, 도메인이 바뀌거나, 정책이 무엇인가를 차단할 수 있습니다. await fetch(...)만 하고 데이터를 가정한 UI를 렌더링하면, “가끔 되고 가끔 안 되는” 이상한 반쯤 고장난 인터페이스를 얻게 됩니다. 항상 try/catch를 쓰고, res.ok를 확인하며, “불러오는 중…”과 정중한 에러 메시지를 보여주세요.

오류 №6: openExternal을 숨은 리다이렉트처럼 쓰는 것.
가끔은 어떤 버튼 클릭이든 바로 외부 사이트(특히 체크아웃)로 사용자를 몰고 가고 싶을 수 있습니다. 텍스트에 아무런 컨텍스트도 없이요. 이는 사용자와 Store 리뷰어 모두에게 어색합니다. 좋은 톤은 곧 일어날 일을 명시적으로 쓰는 것입니다. GPT가 “상점 페이지를 열겠습니다…”라고 말하거나, 버튼 라벨이 충분히 투명해야 합니다(“상점 사이트에서 결제 진행”).

오류 №7: 위젯이 대화의 유일한 “주인”이라고 착각하는 것.
UI가 온갖 링크와 네트워크 요청으로 복잡한 시나리오를 강요하면서, 채팅과 후속 질의(follow-up)를 무시하면 UX도, 모델 성능도 나빠집니다. 아키텍처를 기억하세요. GPT가 App을 언제 보여줄지, 어떻게 결과를 사용할지를 결정하고, 위젯은 그저 제안하고 시각화합니다. 탐색과 네트워크 호출은 전체 대화에 자연스럽게 어우러지도록 설계해야 하며, 모든 스포트라이트를 빼앗아선 안 됩니다.

1
설문조사/퀴즈
위젯 (Apps SDK), 레벨 3, 레슨 4
사용 불가능
위젯 (Apps SDK)
위젯 (Apps SDK): 상태, UI, 그리고 샌드박스
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION