CodeGym /행동 /ChatGPT Apps /템플릿의 구성: 프로젝트 구조와 핵심 파일

템플릿의 구성: 프로젝트 구조와 핵심 파일

ChatGPT Apps
레벨 2 , 레슨 1
사용 가능

1. 소개

ChatGPT App HelloWorld 프로젝트는 ‘CodeGym의 건드리면 안 되는 마법의 블랙박스’가 아닙니다. 평범한 Next.js‑프로젝트이며, 다만 그 안에 동시에 다음이 존재합니다:

  • ChatGPT 내부에서 렌더링되는 프런트엔드,
  • 도구(tools) 호출에 응답하는 MCP‑서버,
  • 이 모든 것을 ChatGPT와 이어주는 설정.

어디에 무엇이 있는지 이해하지 못하면 보통 세 가지 전형적인 시나리오가 발생합니다:

  1. 개발자가 서버 파일에 실수로 window를 사용해 런타임이 죽고, 스택 전체를 미워하게 됩니다.
  2. UI에 버튼을 추가하려다 잘못된 page.tsx(예: 위젯이 아닌 앱 루트)를 수정하고, ChatGPT에서 변경이 보이지 않습니다.
  3. 실수로 OPENAI_API_KEY를 클라이언트 쪽에 넣어, 키가 브라우저로 유출됩니다.

그래서 오늘의 목표는 지도를 그리는 것입니다. UI는 어디, MCP는 어디, 설정은 어디에 있고, 다음을 하고 싶을 때 어디를 봐야 하는지:

  • 위젯의 외형을 바꾸기;
  • 새로운 tool 추가;
  • 플랫폼 설정(CORS, assetPrefix 등)을 조정하기.

2. 프로젝트의 상위 수준 구조

ChatGPT App HelloWorld의 Next.js‑프로젝트는 App Router를 사용하며 app/ 폴더를 중심으로 구성됩니다. 하나의 페이지 트리 안에 다음이 공존합니다:

  • ChatGPT 내부에서 렌더링될 위젯 UI,
  • tool 호출을 처리하는 MCP‑endpoint.

전형적인 디렉터리 트리(단순화된 예; 템플릿마다 폴더 이름은 다를 수 있지만 패턴은 동일합니다):

my-chatgpt-app/
├─ app/
│  ├─ api/                          // REST API
│  │  └─ time/                      // GET /api/time 서버 시간을 반환
│  │     └─ route.ts
│  ├─ hooks/                        // 공식 Apps SDK의 훅 모음
│  │  ├─ use-call-tool.ts
│  │  ├─ use-display-mode.ts
│  │  └─ use-open-external.ts
│  ├─ mcp/                          // MCP 서버: ChatGPT가 tools를 호출할 때 요청이 오는 곳
│  │  └─ route.ts
│  ├─ globals.css                   // 앱 전체의 루트 globals.css
│  ├─ layout.tsx                    // 앱 전체의 루트 layout
│  └─ page.tsx                      // ChatGPT 내 위젯 페이지
├─ public/                          // 정적 자원: 아이콘, 매니페스트 등
├─ next.config.ts                   // Next.js 설정 및 Apps 관련 설정(assetPrefix 등)
├─ proxy.ts                         // iframe 내 동작을 위한 CORS/헤더(이전의 middleware.ts)
├─ package.json                     // 프로젝트 의존성
├─ tsconfig.json                    // TypeScript 구성
└─ .env.local                       // 시크릿: OPENAI_API_KEY 등

위젯이 여러 개인 경우, 보통 app/page.tsx가 아니라 app/widget/page.tsx에 둡니다. 하지만 논리는 동일합니다. 여전히 위젯 페이지는 하나이고, MCP‑서버 역할을 하는 endpoint도 하나입니다.

이렇게 생각하면 편합니다: 여러분의 리포지토리는 ‘두 얼굴의 야누스’입니다:

  • 한 ‘얼굴’은 경로 /mcp로, ChatGPT가 도구를 호출하려 할 때 가는 곳;
  • 다른 ‘얼굴’은 경로 /widget(또는 /)로, 모델이 여러분의 UI를 보여주기로 하면 iframe에 로드되는 곳입니다.

