텍스트 기반 인터페이스를 가짐. • 사용자: "내일 저녁 7시에 2명 예약해 줘" • 에이전트 : "어느 날짜로 예약해 드릴까요?" → "몇 시인가요?" → "몇 분이신가요?" • 핵심 문제: 구조화된 선택, 다중 필터, 수치 조절이 필요할 때 텍스트 채팅방식은 한계가 있음. 9
는.. LLM + Instructions + Tools + Memory (State) + Planning/Runtime Loop • 에이전트 의존성 에이전트는 클라우드 서버, 타사 플랫폼, 혹은 분리된 에이전트 런타임에서 동작. • UI 스레드 직접 제어 불가 에이전트는 앱의 레이아웃 트리나 상태에 직접 접근하기 어려움. • 네트워크 메시지 REST API나 스트림, 소켓 등을 통해 텍스트와 데이터만 주고받을 수 있음. 10
오버헤드: 작은 카드 하나를 그리기 위해 WebView 엔진과 별도 프로세스 메모리를 점유 • 시각적 이질감: 스크롤 물리 엔진, 폰트 렌더링, 터치 피드백이 네이티브 앱과 차이가 발생 • 보안 (XSS & Injection): 원격에서 생성된 JavaScript를 실행할 경우 스크립트 주입 등 보안 위험 증가 • 디자인 시스템: 앱의 Material 3 다이나믹 컬러, 다크 모드, 사내 디자인 토큰과 자동으로 연동되지 않음 MCP Apps: https://modelcontextprotocol.io/extensions/apps/overview 12
UI 라이브러리가 아니라 메시지 포맷과 통신 규칙(Protocol) • A2UI (Agent to User Interface) AI 에이전트 환경을 위해 설계된 오픈 선언형 UI 프로토콜 (Apache 2.0) • JSON 기반 전송 에이전트가 대화 맥락에 맞는 인터랙티브 UI 구조와 데이터를 스트리밍 • 네이티브 렌더링 & 코드 미실행 안드로이드 앱이 원격 코드를 실행하지 않고 Compose 컴포넌트로 렌더링 13
expressive like code." • Safe like data 브라우저나 앱에서 임의의 실행 코드를 돌리는 것이 아닌 정형화된 데이터 규격으로 전달 • Expressive like code 단순한 텍스트/표 출력을 넘어, 프론트엔드 코드를 작성하듯 컴포넌트들을 자유롭게 조합, 배치하고 인터랙션을 정의할 수 있는 풍부한 표현력(Expressive) • LLM에 적합한 구조화 출력: 임의의 실행 코드가 아닌 스키마 검증이 가능한 JSON(JSONL) 메시지 스트림 생성 • 전송 계층 비의존성 A2A, AG-UI, MCP, SSE, WebSocket 등 다양한 전송 계층 위에서 동일하게 동작 • 클라이언트 주도 제어 UI의 구조는 에이전트가 제안하고, 보안 검증과 스타일링은 앱에서 통제 14
실행 불가 (No Code Execution): 스크립트나 eval이 없는 순수 JSON 데이터만 파싱함 2. 컴포넌트 화이트리스트 (Component Allowlist): 카탈로그에 등록되지 않은 컴포넌트 이름은 파서·검증 단계에서 거부됨 3. 계층 배치 제약 (Composition Constraints): allowedParents / allowedChildren 규칙으로 허용되지 않은 중첩을 차단함 4. 사용자 직접 클릭 필수화 & URL 스킴 제한: openUrl은 사용자의 실제 클릭 제스처(requiresUserActivation)와 허용 스킴(http·https·mailto·tel)만 통과시킴 15
프로토콜 분리, 메시지 순서 보장, JSONL 줄바꿈 단위 프레이밍, 양방향 이벤트 채널 • AG-UI & A2A (Agent-to-Agent) 실시간 프론트엔드 통합 및 멀티 에이전트 간 UI 위임을 위한 표준 바인딩 • MCP (Model Context Protocol) 툴 호출 결과나 리소스 구독을 통해 동적 A2UI 화면을 스트리밍 • 표준 웹 프로토콜 SSE(Server-Sent Events), WebSocket, 일반 HTTP Chunked 스트리밍과 호환 16
아님 Compose나 Flutter를 대체하는 것이 아니라 그 위에서 그려지는 통신 규격임 • 정적 웹을 위한 HTML 대체재가 아님 고정된 웹페이지가 아닌 실시간 대화형 에이전트 워크플로우에 특화되어 있음 • 서버 주도 (Server Driven) 스타일링 시스템이 아님 에이전트가 임의의 헥스 색상(#FF0000)이나 픽셀 패딩을 강제할 수 없음 • 웹 전용 기술이 아님 Android(Jetpack Compose), iOS(SwiftUI), Flutter, Web에서 각각 네이티브로 동작함 17
not use A2UI for) 적절하지 않은 케이스 정적 웹사이트 · 고정 화면 단순 텍스트 전용 채팅 공식 권장 대안 왜 A2UI 대신 대안을 써야 하는가? HTML / CSS / 일반 문맥에 따라 동적 생성할 필요가 없는 화면에는 Compose 프로토콜이 불필요한 오버헤드 Markdown Sandboxed 앱과 통합되지 않는 독립 원격 위젯 iframe / MCP Apps UI와 에이전트를 한 몸으로 급속 개발 폼 입력·상태 조작 없이 텍스트 질의응답만 오가는 챗봇은 마크다운으로 충분 앱 테마·상태 공유 없이 원격 서버가 자체 HTML/JS UI를 직접 제어해야 할 때 AG-UI / 신뢰 경계·크로스플랫폼 분리 없이 단일 앱과 CopilotKit 에이전트를 강결합해 빠르게 묶을 때 A2UI가 적합한 시나리오: 에이전트 생성 동적 UI · 멀티 에이전트 환경 · 크로스플랫폼 네이티브 렌더링 · 코드 미실행 보안 · 앱 디자인 일관성 19
UI (SDUI) A2UI (Agent-to-UI) 핵심 목적 스토어 심사 없이 정적 화면·배너 구성 변경 대화 맥락에 맞는 맞춤형 실행 UI 실시간 합성 UI 작성 주체 기획자, 디자이너, 백엔드 개발자, 마케터 (CMS 어드민 사전 등록) AI 에이전트 (LLM) 가 매 턴 동적 생성 데이터 구조 깊게 중첩된 단일 JSON 트리 (일괄 수신) 평탄화된 Adjacency List + JSONL 스트리밍 로컬 인터랙션 제한적 (주로 전시·화면 이동 위주) JSON Pointer 양방향 바인딩 + 로컬 내장 함수 보안·스타일 통제 자사 서버를 신뢰 (서버가 색상·패딩 직접 지정) 클라이언트 제어 (허용 카탈로그만 렌더링, 앱 테마 적용) 발신자 & 규격 사내 독자 JSON 규격, 원격 백엔드 서버 전용 오픈 표준(Apache 2.0) · 원격 + 온디바이스 LLM 20
항목 androidx.compose.remote androidx.a2ui (A2UI) 설계 목적 서버·타 프로세스 UI 원격 재생 (Player) LLM의 맥락 맞춤 실행 UI 합성 전송 포맷 압축 바이너리(.rc) 드로잉 명령어 1차원 플랫 JSONL 스트리밍 생성 주체 서버가 Kotlin(remote-creation) 빌드 LLM이 카탈로그 스키마로 생성 픽셀·보안 서버가 좌표·색상·폰트 픽셀 단위 지시 앱 테마·액션 가드 통제 핵심 용도 프로모션 배너 · 홈 위젯 · Wear 타일 사용자 요청마다 달라지는 멀티턴 UI 21
버전 릴리스 시기 핵심 마일스톤 및 상태 v0.8 2025 Q4 (2025.11) Initial public release 최소 기능 공개 (Minimal support) v0.9 2026 Q2 (2026.01) Feature complete 4+2개 메시지 규격 및 기본 카탈로그 18개 함수 v0.9.1 2026.05 Current stable release 현재 프로덕션 안정화 표준 릴리스 v1.0 2026 Q3 Candidate 다음 후보 스펙 (@path, @call 문법 등) 1.0.0-alpha02 (26.10.29) Jetpack Compose Renderer 정식 출시: Native Compose UI, Material Design 3 · Android 통합 Android Renderer 22
기존 방식의 한계 (One-Way Output) • 기존 챗봇이나 단순 UI 생성은 에이전트 → 사용자 출력으로 끝나는 단방향(One-way) 구조 • A2UI 양방향 루프 (Bidirectional Loop) 1. UI 생성: 에이전트가 대화 맥락에 맞는 선언형 UI 스트리밍 2. 사용자 조작: 버튼 클릭 · 슬라이더 · 폼 입력 수행 3. 이벤트 역전달: 조작 결과(action, error)가 에이전트로 반환 4. 상태·UI 갱신: 에이전트가 추론 후 상태를 갱신해 새 UI 렌더링 14
설계하는 주체 • 명칭의 변경 v0.9 server → v1.0 agent • 핵심 역할 사용자의 자연어 의도를 파악해 가장 적합한 UI 조합을 설계 • Inbound 렌더러의 카탈로그 지원 범위(a2uiClientCapabilities), 사용자 액션(action), 검증 에러(error) • Outbound 대화 문맥에 맞춘 선언형 UI 컴포넌트 스트림(updateComponents)과 상태 데이터 (updateDataModel) 25
• 명칭의 변경 v0.9 client → v1.0 renderer 안드로이드 Jetpack Compose 앱 자체가 바로 렌더러 • 카탈로그 소유자 자신이 화면에 그릴 수 있는 컴포넌트 목록과 내장 함수를 정의하고 보유 • 엄격한 규격검증 에이전트가 보낸 JSON 메시지를 스키마로 검증하고, 허용된 네이티브 컴포저블로만 변환 • 상호작용 사용자의 로컬 입력과 버튼 탭을 수집해 에이전트가 이해할 수 있는 이벤트 메시지로 전송 26
알려줘요 val capabilities = A2uiClientCapabilities( supportedCatalogIds = listOf( // 1. 공식 기본 카탈로그 ID (A2uiBasicCatalogV1.CatalogId) "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json", // 2. 앱 자체 커스텀 카탈로그 ID 예시 (사내 도메인 URI 식별자) "https://droidknights.com/a2ui/catalog/enterprise/v2", ), inlineCatalogs = listOf(customV2Catalog.toInlineCatalog()), // 인라인 스키마 직접 전송 ) • 지원 카탈로그 전달 (A2uiClientCapabilities) UI 요청 시 앱이 렌더링할 수 있는 카탈로그 ID 목록을 에이전트에 함께 전달 • 미지원 컴포넌트 생성 방지 앱에 등록되지 않은 컴포넌트는 에이전트가 처음부터 생성하지 않도록 스키마 범위를 제한 27
• 격리된 최상위 UI 경계: 고유한 surfaceId 아래 독립된 컴포넌트 트리와 전용 DataModel을 캡슐화 • 명확한 생명주기: createSurface로 생성되어 화면(Composition)에 표시되고, deleteSurface 수신 시 상태와 함께 메모리에서 해제됨 • 루트 컨테이너 규칙: 모든 서피스는 "id": "root" 컴포넌트에서 트리 탐색을 시작함 • 데이터 동기화 경계: sendDataModel: true 설정 시 해당 서피스의 데이터 모델 전체를 액션과 함께 에이전트로 전달 28
Catalog • JSON Schema 기반 기술 명세: 에이전트와 렌더러가 사전 합의한 컴포넌트(Components)와 함수(Functions)의 집합 • 보안 화이트리스트이자 프롬프트 스키마: 렌더러에게는 허용 컴포넌트 경계이고, LLM에게는 출력 스키마 명세서 • Basic Catalog 기본 제공: 표준 모바일 UI의 90%를 커버하는 18종 표준 컴포넌트와 14종 내장 함수(+ @index) 탑재 • 도메인 확장성: 기본 카탈로그 위에 사내 디자인 시스템이나 범용 공통 위젯(BidirectionalExchangeInput, CircularClimateDial 등)을 결합
G · B U I LT- I N F U N C T I O N S 로컬에서 즉시 실행되는 14종 내장 함수 네트워크 왕복 없이 기기에서 즉시 계산되는 검증·포맷팅·논리 함수와 @index 컨텍스트 분류 유효성 검증 (5종) 내장 함수 목록 타이핑할 때마다 필수값·정규식·글자수 length · numeric (min/max)·수치 범위를 로컬에서 즉시 검사 · formatCurrency · formatDate · pluralize 논리·액션·컨텍스트 네트워크 왕복 0회, 기기 로컬 즉시 실행 required · email · regex · formatString · formatNumber 포맷팅·다국어 (5종) 핵심 동작 데이터 경로(${/path}) 값 치환 및 로케일 기반 숫자·통화(KRW)·날짜 포맷 변환 and · or · not · openUrl (+ 다중 조건 결합, 안전한 외부 브라우저 실행 @index) (http/https/mailto/tel), 리스트 인덱스 참조 24
완전한 분리 // 1. 고정 문자열 (Literal): 정적 텍스트 직접 전달 { "text": "환전 안내 문구" } // 2. 데이터 경로 연결 (JSON Pointer): DataModel 값 실시간 구독 { "text": { "path": "/exchange/krwAmount" } } // 3. 내장 함수 호출 (Function Call): 통화 문자열로 포맷팅 { "text": { "call": "formatCurrency", "args": { "currency": "KRW" } } } • 3가지 값 바인딩 방식: 컴포넌트 프로퍼티에는 고정 값(리터럴), 데이터 경로(path), 또는 함수 호출(call)을 연결할 수 있음 • 구조 재전송 없는 상태 갱신: UI 트리는 한 번만 보내고 updateDataModel로 값만 바꾸면 바인딩된 위젯만 리컴포지션됨 35
class는 어디로 갔을까요? // 기존 정적 방식: 빌드 타임에 응답 구조를 미리 알아야만 작성 가능 @Serializable data class ExchangeDto(val currency: String, val amount: Long) // A2UI 동적 스키마: 런타임에 JSON 경로(/exchange/amount)로 상태 바인딩 val amountProp = A2uiProperty.dynamicNumber("foreignAmount", required = true) 런타임에 결정되는 필드 구조: 사용자의 질문과 대화 맥락에 따라 화면에 필요한 입력 필드 구성이 매번 달라짐 컴파일 타임 DTO에서 프로퍼티 스키마로: 고정 data class 대신 카탈로그의 A2uiProperty 스키마가 런타임 수신 시점에 타입을 검증함 36
E R STAT E JSON Pointer로 접근하는 단일 반응형 상태 트리 • 동적 JSON 트리와 타입 안전한 프로퍼티 읽기: 서피스의 DataModel은 JSON Pointer(RFC 6901) 경로로 접근하는 반응형 상태 트리임 • 사전 DTO 정의 없는 경로 바인딩: 앱이 빌드 타임에 미리 정의하지 않은 /exchange/jpyAmount나 /exchange/eurAmount 경로도 즉시 읽고 씀 • 정밀한 부분 리컴포지션: 특정 경로(/exchange/jpyAmount)의 값이 바뀌면 그 경로를 bind()로 구독 중인 컴포저블만 다시 그려짐 29
vs functionCall // 1. Agent Event: "/exchange" 하위 데이터를 묶어 에이전트로 전송 "action": { "event": {"name": "submit_exchange", "context": {"payload": {"path": "/exchange"}}} } // 2. Local Function Call: 네트워크 요청 없이 기기 로컬에서 즉시 실행 "action": { "functionCall": {"call": "openUrl", "args": {"url": "https://droidknights.com"}} } • Agent Event (event): 네트워크를 통해 에이전트에게 사용자 행동과 컨텍스트 데이터를 전송하고 새로운 UI 응답을 유도 • Local Function Call (functionCall): 대화 턴이나 네트워크 요청 없이 기기 로컬에 등록된 카탈로그 함수를 즉시 실행 31 38
2 · C R E AT E & U P D AT E C O M P O N E N T S createSurface & updateComponents JSON // 1. createSurface: 고유 surfaceId와 catalogId로 새 캔버스 생성 { "version": "v0.9.1", "createSurface": { "surfaceId": "exchange_surface", "sendDataModel": true, "catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" } } // 2. updateComponents: 대상 서피스에 위젯 추가 또는 덮어쓰기(Upsert) { "version": "v0.9.1", "updateComponents": { "surfaceId": "exchange_surface", "components": [...] } } 점진적 업데이트(Upsert): 전체 화면을 다시 보내지 않고 동일한 id로 기존 컴포넌트를 갱신하거나 새 id를 트리에 추가함 39
4 · D ATA & D E L E T E updateDataModel & deleteSurface JSON // 3. updateDataModel: UI 재전송 없이 특정 경로(path)의 데이터 값만 변경 { "version": "v0.9.1", "updateDataModel": { "surfaceId": "exchange_surface", "path": "/exchange/jpyAmount", "value": 50000 } } // 4. deleteSurface: 작업 완료 시 해당 서피스의 상태와 메모리를 즉시 해제 { "version": "v0.9.1", "deleteSurface": { "surfaceId": "exchange_surface" } } • 선택적 데이터 갱신: UI 트리 재전송 없이 특정 경로(path)의 값만 변경하며, value: null을 보내면 해당 키를 삭제함 • 명확한 메모리 해제: 작업이 끝나면 deleteSurface 한 줄로 서피스 상태를 깔끔하게 정리함 40
R R O R F E E DB A C K 스키마 검증 실패 시 에러 리포팅과 재생성 유도 JSON // 카탈로그에 없는 위젯 수신 시 A2UI 엔진이 자동 생성해 반환하는 에러 메시지 { "version": "v0.9.1", "error": { "code": "VALIDATION_FAILED", "surfaceId": "exchange_surface", "path": "/components/0/component", "message": "Unknown component type: Hallucinated3DChart" } } • 명시적 에러 리포팅: 유효하지 않은 메시지를 조용히 무시하면 에이전트는 실패 원인을 알 수 없음 • 에러 피드백을 통한 재생성 유도: 실패한 프로퍼티 경로(path)와 사유를 반환하면 LLM이 유효한 컴포넌트로 수정해 다시 전송함 42
지원 카탈로그 전달 & 서피스 생성 a2uiClientCapabilities로 지원 카탈로그를 전달하고 createSurface로 서피스 초기화 • 2~3단계 · 컴포넌트 & 데이터 스트리밍 updateComponents와 updateDataModel이 스트리밍으로 도착하며 즉시 렌더링 • 4~6단계 · 로컬 상태 바인딩 · 액션 전송 · 종료 타이핑·슬라이더 조작은 네트워크 통신 없이 로컬에서 즉시 리컴포지션 제출 시 action(또는 error) 전송 → 멀티턴 화면 갱신 후 deleteSurface로 해제 44
1 · 인앱 직접 연동 (온디바이스 AI & Gemini API) 온디바이스 AI (로컬 추론): API 키 없이 로컬 모델 (google/gemma-4-e2b 등)에서 A2UI JSONL을 실시간 스트리밍 생성 클라우드 Gemini API: catalog.toJsonSchemaString() 스키마를 주입해 gemini-3.8-flash에서 JSONL 수신 • 옵션 2 · 백엔드 에이전트 서버 연동 (Server-Based) 역할 분리: LLM 추론과 결제·계좌 Tool은 백엔드 서버에 두고, 안드로이드 앱은 UI 렌더링에 집중 • 공통 핵심: 온디바이스 AI든 클라우드 서버든 안드로이드 앱은 똑같은 JSONL만 받아 화면을 그림 46
Protocol a2ui-model Engine a2ui-engine State compose-runtime UI Schema Renderer Testing compose-ui material3-a2ui compose-ui-testing 모듈 정의 및 핵심 역할 프로토콜 데이터 모델: JSONL 메시지·컴포넌트·데이터 모델 타입 및 14종 내장 함수 스펙 코어 상태·검증 엔진: 메시지 스트림 처리(processInput), JSON Pointer 상태 트리, 스키마 검증 반응형 상태 브리지: 엔진 상태를 Compose SnapshotState로 매핑 및 양방향 바인딩 컴포넌트·카탈로그 인터페이스: A2uiComponent와 표준 18종(TypedContent) 인터페이스 정의 Material 3 렌더러: A2uiSurface 엔트리포인트와 표준 Material 3 위젯 18종 구현 로컬 UI 테스트 도구: LLM 서버 없이 서피스를 테스트하는 A2uiTestController 및 스텁 49
순수 Kotlin 코어 계층 (Compose 의존성 없음) a2ui-model ◀ a2ui-engine (단방향 의존): 프로토콜 파싱, JSON Pointer 상태 트리, 서피스별 메시지 큐, 스키마 검증을 독립 수행 • Jetpack Compose 브리지 & 렌더러 계층 compose-runtime ◀ compose-ui ◀ material3-a2ui, compose-ui-testing • SnapshotState 반응형 바인딩, A2uiBasicCatalogV1 18종 컴포넌트 인터페이스, M3 기본 렌더러 및 로컬 UI 테스트 도구 제공 • 계층 분리 설계: 프로토콜·상태 엔진 계층과 UI 렌더링 계층이 분리되어 있어 테스트와 커스텀 디자인 적용이 용이함 50
생성 KOTLIN // 1. 내장 함수 14종(formatString, openUrl 등) 기본 구현체 생성 val defaultFunctions = A2uiBasicCatalogV1.Functions(context, localeProvider) // 2. Material 3 기본 위젯 18종이 포함된 카탈로그 생성 val basicCatalog = materialA2uiBasicCatalogV1( image = myImage, video = myVideo, audioPlayer = myAudio, functions = defaultFunctions, ) 기본 카탈로그 준비: materialA2uiBasicCatalogV1() 호출 한 번으로 18종 기본 위젯과 14종 내장 함수가 준비됨 52
데이터 읽고 쓰기: bind & bindUpdater KOTLIN // 1. DataModel 경로(/user/name)의 현재 값을 실시간 구독 val userName = properties.bind(nameProperty).orEmpty() // 2. 사용자가 입력창을 수정할 때 DataModel 경로에 새 값을 쓰는 함수 val onNameChange = properties.bindUpdater(nameProperty) OutlinedTextField(value = userName, onValueChange = { onNameChange?.invoke(it) }) • 읽기(bind)와 쓰기(bindUpdater): 커스텀 위젯 안에서 DataModel의 값을 실시간으로 읽고 수정할 때 사용하는 두 가지 핵심 함수임 56
로딩(isReady)과 에러 리포팅 KOTLIN interface CustomCardSchema : A2uiComponent { // 1. 필수 데이터가 도착하기 전까지 자동으로 로딩 스켈레톤 표시 @Composable override fun A2uiComponentScope.isReady(p: A2uiComponentProperties) = p.bind(titleProperty) != null // 2. 잘못된 값 수신 시 실패한 프로퍼티 경로(path)를 에이전트에 리포팅 @Composable override fun A2uiComponentScope.Content(p: A2uiComponentProperties, m: Modifier) { if (!isValid(p)) reportError(A2uiException.A2uiRuntimeException("Invalid")) } } 로딩 스켈레톤과 에러 리포팅: isReady가 false인 동안 ShimmerIndicator를 자동 표시하고, reportError로 잘못된 값을 에이전트에 알림 58
UI 테스트와 프리뷰: TestController & @Preview KOTLIN // 1. [A2uiTestController] LLM 서버 없이 로컬에서 UI 입력 및 상태 변화 검증 val controller = A2uiTestController(catalog, initialData, initialComponents) val surface = controller.start() setContent { A2uiTestSurface(surface) } onNodeWithText("초기값").performTextReplacement("새 입력값") assertEquals("새 입력값", controller.getData<String>("/user/name")) // 2. [rememberA2uiPreviewSurface] Android Studio @Preview에서 즉시 확인 val preview = rememberA2uiPreviewSurface(catalog = catalog, jsonMessages = jsonl) • LLM 없이 빠른 로컬 검증: A2uiTestController로 상태 변화를 단위 테스트하고 rememberA2uiPreviewSurface로 @Preview에서 바로 확인함 59
외화 환전 도메인 요구사항 분석 • 복잡한 7단계 고정 화면 플로우 (Funnel) 7단계 화면 전환: 통화 선택 → 금액, 우대쿠폰 → 수령 지점 → 계좌, 권종 → 약관 동의 → PIN 입력까지 순차 이동 • 실시간 양방향 환율 계산 & 출금 가능 잔액 검증 상태 계산: 외화(50,000 JPY) ↔ 원화(423,940 KRW, 100엔=847.88원) 즉시 역산 및 잔액 초과 검증 • 사용자 요청별 동적 폼 구성과 클라이언트 보안 통제 맥락 맞춤 조합 & ActionInterceptor: 공항 수령, 소액권, 다중 통화 등 요청별 UI 구성과 생체인증, 잔액 차감 통제 61
네비게이션 플로우 KOTLIN // [LegacyCurrencyExchangeScreen.kt] 기존 7단계 고정 네비게이션 퍼널 sealed interface ExchangeLegacyNavKey : NavKey { data object Step1And2Currency : ExchangeLegacyNavKey // 1~2단계: 메뉴·통화 data object Step3EnterAmount : ExchangeLegacyNavKey // 3단계: 금액·쿠폰 data object Step4PickupBranch : ExchangeLegacyNavKey // 4단계: 수령 지점 data object Step5AccountDenom : ExchangeLegacyNavKey // 5단계: 계좌·권종 data object Step6TermsAndPin : ExchangeLegacyNavKey // 6~7단계: 약관·PIN } 고정 퍼널의 구조적 특성: 사용자가 '인천공항 1터미널 5만 엔 환전'처럼 모든 조건을 알고 있어도 1~7단계 화면을 순차 이동해야 함 62
전환 흐름 1~4단계 (메뉴 · 통화 · 금액 · 지점) 순차 진입: 1단계 외환 메뉴 → 2단계 통화(JPY) 선택 → 3단계 50,000엔 및 우대쿠폰 → 4단계 인천공항 제1터미널 선택 • 5~7단계 및 완료 (계좌 · 약관 · PIN) 반복 확인: 5단계 급여통장·소액권 슬라이더 → 6단계 약관 2건 동의 → 7단계 비밀번호 4자리 → 총 7번의 화면 이동 • 중간 조건 변경 시 백스택 이동 역방향 복귀: 6~7단계에서 금액이나 터미널을 바꾸려면 백스택(removeLastOrNull)을 따라 이전 화면으로 돌아가야 함 62
플로우 vs A2UI 단일 서피스 비교 비교 항목 기존 뱅킹 앱 (CurrencyExchangeDem (LegacyCurrencyExchangeS o.kt) creen) 화면 전환 단계 양방향 환율 & 잔액 검증 요청별 동적 구성 제출 후 상태 전환 & 잔액 반영 • A2UI 에이전트 (CurrencyExchangeCatalog) 메뉴 → 통화 → 금액 → 지점 → "인천공항 1터미널 5만 엔 환전해 줘" 요청으로 단일 계좌 → 약관 → PIN (7단계 이동) 서피스 구성 화면별 ViewModel 상태를 BidirectionalExchangeInput: 로컬 즉시 역산 및 수동으로 전달하며 재계산 잔액 초과 실시간 차단 단일 통화·다중 통화·목표환율 사용자 요청에 맞춰 필요한 컴포넌트를 조합하여 예약이 별도 메뉴로 분리 exchange_surface에 렌더링 정적 완료 화면으로 이동 submit_exchange 승인 즉시 계좌 잔액 차감 및 동일 서피스를 QR 바우처로 갱신 핵심 차이점: 7단계 화면 이동을 Pre-fill된 단일 서피스(exchange_surface)로 압축하고, 승인 즉시 통장 잔액 차감과 QR 바우처 전환을 수행함 63
모바일 뱅킹 홈 FAB 진입과 A2UI 서피스 전환 • 실제 뱅킹 홈 (BankingHomeScreen) + BankingAppState 연동 뱅킹 홈 구성: 주거래 통장 잔액(31,520,000원), 간편 송금·카드결제 모달, 우측 하단 🤖 AI 챗봇 플로팅 버튼 질문 vs 실행 분기: 단순 환율·잔액 질문은 빠른 텍스트 답변으로, 환전·송금 요청은 A2UI 서피스로 즉시 렌더링 • 거래 승인 시 앱 상태(BankingAppState) 실시간 반영 잔액 차감 & 한도 보호: 보유 잔액 초과 금액은 입력·생체인증 단계에서 즉시 거절되며, 환전 승인 즉시 뱅킹 홈 잔액에서 실시간 차감됨 64
사전 구현: 커스텀 위젯 작성 및 카탈로그 등록 양방향 계산 위젯 구현 외화 입력 시 로컬에서 원화를 즉시 계산하는 BidirectionalExchangeInput 작성 카탈로그 결합 및 등록 기본 M3 18종에 환전 커스텀 위젯 3종(+ domainComponents)을 합쳐 카탈로그 생성 2. 초기 화면 렌더링과 데이터 경로 연결 초기 JSONL 3줄 수신 : 1. 빈 카드 생성(createSurface) → 2. 초기값 저장(updateDataModel) → 3. 위젯 배치 입력 검증과 버튼 경로 바인딩 : 입력값 형식 검증(checks)과 신청 버튼에 데이터 경로(/exchange/jpyAmount) 연결 3. 생체인증 가로채기 및 단일 서피스 멀티턴 완료 보안 가로채기 및 이벤트 전송 ProvideActionInterceptor로 생체인증을 먼저 수행한 뒤 수정된 10만 엔(847,880원) 전송 같은 카드에 2·3단계 덮어쓰기 같은 exchange_surface 안에서 수령처 선택(2단계) → 모바일 QR 바우처 발급(3단계) 완료 66
위젯 구현: BidirectionalExchangeInput KOTLIN // [BidirectionalExchangeInputComponent.kt] 외화 입력 시 원화를 로컬에서 즉시 계산 @Composable override fun A2uiComponentScope.TypedContent( rate: Double, scale: Double, foreignAmt: Long, krwAmt: Long, ... ) = OutlinedTextField(value = foreignAmt.toString(), isError = krwAmt > balance, onValueChange = { text -> // 서버 통신 없이 원화 환산 후 두 경로 업데이트 val newForeign = text.filter { it.isDigit() }.toLongOrNull() ?: 0L onForeignChange(newForeign) onKrwChange((newForeign / scale * rate).toLong()) } ) 로컬 즉시 계산 및 잔액 검증: 숫자를 바꿀 때마다 서버를 거치지 않고 앱 내부에서 원화를 계산하며, 통장 잔액 초과 시 즉시 에러(isError)를 표시함 67
입력값 유효성 검사(checks)와 버튼 데이터 경로 연결 JSON // 1. 공항 수령용 여권번호 입력창: 9자리 입력 여부를 서버 통신 없이 로컬에서 즉시 검사(checks) { "id": "passport_input", "component": "TextField", "value": { "path": "/exchange/passportNo" }, "checks": [{ "condition": { "call": "length", "args": { "min": 9, "max": 9 } } }] } // 2. [환전 신청] 버튼: 고정 숫자(50000) 대신 클릭 시 읽어올 데이터 경로(path)만 연결 { "id": "btn_submit", "component": "Button", "action": { "event": { "name": "submit_exchange", "context": { "foreignAmount": { "path": "/exchange/jpyAmount" } } } } } 버튼에 데이터 경로(path) 연결: 버튼에 고정 숫자 대신 데이터 경로({"path": "/exchange/jpyAmount"})만 연결해 두므로 금액 수정 시 버튼을 다시 그릴 필요가 없음 69
시 앱이 가로채서 잔액 확인 & 생체인증 KOTLIN // 1. [ProvideActionInterceptor] [환전 신청] 클릭을 가로채 생체인증 모달 먼저 표시 ProvideActionInterceptor(onIntercept = { actionPayload -> if (isSubmitExchange(actionPayload)) { pendingPayload = actionPayload // 이벤트를 보류하고 네이티브 생체인증 호출 true // true 반환 시 에이전트로 바로 전송되는 것을 차단 } else false }) { A2uiComponent(component = childState.component) } 전송 직전 클라이언트 보안 통제: 사용자가 [환전 신청]을 누르면 외부로 전송되기 전에 앱이 가로채서 잔액 확인과 생체인증을 먼저 수행함 70
같은 카드를 다음 단계로 바꾸는 코드 KOTLIN // [CurrencyExchangeDemo.kt] 새 화면을 열지 않고 같은 "exchange_surface"에 덮어쓰기 processor.outboundEvents.collect { msg -> when ((msg as? A2uiClientEventMessage)?.type) { "submit_exchange" -> { // [1단계 -> 2단계] 잔액 차감 후 수령 방법 UI 주입 bankingState.recordCurrencyExchange(currencyCode, foreignAmt, krwAmt, branch) step2Jsonl.forEach { processor.processInput(parser, it) } } "issue_exchange_voucher" -> // [2단계 -> 3단계] 모바일 QR 바우처 완료 UI 주입 step3Jsonl.forEach { processor.processInput(parser, it) } } } 같은 surfaceId 재사용: 에이전트가 같은 surfaceId("exchange_surface")로 새 updateComponents를 보내면 화면 이동 없이 카드 내부 UI만 교체됨 72
없이 하나의 카드 안에서 끝나는 3단계 완성 화면 • 1단계 · 환전 금액 확인 및 신청 (초기 화면) • 양방향 계산 & 잔액 확인: 50,000 엔 ↔ 423,940 원 (100엔=847.88원) 실시간 계산 후 [환전 신청] 클릭 • 2단계 · 통장 잔액 차감 및 수령 방법 선택 (submit_exchange 이후) • 화면 이동 없이 카드 내용만 교체: 승인 즉시 계좌 잔액 차감(31,096,060원) + 수령 방식(모바일 QR / 창구) 선택 • 3단계 · 모바일 QR 바우처 발급 완료 (issue_exchange_voucher 이후) • 최종 바우처 전환: 같은 카드(exchange_surface)가 QR 바우처(QR-EX-2026-8492) 완료 화면으로 업데이트됨 74
기술 선택지 핵심 동작 방식 최적의 도입 시나리오 일반 Jetpack Compose 앱 컴파일 타임에 정적 UI 트리 확정 로그인, 설정, 흐름이 고정된 핵심 비즈니스 화면 androidx.compose 백엔드 JVM이 빌드한 바이너리(.rc) .remote 재생 서버가 픽셀 단위로 제어하는 배너, 홈 위젯, Wear 타일 기존 JSON SDUI 사전 정의된 고정 화면 템플릿 JSON 전달 전 사용자에게 동일한 레이아웃을 내려주는 프로모션, A/B 테스트 Android AppFunctions OS 시스템 에이전트에 백그라운드 함수 앱 UI 진입 없이 시스템 어시스턴트가 단일 기능 호출 노출 androidx.a2ui (1.0.0-alpha02) 카탈로그 스키마 검증 + 반응형 JSONL 사용자 요청마다 UI 조합이 달라지는 멀티턴 에이전트 스트리밍 워크플로우 74
WebView·스크립트 실행 없이 순수 JSONL만 받아 100% 네이티브 Compose로 렌더링 • 1차원 배열 스트리밍과 UI 구조, 데이터 모델의 분리 위젯을 1차원 배열로 받아 즉시 그리고, 구조(updateComponents)와 값(updateDataModel)을 분리 • 기본 카탈로그(18종 위젯)와 손쉬운 커스텀 위젯 확장 material3-a2ui로 바로 시작하고, 커스텀 위젯(bind / bindUpdater)과 사내 디자인(TypedContent) 결합 • 전송 계층 독립성과 온디바이스·클라우드 AI 완벽 호환 SSE·MCP·A2A는 물론 온디바이스 AI(Gemma 4)와 클라우드 LLM 어디서든 동일한 앱 코드로 동작 75
뒤 편집하세요 PowerPoint는 다른 이름으로 저장, Google Slides는 파일 > 사본 만들기입니다. 복제한 파일에서 이 안내 슬라이드는 지우고 쓰면 됩니다. 폰트가 설치돼 있어야 레이아웃이 유지됩니다. 같은 폴더의 fonts.zip을 먼저 설치하세요. 2026.11.02 · ST센터, 서울 77
2 · 커스텀 위젯 0개로 기본 카탈로그 만들기 KOTLIN // 처음 시작할 때는 커스텀 위젯을 하나도 안 만들어도 된다! 공식 18종 M3 기본 카탈로그 생성: val context = LocalContext.current val basicCatalog: A2uiCatalog = materialA2uiBasicCatalogV1( // 15종 위젯(Text, Card, Button, TextField 등)은 기본값이 내장되어 있어 생략 가능! // 서드파티 라이브러리를 강제하지 않는 미디어 3종(image, video, audioPlayer)과 Functions(context)만 연결한다: image = MaterialA2uiBasicCatalogV1Defaults.image { url, desc, mod, scale -> AsyncImage(url, desc, mod, contentScale = scale) }, video = MaterialA2uiBasicCatalogV1Defaults.video { url, mod, _ -> /* 처음엔 비워두어도 OK */ }, audioPlayer = MaterialA2uiBasicCatalogV1Defaults.audioPlayer { url, desc, mod, _ -> /* 처음엔 비워두어도 OK */ }, functions = A2uiBasicCatalogV1.Functions(context), // 14종 내장 함수 + 안전한 openUrl(CATEGORY_BROWSABLE) 자동 연결! ) • 초보자가 가장 많이 묻는 질문: "왜 materialA2uiBasicCatalogV1()에 4개 인자만 필수인가요?" • 이유: AndroidX가 특정 미디어 라이브러리(Coil, ExoPlayer)를 강제하지 않도록 4개 훅만 열어두었으며, A2uiBasicCatalogV1.Functions(context) 한 줄로 14종 내장 함수와 openUrl이 즉시 완성된다! 54
Step 3 · LLM 없이 테스트할 첫 JSONL 3줄 준비 KOTLIN val helloJsonl = listOf( // 처음엔 LLM 서버 없이 로컬 문자열 3줄로 바로 테스트한다! // 1줄째 (createSurface): "hello" 서피스를 생성하고 기본 카탈로그 ID 지정 """{"version":"v0.9.1","createSurface":{"surfaceId":"hello","catalogId":"${A2uiBasicCatalogV1.CatalogId}"}}""", // 2줄째 (updateDataModel): 초기 데이터("/user/name")에 "드로이드나이츠" 저장 """{"version":"v0.9.1","updateDataModel":{"surfaceId":"hello","path":"/user","value":{"name":"드로이드나이츠"}}}""", // 3줄째 (updateComponents): root(Column) 아래에 이름 입력창(TextField)과 인사하기 버튼(Button) 배치 """{"version":"v0.9.1","updateComponents":{"surfaceId":"hello","components":[ {"id":"root","component":"Column","children":["tf","btn"]}, {"id":"tf","component":"TextField","label":"이름","value":{"path":"/user/name"}}, {"id":"btn","component":"Button","child":"txt","action":{"event":{"name":"greet"}}} ]}}""") • 항상 이 3줄 순서: ① createSurface(캔버스 생성) → ② updateDataModel(초기 상태값 적재) → ③ updateComponents("id":"root"부터 위젯 조립) 55
Step 4 · Compose 화면에 첫 A2uiSurface 띄우기 KOTLIN @Composable fun HelloA2uiScreen(basicCatalog: A2uiCatalog, helloJsonl: List<String>) { // 1. JSON 파서와 메시지 프로세서를 remember로 생성하고 활성 서피스 목록을 구독한다 val parser = remember { A2uiMessageParser() } val processor = remember { A2uiMessageProcessor(catalogs = listOf(basicCatalog)) } val surfaces by processor.activeSurfaces.collectAsState() LaunchedEffect(processor) { // 2. 백그라운드 수신 루프를 켜고 JSONL 3줄을 순서대로 밀어 넣는다! launch { processor.collectMessages() } helloJsonl.forEach { line -> processor.processInput(parser, line) } } surfaces.forEach { s -> key(s.id) { A2uiSurface(surfaceModel = s) } } // 3. key(s.id)로 상태 보존 렌더링! } • 실행 결과: 화면에 네이티브 Material 3 입력창과 버튼이 뜨고, 사용자가 이름을 타이핑하면 로컬 /user/name 상태가 실시간으로 바뀐다! 56
FLOW 기초 입문 Step 6 · 에이전트(LLM + Tool)와 앱 통신 구조 • androidx.a2ui는 네트워크 계층을 내장하지 않는다 (100% 전송 계층 독립) • 앱이 연결할 스트림은 단 2개: 입력 processor.processInput(parser, jsonLine) 과 출력 processor.outboundEvents 뿐이다! • 에이전트(Gemini + Function Calling Tool)와 안드로이드 앱의 4단계 통신 루프 1. 프롬프트 & 지원 카탈로그 전달 (App → Agent): 사용자 입력(또는 A2uiClientEventMessage)과 A2uiClientCapabilities 전송 2. Tool 실행 (Agent ↔ Tools): LLM이 등록된 비즈니스 Tool(환율 조회·계좌 확인·DB 처리)을 먼저 실행해 데이터 확보 3. JSONL 스트리밍 (Agent → App): Tool 결과를 바탕으로 createSurface → updateDataModel → updateComponents 한 줄씩 생성 4. 줄바꿈(\n) 버퍼링 & 렌더링: 스트리밍 토큰을 \n 단위로 모아 완성된 줄마다 processor.processInput(parser, line) 호출! 58