LLM에게 “화면을 만들어 줘”라고 하면 보통 둘 중 하나가 돌아온다. 마크다운 덩어리이거나, 컴포넌트 트리를 담은 커다란 JSON이다. 앞의 것은 상호작용이 없고, 뒤의 것은 토큰이 많고 중간에 끊기면 파싱이 안 된다. thesysdev/openui는 이 문제를 “모델이 쓰는 전용 언어”로 푼다. OpenUI Lang이라는 한 줄 한 문장짜리 선언형 언어를 정의하고, 내 앱의 컴포넌트 목록(Zod 스키마)에서 시스템 프롬프트를 자동 생성하며, 모델이 토큰을 흘려보내는 동안 파서가 매 청크마다 “지금까지 온 텍스트로 그릴 수 있는 화면”을 만들어 React·Vue·Svelte·Angular 렌더러로 넘긴다. 2026-10-12에 스펙 1.0과 @openuidev/lang-core·@openuidev/react-lang 1.0.0이 함께 나왔고, 오늘(2026-10-21) GitHub Trending TypeScript 일간 10위(+390)에 올랐다.

이 글은 “JSON보다 토큰 67% 절약” 같은 홍보 문구 대신 실무자가 도입 전에 알아야 할 것을 본다. ① 모노레포 구조와 패키지별 성숙도, ② 언어 문법과 1.0 스펙의 핵심 규칙(스트리밍=배치 파싱, 가장 작은 조각만 버리는 오류 복구, 이름 단위 증분 편집, ]]>openui: 메시지 프로토콜), ③ 설치 시 텔레메트리, ④ 경쟁 방식과의 비교. 그리고 박스(리눅스 VM, Node 20.19.2)에 격리 HOME·텔레메트리 차단 환경을 만들고 API 키 없이 공식 테스트 226건, 공개 벤치마크 샘플 7종의 토큰 수 재현(압축 JSON 대조군 추가), 글자·토큰·무작위 단위 스트리밍 등가성 21회, 의도적으로 망가뜨린 프로그램의 오류 복구, 증분 편집 병합, 시스템 프롬프트 크기와 결정성을 직접 측정했다. 수치는 2026-10-21 16:49~17:10 KST 실측이거나 GitHub·npm API, 저장소 스펙 문서에서 확인한 값이다.
한눈에 보기: 오늘 기준 스냅숏
| 항목 | 값 |
|---|---|
| 저장소 | github.com/thesysdev/openui (생성 2024-12-02 19:40 KST) · 기본 브랜치 main · TypeScript 모노레포(pnpm 10.33.0) |
| 만든 곳 | Thesys(생성형 UI API ‘C1’을 서비스하는 회사). README 벤치마크에 자사 C1 JSON 포맷도 비교 대상으로 들어 있다 |
| 라이선스 | MIT (루트 LICENSE, 각 패키지 package.json) |
| 버전 | 스펙 1.0(2026-10-12) · @openuidev/lang-core 1.0.1(2026-10-15 13:23 KST) · react-lang 1.0.1 · react-ui 0.18.1 · CLI 0.6.0. GitHub 릴리스 82개(패키지별 changeset 태그) |
| GitHub 지표 | 12,768 stars · 863 forks · 열린 이슈+PR 169 · 커밋 1,053 · 기여자 53명(익명 포함) · 마지막 커밋 9823ec0(2026-10-19 18:40 KST) |
| Trending | TypeScript 일간 #10(+390, 16:49 KST). 전체 일간 목록에는 없음 |
| npm 주간 다운로드 | lang-core 237,319 · react-lang 184,706 · react-ui 44,275 · react-headless 44,248 · cli 15,146 · vue-lang 784 · a2ui 217 |
| 런타임 요구 | Node ^20.19.0 || >=22.12.0, Zod 3.25+ 또는 4. react-lang은 react·react-dom을 peer로 요구, MCP SDK는 선택 peer |
왜 지금 주목받나: ‘1.0 스펙 + 적합성 픽스처’
생성형 UI(Generative UI)는 이미 여러 진영이 있다. Vercel의 json-render는 JSON 패치 스트림, Google의 A2UI는 플랫폼 중립 JSON 프로토콜, CopilotKit은 에이전트-프런트엔드 이벤트 프로토콜(AG-UI) 위에서 UI를 다룬다. OpenUI가 이번에 눈에 띈 이유는 “라이브러리”가 아니라 스펙을 들고 나왔다는 점이다. spec/ 폴더에 개요(344줄)·언어 규칙(647줄)·프롬프트/메시지 프로토콜(435줄) 세 문서와, 다른 언어로 클라이언트를 만들어도 같은 결과를 내는지 확인하는 적합성 픽스처 59건(문법 7·진입점 7·스트리밍 4·평가 9·상태 5·액션 10·편집 5·오류 12)이 들어 있다. 스펙 변경 기록을 보면 0.9 커뮤니티 리뷰(2026-07-22), 1.0-beta(08-05)를 거쳐 10-12에 1.0이 확정됐다. 같은 날 react-lang이 0.x에서 1.0.0으로 올라갔다.
저장소 구조: 패키지 17개, 성숙도는 제각각
모노레포 packages/에는 17개 패키지가 있다. 아래 LOC는 테스트·스토리 파일을 뺀 src의 .ts/.tsx/.vue/.svelte 줄 수를 박스에서 센 값이다. 핵심은 lang-core(9,192줄)이고, UI 키트인 react-ui(44,372줄)가 가장 크다. 1.0에 도달한 것은 lang-core와 react-lang 둘뿐이다.
| 패키지 | 버전 | LOC | 역할 |
|---|---|---|---|
lang-core |
1.0.1 | 9,192 | 프레임워크 무관 파서·스트리밍 파서·프롬프트 생성·런타임 평가·병합. 이 글 실측의 대상 |
react-lang |
1.0.1 | 2,307 | Renderer, 훅, 라이브러리 정의. lang-core 위의 얇은 React 층 |
react-ui |
0.18.1 | 44,372 | 차트·폼·테이블·레이아웃 등 내장 컴포넌트 라이브러리 2종과 채팅 레이아웃 |
react-headless |
0.18.1 | 5,794 | 채팅 상태·스트리밍 어댑터·메시지 포맷 변환(UI 없음) |
vue-lang / svelte-lang / angular-lang |
0.4.2 | 1,154 / 626 / 2,462 | 각 프레임워크 바인딩. lang-core README 기준 아직 lang-core 0.4에 머물러 있음 |
openui-cli |
0.6.0 | 6,298 | npx @openuidev/cli create 스캐폴딩, 라이브러리에서 프롬프트·JSON 스키마 생성 |
langchain |
0.0.3 | 1,241 | LangChain/LangGraph 에이전트 출력을 AG-UI로 스트리밍 |
a2ui |
0.5.0 | 1,443 | A2UI v1.0 프로토콜 클라이언트에 OpenUI Lang 페이로드를 싣는 어댑터 |
server / observability* / devtools |
0.3.0 / 0.0.x / 0.3.0 | 1,370 / 985 / 6,338 | 서버 유틸, 이벤트 버스와 Thesys 클라우드 싱크, 개발용 Inspect 위젯 |
react-email / assistant-ui / browser-bundle |
0.4.0 / 0.2.0 / 0.2.1 | 3,241 / 515 / 번들 | 이메일 HTML 생성, assistant-ui 연동, CDN·iframe용 스크립트 번들 |
그 밖에 examples/에 에이전트 프레임워크(Google ADK, LangGraph Platform, Mastra, Vercel AI SDK 등), 앱 프레임워크(Angular, FastAPI, React Native, Svelte, Vue), 디자인 시스템(Material UI, shadcn), 쿡북(예약 비서, 대화형 분석, 문서 비교) 예제가 있고, benchmarks/에 토큰 벤치마크 재현 스크립트와 샘플이 있다.
OpenUI Lang: ‘한 줄에 한 이름’이 전부다
언어의 뼈대는 단순하다. 모든 줄은 이름 = 값이고, 값은 문자열·숫자·배열·객체·컴포넌트 호출·식이다. 컴포넌트 인자는 위치 기반이라 prop 이름을 쓰지 않는다. 이것이 토큰 절약의 주된 원천이다. 이름은 정의보다 먼저 써도 되므로(호이스팅) 모델은 첫 줄에 화면의 골격(root)을 쓰고 아래로 내려가며 채운다.
root = Card([header, chart, askBtn])
header = Header("Monthly Revenue", "Last 6 months")
chart = BarChart(labels, [series])
labels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
series = Series("Revenue", [12000, 15000, 14000, 18000, 21000, 25000])
askBtn = Button("Explain this chart", @ToAssistant("Explain the revenue chart"))
| 요소 | 예 | 의미 |
|---|---|---|
| 컴포넌트 호출 | Button("저장", saveAction, "primary") |
라이브러리가 정한 prop 순서대로 매핑. 뒤쪽 선택 인자는 생략, 중간은 null |
| 참조·호이스팅 | root = Card([header]) 후 header = ... |
정의 전 사용 가능. 아직 안 온 참조는 배열에서 빠짐(자리 표시 없음) |
| 반응형 상태 | $query = "" |
$ 변수는 입력과 양방향 바인딩, 읽는 문장은 자동 갱신 |
| 데이터 | tickets = Query("listTickets", {days: $days}, {rows: []}) |
호스트가 제공하는 도구 호출. 세 번째 인자는 로딩 중 기본값. Mutation은 액션으로만 실행 |
| 액션 | @ToAssistant, @OpenUrl, @Set, @Reset, @Run |
내장 5종 + 라이브러리 정의 커스텀 액션. Action([...])로 순차 실행 |
| 내장 함수 | @Filter, @Sort, @Sum, @Each 등 |
집계·정렬·수학·반복을 클라이언트에서 계산(모델 왕복 없음) |
| 식 | total > 100000 ? "On track" : "Behind" |
산술·비교·논리·삼항·멤버 접근. rows.amount는 배열에서 필드만 추출 |
주목할 점은 언어가 데이터 흐름까지 품는다는 것이다. Query와 $ 상태, 내장 함수 덕분에 “필터를 바꾸면 차트가 다시 그려지는” 상호작용이 모델 재호출 없이 클라이언트에서 돈다. 반대로 말하면 OpenUI 렌더러는 단순한 템플릿 엔진이 아니라 작은 반응형 런타임이고, 모델이 쓴 프로그램이 내 도구(tool)를 호출할 수 있는 경로가 생긴다. 이 점은 아래 보안 절에서 다시 다룬다.