혼동을 줄이기 위해 파일을 세 그룹으로 정리해 둡시다:

  1. UI 레이어 — React/Next 페이지와 관련된 모든 것 (app/widget, 컴포넌트, 스타일).
  2. MCP 레이어app/mcp/route.ts 및 그가 사용하는 파일들.
  3. 접착 레이어와 설정next.config.ts, proxy.ts, .env.local, package.json, tsconfig.json.

곧 각 레이어를 차례로 살펴보겠습니다.

3. 위젯의 위치: 폴더 app/widget 및/또는 app/page.tsx

가장 자주 다루게 될 것부터 시작해 봅시다 — 위젯, 즉 ChatGPT 안에서 보이는 UI입니다.

대부분의 최신 프로젝트에는 다음 중 하나가 있습니다:

  • app/widget/page.tsx 폴더 — 위젯이 별도의 프리픽스 /widget 아래에 위치,
  • 또는 루트 app/page.tsx — 위젯이 루트 페이지와 동일.

위젯 파일의 핵심 특징:

  • 파일 최상단에 'use client'가 있습니다. 컴포넌트가 브라우저에서 동작하며 window 및 Apps SDK와 통신하기 때문입니다;
  • 평범한 React 컴포넌트이며, 마크업을 렌더링하고(이 강의 뒤에서 조금 더) window.openai와 통신합니다.

아주 단순한 학습용 위젯 예시(프로젝트에서 이와 유사한 코드를 이미 볼 수 있을 것입니다):

// app/widget/page.tsx
'use client';

import React from 'react';

export default function WidgetPage() {
  return (
    <main className="p-4">
      <h1 className="text-xl font-semibold">
        HelloWorld — ChatGPT App
      </h1>
      <p className="text-sm text-gray-500">
        여기에서 우리 위젯의 UI를 만들어 갑니다.
      </p>
    </main>
  );
}

템플릿에서 위젯이 바로 app/page.tsx에 있다면, 중간 widget 폴더만 없는 거의 동일한 코드일 것입니다.

몇 가지 포인트를 꼭 기억하세요.

첫째, 'use client' 지시문은 필수입니다. 위젯은 window.openai에 읽고/쓰고, 이벤트를 청취하는 등 브라우저 환경에서만 가능한 일을 합니다. 이 지시문을 제거하면 Next가 페이지를 서버 컴포넌트로 처리하려고 하며, “window is not defined” 같은 오류를 만나게 됩니다.

둘째, 전혀 마법적이지 않은 평범한 React 컴포넌트입니다. 여러분은 다음을 할 수 있습니다:

  • components/ 아래에서 하위 컴포넌트로 분리,
  • Tailwind 또는 다른 어떤 CSS 시스템 사용,
  • 컨텍스트, 훅 등을 연결.

셋째, 나중에는 바로 이곳에서 다음을 수행합니다:

  • 실제 데이터를 렌더링하기 위해 window.openai.toolInputwindow.openai.toolOutput을 읽고,
  • widgetStatewindow.openai.setWidgetState로 저장하고,
  • openExternal, callTool 등 런타임 메서드를 호출합니다.

지금은 이것만 기억하세요: 시각적 인터페이스를 바꾸고 싶다면 — 거의 확실히 app/widget/page.tsx 또는 app/page.tsx입니다.

4. 루트 레이아웃: app/layout.tsx — 앱 전체의 ‘틀’

다음으로 중요한 파일은 app/layout.tsx입니다. 이 파일은:

  • HTML 구조(<html>, <body>)를 정의하고,
  • 전역 스타일(globals.css)을 연결하며,
  • 종종 Apps SDK를 위한 ‘bootstrap’(이벤트를 청취하고 window.openai와 React를 연결하는 래퍼)을 초기화합니다.

간단한 예시:

// app/layout.tsx
import './globals.css';
import type { ReactNode } from 'react';
import { OpenAIAppProvider } from '@/lib/openai-app-provider';

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <NextChatSDKBootstrap baseUrl={baseURL} />
      </head>
      <body className={`${geistSans.variable} ${geistMono.variable} antialiased h-full overflow-hidden`}>
        {children}
      </body>
    </html>
  );
}

여기서 NextChatSDKBootstrap이라는 이름은 임의이며, 템플릿에 따라 OpenAIAppProvider 또는 다른 컴포넌트일 수 있습니다. 역할은 보통 하나입니다: React 트리와 Apps SDK 런타임 간 연결을 설정하고, (theme, displayMode, toolInput 등) 전역 데이터를 구독해 자식에 전달합니다.

