Skip to content
푸땡로그
Go back

SDUI로 본 json-render와 A2UI: 같은 카드로 비교한 서버·클라이언트 계약

SDUI(Server-Driven UI)는 서버가 화면 구성을 데이터로 보내고 클라이언트가 자기 컴포넌트로 그리는 방식입니다. 이 방식이 동작하려면 서버와 클라이언트가 약속한 형식, 곧 계약이 있어야 합니다. 어떤 컴포넌트를 보낼 수 있는지, 값은 어디서 오는지, 버튼을 누르면 무엇이 실행되는지를 양쪽이 같게 알아야 합니다.

생성형 UI에서는 이 화면 데이터를 모델이나 에이전트가 만듭니다. Vercel의 json-render와 Google이 공개한 A2UI는 둘 다 모델에게 코드 대신 정해진 컴포넌트 목록 안의 JSON을 만들게 합니다. 두 방식은 계약을 두는 위치가 다릅니다. json-render는 한 앱의 TypeScript 코드에 계약을 둡니다. A2UI는 에이전트와 클라이언트가 합의한 카탈로그 ID와 메시지의 프로토콜 버전으로 계약을 확인합니다. 아래에서는 같은 주문 카드 하나를 두 형식으로 적고, 계약을 어긴 출력이 어디서 걸러지는지 비교합니다.

기준: 2026-10-24 · json-render 0.21.3(Vercel Labs, 0.x) · A2UI v0.9.1(공개 프리뷰, 메시지의 version은 v0.9) · A2UI Python SDK a2ui-core 0.4.0

json-render 설치와 스트리밍 렌더링은 Vercel json-render 시작하기에서 다뤘습니다. 그 글에는 json-render·A2UI·Open Generative UI 중 하나를 고르는 기준도 있습니다. 이 글은 두 형식의 계약 비교에 집중합니다.


SDUI에서 서버와 클라이언트가 약속하는 것

Airbnb의 SDUI 시스템 Ghost Platform이 대표 사례입니다. InfoQ 정리 ↗에 따르면 백엔드 응답 하나가 웹·iOS·Android 앱의 화면 레이아웃을 정합니다. 섹션 배치, 섹션에 표시할 데이터, 사용자 상호작용에 대한 액션도 같은 응답이 정합니다. 서버와 클라이언트는 GraphQL로 정의한 공통 스키마를 공유합니다. 이 스키마가 섹션(section)·화면(screen)·액션(action)의 형태를 정하고, 각 플랫폼의 클라이언트 프레임워크는 이 스키마로 화면을 그립니다.

양쪽이 지켜야 할 계약을 작성자 기준으로 나누면 네 가지입니다.

flowchart TD
  P["화면 데이터를 만드는 쪽<br/>서버 · 모델 · 에이전트"] -->|"UI JSON"| C["클라이언트 카탈로그<br/>허용한 컴포넌트만"]
  C --> R["렌더링"]
  D["데이터"] --> R
  R -->|"사용자 행동"| A["액션"]
  A --> H["앱의 핸들러 또는<br/>서버·에이전트"]

모델은 프롬프트로 받은 규칙을 어길 수 있습니다. 그래서 생성형 UI에서는 모델 출력을 계약에 비춰 검사하는 단계까지 계약에 포함됩니다. A2UI v0.9.1 프로토콜 문서 ↗도 v0.9가 스키마를 프롬프트에 넣는 방식이라 생성 후 검증이 더 필요하다고 설명합니다.


같은 카드: json-render 스펙

예제 카드는 제목 “주문 1024”, 배송 상태, “배송 조회” 버튼으로 구성합니다. 상태 문구는 데이터에서 읽고, 버튼을 누르면 주문 번호와 함께 track_order 동작을 요청합니다. 컴포넌트 이름과 액션 이름은 작성자가 정한 설명용 예제입니다.

json-render에서는 앱이 카탈로그를 TypeScript로 정의합니다. 모델에게 주는 프롬프트도, 출력을 검사하는 함수도 이 카탈로그에서 나옵니다.

catalog.ts

TypeScript
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { z } from "zod";