아키텍처: 라이브러리가 곧 계약서
흐름은 다섯 단계다. (1) 앱이 defineComponent로 컴포넌트와 Zod 스키마를 정의하고 createLibrary로 묶는다. (2) generateSystemPrompt가 라이브러리 스펙(LibrarySpec: JSON 스키마 + 함수·액션 목록)에서 문법 규칙·컴포넌트 시그니처·도구 목록·편집 모드 규칙을 담은 시스템 프롬프트를 만든다. (3) 모델은 어떤 제공자든 상관없이 OpenUI Lang 텍스트를 스트리밍한다. (4) lang-core의 스트리밍 파서가 매 청크마다 렉싱→문장 분리→미완성 꼬리 자동 닫기→트리 실체화→스키마 검증을 수행한다. (5) 렌더러가 트리를 그리고, 클릭·폼 제출·Query는 호스트 앱의 이벤트·도구로 나간다.
// 서버: 내 라이브러리에서 시스템 프롬프트 생성
import { generateSystemPrompt } from "@openuidev/lang-core";
import { openuiLibrary, openuiPromptOptions } from "@openuidev/react-ui";
const systemPrompt = generateSystemPrompt({
library: openuiLibrary.toSpec(),
promptOptions: openuiPromptOptions, // tools, editMode, bindings 등
});
// 클라이언트: 스트리밍 텍스트를 그대로 넘긴다
import { Renderer } from "@openuidev/react-lang";
<Renderer library={openuiLibrary} response={text} isStreaming={isStreaming} />
lang-core만 쓰면 프레임워크 없이도 같은 일을 할 수 있다. createStreamingParser(schema, "Stack")를 만들고 sp.set(지금까지의_전체_텍스트, 끝났는지)를 부르면 { root, meta: { incomplete, unresolved, orphaned, statementCount, errors } }가 돌아온다. 증분 텍스트가 아니라 누적 전체 텍스트를 넘기는 API라는 점이 특이한데, 내부에서 완료된 문장은 캐시하고 마지막 미완성 꼬리만 다시 파싱한다.
1.0 스펙의 핵심 규칙 ①: 스트리밍은 배치 파싱과 같아야 한다
| 규칙 | 내용 (language.md §4) |
|---|---|
| 문장 완료 | 줄바꿈이 도착하고 다음 줄 첫 글자가 ?(삼항 이어짐)가 아닐 때 완료. 완료된 문장은 한 번 파싱 후 불변 |
| 미완성 꼬리 | 닫히지 않은 문자열을 닫고(이스케이프 중이면 \ 보정), 열린 괄호를 역순으로 닫은 뒤 파싱. 실패하면 그 문장은 생략 |
| 덮어쓰기 금지 | 자동 닫기로 만든 임시 문장은 같은 이름의 완료 문장을 덮지 못함 |
| 재조정 | 새 텍스트가 이전 완료 접두사로 시작하지 않으면(예: 코드펜스가 뒤늦게 도착) 캐시를 버리고 처음부터 |
| 자리 표시 없음 | 아직 정의 안 된 참조는 배열에서 빠짐. 스켈레톤을 그리지 않음 |
| 상호작용 대기 | 스트리밍 중 Query·Mutation 실행 금지. 상태 기본값은 도착하는 대로 초기화 |
| 종료는 호스트가 | 언어에 종료 표시가 없음. 호스트가 isStreaming=false로 알리면 꼬리를 확정 |
| 등가성 | 스트림 종료 시 결과 = 전체 텍스트 배치 파싱 결과(문장·진입점·오류·상태 기본값) |
핵심 규칙 ②·③: 가장 작은 조각만 버리고, 바뀐 줄만 보낸다
오류 처리 원칙은 “실수는 가능한 가장 작은 단위만 제거하고 나머지는 렌더”다. 잘못된 인자는 prop만, 잘못된 컴포넌트는 그 컴포넌트만, 잘못된 문장은 그 문장만 빠진다. 각 오류는 모델에 그대로 되돌려줄 수 있는 같은 모양의 객체(source, code, statementId, component, path, message, hint)로 보고된다.
| 코드 | 의미 | 복구 |
|---|---|---|
unknown-component |
라이브러리에 없는 컴포넌트 | 그 컴포넌트만 제거 |
missing-required / null-required |
필수 prop 누락·null | 스키마 기본값이 있으면 채움, 없으면 컴포넌트 제거 |
type-mismatch |
타입·enum 불일치 | 기본값 → 없으면 선택 prop·배열 항목 제거, 필수면 컴포넌트 제거 |
excess-args |
prop보다 많은 인자 | 초과분만 버리고 렌더 |
unknown-function |
정의 안 된 @이름 |
호출이 null로 평가, 문장은 유지 |
no-root |
쓸 수 있는 진입점 없음 | 대체 진입점이 그려지면 경고, 아니면 치명 |
parse-failed / parse-exception |
문장이 하나도 안 나옴 / 파서 자체 예외 | 치명(클라이언트는 반드시 catch) |
tool-not-found / tool-error |
없는 도구 / 도구 실패 | Query는 기본값 유지, 액션 계획은 중단 |
증분 편집은 대시보드·캔버스처럼 “같은 화면을 고쳐 나가는” 표면을 위한 장치다. 모델에게 현재 프로그램을 주면, 모델은 바뀐 문장만 담은 패치를 돌려준다. 호스트는 패치 스트리밍이 끝난 뒤 mergeStatements로 이름 단위 병합을 한다. 같은 이름은 교체, 새 이름은 추가, name = null은 삭제, 병합 후 root에서 도달할 수 없는 문장은 정리(상태 선언은 유지)된다. 스트리밍 도중에 병합하면 아직 덜 쓴 root 때문에 멀쩡한 문장이 지워질 수 있어 반드시 패치 완료 후 병합하라고 스펙이 못 박는다.