중요한 실무 포인트: 전역 컨텍스트, 스타일 또는 UI 라이브러리(예: shadcn/ui)를 연결하려면 — 거의 항상 app/layout.tsx에 추가합니다(또는 위젯에만 특화된 설정/컴포넌트라면 app/widget 내부의 layout).

NextChatSDKBootstrap 분석

NextChatSDKBootstrapVercel의 공식 템플릿에서 참고했습니다. Next를 만들고 발전시키는 팀입니다. 그들의 사이트에는 ChatGPT App on Next에 대한 좋은 글이 있고, Starter Template도 있습니다. 몇몇 부분은 다소 구식이 되었지만, 앞으로도 최신 상태를 유지할 가능성이 높다고 생각합니다.

NextChatSDKBootstrap이 제공하는 핵심 5가지를 정리해 봅시다:

  • 1. 하이드레이션 문제를 해결
    ChatGPT는 먼저 여러분의 위젯 HTML을 자신의 서버에서 로드해 정리하고 패치합니다. 그 결과 하이드레이션 메커니즘이 경고를 뿜어내며 콘솔에 Warnings가 가득할 수 있고, 이는 리뷰 통과에 방해가 됩니다.
  • 2. 브라우저 히스토리 패치
    여러분의 위젯은 ChatGPT의 특수 도메인에서 iframe으로 로드됩니다. 자신의 도메인을 사용하려고 하면 샌드박스를 깨게 됩니다. 그래서 브라우저 히스토리에는 도메인 없이 경로만 저장합니다.
  • 3. fetch() 함수 대체
    도메인 없는 상대 경로로 호출하는 모든 fetch()는 위젯에서 동작하지 않습니다. iframe의 도메인이 다르기 때문이죠. 그래서 도메인 없이 호출된 요청을 올바른 URL로 보내도록 fetch()를 우리 쪽 구현으로 바꿉니다. 도메인이 명시된 경우에는 그대로 동작합니다.
  • 4. 링크 클릭이 정상 동작
    링크가 iframe 내부에서 열리면 ChatGPT는 이를 승인하지 않습니다. 따라서 링크 클릭을 감지해 외부 창으로 열도록 openExternal()을 사용합니다.
  • 5. head base 설정(DEPRECATED)
    과거에는 <base><head>에 추가했지만, 이제는 동작하지 않습니다. 샌드박스가 어떤 base 설정도 리셋해 버리므로, 스크립트, 리소스, 폰트, API 등 모든 것에 대해 절대 경로를 사용하는 것을 권장합니다.

5. MCP‑서버: app/mcp/route.ts

이제 ‘두 얼굴의 야누스’ 중 두 번째 — 서버로 넘어가겠습니다. MCP를 통해 ChatGPT와 대화합니다.

파일 app/mcp/route.ts는 평범한 App Router의 Route Handler로서 다음을 수행합니다:

  • ChatGPT로부터 HTTP 요청(보통 MCP 형식의 JSON‑payload를 담은 POST)을 받고,
  • MCP‑서버(@modelcontextprotocol/sdk 또는 얇은 래퍼 기반)에 전달하며,
  • MCP 형식의 JSON 응답을 반환합니다.

두 가지 접근이 있습니다. 순수 MCP SDK로 직접 작성하거나, Next/Vercel 쪽의 몇 가지 클래스로 모서리를 둥글게 만들 수도 있습니다.

아래는 순수 TS MCP SDK 버전입니다:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

// 1. MCP 서버 생성
const server = new McpServer({
  name: "simple-mcp-server",
  version: "1.0.0",
});

// 2. MCP 리소스 등록
// 3. MCP 도구 등록

// 4. HTTP 전송 계층
const transport = new HttpServerTransport({
  port: 3001,
  path: "/mcp",
});

// 5. 서버 시작
await server.connect(transport);

하지만 더 편하게 작업하려면 몇 가지 준비된 클래스를 사용하는 편이 좋습니다:

// app/mcp/route.ts
import { NextRequest } from 'next/server';
import { createMcpHandler } from "mcp-handler";

const handler = createMcpHandler(async (server) => {
  const gateway = new McpGateway(server);
  await gateway.initialize();
  gateway.registerResources();
  gateway.registerTools();
});

export const GET = handler;
export const POST = handler;