export const catalog = defineCatalog(schema, {
  components: {
    Card: {
      props: z.object({ title: z.string() }),
      slots: ["default"],
      description: "제목이 있는 카드",
    },
    Text: {
      props: z.object({ text: z.string() }),
      description: "한 줄 텍스트",
    },
    Button: {
      props: z.object({ label: z.string() }),
      events: ["press"],
      description: "누르면 press 이벤트를 내는 버튼",
    },
  },
  actions: {
    track_order: {
      params: z.object({ orderId: z.string() }),
      description: "배송 조회 화면을 연다. params: { orderId: string }",
    },
  },
});

모델은 이 카탈로그로 만든 프롬프트(catalog.prompt())를 받고 아래 스펙을 만듭니다. 실제로는 스펙의 한 경로씩 채우는 JSON Patch 줄을 스트리밍하고, 클라이언트가 이를 모아 아래 모양을 만듭니다.

order-card.json

JSON
{
  "root": "order",
  "elements": {
    "order": {
      "type": "Card",
      "props": { "title": "주문 1024" },
      "children": ["status", "track"]
    },
    "status": {
      "type": "Text",
      "props": { "text": { "$state": "/order/status" } },
      "children": []
    },
    "track": {
      "type": "Button",
      "props": { "label": "배송 조회" },
      "on": {
        "press": { "action": "track_order", "params": { "orderId": "1024" } }
      },
      "children": []
    }
  },
  "state": { "order": { "status": "배송 중" } }
}

스펙에는 버전 필드가 없습니다. 프롬프트·검사·렌더러가 모두 같은 앱의 카탈로그 코드에서 나오므로, 계약의 버전은 그 앱의 배포와 json-render 패키지 버전입니다. json-render Specs 문서 ↗는 json-render가 스키마에 묶이지 않는다(schema-agnostic)고 설명합니다. 위 root·elements 구조는 React 렌더러가 쓰는 스키마입니다.


같은 카드: A2UI 메시지

A2UI ↗는 에이전트가 UI를 선언형 JSON 메시지로 보내고 클라이언트가 자기 카탈로그로 그리는 공개 프로토콜입니다. Google이 2025년 12월 Google Developers Blog ↗에서 공개했습니다. 현재 production 릴리스는 v0.9.1이고 v1.0 명세는 릴리스 후보입니다.

A2UI에서는 클라이언트가 먼저 자신이 그릴 수 있는 카탈로그를 알립니다. 이 정보는 A2UI 메시지가 아니라 A2A 같은 전송 계층의 메타데이터에 실립니다.

a2uiClientCapabilities(클라이언트 → 에이전트 메타데이터)

JSON
{
  "v0.9": {
    "supportedCatalogIds": [
      "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"
    ]
  }
}

에이전트는 이 카탈로그의 컴포넌트로 메시지 세 개를 보냅니다. 아래는 읽기 쉽게 배열로 묶었습니다. 전송할 때는 JSONL의 한 줄이나 SSE 이벤트 하나에 메시지 하나씩 담아 순서대로 보냅니다.

에이전트 → 클라이언트 메시지

JSON
[
  {
    "version": "v0.9",
    "createSurface": {
      "surfaceId": "order-1024",
      "catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"
    }
  },
  {
    "version": "v0.9",
    "updateComponents": {
      "surfaceId": "order-1024",
      "components": [
        { "id": "root", "component": "Card", "child": "body" },
        {
          "id": "body",
          "component": "Column",
          "children": ["title", "status", "track"]
        },
        {
          "id": "title",
          "component": "Text",
          "text": "주문 1024",
          "variant": "h3"
        },
        {
          "id": "status",
          "component": "Text",
          "text": { "path": "/order/status" }
        },
        {
          "id": "track",
          "component": "Button",
          "child": "track-label",
          "action": {
            "event": { "name": "track_order", "context": { "orderId": "1024" } }
          }
        },
        { "id": "track-label", "component": "Text", "text": "배송 조회" }
      ]
    }
  },
  {
    "version": "v0.9",
    "updateDataModel": {
      "surfaceId": "order-1024",
      "path": "/order",
      "value": { "status": "배송 중" }
    }
  }
]

