ChatGPT Intelligent UI를 리버스 엔지니어링했다. 실제로는 이렇게 작동한다
ChatGPT가 (마침내) Generative UI를 출시했고, 하룻밤 사이에 이 분야의 이름을 Intelligent UI로 바꿔 버렸다. 우리는 그들이 이것을 어떻게 구현했는지 직접 봐야 했다.
OpenAI @OpenAI · 2026-10-08 03:05 KST
GPT-6와 Intelligent UI가 이제 ChatGPT의 모든 사용자에게 배포되기 시작했다.
ChatGPT의 Intelligent UI는 일상적인 질문을 더 시각적으로, 복잡한 주제를 더 이해하기 쉽게 만들고, 작업에 필요한 인터랙티브 도구를 그 자리에서 쓸 수 있게 하는 빠르고 인터랙티브한 답변을 제공한다.
이 글은 OpenAI가 대표 기능인 Intelligent UI를 어떻게 구현했는지, 즉 그 계층과 포맷, 그리고 Web과 Mobile에 걸친 네이티브 렌더링을 깊이 들여다본 연구다.
구성 요소
ChatGPT의 구현은 모델, 백엔드 서버, 클라이언트 사이에 작업을 나눈다:
- 추론 포맷: 모델은 Markdown에 JSX 비슷한 태그와 JavaScript를 결합한 DIL로 인터페이스를 쓴다.
- 서버 측 컴파일: 서버는 부분 응답 하나하나를 JavaScript 프로그램 하나와, 텍스트와 데이터를 담은 JSON 문서 하나로 변환한다.
- 클라이언트 런타임: 샌드박스 런타임이 프로그램을 실행해 UI 연산(operation)을 만들어 낸다.
- 렌더링: ChatGPT는 그 연산을 자신의 네이티브 컴포넌트에 적용한다.
- 디자인 시스템과 카탈로그: 모델이 쓸 수 있는 컴포넌트, 속성, 디자인 토큰.
추론 포맷
이것이 모델이 쓰는 것이다. ChatGPT에서 그것은 OpenAI가 DIL이라 부르는 언어다. 산문에는 Markdown, 컴포넌트에는 JSX 비슷한 태그, 상태와 로직에는 JavaScript를 쓴다. 작은 응답 하나를 모든 계층에 걸쳐 따라가 보겠다:
## Team plan estimate
Drag the slider to see the **monthly price** for your team.
{@body const [seats,setSeats] = DIL.useState(8)}
{@body const price = seats*29}
<box border padding={3} gap={2}>
<slider min={1} max={50} value={seats} onChange={setSeats}/>
<title size="xl">${price}/mo</title>
</box>제목과 문단은 평범한 Markdown이다. 태그는 ChatGPT 카탈로그의 컴포넌트다. 두 개의 {@body …} 줄은 JavaScript다. 첫 줄은 상태 하나, seats를 선언하고, 둘째 줄은 거기서 price를 끌어낸다. 슬라이더가 seats에 묶여 있으므로 슬라이더를 움직이면 가격이 갱신된다.
전용 포맷이 필요한 이유는 모델이 인터페이스를 토큰 하나씩 쓰기 때문이다.
- 안정적으로 쓰기 쉬워야 한다. 그래서 모델이 이미 잘 아는 표기법으로 만들었다.
- 반쯤 쓴 상태에서도 쓸 수 있어야 한다. 명령문은 각자 한 줄을 차지하고, 열린 요소는 어느 것이든 자동으로 닫을 수 있다. 덕분에 서버는 부분 응답을 마지막 완결 구문에서 잘라 내고도 컴파일할 수 있다.
서버 측 컴파일
클라이언트는 모델의 출력을 쓰인 그대로 실행하지 않는다. OpenAI의 서버가 그 출력을 JavaScript 프로그램과 JSON 문서로 컴파일하고, 둘은 메시지와 함께 (model_dil_v2로) 저장된다. 위 응답은 다음과 같이 컴파일된다 (읽기 쉽게 정리했다):
function __dilSafe(evaluate, failureValue) {
try { return evaluate(); } catch { return failureValue; }
}
DIL.render(__dil.jsx(() => {
const __dilConstants = DIL.useConstants();
const __dilModelDataBindings = DIL.useAppData((appData) => appData.opGenui?.modelDataBindings ?? {});
const [seats, setSeats] = DIL.useState(8, { key: "seats" });
const price = __dilSafe(() => seats * 29, undefined);
return __dil.jsx(__dil.Fragment, null,
__dil.jsx("title", { size: "lg" }, __dilConstants["0"]),
__dil.jsx("text", null, __dilConstants["1"], __dil.jsx("bold", null, __dilConstants["2"]), __dilConstants["3"]),
__dil.jsx("box", { border: true, padding: 3, gap: 2 },
__dilSafe(() => __dil.jsx("slider", { min: 1, max: 50, value: seats, onChange: setSeats }), null),
__dil.jsx("title", { size: "xl" }, __dilConstants["4"], __dilSafe(() => price, null), __dilConstants["5"])));
}, { key: "body:2" }));{
"constants": {
"0": "Team plan estimate",
"1": "Drag the slider to see the ",
"2": "monthly price",
"3": " for your team.",
"4": "$",
"5": "/mo"
},
"appData": { "opGenui": { "componentResults": {}, "modelDataBindings": {} } }
}Markdown은 컴포넌트와 같은 트리로 컴파일된다. 제목은 title이, 문단은 안에 bold를 품은 text가 되고, 그 단어들은 상수 표로 옮겨진다.
컴파일은, 그렇지 않았다면 모든 클라이언트가 되풀이해야 했을 일을 대신 한다:
- 평범한 함수 호출. 마크업이 __dil.jsx 호출로 바뀐다. 그래서 JavaScript 런타임은 DIL 파서 없이도 프로그램을 평가할 수 있다.
- 오류 격리. 식은 __dilSafe로 감싸여 있다. 그래서 예외를 던지는 식은 렌더 전체를 중단시키는 대신 요소 하나만 없앤다.
- 텍스트는 별도 표에. 정적 텍스트가 상수 표로 옮겨진다. 그래서 응답이 스트리밍되는 동안 늘어나는 텍스트는 프로그램이 아니라 데이터를 바꾼다.
- 안정된 상태 키. 각 상태에는 키({ key: "seats" })가 붙는다. 그래서 그 값은 매번의 재컴파일에도 살아남는다.
- 수리와 검증. 미완성 명령문과 태그는 버리고, 닫히지 않은 요소는 닫는다. 카탈로그 대비 검증에 실패한 속성은 제거하고 진단(diagnostic)으로 기록한다.
JSON 문서에는 텍스트 상수와 함께, 서버가 그 응답을 위해 가져온 데이터가 담긴다. 이미지 검색 결과가 그런 예다 (Data 참고).
클라이언트 런타임
클라이언트는 컴파일된 프로그램과 JSON 문서를 받는다. 클라이언트의 일은 프로그램을 실행하는 런타임과 결과를 그리는 렌더러로 나뉜다.
프로그램은 모델이 쓴 코드이므로 ChatGPT 페이지 안에서 실행되지 않는다. ChatGPT는 숨겨진 iframe(runner.html)을 띄운다. 이 iframe은 allow-scripts로 샌드박스 처리되어 있고 콘텐츠 보안 정책이 default-src 'none'이며, Web Worker 하나를 시작한다.
- 잠금(Lockdown). 프로그램을 평가하기 전에 워커는 자신의 전역 범위에서 네트워크 접근, 타이머, 메시징, 동적 코드 평가를 제거하고, 남은 전역을 동결한다.
- 평가(Evaluation). 그런 다음 워커는 new Function으로 프로그램을 평가한다. 런타임 객체(DIL, __dil, GenUI)와 카탈로그의 컴포지트 컴포넌트는 매개변수로 전달된다.
- 감시(Watchdog). 제한 시간 안에 응답하지 않는 프로그램은 격리되고, 워커는 재시작된다.
런타임은 React 방식의 작은 리컨실러(reconciler)다. 리컨실러는 컴포넌트를 렌더링하고, 훅 상태를 키가 붙은 슬롯에 보관한다. 그런 다음 결과 트리를 이전 트리와 비교해 차이를 연산 목록으로 인코딩한다. 무언가를 그리지는 않는다.
다음은 첫 렌더에서 나오는 연산을 노드 하나당 한 줄로 보여 주는 예시다. 각 요소의 속성 이름을 나열하는 항목은 생략했다:
CREATE #1 title SET size = "lg" PLACE under root at 0
CREATE #2 text "Team plan estimate" PLACE under #1 at 0
CREATE #3 text PLACE under root at 1
CREATE #4 text "Drag the slider to see the " PLACE under #3 at 0
CREATE #5 bold PLACE under #3 at 1
CREATE #6 text "monthly price" PLACE under #5 at 0
CREATE #7 text " for your team." PLACE under #3 at 2
CREATE #8 box SET border = true, padding = 3, gap = 2 PLACE under root at 2
CREATE #9 slider SET min = 1, max = 50, value = 8, onChange = fn#1 PLACE under #8 at 0
CREATE #10 title SET size = "xl" PLACE under #8 at 1
CREATE #11 text "$" PLACE under #10 at 0
CREATE #12 text "232" PLACE under #10 at 1
CREATE #13 text "/mo" PLACE under #10 at 2함수는 워커를 떠나지 않는다. 슬라이더의 핸들러는 식별자(fn#1)로만 전송된다. 전송 구간에서 연산은 정수로 된 이진 시퀀스로 인코딩되고, 문자열은 별도의 표에 담긴다.
렌더링
ChatGPT 페이지는 연산을 자신의 컴포넌트 트리에 적용한다. CREATE 하나하나가 ChatGPT 디자인 시스템의 네이티브 컴포넌트를 하나씩 생성하고, 페이지는 변경이 도착하는 대로 애니메이션을 입힌다. 페이지는 알려진 컴포넌트 타입에 대한 연산만 받아들이므로, 모델 출력은 임의의 마크업이나 스타일을 들여올 수 없다. 예외는 일부 속성이 받아들이는 raw CSS 값(디자인 시스템과 카탈로그 참고)과 iframe 안에서 실행되는 AppBlock 앱(비상구 참고)이다.
상호작용은 반대 방향으로 흐른다. 사용자가 슬라이더를 9로 끌면 페이지는 핸들러의 식별자와 인자를 워커에 보낸다. 워커는 setSeats(9)를 호출하고, 다시 렌더링한 뒤, 갱신 연산을 돌려준다. 모델 호출은 전혀 끼어들지 않는다.
디자인 시스템과 카탈로그
카탈로그는 모델이 무엇을 요청할 수 있는지를 정한다. 카탈로그가 필요한 이유는 모델이 raw 레이아웃·스타일 규칙으로 인터페이스를 짓지 않기 때문이다. 모델은 ChatGPT가 이미 그릴 줄 아는 컴포넌트 중에서 고르고, padding={3} 같은 디자인 토큰으로 스타일을 입힌다. 픽셀 폭이나 hex 색상 같은 raw CSS 값도 일부 속성에는 허용되지만, 디자인 토큰이 우선이다. 그 결과:
- 생성된 인터페이스는 ChatGPT의 나머지 화면과 똑같아 보인다. 모든 플랫폼에서 그렇다.
- 컴파일러가 출력을 대조할 스키마를 갖는다. 컴포넌트에 존재하지 않는 속성이나 타입이 틀린 리터럴은 컴파일 중에 제거되고 진단으로 기록된다.
우리가 캡처한 응답 하나에서 컴파일러는 두 속성을 제거했다. 아이콘의 fill(unknown_prop 진단)과 박스의 gap="1"(invalid_literal 진단)이다.
카탈로그는 세 부분으로 이뤄진다:
- 네이티브 컴포넌트. ChatGPT 클라이언트 코드의 컴포넌트 레지스트리에 약 70개의 컴포넌트가 정의돼 있다. 우리가 캡처한 응답에는 그중 39개가 등장한다.
- 간격, 반경, 색상, 크기에 대한 디자인 토큰.
- OpenAI가 DIL로 작성해 미리 빌드한 상태로 샌드박스에 보내는 컴포지트 컴포넌트. 이미지 컴포넌트와 상품 컴포넌트가 그런 예다. 우리 캡처에서 모델은 이 컴포넌트를 사용했지만 자기 컴포넌트를 정의한 적은 없다.
스트리밍
텍스트 스트리밍은 간단하다. 새 토큰 하나하나를 이미 화면에 있는 것 뒤에 덧붙이면 된다. 인터페이스 스트리밍은 세 가지 이유로 더 어렵다:
- 출력은 대개 아직 실행할 수 없다. 대부분의 순간에 출력은 태그나 식이 아직 열려 있는 미완성 프로그램이고, 쓰인 그대로는 실행할 수 없다.
- 인터페이스는 자라는 동안에도 계속 작동해야 한다. 사용자가 이미 만진 컴포넌트는 자기 상태를 유지해야 한다.
- 일부 내용은 따로 도착한다. 이미지 같은 데이터는 텍스트가 아니라 서버에서 온다.
서버 측 스트리밍
덧붙여지는 토큰의 단순한 스트림으로는 이것을 표현할 수 없다. 그래서 ChatGPT는 원시 텍스트, 컴파일된 프로그램, 그 데이터를 나란히 담은 구조화된 메시지에 대한 패치를 스트리밍한다.
응답은 서버 전송 이벤트 스트림(POST /backend-api/f/conversation)으로 브라우저에 도착한다. 각 이벤트는 만들어지고 있는 메시지에 대한 JSON-Patch 방식의 업데이트다. 이벤트 하나가 대개 원시 DIL 텍스트와 그 컴파일된 형태를 함께 갱신한다. 다음은 캡처한 응답에서 나온 업데이트 하나를 줄인 것이다:
{"o": "patch", "v": [
{"p": "/message/content/parts/0", "o": "append", "v": " Sunday lamb roast with friends — generous food, …"},
{"p": "/message/metadata/model_dil_v2/code", "o": "replace", "v": "DIL.render(__dil.jsx(()=>{…"},
{"p": "/message/metadata/model_dil_v2/constants", "o": "append", "v": {"0": "Here's a plan for a proper Sunday lamb roast with friends — …"}},
{"p": "/message/metadata/model_dil_v2/constants", "o": "append", "v": {"1": "Since you're"}},
{"p": "/message/metadata/model_dil_v2/fallbackMarkdown", "o": "append", "v": " Sunday lamb roast with friends — …"}
]}서버는 증분 컴파일을 하지 않는다. 몇백 밀리초마다, 아마도 모델 출력의 새 청크가 올 때마다, 모델이 지금까지 쓴 모든 것을 다시 컴파일해 그 결과를 보낸다. 컴파일은 어떤 태그도 나타나기 전인 첫 토큰부터 시작된다.
컴파일러가 감당해야 하는 일:
- 반쯤 쓴 응답 컴파일하기
- 텍스트 갱신하기
- UI 갱신하기
타임라인은 대략 이렇다:
클라이언트 측 스트리밍
페이지는 새 업데이트가 올 때마다 샌드박스 워커에 넘긴다. 워커는 그것을 평가하고, 기존 상태로 다시 렌더링한 뒤, 갱신 연산을 페이지에 보낸다. 컴파일 중에 붙은 키 덕분에 상태는 재컴파일을 거쳐도 값을 유지한다. 새 프로그램이 평가나 렌더링에 실패하면 워커는 마지막으로 작동했던 프로그램을 유지한다.
그다음 페이지는 각 변경에 애니메이션을 입힌다:
- 텍스트는 0.7초에 걸쳐 페이드인한다.
- 새 행과 그리드 항목은 0.42초에 걸쳐 미끄러져 들어온다.
- 차트는 1.8초에 걸쳐 그려진다.
- 컨테이너 높이는 튀지 않고 전환 애니메이션으로 바뀐다.
비상구: AppBlock, iframe 안의 앱
어떤 요청은 네이티브 컴포넌트가 다루도록 설계되지 않은 것을 요구한다. Web Audio로 소리를 합성하는 드럼 머신 같은 것이다. 이런 경우 모델은 AppBlock을 쓸 수 있다. AppBlock은 HTML, CSS, JavaScript로 된 독립형 웹 앱으로, 응답 안에 임베드된다. 다음은 그런 앱 하나의 시작 부분을 줄인 것이다:
<AppBlock title="Drum Lab" icon="app-chatgpt" variant="inline" app_block_id="drum-lab-01">
<div id="dl" class="w-full min-w-0 space-y-4 text-base">
<style>
#dl{color:var(--viz-text)}#dl button{touch-action:manipulation}#dl .panel{background:var(--viz-panel);border:1px solid var(--viz-border);border-radius:15px}…
</style>
…
<button id="dl-play" class="btn" style="background:var(--viz-text);color:var(--viz-card);min-width:100px">▶ Play</button>
…
</div>
<script>
(function(){
const root=document.getElementById('dl');if(root.dataset.init)return;root.dataset.init="yes";
…
function audioInit(){if(!audio){const C=window.AudioContext||window.webkitAudioContext; if(!C)return false;audio=new C();…
…
})();
</script>
</AppBlock>AppBlock은 Intelligent UI 컴포넌트와 다른 방식으로 렌더링된다.
전체 그림
당신이 프롬프트를 입력한다. 모델이 인터페이스를 쓰기 시작하고, 서버는 그것을 ChatGPT가 실행할 수 있는 형태로 바꾸며, 페이지는 응답이 스트리밍되어 들어오는 대로 그것을 한 조각씩 짓는다. 일단 화면에 뜨고 나면, 슬라이더를 옮기거나 체크박스에 표시하는 일은 모델에게 다시 묻지 않고 인터페이스를 로컬에서 갱신한다.
모델 전용 언어, 서버 컴파일 단계, 네이티브 렌더러, 그리고 근거에 묶인(grounded) 디자인 시스템이 이 모든 것을 하나로 묶는다. 각 부분이 핵심적인 역할을 맡고, 함께 모여 전 세계 수십억 사용자에게 차세대 AI 네이티브 인터페이스를 제공한다. 참 대단한 시대에 살고 있다!
방법론
모든 관찰은 우리 자신의 ChatGPT 계정, ChatGPT 웹앱이 만들어 내는 트래픽, 그리고 chatgpt.com이 공개적으로 서빙하는 JavaScript에서 나왔다. 관찰은 2026년 10월에 GPT-6와 GPT-6 Thinking으로 이뤄졌다.
분석은 Codex와 Claude의 도움을 받았다. 글은 Codex로 썼고, 시각화는 Claude가 했다.
(더 깊이 다룬 판본은 https://www.openui.com/blog/how-chatgpt-intelligent-ui-works에 있다)