여기서 McpGatewayMcpServer를 감싼 래퍼 클래스입니다. SDK로(예: lib/mcp/server.ts에서) 생성할 수도 있습니다. 우리 경우에는 모두 app/mcp/route.ts 안에 들어 있습니다. 이 파일에 무엇이 들어 있는지 완전히 살펴봅시다.

type ContentWidget

파일의 시작 부분에 ContentWidget 타입이 정의되어 있습니다. 위젯의 모든 데이터를 담고 있으며, 두 곳에서 사용됩니다: 위젯을 mcp‑resource로 등록할 때와, mcp‑tool이 metadata를 반환할 때(어떤 위젯으로 결과를 표시할지 지정).

type ContentWidget = {
  id: string;            // 고유 이름/key
  title: string;         // 제목
  description: string;   // 설명
  templateUri: string;   // 위젯의 고유 URI, 아무 값이나 가능. 동작에는 영향 없음.
  invoking: string;      // 로딩 중 위젯 상단에 표시할 문구
  invoked: string;       // 로딩 완료 후 위젯 상단 문구
  html: string;          // 위젯의 전체 HTML 코드
  widgetDomain: string;  // 위젯의 "도메인". 동작에는 영향 없음.
};

class McpGateway

McpServer를 감싼 래퍼로, 몇 가지를 단순화합니다. 6개의 메서드를 가집니다:

  • initialize() — 위젯의 HTML을 로드
  • registerResources() — 위젯을 mcp‑resources로 등록
  • registerTools() — 함수를 mcp‑tools로 등록
  • widgetMeta() — 위젯 메타데이터 반환
  • getAppsSdkCompatibleHtml() — 위젯의 HTML을 로드하고 약간 패치
  • makeImgUrlsAbsolute() — HTML 패치: 이미지 링크를 절대 경로로 변경

각 메서드를 자세히 살펴보겠습니다:

public async initialize()

이 메서드는 인터넷에서 위젯의 HTML 코드를 로드해 ContentWidget 타입 객체를 채웁니다.

{
  id: "hello_world",                         // 위젯의 고유 key
  templateUri: "ui://widget/hello_world.html", // 위젯의 고유 URI. "ui:"에는 의미가 없음.
  title: "HelloWorld Widget",               // 위젯 이름
  description: "Displays the HelloWorld widget", // LLM에게 위젯이 무엇을 하는지 설명
  invoking: "Loading widget...",            // 로딩 중 위젯 상단 문구
  invoked: "Widget loaded",                 // 로딩 완료 후 위젯 상단 문구
  html: htmlWidget,                         // 위젯의 HTML
  widgetDomain: baseURL,                    // 위젯의 "도메인". 현재 동작에는 영향 없음.
}

public registerResources()

위젯을 mcp‑resources로 등록합니다. server.registerResource()를 호출하며, 이때 4개의 매개변수를 전달합니다:

  • MCP 리소스의 id/key
  • 리소스의 URI(MCP 프로토콜을 위한 값으로, 위젯에 대해서는 사실상 고유 주소의 동의어)
  • MCP 리소스의 메타데이터
  • MCP 리소스를 반환하는 함수

위젯 메타데이터

{
  title: widget.title,                 // 리소스/위젯 이름
  description: widget.description,     // 리소스/위젯 설명
  mimeType: "text/html+skybridge",     // 중요! 이 MIME만 위젯으로 렌더링됨
  _meta: {
    "openai/widgetDescription": widget.description, // 위젯 설명
    "openai/widgetPrefersBorder": true,            // ChatGPT에 위젯 테두리 렌더링 요청
  },
}

MCP 리소스로서의 위젯

{
  uri: uri.href,                        // 우리 URI(파라미터 uri에서 가져옴)
  mimeType: "text/html+skybridge",      // 중요! 이 MIME만 위젯으로 렌더링됨
  text: widget.html,                    // 위젯의 HTML
  _meta: {
    "openai/widgetDescription": widget.description, // 위젯 설명
    "openai/widgetPrefersBorder": true,            // ChatGPT에 위젯 테두리 렌더링 요청
    "openai/widgetDomain": widget.widgetDomain,    // 위젯의 "도메인". 현재 동작에는 영향 없음.
    "openai/widgetCSP": {                          // 중요! 위젯이 접근 가능한 도메인:
      connect_domains: [                           // 연결용 도메인(fetch 등)
        baseURL,
        "https://codegym.cc",
      ],
      resource_domains: [                          // 리소스용 도메인(css/fonts/img)
        baseURL,
        "https://codegym.cc",
        "https://cdn.tailwindcss.com",
        "https://persistent.oaistatic.com",
        "https://fonts.googleapis.com",
        "https://fonts.gstatic.com"
      ]
    }
  },
}