클라이언트 → 에이전트 메시지

JSON
{
  "version": "v0.9",
  "action": {
    "name": "track_order",
    "surfaceId": "order-1024",
    "sourceComponentId": "track",
    "timestamp": "2026-10-24T13:00:00Z",
    "context": { "orderId": "1024" }
  }
}

계약이 어디에 있는지 나란히 보면

계약json-render 0.21.3A2UI v0.9
컴포넌트 목록앱의 Zod 카탈로그(TypeScript)JSON Schema로 쓴 카탈로그, catalogId로 식별
목록을 알리는 방법같은 카탈로그에서 만든 시스템 프롬프트클라이언트가 supportedCatalogIds를 메타데이터로 보냄
트리root 키와 elements 맵, children평면 목록과 id: "root", child·children
데이터스펙 안의 state와 {"$state": 경로}updateDataModel 메시지와 {"path": 경로}
액션카탈로그에 선언한 이름, 같은 앱의 핸들러가 실행action 메시지로 에이전트에 전달, 이름은 에이전트가 정함
버전스펙에 없음, 앱이 패키지 버전을 고정메시지마다 version, surface마다 catalogId
전송앱의 API 라우트가 JSON Patch 줄을 스트리밍A2A·AG-UI·MCP·SSE 등, 메시지 순서 보장 필요

두 방식의 차이는 계약이 누구 사이에 있느냐에서 나옵니다.

json-render의 계약은 한 앱 안에 있습니다. 모델에게 주는 프롬프트, 출력 검사, 렌더러, 액션 핸들러가 같은 카탈로그 코드에서 나옵니다. 카탈로그를 고치면 같은 배포에서 네 곳이 함께 바뀝니다. 그래서 스펙에 버전이나 카탈로그 ID를 실을 필요가 없습니다. 단, Specs 문서 ↗는 스펙을 데이터베이스에 저장해 둘 수도 있다고 설명합니다. 저장한 스펙을 나중에 다시 그리는 앱이라면 저장 당시의 카탈로그가 지금과 다를 수 있으므로, 스펙과 함께 카탈로그 버전을 기록해 둡니다.

A2UI의 계약은 따로 배포되는 에이전트와 클라이언트 사이에 있습니다. 클라이언트는 지원하는 카탈로그를 알리고, 에이전트는 메시지마다 프로토콜 버전을 적고, surface마다 카탈로그를 고정합니다. 프로토콜 문서 ↗는 전송 계층이 메시지 순서를 지켜야 한다고 정합니다. surface를 만들기 전에 업데이트가 도착하면 화면 상태가 깨지기 때문입니다.

액션 이름의 위치도 다릅니다. json-render에서는 클라이언트 앱이 액션을 실행하므로 카탈로그에 이름과 파라미터를 선언합니다. A2UI에서 클라이언트는 이벤트를 에이전트에 전달하기만 합니다. 그래서 카탈로그는 이벤트 이름을 정하지 않고, 이름을 해석하는 일은 에이전트 몫입니다.


계약을 어긴 출력은 어디서 걸러지나

위 카드를 한 곳씩 틀리게 바꾸면 두 방식의 검사 도구가 아래 표처럼 판정합니다. json-render는 @json-render/core의 두 함수를 씁니다. validateSpec은 루트와 자식 참조 같은 구조를, catalog.validate는 요소의 type이 카탈로그에 있는지를 검사합니다. A2UI는 공식 JSON Schema(기본 카탈로그 포함)와 Python SDK a2ui-core의 엄격 검증을 씁니다.

틀린 출력json-render: validateSpec / catalog.validateA2UI: JSON Schema / a2ui-core 엄격 검증
카탈로그에 없는 컴포넌트(Badge)통과 / 실패실패 / 실패(Unrecognized component type)
없는 자식을 참조(eta)실패(missing_child) / 통과통과 / 실패(Dangling reference)
루트 요소가 없음실패(root_not_found) / 통과통과 / 실패(Missing root component)
제목에 문자열 대신 숫자통과 / 통과실패 / 실패
제목 속성을 뺌통과 / 통과실패 / 실패('text' is a required property)
정하지 않은 액션 이름(cancel_order)통과 / 통과통과 / 통과

