ChatGPT Apps 설계법: context asymmetry와 widget state를 먼저 잡아야 하는 이유
ChatGPT Apps를 만들 때 가장 많이 하는 실수는 웹앱 개발 습관을 그대로 가져오는 것이다. 버튼을 누르면 UI state가 바뀌고, 필요할 때 API를 lazy-load하고, 화면에서 선택한 항목은 프론트엔드가 알고 있으면 된다고 생각한다. 하지만 ChatGPT App에는 user, UI, model이라는 세 주체가 동시에 있다. 이 셋이 같은 정보를 갖고 있지 않으면 앱이 금방 어색해진다.
OpenAI 개발자 블로그에 실린 Alpic의 사례는 이 문제를 context asymmetry라고 부른다. 예를 들어 사용자가 위젯에서 특정 상품을 선택한 뒤 채팅창에 '이 상품 자세히 알려줘'라고 묻는 상황을 보자. UI는 사용자가 어떤 상품을 보고 있는지 안다. 사용자도 당연히 안다. 하지만 모델은 모른다. 모델에게 선택 상태가 전달되지 않았다면 엉뚱한 답을 하거나 다시 질문한다.
이 글의 목표는 ChatGPT Apps를 만들 때 context, widget state, tool output, CSP, display mode를 어떤 순서로 설계해야 하는지 실무 기준으로 정리하는 것이다.
문제: UI와 모델이 서로 다른 현실을 본다
전통적인 웹앱은 서버와 클라이언트 사이의 상태 동기화가 주된 문제다. ChatGPT Apps에서는 여기에 모델 컨텍스트가 추가된다. 사용자가 보는 화면, 위젯이 가진 데이터, 모델이 대화에서 아는 정보가 서로 다를 수 있다.
모든 정보를 모델에게 보내면 해결될 것 같지만 그렇지 않다. 여행 앱을 예로 들면 위젯에는 이미지, 가격 옵션, 지도 좌표, 필터 결과, 예약 가능 슬롯이 필요할 수 있다. 이 데이터를 전부 모델 컨텍스트에 넣으면 token cost와 latency가 늘고, 모델이 중요하지 않은 정보에 흔들린다. 반대로 너무 적게 보내면 사용자가 가리키는 대상을 이해하지 못한다.
그래서 첫 설계 질문은 '무엇을 공유할까'가 아니라 '누가 알아야 하는 정보인가'여야 한다. 모델과 위젯이 같이 알아야 하는 것은 structuredContent로 두고, 위젯만 알아야 하는 것은 _meta로 숨긴다. 게임 앱에서 정답 단어는 위젯만 알아야 할 수 있고, 추리 게임에서 범인의 정체는 모델만 알아야 할 수 있다.
원인: lazy-loading 습관이 agentic UI와 충돌한다
웹에서는 lazy-loading이 좋은 기본값이다. 사용자가 상세 화면을 열 때 데이터를 가져오고, 필요한 순간에 API를 호출한다. ChatGPT Apps에서는 tool call이 몇 초 지연될 수 있고, sandbox와 reasoning 과정까지 끼어든다. 사용자가 대화로 이어서 물어보는 흐름에서는 매번 추가 tool call이 UX를 끊는다.
Alpic은 초기 tool response에 필요한 데이터를 더 적극적으로 담고, 위젯은 window.openai.toolOutput으로 hydrate하는 방식을 권한다. 물론 위젯이 public API에서 직접 가져와도 되는 데이터라면 XHR을 쓸 수 있다. 하지만 모델이 대화 중 그 데이터를 근거로 판단해야 한다면 처음부터 tool response 설계에 포함해야 한다.
핵심은 '최소 payload'가 아니라 '다음 대화에 필요한 payload'다. AI 앱에서는 사용자가 화면을 보고 말로 이어 묻는다. 화면과 대화가 이어지려면 모델이 참조할 수 있는 state가 있어야 한다.
해결: widget state를 명시적으로 모델 컨텍스트에 올린다
사용자 인터랙션을 모델에게 알려주는 대표 방법은 window.openai.setWidgetState(state)다. 사용자가 탭을 바꾸거나, 상품을 선택하거나, 지도에서 위치를 클릭했을 때 다음 model interaction에 필요한 상태를 저장한다.
다만 모든 클릭 핸들러에 imperative update를 넣으면 금방 관리가 어려워진다. 블로그에서는 data-llm 같은 선언적 속성으로 UI context를 표현하고, Vite plugin이 이 값을 긁어 widgetState를 갱신하는 패턴을 소개한다. 예를 들어 사용자가 detail tab을 보고 있으면 'User is viewing product details', review tab을 보고 있으면 'User is viewing reviews' 같은 맥락을 모델에게 전달한다.
실무에서는 더 단순하게 시작해도 된다. 핵심 화면별로 모델이 알아야 할 state schema를 먼저 만든다. 예: selectedProductId, selectedVariant, visibleFilters, currentStep, lastUserAction. 이 중 대화 follow-up에 필요한 것만 widget state로 올린다.
production에서 자주 깨지는 지점
첫째는 CSP다. ChatGPT Apps는 iframe 환경에서 실행되기 때문에 Content Security Policy가 느슨하면 배포 후 깨진다. connectDomains, resourceDomains, frameDomains, redirectDomains를 app manifest에서 명확히 선언해야 한다. staging API와 production API를 빠뜨리는 실수가 흔하다.
둘째는 display mode다. 위젯은 inline, picture-in-picture, fullscreen으로 나타날 수 있다. 모바일에서는 닫기 버튼이나 입력창과 겹치는 safe zone 문제가 생긴다. 지도, 테이블, 복잡한 비교 UI는 fullscreen이 필요할 수 있고, 짧은 선택 UI는 inline이 낫다. 이 기준을 정하지 않으면 한 화면에서만 맞는 컴포넌트를 만들게 된다.
셋째는 tool visibility다. 모델이 호출하면 안 되는 tool은 private으로 두고, readOnly, destructiveHint, openWorldHint 같은 annotation을 정확히 넣어야 한다. AI 앱은 UI 버튼과 모델 tool call이 함께 존재하기 때문에 action boundary가 더 중요하다.
실무 설계 순서
처음부터 멋진 위젯을 만들지 말고 정보 흐름 표부터 만든다. 열은 user, model, widget, server로 잡고 행은 주요 상태다. 예를 들어 여행 검색 앱이면 목적지, 날짜, 예산, 추천 목록, 선택된 호텔, 결제 가능 여부, 비공개 할인 코드 같은 행이 생긴다. 각 상태가 어느 주체에게 보여야 하는지 표시하면 structuredContent와 _meta의 경계가 보인다.
그 다음 tool call 단위를 정한다. 검색 결과 tool은 모델과 위젯이 같이 볼 요약 데이터를 반환하고, 이미지 원본이나 상세 렌더링용 데이터는 _meta로 보낸다. 사용자가 선택한 결과는 widget state로 올린다. 예약이나 결제는 draft 단계와 approval 단계를 분리한다.
마지막으로 QA 시나리오를 대화 중심으로 만든다. '세 번째 상품 더 보여줘', '방금 고른 것과 비교해줘', '가격 낮은 순으로 다시 봐줘' 같은 follow-up이 통과해야 한다. 버튼 클릭 테스트만으로는 ChatGPT Apps의 품질을 검증할 수 없다.
실행 체크리스트
- user, model, widget, server별로 알아야 할 정보를 표로 나눈다.
- 모델과 위젯이 같이 볼 데이터는
structuredContent로 둔다. - 위젯만 필요한 렌더링 데이터는
_meta로 숨긴다. - follow-up 대화에 필요한 선택 상태는
setWidgetState로 올린다. - lazy-loading보다 다음 대화에 필요한 초기 payload를 우선한다.
- CSP의 connect/resource/frame/redirect domain을 배포 전에 점검한다.
- inline, PiP, fullscreen 기준을 화면별로 정한다.
- write action은 private tool, annotation, approval 기준을 함께 설계한다.