앞으로 openai/widgetCSP를 여러 번 다루겠지만, 지금은 두 가지 포인트만 짚고 넘어가겠습니다:

  • connect_domains — 다음을 위한 도메인 목록:
    • fetch()
    • 스크립트 로딩
    • openExternal()
  • resource_domains — 다음을 위한 도메인 목록:
    • 이미지
    • CSS
    • 폰트

이론적으로 200개의 도메인을 나열할 수는 있지만, 그런 목록으로 리뷰를 통과할 수 있을지는 또 다른 문제입니다.

이미 공개된 앱들의 설정을 살펴보니 amplitude.com도 있었습니다. 이것도 긍정적인 신호입니다. 괜찮은 분석 도구는 누구에게나 도움이 되니까요.

public registerTools()

함수를 mcp‑tools로 등록합니다. server.registerTool()를 호출하며, 여기에 3개의 매개변수를 전달합니다:

  • MCP‑tool의 id/key
  • MCP‑tool의 메타데이터
  • MCP‑tool을 반환하는 함수

도구 메타데이터

이 목록의 모든 파라미터가 중요합니다. 자세한 내용은 다음 강의에서 다룰 예정입니다.

{
  title: widget.title,                               // 도구 이름
  description: "Returns HelloWorld widget",          // 중요! 도구가 무엇을 하는지 설명
  inputSchema: z.object({}).describe("No inputs"),   // 도구 입력 스키마. Zod 사용 가능
  _meta: this.widgetMeta(widget),                    // 위젯 메타데이터: 어떤 위젯을 표시할지
  annotations: {
    destructiveHint: false,                          // 중요한 변경을 수행 — 확인 필요
    openWorldHint: false,                            // 서드파티 서비스를 변경
    readOnlyHint: true                               // 읽기 전용
  },
}

무언가 중요한 일을 하는 함수

async (input, extra) => {
  // 1. 파라미터 검증
  // 2. 중요한 작업 수행
  return {
    content: [{ type: "text", text: "HelloWorld MCP-tool" }], // AI를 위한 결과 설명
    structuredContent: {                                      // 중요! 이것이 결과 JSON입니다.
      timestamp: new Date().toISOString()                     // 임의의 데이터를 담을 수 있음.
    },
    _meta: this.widgetMeta(widget),                           // JSON을 표시할 위젯의 메타데이터
  };                                                          // 없을 수도 있음 — 이 경우 위젯이 표시되지 않음
}

private widgetMeta(widget: ContentWidget)

위젯 메타데이터를 반환 — 이를 통해 ChatGPT가 JSON 결과를 표시할 위젯을 결정합니다.

{
  "openai/outputTemplate": widget.templateUri,            // 위젯의 URI
  "openai/toolInvocation/invoking": widget.invoking,      // 로딩 중 위젯 상단 문구
  "openai/toolInvocation/invoked": widget.invoked,        // 로딩 완료 후 위젯 상단 문구
  "openai/widgetAccessible": true,                        // 위젯에서 MCP 도구 호출 가능
  "openai/resultCanProduceWidget": true,                  // MCP 도구가 위젯을 반환함
}

특히 "openai/outputTemplate"에 대해 언급하고 싶습니다. MCP 프로토콜에는 세 가지 개념이 있습니다(모듈 6에서 자세히 배웁니다):

  • MCP Resources
  • MCP Templates
  • MCP Tools

그런데 이 "openai/outputTemplate"MCP Templates와 아무 관련이 없습니다. MCP Templates는 ChatGPT Apps에서 전혀 사용되지 않습니다. 여기서 template이라는 단어는 다음에서 왔습니다:

위젯은 JSON 표시를 위한 템플릿처럼 설계되었습니다. MCP‑tool이 JSON을 반환하면, 모델이 위젯을 표시하고 ToolOutput 파라미터로 JSON을 전달하며, 위젯은 해당 JSON을 예쁘게 렌더링합니다. outputTemplate은 위젯의 동의어일 뿐입니다.

여기까지로 충분합니다. 도구를 어떻게 기술하고, JSON Schema와 핸들러를 어떻게 구성하는지는 모듈 4에서 자세히 다룹니다. 지금은 도구(tools)와 로직과 관련된 것이라면 — app/mcp/route.ts 주변을 확인하면 된다는 점만 기억하세요.

