1. 샌드박스란 무엇이며 왜 위젯이 ‘갇혀’ 있는가
ChatGPT가 여러분의 위젯을 표시할 때, 그것을 일반적인 <iframe src="https://your-site">로 아니다로 렌더링합니다. 위젯은 별도의 origin과 엄격한 보안 설정을 가진 격리된 iframe인 관리형 “샌드박스”에서 실행됩니다.
기술적으로는 대략 다음과 같습니다:
flowchart TD
User["ChatGPT의 사용자"]
Chat["ChatGPT UI + 모델"]
Iframe["위젯
샌드박스된 iframe"]
MCP["MCP / 백엔드"]
User --> Chat
Chat -->|도구 호출| MCP
MCP -->|structuredContent + _meta| Chat
Chat -->|window.openai.*| Iframe
Iframe -->|callTool / follow-up| Chat
Chat --> MCP
여러분의 코드는 이 iframe 내부에서만 실행되며, 나머지 세계에 대한 접근은 호스트(ChatGPT)가 제공하는 엄격히 통제된 API를 통해서만 가능합니다. 위젯은 다음을 해서는 안 됩니다:
- ChatGPT 자체(DOM, 스타일, 성능)를 망가뜨리기;
- 사용자 프라이버시를 침해하기;
- 무분별하게 네트워크에 접근하기.
이로부터 샌드박스의 핵심 제한 사항이 도출됩니다.
DOM과 origin 격리
위젯은 샌드박스 전용 도메인(예: https://sandbox-apps.oaiusercontent.com)에서, iframe에 sandbox 속성이 설정된 채로 동작합니다. 이는 곧:
- window.parent나 ChatGPT의 document에 접근할 수 없으며 — 시도하면 SecurityError가 발생합니다;
- postMessage 같은 크로스 도메인 기능은 호스트가 제어합니다;
- “CSS로 ChatGPT 인터페이스를 고치기” 같은 시도는 실패로 끝납니다.
네트워크와 CSP 제한
브라우저와 호스트의 CSP 정책은 위젯의 네트워크 접근을 제한합니다:
- fetch는 화이트리스트에 있는 도메인에만 접근할 수 있으며, 이 도메인들은 심사를 통과해야 합니다;
- 위젯에서 접근 가능한 도메인은 MCP 응답의 openai/widgetCSP로 명시적으로 선언해야 하며, 그렇지 않으면 요청이 차단됩니다;
- 권장 경로: 중요한 작업은 위젯에서 직접 네트워크를 호출하지 말고 MCP 도구와 callTool을 통해 백엔드로 보내십시오(자세한 내용은 모듈 4에서 다룹니다).
실무적으로는 위젯을 얇은 UI 레이어로 생각하세요. 인터넷에서 자유롭게 동작하는 일반 SPA가 아니라, 엄격히 정의된 채널을 통해 ChatGPT와 여러분의 서버와 대화합니다.
스토리지와 리소스
로컬 스토리지(localStorage, sessionStorage)는 사용할 수 있지만, 쿠키는 사용할 수 없습니다. 이 점을 앱 설계 시 고려하세요. 메모리와 CPU는 제한됩니다. 예를 들어 위젯 내부에서 10억까지의 소수를 모두 계산하려 하면, 호스트는 iframe을 종료할 권리가 있습니다.
따라서 중요한 결론: 위젯에서 무거운 연산과 장수 “캐시”는 금지. 복잡한 로직은 서버 측에서 처리하고, React 컴포넌트는 UI에 집중하세요.
2. window.openai: 위젯과 ChatGPT 사이의 브리지
위젯이 무엇인가를 알 수 있도록(도구 결과, 표시 모드, 로케일, 상태 등), ChatGPT는 초기화 시 iframe 창에 전역 객체 하나를 주입합니다 — 바로 window.openai.
이것은 여러분의 코드나 npm 패키지가 아닌, 호스트 객체이며, 플랫폼 자체가 제공합니다. 내부적으로는 호스트와 iframe 사이의 이벤트/메시지에 연결되어 있지만, 대부분의 경우 이를 의식할 필요는 없습니다. 단, 몇 가지를 기억하세요.
누가 언제 window.openai를 생성하는가
window.openai는 다음의 경우에만 존재합니다:
- ChatGPT가 여러분의 위젯을 위해 생성한 그 iframe 내부에서;
- HTML 템플릿이 올바른 mimeType(text/html+skybridge)으로 제공되고 모든 검증을 통과했을 때.
이 타입은 HelloWorld App 모듈에서 이미 보았으며, 위젯 페이지가 일반 text/html 대신 반환하는 MIME 타입입니다.
만약 위젯 페이지를 브라우저에서 직접 열면:
console.log(window.openai); // undefined
이것이 정상입니다. 따라서 로컬 개발 혹은 스토리북과 같은 “standalone” 모드를 고려한다면, 항상 객체 유무를 점검하세요.
아주 단순한 예시(최종 코드가 아닌, 개념 설명):
if (typeof window !== "undefined" && (window as any).openai) {
console.log("We are inside ChatGPT sandbox!");
}
초기화의 비동기성
내부적으로 ChatGPT는 새로운 데이터가 들어올 때마다(새로운 toolOutput, displayMode 변경 등) 내부 이벤트 openai:set_globals를 사용해 window.openai를 갱신합니다.
즉, 그 안의 “값”들은 정적이지 않습니다. 모델이 MCP 도구를 호출하고, 백엔드가 새로운 structuredContent를 반환하면, window.openai.toolOutput은 여러분의 React 컴포넌트 바로 아래에서 바뀔 수 있습니다.
따라서 두 가지를 권장합니다:
- 시작 시 한 번만 const toolOutput = window.openai.toolOutput 같은 “스냅샷”을 만들고 그것이 영원히 유효하다고 가정하지 마세요. 동일한 위젯 인스턴스가 ChatGPT에 의해 재사용될 수 있습니다.
- 변경 사항에 구독할 수 있는 훅 레이어를 사용하세요(아래에서 설명).
3. window.openai의 해부: 데이터, API, 컨텍스트
공식 문서는 window.openai의 필드와 메서드를 꽤 간결하게 표로 정리합니다. 여기서는 조금 더 “사람 친화적”으로 묶어보겠습니다.
주요 필드와 메서드
window.openai = {
// State & data
toolInput, // JSON: 모델이 MCP 도구에 전달한 파라미터
toolOutput, // JSON: MCP 도구가 모델에 반환한 파라미터
toolResponseMetadata, // MCP 도구 응답의 _meta: {...}
widgetState, // 위젯의 저장된 상태를 읽을 수 있습니다
setWidgetState, // 여기로 여러분의 위젯 상태를 저장할 수 있습니다
// Runtime APIs
callTool, // MCP 도구를 호출
sendFollowUpMessage, // 채팅에서 모델에게 비공개로 메시지를 보냅니다: 모델이 응답을 시작합니다.
requestDisplayMode, // 위젯을 fullscreen, pip, inline 모드로 요청
requestModal, // 위젯을 모달 창으로 전환하도록 요청
requestClose, // 위젯을 닫습니다. 모달을 닫으면 다시 위젯으로 전환됩니다.
requestCheckout, // 결제 모달을 엽니다. 서버는 ACP를 구현해야 합니다
notifyIntrinsicHeight, // 위젯 높이 변경을 알림
openExternal, // 새 창에서 링크 열기
// Context
theme, // 다크 또는 라이트 테마
displayMode, // 현재 표시 모드(요청한 모드와 다를 수 있음)
maxHeight, // 허용되는 최대 위젯 높이
safeArea, // “안전한 표시 영역” — 노치가 있는 휴대폰에서 유효
view,
userAgent, // 브라우저의 userAgent
locale // 브라우저의 locale
}
같은 내용을 표로도 보겠습니다:
| 카테고리 | 속성 / 메서드 | 용도 |
|---|---|---|
| State & data | |
도구가 호출될 때 전달된 인수. 읽기 전용. |
| State & data | |
MCP 응답의 structuredContent. 위젯과 모델이 보는 데이터. |
| State & data | |
응답의 _meta. 위젯에서만 보이며, 모델은 읽지 않음. |
| State & data | |
ChatGPT가 위젯 렌더 사이에 보관하는 UI 상태 스냅샷. |
| State & data | |
widgetState의 새 스냅샷을 동기적으로 저장. |
| Function | |
위젯에서 MCP 도구 호출. |
| Function | |
위젯 명의로 채팅에 메시지를 보내달라고 ChatGPT에 요청. 모델이 응답을 시작. |
| Function | |
호스트에 inline / fullscreen / pip 요청. |
| Function | |
모달 창 열기 요청. |
| Function | |
콘텐츠 높이가 변경되었음을 알림. |
| Function | |
ACP 프로토콜 기반 결제 다이얼로그 열기. |
| Function | |
사용자 브라우저에서 외부 링크 열기. |
| Context | |
환경 신호: 테마, 모드, 사용 가능한 높이, 로케일 등. |
이 표를 한 번에 외울 필요는 없습니다 — “지도”처럼 참고하세요. 이제 “참고서”가 아니라 자연스러운 흐름으로 살펴봅시다.
toolInput과 toolOutput: 데이터는 어디서 오는가
모델이 여러분의 도구를 호출하기로 결정하면 JSON 인수를 구성합니다. 이 인수는:
- MCP 서버의 핸들러에 input으로 전달되고;
- 동시에 위젯의 window.openai.toolInput에도 전달됩니다.
도구 실행 후 서버는 다음을 반환합니다:
- structuredContent — UI를 위한 구조화된 데이터;
- _meta — 위젯 전용의 비공개 데이터;
- content — 모델이 사용자에게 “무엇이 일어났는지” 설명할 수 있도록 하는 텍스트.
structuredContent는 window.openai.toolOutput가 되고, _meta는 window.openai.toolResponseMetadata가 됩니다.
미니 예시(바닐라 JS, React 없음):
const root = document.getElementById("root");
// 널 병합 연산자를 안전하게 사용할 수 있습니다
const gifts = window.openai.toolOutput?.gifts ?? [];
root.textContent = `발견된 선물: ${gifts.length}개`;
widgetState와 setWidgetState: 위젯의 기억
widgetState는 플랫폼이 렌더 사이, 심지어 대화의 여러 턴에 걸쳐서도 여러분의 UI에 대해 기억하려고 하는 것입니다.
widgetState에 자연스러운 항목 예:
- 선택된 선물;
- 현재 정렬(가격순 / 인기순);
- 목록의 페이지 번호.
부자연스러운 항목 예:
- 외부 API의 원시 응답;
- base64 이미지;
- 비밀 토큰.
두 가지를 기억하세요:
- widgetState는 컨텍스트와 함께 모델에 전달되므로 민감한 정보를 넣지 마세요.
- 용량은 제한되어 있습니다(대략 4천 토큰). 작은 DB처럼 쓰지 마세요.
가장 단순한 사용 예(훅 없이, 바닐라 JS):
const current = window.openai.widgetState ?? { selectedGiftId: null };
function selectGift(id) {
window.openai.setWidgetState({ ...current, selectedGiftId: id });
}
실제 코드에서는 이를 React 훅으로 감쌉니다.
런타임 API: callTool, sendFollowUpMessage 등
이 메서드들은 위젯이 단지 “그리는” 것에 그치지 않고 대화와 서버에 상호작용할 수 있게 해줍니다.
전형적인 시나리오 몇 가지:
- callTool("search_gifts", { budget: 50 }) — 사용자가 “예산 변경” 버튼을 눌렀고, 서버를 호출해 UI를 갱신하는 경우;
- sendFollowUpMessage({ prompt: "지금 것보다 더 비싼 아이디어를 보여줘" }) — 사용자가 직접 텍스트를 입력하게 하는 대신 follow-up 버튼을 눌러 채팅에 새 메시지를 생성하는 경우;
- requestDisplayMode({ mode: "fullscreen" }) — inline 모드가 비좁다면, 위젯이 정중히 전체 화면으로 전환을 요청할 수 있습니다;
- openExternal({ href: "https://myshop.com/checkout?giftId=123" }) — 검증된 채널을 통해 외부 사이트(결제, 프로필 등)로 사용자를 보냅니다.
이 모든 것은 인터넷으로 직접 나가는 것이 아니라 ChatGPT를 거쳐 “선로”를 통해 이뤄집니다.
환경 컨텍스트: 테마, 모드, 높이, 로케일
theme, displayMode, maxHeight, locale 같은 필드로 위젯이 어떤 환경에서 살고 있는지 파악할 수 있습니다.
예:
const theme = window.openai.theme; // "light" 또는 "dark"
const mode = window.openai.displayMode; // "inline" | "fullscreen" | "pip"
const maxH = window.openai.maxHeight; // 사용 가능한 높이
const locale = window.openai.locale; // "en-US", "de-DE", ...
이 신호를 통해 다음을 할 수 있습니다:
- 테마에 맞춰 색상과 여백을 조정;
- 모드에 따라 레이아웃을 변경(inline vs fullscreen);
- 사용자 언어(로케일)에 맞춰 UI 라벨을 현지화(이에 대해서는 별도 모듈에서 다룹니다).
플랫폼은 공간, 테마, 로케일 같은 신호를 제공합니다. 이를 useOpenAIGlobal, useDisplayMode, useMaxHeight 같은 훅으로 활용해, 위젯이 ChatGPT에 “자연스럽게” 녹아들도록 하세요.
4. window.openai 위에 얹은 훅: 전역 객체를 직접 만지지 않기
window.openai에 직접 접근하는 방식은 프로토타입 단계에서는 편하지만, 금세 코드가 뒤엉키게 됩니다: 이벤트 구독, undefined 체크, 반복되는 래퍼 등. 그래서 Apps SDK의 Next.js 템플릿에는 이러한 디테일을 감추고 반응형으로 만들어주는 React 훅 세트가 준비되어 있습니다.
일반적인 훅 인덱스는 다음과 같습니다:
// app/hooks/openai/index.ts
export { useCallTool } from "./use-call-tool";
export { useSendMessage } from "./use-send-message";
export { useOpenExternal } from "./use-open-external";
export { useRequestDisplayMode, useRequestModal, useRequestClose } from "./use-request-display-mode";
export { useRequestCheckout } from "./use-request-checkout";
// State hooks
export { useDisplayMode } from "./use-display-mode";
export { useWidgetProps } from "./use-widget-props";
export { useWidgetState } from "./use-widget-state";
export { useOpenAIGlobal } from "./use-openai-global";
export { useMaxHeight } from "./use-max-height";
export { useIsChatGptApp } from "./use-is-chatgpt-app";
이름이나 정확한 경로는 템플릿마다 조금 다를 수 있지만, 핵심 아이디어는 동일합니다: window.openai.* 대신 훅을 사용한다는 점입니다. 핵심 훅들을 살펴봅시다.
useWidgetProps: 도구의 입력과 출력
useWidgetProps는 보통 위젯에 필요한 데이터 객체를 반환합니다: toolInput, toolOutput, toolResponseMetadata 그리고 때로는 isLoading 같은 추가 플래그를 포함합니다.
예:
import { useWidgetProps } from "../hooks/openai";
type Gift = { id: string; title: string; price: number };
export function GiftList() {
const { toolOutput } = useWidgetProps<{ gifts: Gift[] }>();
const gifts = toolOutput?.gifts ?? [];
if (!gifts.length) {
return <div>아직 추천할 선물이 없습니다.</div>;
}
return (
<ul>
{gifts.map((g) => (
<li key={g.id}>{g.title} — ${g.price}</li>
))}
</ul>
);
}
컴포넌트 코드에 window.openai가 전혀 없습니다 — 좋은 신호입니다.
useWidgetState: widgetState에 대한 “반응형 래퍼”
useWidgetState는 widgetState를 일반적인 React state처럼 다룰 수 있게 해줍니다: [state, setState]를 받고, 훅이 내부적으로 window.openai.widgetState 와 setWidgetState를 동기화합니다.
예:
import { useWidgetState } from "../hooks/openai";
type UiState = { selectedGiftId: string | null };
export function SelectedGiftIndicator() {
const [uiState, setUiState] = useWidgetState<UiState>(() => ({
selectedGiftId: null,
}));
if (!uiState?.selectedGiftId) {
return <div>아직 선물을 선택하지 않았습니다.</div>;
}
return (
<div>
선택한 선물 id={uiState.selectedGiftId}
<button onClick={() => setUiState({ selectedGiftId: null })}>
초기화
</button>
</div>
);
}
버튼을 클릭하면 setUiState는 React state를 업데이트할 뿐 아니라 ChatGPT 쪽에도 새 상태를 저장합니다.
useOpenAIGlobal: window.openai의 임의 필드에 접근
테마나 모드처럼 특정 전역 필드 하나가 필요하다면 범용 훅 useOpenAIGlobal(key)을 사용할 수 있습니다. 이 훅은 openai:set_globals 이벤트를 구독하고 항상 최신 값을 반환합니다.
예:
import { useOpenAIGlobal } from "../hooks/openai";
export function ThemeAwareBlock() {
const theme = useOpenAIGlobal<"light" | "dark">("theme");
const background = theme === "dark" ? "#222" : "#fff";
const color = theme === "dark" ? "#fff" : "#000";
return <div style={{ background, color }}>ChatGPT 테마를 따릅니다</div>;
}
useCallTool, useSendMessage, useOpenExternal 등
- useCallTool(name) — 지정한 이름의 MCP 도구를 호출하는 함수를 반환합니다. callTool의 래퍼입니다.
- useSendMessage() — sendFollowUpMessage를 감싸, 위젯이 메시지 전송을 시작할 수 있게 합니다.
- useOpenExternal() — openExternal({ href })의 편의 헬퍼입니다.
- useRequestDisplayMode()와 useRequestModal() — 모드 변경 / 모달 열기 요청용 래퍼입니다.
아래는 거의 모든 것을 한 번에 사용하는 미니 위젯 GiftGenius의 기본 예시입니다:
import {
useWidgetProps,
useWidgetState,
useCallTool,
useSendMessage,
useOpenExternal,
} from "../hooks/openai";
type Gift = { id: string; title: string; url: string; price: number };
export function GiftWidget() {
const { toolOutput } = useWidgetProps<{ gifts: Gift[] }>();
const gifts = toolOutput?.gifts ?? [];
const [ui, setUi] = useWidgetState<{ selectedId: string | null }>(() => ({
selectedId: null,
}));
const callSearch = useCallTool("search_gifts");
const sendMessage = useSendMessage();
const openExternal = useOpenExternal();
if (!gifts.length) {
return <div>아직 아이디어가 없습니다. GPT에게 결과를 새로 고쳐 달라고 요청해 보세요.</div>;
}
return (
<div>
{gifts.map((g) => (
<button
key={g.id}
style={{
display: "block",
fontWeight: ui?.selectedId === g.id ? "bold" : "normal",
}}
onClick={() => setUi({ selectedId: g.id })}
>
{g.title} — ${g.price}
</button>
))}
<div style={{ marginTop: 12 }}>
<button
onClick={() =>
sendMessage({ prompt: "지금 것보다 더 비싼 선물을 보여줘." })
}
>
더 많은 아이디어 요청
</button>
<button
onClick={async () => {
await callSearch({ budget: 200 });
}}
>
예산 $200으로 새로 고침
</button>
{ui?.selectedId && (
<button
onClick={() =>
openExternal({
href: `https://giftgenius.example.com/checkout?id=${ui.selectedId}`,
})
}
>
구매 페이지로 이동
</button>
)}
</div>
</div>
);
}
이 페이지는 아직 미완성입니다(다음 모듈에서 UX, 에러 처리 등을 보완합니다). 하지만 접근 방식은 이미 드러납니다: window.openai에 직접 접근하지 말고 훅만 사용하세요.
5. 실습: 샌드박스와 window.openai 익히기
“위젯은 일반 웹사이트가 아니다”라는 감각을 익히려면, 간단한 연습을 해보는 것이 좋습니다.
연습: “환경 살펴보기”
현재 위젯의 app/page.tsx에 첫 렌더 시 동작하는 간단한 effect를 추가해 보세요:
import { useEffect } from "react";
import { useIsChatGptApp } from "../hooks/openai";
export default function Root() {
const isChatGpt = useIsChatGptApp();
useEffect(() => {
if (typeof window !== "undefined") {
console.log("window.origin =", window.origin);
console.log("window.openai =", (window as any).openai);
}
}, []);
return (
<main>
<h1>GiftGenius widget</h1>
<p>ChatGPT 내부에서 실행 중: {String(isChatGpt)}</p>
</main>
);
}
DevTools를 여세요: ChatGPT 창에서(터널 뷰어가 허용한다면) 직접 열거나, 페이지를 직접 열어 로컬 브라우저에서 확인해도 됩니다. 두 경우를 비교하세요:
- 일반 브라우저에서 실행하면 isChatGptApp은 false이고, window.openai는 보통 undefined입니다;
- ChatGPT를 통해 실행하면 toolInput, toolOutput, theme 등 필드를 가진 객체를 보게 됩니다.
이는 좋은 직관을 줍니다: 같은 React 코드라도 환경에 따라 다르게 동작하며, 그 차이를 흡수하려고 훅이 존재합니다.
연습: “플랫폼이 주는 모든 것 출력하기”
디버깅용 임시 컴포넌트를 추가하세요:
import { useWidgetProps, useOpenAIGlobal } from "../hooks/openai";
export function DebugPanel() {
const { toolInput, toolOutput, toolResponseMetadata } = useWidgetProps();
const theme = useOpenAIGlobal("theme");
const displayMode = useOpenAIGlobal("displayMode");
return (
<pre style={{ fontSize: 10, maxHeight: 200, overflow: "auto" }}>
{JSON.stringify(
{ toolInput, toolOutput, toolResponseMetadata, theme, displayMode },
null,
2
)}
</pre>
);
}
그리고 기본 UI 아래에 <DebugPanel />을 임시로 삽입하세요. 그러면 다음을 한눈에 볼 수 있습니다:
- MCP에서 toolOutput으로 어떤 필드들이 오는지;
- _meta에 무엇이 들어 있는지(예: locale, userLocation 등);
- 위젯을 확장했을 때 displayMode가 어떻게 바뀌는지.
이 컴포넌트는 나중에 제거하거나 DEBUG_WIDGET 같은 플래그로 조건부 표시해도 좋습니다.
6. 관계: ChatGPT ↔ 위젯 ↔ MCP/서버
위젯을 시스템의 “주인공”으로 보지 않으려면, 각자의 역할을 다시 정리해 보는 것이 도움이 됩니다.
- 사용자가 메시지를 보냅니다: “여자친구를 위한 선물을 골라줘, 예산은 $50”.
- ChatGPT 모델은 MCP 도구 search_gifts를 다음 인수로 호출하기로 결정합니다: { recipient: "girlfriend", budget: 50 }.
- MCP 서버는 비즈니스 로직을 실행하고 다음을 반환합니다:
- 모델을 위한 간단한 설명 content;
- 선물 배열이 담긴 structuredContent;
- 기술적 세부 정보(예: source와 통화)가 담긴 _meta.
- ChatGPT는:
- 사용자에게 텍스트 메시지를 표시합니다(“몇 가지 옵션을 찾았습니다...”);
- 위젯 iframe을 만들고 structuredContent와 _meta를 window.openai.toolOutput 및 toolResponseMetadata로 전달합니다.
- 여러분의 위젯은:
- toolOutput에 따라 UI를 렌더링하고;
- 사용자 상호작용 시 callTool을 호출하거나 follow-up을 보냅니다;
- 모델은 이후 이 결과를 바탕으로 다음 행동을 결정합니다.
중요한 결론: 위젯이 프로세스의 유일한 주체가 되는 일은 없습니다. 위젯은 모델과 MCP 서버 생태계 안에서 살아가는 UI 레이어입니다. 인증, 민감 데이터 접근, 핵심 비즈니스 로직처럼 복잡한 일은 서버 측에 남겨야 합니다. 위젯은 편리한 인터페이스와 사용자와의 깔끔한 소통을 책임집니다.
7. 샌드박스의 정책과 규칙
격리된 iframe과 window.openai는 보안과 프라이버시 요구사항 때문에 존재합니다. OpenAI의 공식 가이드는 몇 가지 원칙을 강조합니다.
첫째, 데이터 최소화. 위젯을 통해 가능한 한 많은 PII(personally identifiable information, 개인식별정보)를 캐내고 수집하려 해서는 안 됩니다. 정말 필요한 것만 도구에 명확히 기술해야 하며, 모델과 보안 계층은 이런 호출을 면밀히 검토합니다.
둘째, 은밀한 트래킹과 핑거프린팅 금지. 사용자 기기를 엿보는 시스템을 만들거나, 브라우저 지문을 수집하거나, 제한을 우회하려 해서는 안 됩니다. userAgent, userLocation 같은 파라미터는 UX를 위한 힌트이지, 인증이나 식별을 위한 수단이 아닙니다.
셋째, 여러분이 structuredContent, _meta, widgetState에 넣는 모든 것은 어떤 형태로든 사용자에게 보이거나 Store 심사자가 볼 수 있습니다. 따라서:
- API 키, 토큰, 비밀번호, 관리자 비밀 등은 절대 넣지 마세요;
- 위젯 상태는 로그나 디버깅 화면에서 사용자가 봐도 놀라지 않도록 설계하세요.
넷째, 네트워크 호출. 위젯에서 외부 API로의 직접 요청은 엄격히 제한된 도메인 목록에만 허용되며, 민감하지 않은 시나리오에서만 사용해야 합니다. 돈, 계정, 개인 데이터가 관련되면 — 모두 MCP/백엔드를 통해 처리해야 합니다.
8. 샌드박스와 window.openai를 사용할 때 흔한 실수
실수 1: 위젯을 “iframe 안의 일반 웹사이트”라고 생각하기.
초보자는 습관적으로 window.parent에 접근하거나, ChatGPT 스타일을 바꾸거나, localStorage를 평소처럼 사용하려 시도합니다. 샌드박스에서는 이런 것들이 작동하지 않거나, 불안정하게 동작합니다: origin이 다르고, 스토리지는 격리되며, DOM 접근은 차단됩니다. 관리형 환경에서 살고 있으며, 호스트와의 소통은 window.openai와 훅으로만 해야 한다는 점을 받아들이세요.
실수 2: 여기저기서 직접 window.openai를 만지기.
여러 컴포넌트에서 window.openai.toolOutput를 읽는 코드는 디버깅 악몽의 시작입니다. 이벤트, 비동기성, undefined 체크를 모두 스스로 관리해야 합니다. 처음부터 useWidgetProps, useWidgetState, useOpenAIGlobal 같은 훅을 사용해 openai:set_globals 구독과 상태 동기화를 맡기세요.
실수 3: widgetState에 아무거나(특히 비밀) 저장하기.
가끔은 외부 API 결과의 거대한 객체나 접근 토큰을 “혹시 몰라서” 넣고 싶어집니다. 그 결과 컨텍스트가 커지고 모델 성능이 떨어지며, 기본 보안 요구사항을 위반하게 됩니다. widgetState는 작고, UI 신호만 담아야 하며, 비밀 데이터는 절대 담지 마세요.
실수 4: 위젯에서 인터넷으로 직접 나가기.
샌드박스에서 fetch("https://api.superbank.com/...") 같은 호출은 거의 확실히 CORS에 막힙니다. 설령 완벽히 구성해도, 보안상 좋지 않고 제어도 어렵습니다. 계정, 돈, 개인 데이터가 연루되면 callTool을 통해 MCP/서버 쪽에서 처리하세요.
실수 5: ChatGPT 밖에서 window.openai가 항상 있다고 가정하기.
때때로 개발자는 위젯을 별도 SPA로 띄운 채, window.openai가 undefined일 수 있다는 점을 검사하지 않습니다. 개발 환경에서 이는 “Cannot read properties of undefined” 크래시로 이어집니다. useIsChatGptApp, typeof window !== "undefined" 같은 체크와, 위젯이 아닌 경우를 위한 폴백 UI를 준비하세요.
실수 6: 환경 컨텍스트(theme, displayMode, maxHeight, locale)를 무시하기.
항상 2000px 높이를 고정하고, 무조건 다크 테마를 사용하며, 데스크톱만 기준으로 레이아웃을 짜는 것은 이상한 사용자 경험을 만듭니다. 플랫폼이 공간, 테마, 로케일 신호를 제공하니 useOpenAIGlobal, useDisplayMode, useMaxHeight 등으로 활용해 위젯이 ChatGPT에 “자연스럽게” 보이도록 하세요.
실수 7: 서드파티 스크립트로 정책을 ‘우회’하려 하기.
가끔 트래커나 외부 JS 번들을 끌어오거나, 외부 도메인에서 코드를 “조용히” 실행하고 싶은 유혹이 듭니다. 샌드박스와 CSP 정책은 바로 이런 일을 막기 위해 존재합니다: 서드파티 스크립트는 차단되며, 시스템을 우회하려는 시도는 Store 심사에서 앱이 거부되는 지름길입니다.
GO TO FULL VERSION