A2UI의 JSON Schema는 카탈로그의 컴포넌트마다 속성 스키마가 있어 속성 타입과 필수 속성을 검사합니다. 컴포넌트 사이의 ID 참조는 JSON Schema로 검사하지 못하므로 SDK의 무결성 검사가 맡습니다. json-render의 두 함수는 구조와 컴포넌트 이름까지만 봅니다. 카탈로그에 컴포넌트가 둘 이상이면 catalog.validate는 props를 컴포넌트별 Zod 스키마로 검사하지 않습니다. 속성 값과 액션 파라미터는 레지스트리의 컴포넌트와 핸들러에서 다시 검사합니다(카탈로그 밖 출력의 처리).

정하지 않은 액션 이름은 두 방식 모두 검사 단계에서 통과합니다. json-render에서는 렌더러가 실행할 핸들러를 찾지 못하고 콘솔에 경고만 남깁니다. A2UI에서는 클라이언트가 이름을 그대로 에이전트에 보내므로 에이전트가 모르는 이름을 처리해야 합니다.

검사 코드는 다음과 같습니다.

validate-spec.ts(json-render)

TypeScript
import { validateSpec, type Spec } from "@json-render/core";
import { catalog } from "./catalog";

export function checkSpec(spec: Spec) {
  const structure = validateSpec(spec); // root·children 구조
  const types = catalog.validate(spec); // type이 카탈로그에 있는지
  return { structure, types };
}

check_a2ui.py(A2UI)

python
from a2ui.core.basic_catalog import BasicCatalog
from a2ui.core.processing.message_processor import (
    MessageProcessor,
    MessageProcessorOptions,
)
from a2ui.core.validation import STRICT_VALIDATION


def check_messages(messages: list[dict]) -> None:
    processor = MessageProcessor(
        [BasicCatalog("0.9")],
        options=MessageProcessorOptions(validation_config=STRICT_VALIDATION),
    )
    # 위반이 있으면 A2uiValidationError 또는 A2uiIntegrityError가 발생합니다.
    processor.process_messages(messages)

JSON Schema 검사는 A2UI 저장소의 스키마로 합니다. 저장소의 v0.9.1 테스트 실행기 ↗는 기본 카탈로그를 catalog.json으로 연결하고 draft 2020-12 모드의 ajv로 메시지를 검사합니다.

검사에 실패한 출력은 모델에게 되돌려 고치게 할 수 있습니다. A2UI 문서는 이때 쓰는 VALIDATION_FAILED 오류 형식(surfaceId, path, message)을 정합니다. json-render는 formatSpecIssues로 구조 검사 결과를 수정 요청용 문장으로 바꾸고, autoFixSpec으로 일부 문제를 자동으로 고칩니다.


SDUI 관점에서 고르는 기준

화면 데이터를 만드는 쪽과 그리는 쪽이 같은 배포 단위인지부터 확인합니다.

카탈로그는 두 방식 모두 디자인 시스템을 반영합니다. A2UI 문서는 기본 카탈로그가 시작용이고 대부분의 프로덕션 앱은 자기 디자인 시스템에 맞는 카탈로그를 정의한다고 설명합니다. json-render에서는 카탈로그의 description이 모델이 컴포넌트를 고르는 근거가 됩니다.


다음에 확인할 것


참고 자료

2026년 10월 24일 수정: 같은 주문 카드를 json-render 0.21.3 스펙과 A2UI v0.9 메시지로 적어 계약의 위치와 검증 범위를 비교하도록 다시 썼습니다. 이전 글은 두 형식의 예 없이 개념을 소개했고, 출처 없는 전망이 글의 3분의 1을 차지했습니다.


Share this post on:

Previous Post
DTCG 토큰 파일과 DESIGN.md: 같은 색·간격·타이포를 두 형식으로 맞추는 법
Next Post
WCAG 평가 방법론 WCAG-EM 2.0 공개, AI 고지에도 접근성을 요구한 EU 가이드라인 — A11Y 주간 다이제스트 2026.07.20 ~ 07.26