6. 설정과 ‘접착제’: next.config.ts, middleware.ts, .env

이제 여러분의 Next.js‑프로젝트가 ChatGPT의 iframe 안에서 올바르게 동작하고, HTTPS 터널(ngrok, Cloudflare Tunnel 등)을 통해 ChatGPT에서 접근 가능하도록 하는 데 필요한 핵심 파일들을 살펴봅니다 (터널에 대해서는 따로 이야기합니다).

next.config.ts

이 파일에는 기본 Next.js 설정 외에 보통 다음을 추가합니다:

  • assetPrefix — 정적 파일(JS, /_next/의 CSS)을 ChatGPT 도메인이 아닌 여러분의 개발 URL(터널 또는 Vercel)에서 올바르게 로드하기 위해;
  • 템플릿에 필요한 특수 설정(예: Next 16 실험 플래그 등).

실무에서는 필요한 필드가 있는 nextConfig를 export하는 일반적인 형태입니다. 이 강의에서 중요한 점은 하나입니다: ChatGPT 안에서 위젯이 CSS/JS를 로드하지 못하면, 원인은 대개 assetPrefix입니다.

proxy.ts (이전 middleware.ts)

이 파일은 ChatGPT에서 오는 요청과 여러분의 라우트 사이에 미들웨어 레이어를 삽입합니다. 템플릿에서 보통:

  • iframe ChatGPT가 여러분의 서버에 접근할 수 있도록 CORS 헤더를 세팅하고,
  • 때로는 React Server Components를 위한 추가 헤더도 구성합니다.

지금 모든 세부를 알 필요는 없습니다. 다만 기억하세요: ChatGPT가 CORS 문제를 제기하거나 DevTools에서 접근 금지 관련 이상한 오류가 보이면 proxy.ts를 확인해 보세요.

.env

.env(또는 .env.local) 파일은 시크릿과 환경 변수를 위한 곳입니다:

  • OPENAI_API_KEY(MCP‑서버가 OpenAI API를 직접 호출하는 경우),
  • 내부 API 주소,
  • 서드파티 서비스 토큰 등.

중요한 포인트: Next.js에서는 NEXT_PUBLIC_로 시작하는 변수가 자동으로 JS 번들에 포함되어 브라우저에서 접근 가능합니다. OPENAI_API_KEY에 절대 그렇게 하지 마세요. 시크릿은 반드시 서버 전용 환경 변수여야 합니다.

package.jsontsconfig.json

package.json에서는 다음을 보게 됩니다:

  • Next.js, React, Apps SDK, MCP SDK 및 기타 의존성 버전,
  • dev, build, start 스크립트, 그리고 때로 보조 명령어(린터, 포매터 등).

tsconfig.json에는 익숙한 TypeScript 설정이 들어 있습니다:

  • 경로 별칭(@/lib, @/components),
  • strict 모드,
  • 컴파일 타겟.

이 강의의 관점에서 중요한 것은 템플릿이 표준 TypeScript 스택을 사용하며, 여러분은 이를 표준 방식으로 확장할 수 있다는 점입니다.

7. 개발자를 위한 빠른 ‘프로젝트 내비게이터’

흔히 하는 작업을 할 때 어디로 가야 하는지 고정해 둡시다. 목록 대신 간단한 미니 시나리오로 정리합니다.

위젯의 텍스트/버튼을 바꾸고 싶다면, 위젯 UI 파일을 엽니다: 템플릿에 따라 app/widget/page.tsx 또는 app/page.tsx입니다. 그곳에서 JSX를 수정하고, 새 컴포넌트를 추가하며, 디자인 시스템을 연결합니다. 그리고 바로 여기에서 Apps SDK 런타임 (window.openai 또는 편의 훅)을 사용해 데이터를 표시합니다.

서버에서 무언가를 수행하는 새 버튼을 추가하려면, 역시 UI 파일에서 시작합니다. 위젯의 버튼 클릭 시 window.openai.callTool을 호출하고, 해당 도구의 구현은 app/mcp/route.ts 주변의 MCP‑서버 코드에 추가합니다. UI ↔ tool 로직의 연결은 모듈 4 이후에서 자세히 다룹니다.