마지막으로 저장 형식이 정해져 있다. 호스트는 응답을 ]]>openui:content?library=support%401.2.0, ]]>openui:context(폼 상태·클릭 컨텍스트 JSON), ]]>openui:end 같은 줄로 감싸 저장하고, 모델에게 히스토리를 보낼 때는 이 줄들을 벗긴다. end가 없는 저장 메시지는 “중간에 끊긴 응답”으로 간주하되 도착한 만큼은 렌더한다. 같은 형식이 스트리밍과 저장에 모두 쓰인다는 점이 실무적으로 편하다.
| 줄 | 내용 | 실무 의미 |
|---|---|---|
]]>openui:content |
프로그램(또는 산문). 쿼리스트링으로 라이브러리 id·버전 기록 | 어떤 라이브러리 버전으로 만든 화면인지 추적 |
]]>openui:context |
호스트가 정의하는 JSON(폼 값, 클릭 데이터) | 저장된 메시지를 다시 열 때 폼 상태 복원 |
]]>openui:end |
스트림 정상 종료 표시 | 없으면 끊긴 응답. 그래도 도착분은 렌더 |
| 하위 호환 3원칙 | prop은 끝에만 추가, 순서 변경·삭제 금지, 새 필수 prop엔 기본값 | 위치 인자 언어의 대가. 저장된 UI가 깨지지 않게 |
Button("Save", saveAction, "primary")에는 prop 이름이 없다. 디자인 시스템 담당자가 Button의 prop 순서를 바꾸는 순간 과거 대화의 화면이 조용히 틀어진다. 스펙이 “prop은 끝에만 추가, 순서 변경·삭제 금지”를 라이브러리 작성 규칙으로 명시한 이유다. 컴포넌트 스키마 변경을 API 변경처럼 리뷰하는 절차가 필요하다.설치 텔레메트리: 기본값은 ‘보낸다’
도입 검토에서 놓치기 쉬운 부분이다. lang-core는 postinstall 스크립트로 설치 텔레메트리를 PostHog(CloudFront 프록시 경유)로 보낸다. 소스 주석과 실제 출력에 따르면 무작위 설치 ID, 솔트를 친 SHA-256 프로젝트 ID, lang-core·Node·OS·패키지 매니저 버전, CI·Docker 여부가 포함되고, 저장소 URL·경로·코드·프롬프트는 포함되지 않는다. 런타임 텔레메트리(프롬프트 생성·파싱 이벤트를 10% 샘플링)는 OPENUI_RUNTIME_TELEMETRY_ENABLED=1을 켜야만 동작하는 옵트인이다.
| 경로 | 기본값 | 끄는 법 | 비고 |
|---|---|---|---|
| 설치(postinstall) | 기본 켜짐(옵트아웃) | OPENUI_TELEMETRY_DISABLED=1 또는 DO_NOT_TRACK=1 |
첫 실행 시 stderr 고지문 출력, ~/.config/openui/telemetry.json에 설치 ID·솔트 저장 |
| 런타임(서버 측 프롬프트 생성·파싱) | 기본 꺼짐(옵트인) | OPENUI_RUNTIME_TELEMETRY_ENABLED=1일 때만 |
구성 해시·구조 카운트·오류 코드 개수만. 브라우저에선 전송 안 함 |
| observability-cloud | 명시적으로 설치·설정할 때 | 패키지를 안 쓰면 됨 | OpenUI 이벤트를 Thesys 콘솔로 배치 전송 |
박스에서 네트워크 네임스페이스를 끊은 채(unshare -rn) OPENUI_TELEMETRY_DEBUG=1로 postinstall을 돌려 보니, 고지문이 출력되고 보낼 페이로드(openui_lang_core_installed, project_id_source install_root, npm 11.21.0, linux x64 등)가 그대로 찍혔으며 홈의 .config/openui/telemetry.json에 설치 ID가 생겼다. 흥미롭게도 박스의 npm 11.21.0은 승인되지 않은 의존성 설치 스크립트를 실행하지 않고 “allowScripts에 포함되지 않음” 경고만 남겼다. 즉 텔레메트리가 실제로 나가는지는 패키지 매니저 버전과 설정에 따라 다르다. 사내 CI라면 환경 변수로 명시적으로 끄는 쪽이 깔끔하다.