ChatGPT에 새 기능을 가르치고 싶다면(예: “여행 검색” 또는 “상품 추천”), app/mcp/route.ts에서 가져오는 MCP 레이어로 갑니다. 거기에서 JSON Schema, 설명, 핸들러를 포함해 새 tool을 등록합니다. 위젯은 이후 window.openai.toolOutput을 통해 결과를 읽고, 이를 보기 좋게 표시할 수 있습니다.

정적 파일이 깨지거나, 로컬에서는 정상인데 ChatGPT에서만 위젯 표시가 이상하다면, 접착 레이어를 떠올리세요. 먼저 next.config.ts를 확인하고 (특히 assetPrefix), middleware.ts/proxy.ts(CORS)도 점검하세요. 최근에 터널이나 URL을 변경했거나 Vercel로 배포했다면, 이 설정들의 정확성이 특히 중요합니다.

키나 환경 변수에 문제가 있다고 의심된다면, 다음 세 파일을 확인하세요 — .env.local, package.json (실제로 어떤 의존성과 스크립트를 쓰는지 확인), 그리고 개발 서버 로그. MCP가 필요한 시크릿과 서비스에 접근할 수 있도록 보장하는 조합입니다.

8. 미니 실습: 파일 시스템을 직접 살펴보기

이론은 이론이고, 직접 손으로 어디에 무엇이 있는지 고정해 봅시다. 에디터/IDE에서 바로 따라 할 수 있습니다.

프로젝트의 app 폴더를 열어 위젯을 담당하는 파일이 어디인지 찾아보세요. 템플릿이 app/page.tsx를 사용한다면, 그곳에서 “HelloWorld — ChatGPT App”와 같은 익숙한 문구나 환영 텍스트를 볼 수 있을 것입니다. 위젯용 별도 폴더가 없다면 app/page.tsx를 열어 'use client'와 JSX 마크업이 있는지 확인하세요.

그다음 app/mcp/route.ts를 찾아보세요. 어떤 모듈을 import하는지 확인합니다: 보통 MCP SDK를 직접 사용하거나, lib/mcp/*의 보조 함수를 호출하는 형태를 보게 됩니다. 이 레이어가 얼마나 ‘얇게’ 구성되어 있는지도 살펴보세요 — 이상적으로는 비즈니스 로직이 거의 없고, “JSON 수신 → 서버에 전달 → JSON 반환” 정도만 있어야 합니다.

이후 next.config.tsproxy.ts/middleware.ts도 확인합니다. 모든 내용을 이해할 필요는 없고, 다음만 고정하세요:

  • next.config.ts는 Next 구성(특히 빌드/에셋 서빙 규칙)을 담당하며,
  • proxy.ts는 HTTP 요청에 개입합니다(헤더 작업을 거의 확실히 보게 될 것임).

마지막으로 .env 또는 .env.local을 열어 키가 코드가 아닌 이 파일들에 있는지 확인하세요. 어딘가에서 NEXT_PUBLIC_OPENAI_API_KEY를 보면 — 로컬 개발 단계일 때 바로 수정할 절호의 기회입니다.

9. 시각적 도식: ChatGPT가 여러분의 템플릿과 상호작용하는 방식

전체 그림을 완성하려면 간단한 흐름을 보는 것이 도움이 됩니다:

flowchart TD
    U[ChatGPT의 사용자] -->|요청을 입력| M[ChatGPT 모델]

    M -->|tool 호출| MCP["당신의 MCP endpoint
app/mcp/route.ts"] MCP -->|"MCP JSON 응답(structuredContent, _meta, UI 링크)"| M M -->|UI 표시 결정| WIDGET_URL["위젯 URL
(/widget 또는 /)"] WIDGET_URL -->|iframe| W[당신의 위젯
app/page.tsx] W -->|window.openai.toolOutput
+ widgetState 읽기| U

여기서 중요한 점은 거의 항상 ChatGPT 모델이 주도한다는 것입니다. 전통적인 웹앱처럼 사용자의 브라우저가 주도하는 것이 아닙니다. app/mcp/route.tsapp/widget/page.tsx는 동일한 Next.js‑프로젝트의 서로 다른 ‘문’입니다: 하나는 로봇(MCP)을 위한 문, 다른 하나는 UI를 위한 문입니다.

이 프로젝트 지도를 머릿속에 유지(UI → MCP 레이어 → 설정)하고, 앞서 언급한 지뢰들을 의식적으로 피한다면, 이후 과정에서는 ‘모든 걸 망치는 바로 그 파일’을 찾느라 헤매지 않고 App의 로직과 UX에 집중할 수 있을 것입니다.

10. 템플릿 구조 작업 시 흔한 오류

오류 №1: 위젯을 일반 사이트 페이지로 착각.
가끔 템플릿에 app/page.tsxapp/widget/page.tsx가 모두 있어 ‘잘못된’ 파일을 수정하고, 왜 ChatGPT에 변경이 반영되지 않는지 의아해합니다. 위젯은 MCP 도구의 outputTemplate/iframe으로 사용되는 바로 그 페이지입니다. 다른 라우트를 변경하면 ChatGPT는 이를 알 방법이 없습니다. 언제나 템플릿의 README를 확인해 어떤 URL이 위젯인지 확인하세요.

오류 №2: 서버의 MCP 파일에서 클라이언트 코드(window, document)를 작성.
app/mcp/route.ts와 그가 import하는 모든 것은 서버에서 실행됩니다. 거기서 window나 DOM‑API를 사용하면 런타임이 크래시합니다. UI에서 처리해야 하는 일이라면 거의 확실히 app/widget 아래나 다른 클라이언트 컴포넌트여야 합니다. MCP 레이어는 순수 백엔드입니다: 요청, 데이터베이스, 외부 API, 그리고 구조화된 응답 생성.

오류 №3: assetPrefix와 CORS 설정을 무시.
로컬 localhost:3000에서는 멀쩡한데, 터널을 통해 ChatGPT에서 App을 열면 스타일이 사라지고 JS가 로드되지 않으며, 콘솔에 CORS 오류가 가득합니다. 대개 이유는 next.config.ts의 구성이나 middleware.ts/proxy.ts가 새 퍼블릭 URL을 고려하지 않았거나 리팩터링 중 깨졌기 때문입니다. 이 파일들을 바꿀 때는 여러분의 코드가 ChatGPT 도메인의 iframe 내부에서 동작한다는 사실을 항상 염두에 두세요. 직접 localhost에 있는 것이 아닙니다.

오류 №4: 시크릿을 .env가 아닌 코드나 NEXT_PUBLIC_* 변수에 저장.
app/widget/page.tsx 어딘가에 const apiKey = 'sk-...'처럼 OPENAI_API_KEY를 숨기는 것은 최악의 아이디어입니다. 키가 JS 번들에 포함되어 모든 사용자에게 노출됩니다. 거의 그만큼 나쁜 것이 NEXT_PUBLIC_OPENAI_API_KEY를 만드는 것입니다. NEXT_PUBLIC_ 접두사는 브라우저로의 노출을 보장하기 때문입니다. 시크릿은 언제나 .env에(이 접두사 없이) 두고 서버 사이드에서만 사용하세요(MCP 서버, 백엔드 함수).

오류 №5: 템플릿을 ‘너무 똑똑하다’고 보고 건드리기를 두려워함.
어떤 개발자들은 공식 스타터를 신성한 무언가처럼 대하며 “괜히 만졌다가 통합이 깨질까 봐” 손대지 않습니다. 그러다 결국 코드를 옆길로 작성해 아키텍처를 복잡하게 만들고 같은 지뢰를 밟습니다. 실제로 템플릿은 Apps SDK를 위한 몇 가지 설정이 추가된, 잘 정리된 Next.js‑프로젝트일 뿐입니다. app/이 UI와 MCP이고, 나머지는 평범한 설정 파일이라는 이해는 여러분을 자유롭게 해 줍니다: 더 이상 마법 상자가 아니라, 익숙한 React/Next 프로젝트처럼 다룰 수 있습니다.

오류 №6: 모든 문제를 ‘위젯 레벨’에서 해결하려고 시도.
UI에서 비즈니스 로직, 데이터베이스 접근, 외부 API 요청까지 모두 처리하고 싶을 때가 있습니다. ChatGPT Apps 문맥에서는 특히 좋지 않은 생각입니다. 위젯은 매우 엄격한 샌드박스에서 동작하고, 여러분의 시크릿을 볼 수 없으며, window.openai에 크게 의존합니다. 진지한 작업은 MCP 레이어와 백엔드 서비스에 두고, 위젯은 구조화된 데이터를 표시하며 필요 시 도구를 트리거하는 얇은 프레젠테이션 레이어여야 합니다.

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