박스 실측 ①: 환경과 공식 테스트
| 항목 | 내용 |
|---|---|
| 격리 | 작업 폴더 전용 HOME·npm 캐시, OPENUI_TELEMETRY_DISABLED=1·DO_NOT_TRACK=1, API 키·모델 호출 없음 |
| 런타임 | 리눅스 VM, Node 20.19.2, npm 11.21.0, pnpm 10.33.0(npx) |
| 설치 | npm install @openuidev/lang-core@1.0.1 zod js-tiktoken → 패키지 5개, 1.9초 (lang-core 의존성은 ci-info 하나, MCP SDK는 선택 peer) |
| 공식 테스트 | 저장소 9823ec0에서 lang-core vitest run: 14개 파일 226건 모두 통과, 1.31초. 이 중 스펙 적합성 픽스처 59건 통과 |
| 토크나이저 | js-tiktoken o200k_base(README는 tiktoken의 gpt-5 인코더 사용. 같은 계열이라 OpenUI·YAML·JSON 패치 합계가 공식치와 일치) |
| 하지 않은 것 | 실제 LLM 생성(키 필요), React 렌더링·브라우저 E2E, Vue/Svelte/Angular 바인딩 |
박스 실측 ②: ‘67% 절약’을 압축 JSON으로 다시 재 보면
공식 벤치마크는 GPT-5.2가 생성한 OpenUI Lang 7종을 파싱한 뒤 같은 트리를 YAML·Vercel JSON-Render(JSONL 패치)·Thesys C1 JSON으로 변환해 토큰을 센다. 박스에서 저장소의 샘플 파일을 그대로 세 보니 OpenUI 4,800, YAML 9,129, JSON 패치 10,180으로 README와 같았다(C1 JSON은 9,650으로 README 9,948과 약간 다름). 그런데 샘플의 C1 JSON 파일은 2칸 들여쓰기된 예쁜 출력이다. 모델이 실제로 스트리밍할 때 들여쓰기를 꼭 넣을 이유는 없으므로, 같은 파일을 JSON.stringify로 압축해 대조군을 하나 더 만들었다.
| 시나리오 | OpenUI Lang | C1 JSON(압축) | C1 JSON(들여쓰기) | YAML | JSON 패치 | 압축 JSON 대비 절약 |
|---|---|---|---|---|---|---|
| simple-table | 148 | 181 | 322 | 317 | 340 | 18.2% |
| chart-with-data | 231 | 271 | 498 | 465 | 520 | 14.8% |
| contact-form | 294 | 431 | 824 | 763 | 893 | 31.8% |
| dashboard | 1,226 | 1,204 | 2,182 | 2,129 | 2,247 | -1.8% |
| pricing-page | 1,195 | 1,362 | 2,285 | 2,231 | 2,487 | 12.3% |
| settings-panel | 540 | 656 | 1,195 | 1,078 | 1,244 | 17.7% |
| e-commerce-product | 1,166 | 1,356 | 2,344 | 2,146 | 2,449 | 14.0% |
| 합계 | 4,800 | 5,461 | 9,650 | 9,129 | 10,180 | 12.1% |
결과는 분명하다. 들여쓴 C1 JSON 대비 절약률은 50.3%, JSON 패치 대비 52.8%, YAML 대비 47.4%로 공식 수치와 비슷하지만, 압축 JSON 대비로는 12.1%에 그친다. dashboard 시나리오는 오히려 OpenUI(1,226)가 압축 JSON(1,204)보다 22토큰 많았다. 들여쓰기 공백과 반복되는 키 이름이 JSON 쪽 토큰의 상당 부분이었다는 뜻이다. 따라서 “토큰 절반”이 실제로 의미 있는 경우는 비교 대상이 JSON 패치 스트림(예: json-render)이거나 모델이 들여쓴 JSON을 출력하는 경우다. OpenUI의 진짜 차별점은 토큰 수보다 어디서 끊겨도 그릴 수 있는 문법과 클라이언트 측 상태·데이터 런타임에 있다고 보는 편이 정확하다.
박스 실측 ③: 스트리밍 등가성과 비용
7개 샘플을 세 가지 방식으로 쪼개 스트리밍 파서에 넣었다. 글자 단위(최악의 경우), o200k 토큰 단위(실제 LLM 스트림과 비슷), 1~13자 무작위 청크다. 매 청크마다 set()을 호출하고, 마지막에 스트림 종료를 알린 결과를 전체 텍스트 배치 파싱 결과(트리 + 메타, 오류 코드 정렬)와 비교했다.
| 샘플 | 토큰 청크 | 토큰 단위 총/최대 set() | 글자 청크 | 글자 단위 총시간 | 최종 노드 | 3방식 모두 배치와 동일 |
|---|---|---|---|---|---|---|
| simple-table | 148 | 7.7ms / 최대 0.23ms | 433 | 23.3ms | 7 | ✔ |
| chart-with-data | 231 | 6.4ms / 최대 0.1ms | 795 | 26.1ms | 11 | ✔ |
| contact-form | 294 | 11ms / 최대 0.39ms | 1,117 | 43.4ms | 21 | ✔ |
| dashboard | 1,226 | 132.9ms / 최대 2.52ms | 3,478 | 362ms | 47 | ✔ |
| pricing-page | 1,195 | 148ms / 최대 1.02ms | 4,761 | 552.2ms | 50 | ✔ |
| settings-panel | 540 | 46.5ms / 최대 0.79ms | 2,367 | 162.3ms | 27 | ✔ |
| e-commerce-product | 1,166 | 172.3ms / 최대 1.17ms | 4,165 | 600.5ms | 49 | ✔ |
21회 모두 최종 결과가 배치 파싱과 같았고, 노드 수가 줄어드는(화면이 뒤로 가는) 순간은 한 번도 없었다. 모든 샘플에서 root = Stack([ 14글자가 도착한 시점부터 빈 컨테이너가 그려졌다. 비용은 가장 큰 pricing-page(4,777바이트)를 글자 단위로 4,761번 재파싱해도 총 552ms, 토큰 단위로는 148ms였고 단일 set() 최대치는 2.52ms였다. 누적 텍스트를 매번 넘기는 API지만 완료 문장 캐시 덕에 실사용 스트림에서는 렌더 비용이 병목이 될 가능성이 낮다. 단, 이는 파서만의 비용이며 React 재렌더링 비용은 별개다.
import { createStreamingParser } from "@openuidev/lang-core";
const sp = createStreamingParser(schema, "Stack");
let acc = "";
for await (const chunk of llmStream) {
acc += chunk;
const { root, meta } = sp.set(acc, false); // 누적 전체 텍스트를 넘긴다
render(root); // meta.incomplete로 진행 중 표시 가능
}
const final = sp.set(acc, true); // 호스트가 종료를 알려야 꼬리가 확정된다
박스 실측 ④: 일부러 망가뜨린 프로그램
root = Stack([header, chart, table, ghost])
header = CardHeader("Q3 Sales", "by region", "extra-arg", 42)
chart = PieChart3D("oops")
table = Table([Col("Region", ["KR","JP"]), Col("Sales", 123)])
badTag = TagBlock("not-an-array")
| 보고된 오류 | statementId | 컴포넌트 | 경로/처리 |
|---|---|---|---|
excess-args |
header |
CardHeader | - |
unknown-component |
chart |
PieChart3D | - |
type-mismatch |
table |
Col | /type |
missing-required |
table |
Table | /rows |
| (미해결 참조) | ghost |
– | 정의 없음 → 배열에서 제외 |
| (고아 문장) | badTag |
– | root에서 도달 불가 → 렌더 안 함 |
결과적으로 root의 자식 4개 중 header 1개만 살아남았다. header는 초과 인자 2개만 버리고 렌더됐고, 없는 컴포넌트(PieChart3D)와 필수 rows가 빠진 Table은 통째로 제거, 미정의 참조 ghost는 조용히 빠졌다. 파서는 예외를 던지지 않았다. 문자열 중간에서 끊긴 h = CardHeader("Monthly Rev도 incomplete: true와 함께 제목 “Monthly Rev”으로 그려졌다. 실무 팁 두 가지: 1.0의 lang-core는 hint 필드를 따로 채우지 않는 대신 message 자체에 모델이 고칠 단서를 넣는다. 예를 들어 missing required field "/rows" — signature: Table(columns*: array, rows*: array), CardHeader takes 2 arg(s), got 4 (2 excess dropped), 미지 컴포넌트에는 사용 가능한 컴포넌트 목록이 붙는다. 예전의 enrichErrors는 이제 deprecated이므로 서버 측 재시도 루프는 result.meta.errors의 message를 그대로 모델에 돌려주면 된다. 또 unresolved·orphaned는 오류 목록에 들어가지 않으므로 별도로 로깅해야 품질 지표로 쓸 수 있다.
박스 실측 ⑤: 증분 편집과 시스템 프롬프트 비용
| 항목 | 실측 | 비고 |
|---|---|---|
| 원본 프로그램 | 81 토큰 (5문장) | 스펙 예제의 월간 매출 차트 |
| 패치(주간으로 변경) | 66 토큰 (4문장) | header·chart·series 교체, weekLabels 추가 |
| 병합 결과 | root + 4문장, labels 자동 제거 |
도달 불가 문장 GC 확인(mergedHasOldLabels=false) |
chart = null 패치 |
chart 문장 삭제, 고아가 된 labels·series도 정리 | 단, root = Stack([header, chart]) 텍스트에는 chart 참조가 남음 → 렌더 시 미해결로 빠짐 |
| 시스템 프롬프트(픽스처 라이브러리 12개 컴포넌트, 기본) | 4,778자 / 1,114 토큰 | 두 번 생성 SHA-256 동일(결정적, 99764d598971…) |
| 같은 라이브러리 + 도구·편집 모드·바인딩 | 3,903 토큰 | Query·Mutation·Action·Forms·Edit Mode 절이 추가됨 |
| 벤치마크용 시스템 프롬프트(벤치마크 스키마 53개 정의) | 13,080자 / 3,281 토큰 | benchmarks/system-prompt.txt |
여기서 계산 하나가 중요하다. OpenUI가 응답 한 번에 아끼는 토큰은 이번 샘플 기준 압축 JSON 대비 평균 95토큰(661÷7) 남짓이다. 반면 시스템 프롬프트는 1,100~3,900토큰이고 매 요청마다 입력으로 들어간다. 입력 토큰이 출력보다 싸고 프롬프트 캐시가 적용되므로 비용상 큰 문제는 아니지만, “토큰 절약”을 도입 근거로 삼는다면 입력 프롬프트 증가분까지 합산해야 한다. 다행히 프롬프트 생성이 결정적이라 접두사 캐시(prefix caching)와 잘 맞는다. 라이브러리나 옵션이 바뀌지 않는 한 같은 바이트열이 나온다.
경쟁 방식과의 비교
| 방식 | 모델 출력 형식 | 스트리밍 중 렌더 | 데이터·상태 | 대상 플랫폼 | 실무 메모 |
|---|---|---|---|---|---|
| OpenUI Lang | 줄 단위 DSL(위치 인자) | 부분 텍스트도 항상 유효(자동 닫기) | 내장 Query·Mutation·상태·함수 | React·Vue·Svelte·Angular(+이메일) | 스펙 1.0 + 적합성 픽스처, 패키지 성숙도 편차 |
| Vercel json-render | JSON 패치(RFC 6902) 스트림·YAML | 패치 단위로 누적 | 데이터 페칭 내장 없음(README 비교표 기준) | 웹·모바일·PDF·이메일·영상 | JSON이라 기존 도구·검증기 재사용 쉬움 |
| Google A2UI | 플랫폼 중립 JSON 메시지 | 지원 | 호스트 측 | 웹·iOS·Android | OpenUI가 @openuidev/a2ui로 A2UI 위에 OpenUI 페이로드를 싣는 어댑터 제공 |
| CopilotKit(AG-UI) | 에이전트↔프런트 이벤트 프로토콜 | 부분 지원(README 표) | 에이전트 상태 동기화 중심 | 웹 | UI 언어라기보다 전송 계층. OpenUI도 langchain 패키지에서 AG-UI 사용 |
| 도구 호출 + 고정 컴포넌트 | 함수 인자(JSON)로 미리 만든 위젯 선택 | 도구 호출 완료 후 렌더 | 앱 코드 | 제한 없음 | 가장 안전·예측 가능, 대신 레이아웃 자유도 낮음 |
| HTML/JSX 직접 생성 | 자유 형식 코드 | 부분 렌더 어려움 | 임의 | 웹 | 표현력 최대, 샌드박스·XSS·일관성 비용 최대 |
README의 비교표(토큰 1x 대 3~4x, 60 tok/s 기준 지연 4.9초 대 14.2초)는 Thesys가 만든 수치이고, 위 실측처럼 대조군 포맷 설정에 민감하다. 반대로 “자동 닫기 + 가장 작은 조각만 버리기 + 배치와 같은 결과”라는 스트리밍 보장은 픽스처로 검증 가능한 성질이라 신뢰도가 높다. 결정 기준은 토큰이 아니라 내 UI가 얼마나 동적인가다. 고정 위젯 몇 개를 고르는 수준이면 도구 호출로 충분하고, 모델이 대시보드·폼·필터를 조합해야 하는 제품이면 OpenUI 같은 전용 언어가 값을 한다.
보안·운영 체크포인트
| 주제 | 현황 | 실무 판단 |
|---|---|---|
| 모델이 쓴 프로그램이 도구를 호출 | Query("툴이름", 인자)는 스트림 종료 후 자동 실행, Mutation은 사용자 클릭 시 실행 |
프롬프트 인젝션으로 다른 도구·인자가 들어올 수 있다고 가정. 호스트 측 도구 허용 목록·인자 검증·권한 확인 필수. 쓰기 도구는 확인 단계 |
@OpenUrl |
react-lang은 open_url 이벤트만 호스트에 넘김 |
직접 window.open할 때는 react-ui의 safeOpenUrl/safeUrl(javascript:·data:·vbscript:·file: 차단)처럼 스킴 검증 |
| 컴포넌트 화이트리스트 | 라이브러리에 없는 컴포넌트는 렌더 불가(unknown-component) | HTML 직접 생성 대비 큰 장점. 단 커스텀 컴포넌트 내부의 링크·마크다운 렌더는 별도 검증 |
| 저장 UI 호환성 | 위치 인자라 prop 순서 변경 시 과거 화면 오작동 | 컴포넌트 스키마 변경을 API 버전 관리처럼, content?library=id@버전 기록 활용 |
| 설치 텔레메트리 | postinstall 기본 전송(옵트아웃) | CI·사내망에서 OPENUI_TELEMETRY_DISABLED=1 |
| 패키지 성숙도 | lang-core·react-lang만 1.0, 나머지 0.x(langchain 0.0.3) | Vue·Svelte·Angular는 lang-core 0.4 기반이므로 1.0 스펙 기능 차이 확인 |
시작하는 법: 세 가지 진입 경로
| 상황 | 명령 | 얻는 것 |
|---|---|---|
| 새 채팅 앱 | npx @openuidev/cli@latest create --name genui-chat-app |
스트리밍·내장 UI·프롬프트 생성이 연결된 앱 골격 |
| 기존 React 앱 | npm install @openuidev/react-lang @openuidev/react-ui |
openuiLibrary로 시작해 내 디자인 시스템 컴포넌트로 교체 |
| 백엔드·엣지·다른 언어 | npm install @openuidev/lang-core |
프롬프트 생성·서버 측 검증. 다른 언어 구현은 spec/fixtures/1.0으로 적합성 확인 |
| 에이전트 프레임워크 | @openuidev/langchain + LangGraph, 예제: Mastra·Vercel AI SDK·Google ADK |
AG-UI 이벤트로 OpenUI 스트리밍 |
도입 체크리스트
- 버전 고정: lang-core·react-lang 1.0.x와 스펙 버전을 함께 고정하고, 다른 프레임워크 바인딩을 쓴다면 1.0 기능(커스텀 함수·액션, 새 no-root 규칙) 지원 여부를 먼저 확인한다.
- 텔레메트리 정책: 설치 텔레메트리 끄기 환경 변수를 CI·개발 환경에 넣는다.
- 도구 경계: Query·Mutation이 부를 수 있는 도구를 최소화하고, 서버에서 사용자 권한으로 다시 검증한다.
- 오류 루프:
meta.errors의 message(시그니처·사용 가능한 이름 포함)를 모델에 되돌려 증분 패치로 고치게 하는 1회 재시도 루프를 둔다.unresolved·orphaned비율을 품질 지표로 기록한다. - 토큰 계산은 정직하게: 내 모델이 실제로 내놓던 포맷(압축 JSON인지)과 비교하고, 시스템 프롬프트 증가분과 캐시 적중률까지 함께 본다.
- 스키마 변경 규칙: prop은 끝에만 추가, 재정렬·삭제 금지 규칙을 코드 리뷰 체크리스트에 넣는다.
정리
thesysdev/openui는 “LLM이 UI를 출력한다”는 막연한 아이디어를 스펙·적합성 픽스처·다중 프레임워크 런타임으로 구체화한 프로젝트다. 박스 실측에서 공식 테스트 226건(픽스처 59건 포함)이 모두 통과했고, 7개 샘플을 글자·토큰·무작위 단위로 21번 스트리밍해도 결과는 언제나 배치 파싱과 같았으며 화면이 뒤로 가는 순간은 없었다. 망가진 프로그램에서도 예외 없이 살릴 수 있는 조각만 렌더했다. 반면 간판 문구인 토큰 절약은 비교 기준에 따라 크게 달라서, 들여쓴 JSON 대비 약 50%였던 절약률이 압축 JSON 대비로는 12%로 줄었다. 정리하면 OpenUI의 가치는 “토큰을 아끼는 포맷”보다 “어디서 끊겨도 그려지고, 모델 없이도 상호작용하는 생성형 UI 런타임”에 있다. 설치 텔레메트리 기본값과 모델이 쓴 프로그램이 도구를 호출하는 경로만 챙기면, 생성형 UI를 제품에 넣으려는 팀이 진지하게 검토할 만한 출발점이다.