아티클 목록으로 가기

AI 에이전트는 한 번도 본 적 없는 안드로이드 UI를 어떻게 만들어 내는가: a2ui 첫걸음

skydovesJaewoong Eum (skydoves)||13분 소요

AI 에이전트는 한 번도 본 적 없는 안드로이드 UI를 어떻게 만들어 내는가: a2ui 첫걸음

이제 AI 에이전트는 작업을 추론하고, 도구를 호출하고, 대화를 이어 갈 수 있습니다. 여전히 잘 해내지 못하는 것은 네이티브 화면을 보여 주는 일입니다. 항공권을 예약하는 에이전트는 선택지를 텍스트로 설명할 수는 있지만, 여러분의 안드로이드 앱에 진짜 날짜 선택기와 좌석 배치도, 확인 버튼을 건네주지는 못합니다. 그 앱에 어떤 컴포넌트가 있는지 전혀 알지 못하기 때문입니다. 안드로이드 팀이 아직 만들고 있는 새 AndroidX 모듈인 a2ui(Agent to User Interface)는 바로 그 간극을 메우려는 시도입니다. a2ui는 서버에서 돌아가는 에이전트가 한 번도 본 적 없는 클라이언트 위에 네이티브 안드로이드 UI를 만들고 갱신할 수 있게 해 주는 프로토콜과 데이터 계층을 정의합니다. 그 클라이언트가 제공하는 컴포넌트들의, 기계가 읽을 수 있는 설명을 읽어 냄으로써 말이지요.

더 들어가기 전에 한 가지는 분명히 짚어야 합니다. a2ui는 출시된 라이브러리가 아니며, 안정적인 API도 아닙니다. 심지어 공개된 실험적 API조차 아닙니다. 안드로이드 팀이 공개적으로 개발하고 있는 진행 중인 작업이며, 상당 부분이 아직 작성되는 중입니다. Compose 렌더러는 플레이스홀더이고, 메시지 오케스트레이션 계층은 아직 존재하지도 않습니다. 여기 나오는 그 무엇도 오늘 당장 의존할 수 있는 아티팩트로 제공되지 않으며, 코드는 계속 바뀔 것입니다. 아래의 모든 내용은 그 개발 중인 소스에서 읽어 낸 것인데, 바로 그 점이 지금 배워 둘 가치를 만들어 줍니다. 프로토콜과 데이터 모델은 이미 자리를 잡았으므로, 구현이 굳어지기 전에 이 시스템의 개념을 먼저 볼 수 있습니다.

이 글에서는 에이전트 주도 UI(agent-driven UI) 뒤에 자리한 발상, a2ui가 컴포넌트로 하여금 JSON Schema로 에이전트에게 자신을 설명하게 하는 방법, 서피스(surface) 프로토콜이 서버에서 클라이언트로 UI와 데이터를 스트리밍하는 방법, 데이터 바인딩이 그 둘을 잇는 방법, 스키마 검증기가 경계를 지키는 방법, 그리고 지금 구현된 것과 아직 계획 단계인 것이 정확히 무엇인지까지 깊이 있게 파고듭니다.

근본적인 문제: 추론은 하지만 그리지는 못하는 에이전트

에이전트를 실제 작업 앞에 세워 두면 간극이 곧바로 드러납니다. 에이전트는 사용자에게 무엇이 필요한지(필드 세 개짜리 폼, 결과 목록, 확인 대화상자)는 알아낼 수 있지만, 그것을 여러분의 앱에 네이티브 UI로 놓을 방법이 없습니다. 뻔한 우회책이 두 가지 있는데 둘 다 실패합니다. 에이전트가 텍스트나 마크다운을 돌려줄 수 있지만, 이러면 앱이 이미 갖춘 네이티브 컨트롤과 제스처를 모조리 내다 버리게 됩니다. 아니면 에이전트가 언젠가 필요로 할 법한 화면을 전부 미리 정의해 두고 이름으로 하나씩 고르게 할 수도 있지만, 이러면 유연한 에이전트를 두는 의미 자체가 사라집니다.

빠져 있는 것은 공유된 계약입니다. 에이전트에게는, 특정 클라이언트가 제공하는 UI 구성 요소에 대한 기계 판독 가능한 설명이 필요합니다. 앱과 에이전트를 함께 빌드하지 않고도 에이전트가 알맞은 것을 고르고 올바르게 채워 넣을 수 있을 만큼 충분히 상세해야 합니다. 그 설명은 언어 모델이 읽고 추론할 수 있는 것인 동시에, 클라이언트가 검증의 기준으로 삼을 수 있는 것이어야 합니다. 이 두 요구에 대한 a2ui의 답이 JSON Schema이며, 가장 먼저 이해할 것은 컴포넌트가 어떻게 스스로를 JSON Schema로 바꿔 내는가입니다.

에이전트에게 스스로를 설명하는 컴포넌트

a2ui에서 컴포넌트는 에이전트가 미리 알고 있어야 하는 고정된 위젯이 아닙니다. 스스로를 알리는 정의입니다. 엔진은 이를 작은 인터페이스로 모델링합니다.

public interface A2uiCoreComponentDefinition {
    /** AI 에이전트에게 제공될 컴포넌트 이름입니다. */
    public val name: String
    /** AI 에이전트가 언제 사용할지 알 수 있도록 컴포넌트의 동작을 설명합니다. */
    public val description: String
    public val propertySchema: A2uiSchema
}

여기서는 타입만큼이나 주석이 중요합니다. namedescription은 컴파일러가 아니라 언어 모델이 읽으라고 쓴 것이고, propertySchema는 이 컴포넌트가 받아들이는 프로퍼티의 정확한 형태를 설명합니다. 카탈로그는 이런 정의들을, 호출 가능한 함수 집합과 선택적인 테마 스키마와 함께 모아 둡니다.

public interface A2uiCoreCatalog {
    public val id: String
    public val components: List<A2uiCoreComponentDefinition>
    public val functions: List<A2uiFunction>
    public val themeSchema: A2uiSchema?
    public fun getComponent(name: String): A2uiCoreComponentDefinition?
    public fun getFunction(name: String): A2uiFunction?
}

함수도 같은 방식으로 에이전트에게 설명됩니다. 함수 정의는 이름, 동작에 대한 자연어 설명, 인자 스키마, 그리고 에이전트가 이해하는 JSON 호환 타입에서 가져온 반환 타입을 담습니다.

public interface A2uiFunctionDefinition {
    /** AI 에이전트에게 제공될 함수 이름입니다. */
    public val name: String
    /** AI 에이전트가 언제 사용할지 알 수 있도록 함수의 동작을 설명합니다. */
    public val description: String
    public val argumentSchema: A2uiSchema
    public val returnType: A2uiFunctionReturnType
}

이것이 a2ui의 중심에 놓인 설계 결정입니다. 카탈로그는 클라이언트의 능력을, 오롯이 언어 모델을 겨냥해 서술한 설명입니다. 에이전트는 여러분의 앱에 맞춰 컴파일될 필요가 없습니다. 이름과 설명, 스키마를 읽고 어떤 컴포넌트와 함수가 작업에 맞는지 판단한 뒤, 그에 부합하는 UI를 내놓습니다. 무엇이 존재하는지는 클라이언트가 계속 쥐고 있고, 무엇을 보여 줄지는 에이전트가 계속 쥐고 있습니다. 모든 것이 스키마의 정밀함에 달려 있으니, 다음 계층이 바로 그 스키마입니다.

공용어로서의 JSON Schema

a2ui는 JSON Schema를 코틀린으로 직접 모델링합니다. A2uiSchema는 오로지 바깥으로 JSON Schema 문서로 직렬화되는 것만을 소임으로 삼는 sealed 타입입니다.

public sealed class A2uiSchema {
    public abstract val description: String?
    public fun toJsonSchema(): String = toJsonElement().toString()
    internal abstract fun toJsonElement(): JsonElement
}

방향에 주목하세요. 스키마 문자열을 만들어 내는 toJsonSchema()는 있지만, 그것을 도로 읽어 들이는 짝이 되는 파서는 없습니다. 이는 목적에 들어맞습니다. 스키마는 왕복(round-trip)하기 위해서가 아니라, 에이전트에게 알리고 들어오는 페이로드를 검증하기 위해 존재하기 때문입니다. 각 리프(leaf)는 JSON Schema 구성 요소 하나를 모델링합니다. 문자열 스키마는 {"type":"string"}을 내보내고, 객체 스키마는 프로퍼티와 필수 키, 추가 프로퍼티 규칙을 내보냅니다.

public class A2uiObjectSchema(
    public val properties: Map<String, A2uiSchema> = emptyMap(),
    public val required: Set<String> = emptySet(),
    public val isAdditionalPropertiesAllowed: Boolean = true,
    public val additionalPropertiesSchema: A2uiSchema? = null,
    public override val description: String? = null,
) : A2uiSchema() {
    init { require(required.all { properties.containsKey(it) }) }
    // toJsonElement가 type, properties, required, additionalProperties, description을 씁니다
}

전체 집합은 예상할 법한 구성 요소를 아우릅니다. A2uiStringSchema, A2uiNumberSchema, A2uiBooleanSchema, A2uiArraySchema, A2uiObjectSchema, A2uiEnumSchema, A2uiConstSchema, A2uiRefSchema, 그리고 콤비네이터(combinator)인 A2uiAnyOfSchema, A2uiOneOfSchema, A2uiAllOfSchema가 있습니다. 엄격한 검증을 일부러 건너뛰는 비정형 페이로드를 위해 빈 스키마 {}를 내보내는 A2uiAnySchema도 있고, 인라인 대신 $defs 섹션에 $ref로 직렬화되는, 이름 붙은 재사용 가능한 스키마를 위한 A2uiCompositeSchema 베이스도 있습니다.

이 기본 요소들 위에서 a2ui는 commontypes 패키지에 자신만의 어휘를 쌓아 올리며, UI에 특화된 개념들이 사는 곳이 바로 여기입니다. 데이터 바인딩은 데이터 모델을 가리키는 { "path": string } 형태의 스키마입니다. 자식 목록(child list)은 컴포넌트 id의 정적 배열이거나, 동적 목록을 만들어 내려고 컴포넌트를 데이터 모델 배열에 묶는 템플릿 객체, 이 둘 중 하나를 고르는 oneOf입니다. 동적 값인 A2uiDynamicValueSchema 역시 리터럴, 데이터 바인딩, 함수 호출 가운데 하나를 고르는 oneOf로 표현됩니다. 액션은 서버 이벤트나 클라이언트 함수 호출 중 하나를 고르는 oneOf입니다. 그래서 스키마 계층은 컴포넌트를 설명하는 방식일 뿐 아니라, a2ui 인터페이스의 전체 구조, 곧 그 바인딩과 목록, 상호작용을 에이전트가 만들어 낼 수 있고 클라이언트가 확인할 수 있는 언어로 표현하는 방식이기도 합니다.

서피스 프로토콜: 서버가 클라이언트를 움직이는 방식

어휘가 갖춰지고 나면, 런타임 프로토콜은 단순합니다. UI의 단위는 surfaceId로 식별되는 서피스이고, 서버는 메시지를 보내 이를 움직입니다. 서버에서 클라이언트로 향하는 쪽은 sealed 인터페이스입니다. 네 메시지 중 둘은 서피스를 만들고 그 안을 채웁니다.

public sealed interface A2uiServerToClientMessage {
    public val surfaceId: String
}

public class A2uiCreateSurfaceMessage(
    override val surfaceId: String,
    public val catalogId: String,
    public val theme: Map<String, Any?> = emptyMap(),
    public val shouldSendDataModel: Boolean = false,
) : A2uiServerToClientMessage

public class A2uiUpdateComponentsMessage(
    override val surfaceId: String,
    public val components: List<A2uiComponentPayload>,
) : A2uiServerToClientMessage

나머지 둘은 서피스의 데이터 모델에 값을 쓰고, 서피스를 허뭅니다.

public class A2uiUpdateDataModelMessage(
    override val surfaceId: String,
    public val path: String = "/",
    public val value: Any? = null,
) : A2uiServerToClientMessage

public class A2uiDeleteSurfaceMessage(override val surfaceId: String) : A2uiServerToClientMessage

이 네 메시지는 서피스 생명주기에 대응합니다. A2uiCreateSurfaceMessage는 카탈로그에 묶인 서피스를 엽니다. A2uiUpdateComponentsMessage는 컴포넌트를 삽입하거나 교체합니다. A2uiUpdateDataModelMessage는 데이터 모델의 특정 경로에 값을 쓰는데, 값이 null이면 삭제를 뜻합니다. A2uiDeleteSurfaceMessage는 서피스를 허뭅니다. 전송되는 각 컴포넌트는 일부러 최소한으로 꾸며져 있습니다.

public class A2uiComponentPayload(
    public val id: String,
    public val type: String,
    public val properties: Map<String, Any?>,
)

id 하나, "button"이나 "text" 같은 type 하나, 그리고 타입 없는 properties 맵이 전부입니다. 자식이나 바인딩을 위한 별도 필드는 없습니다. 그것들은 properties 안에 들어 있고, 앞 절에서 본 스키마 계층이 의미를 부여하기 때문입니다. 클라이언트는 자신만의 sealed 집합으로 응답합니다.

public sealed interface A2uiClientToServerMessage

public class A2uiUserAction(
    public val type: String,          // "click", "swipe", ...
    public val surfaceId: String,
    public val componentId: String,
    public val timestamp: Long,
    public val context: Map<String, Any?> = emptyMap(),
) : A2uiClientToServerMessage

public class A2uiClientError(
    public val code: String,          // "VALIDATION_FAILED", "RUNTIME_ERROR"
    public val surfaceId: String,
    public val message: String,
    public val context: Map<String, Any?> = emptyMap(),
) : A2uiClientToServerMessage

에러 경로는 에이전트가 읽으라고 설계되었습니다. 소스 KDoc에 따르면 codemessage는, 원격 에이전트가 실패의 범주를 알아보고 진단할 수 있도록 되돌려 보내집니다. 잘못된 페이로드를 내보낸 에이전트는 조용한 실패 대신, 문제가 된 경로를 컨텍스트에 담은 구조화된 VALIDATION_FAILED를 돌려받습니다. 프로토콜 전체는, UI를 제안하는 에이전트와 그것을 렌더링하고 검증하며 사용자가 무엇을 했는지 보고하는 클라이언트 사이의 순환입니다.

데이터 바인딩: 스트리밍되는 데이터 모델에 반응하는 컴포넌트

프로토콜이 컴포넌트와 데이터를 분리하는 이유는 성능과 명료함에 있습니다. 서버는 컴포넌트 템플릿을 한 번 보내고, 그다음 데이터를 델타(변경분)로 스트리밍합니다. 컴포넌트 프로퍼티가 리터럴 값을 담아야만 하는 것은 아닙니다. 서버가 독립적으로 갱신하는 데이터 모델로의 바인딩을 담을 수도 있습니다. 그 모델을 가리키는 경로는 JSON Pointer 문자열이며, 런타임에는 A2uiDataPath가 이를 감쌉니다.

public class A2uiDataPath(public val path: String) {
    public val normalizedPath: String = normalize(path)
    public val segments: List<String> = parse(normalizedPath)
    public val isAbsolute: Boolean
        get() = path.isEmpty() || path.startsWith("/")
}

이 클래스는 문서화된 몇 가지 편의를 곁들여 RFC 6901을 따릅니다. 그래서 """/"는 둘 다 루트를 뜻하고, 끝에 붙은 슬래시 하나는 잘려 나가는데, 이는 다른 a2ui 구현들이 기대하는 동작에 맞추기 위해서입니다. 서버가 /settings/volume에 대해 A2uiUpdateDataModelMessage를 보내면, 그 경로에 묶인 컴포넌트 프로퍼티는 서버가 컴포넌트를 다시 보내지 않아도 갱신됩니다. 바로 이것을 A2uiDynamicValueSchema가 담아 냅니다. 프로퍼티는 리터럴이거나, 데이터 바인딩이거나, 함수 호출일 수 있고, 클라이언트는 현재 데이터 모델을 기준으로 그것을 해석합니다. 동적 목록도 같은 방식으로 동작하는데, 자식 템플릿이 데이터 모델 배열에 묶여 있어서 배열에 항목을 더하면 UI에 자식이 더해집니다. 컴포넌트 트리는 구조를 기술하고, 데이터 모델은 상태를 실어 나르며, 이 둘은 경로로 이어집니다.

엔진의 런타임 모델

클라이언트에서 엔진은 이 메시지들을 관찰 가능한 도메인 모델로 바꿉니다. 서피스 하나는 A2uiCoreSurfaceModel입니다. 이 모델은 해당 서피스의 데이터 모델과 컴포넌트 레지스트리를 소유하고, 사용자가 일으킬 수 있는 액션을 노출합니다.

public class A2uiCoreSurfaceModel(
    public val id: String,
    public val catalog: A2uiCoreCatalog,
    public val dataModel: A2uiCoreDataModel,
    public val componentRegistry: A2uiCoreComponentRegistry,
    private val onDispatchAction: (A2uiUserAction) -> Unit,
    private val onDispatchError: (A2uiClientError) -> Unit,
    // theme, shouldSendDataModel, timeProvider ...
) {
    internal fun updateDataModel(path: A2uiDataPath, value: Any?) = dataModel.update(path, value)
    internal fun updateComponents(payloads: List<A2uiComponentPayload>) =
        componentRegistry.update(payloads)

    public fun dispatchAction(componentId: String, actionDefinition: Map<String, Any?>) { /* A2uiUserAction을 생성 */ }
    public fun dispatchError(componentId: String, exception: A2uiException) { /* 보고 후 A2uiClientError를 방출 */ }
}

활성 서피스는 모두 A2uiCoreSurfaceGroupModel이 쥐고 있으며, 이것이 호스트 UI 프레임워크를 위한 단일한 관찰 지점입니다. 서피스들을 StateFlow로 노출하므로, 렌더러가 이를 수집해 반응할 수 있습니다.

public class A2uiCoreSurfaceGroupModel internal constructor() {
    private val _activeSurfaces = MutableStateFlow<List<A2uiCoreSurfaceModel>>(emptyList())
    /** 현재 활성화된 서피스들을 호스트 UI 프레임워크에 노출합니다. */
    public val activeSurfaces: StateFlow<List<A2uiCoreSurfaceModel>> = _activeSurfaces.asStateFlow()
}

데이터 모델과 컴포넌트 레지스트리는 구체 클래스가 아닙니다. 인터페이스입니다. 컴포넌트 레지스트리의 KDoc은 둘 모두의 설계 의도를 짚어 줍니다. 호스트 프레임워크가 이들을 네이티브 반응형 상태로 뒷받침한다는 것으로, 가령 Compose에서는 SnapshotStateMap이 그 역할을 합니다. 데이터 모델은 JSON 데이터 트리를 위한, 그에 대응하는 저장 인터페이스입니다.

public interface A2uiCoreDataModel {
    public fun update(path: A2uiDataPath, value: Any?)
    public operator fun get(path: A2uiDataPath): Any?
    public fun dispose()
}

이는 의도된 경계입니다. 엔진은 프로토콜 로직과 서피스 구조를 쥐고, 저장과 반응성은 그것을 렌더링하는 플랫폼이 무엇이든 그쪽에 위임합니다. Compose라면, 구현체가 갱신을 스냅샷 상태에 써 넣어 컴포저블 안의 읽기가 자동으로 리컴포즈되도록 할 것입니다. 엔진은 Compose를 결코 언급하지 않으며, 그 덕분에 프로토콜 계층이 특정 UI 툴킷 하나에 얽매이지 않고 독립적으로 남습니다.

경계에서의 검증

페이로드는 에이전트에게서 오기 때문에, 형식이 올바르다고 믿을 수 없습니다. 엔진의 A2uiCoreSchemaValidator는 값을 A2uiSchema와 대조하며 훑다가 처음 어긋나는 지점에서 예외를 던지고, 그 과정에서 JSON 경로 자취를 남깁니다.

internal object A2uiCoreSchemaValidator {
    internal fun validateSchema(payload: Any?, schema: A2uiSchema): Boolean {
        validateSchemaInternal(payload, schema, "$")
        return true
    }
}

검증기는 스키마 타입에 따라 분기하여, 객체는 필수 프로퍼티와 타입이 지정된 프로퍼티에 비추어, 배열은 그 항목 스키마에 비추어, 콤비네이터는 올바른 개수(cardinality)에 맞추어 검증합니다. 그래서 oneOf는 정확히 하나가 일치하기를, anyOf는 적어도 하나가, allOf는 전부가 일치하기를 요구합니다. 무언가 맞지 않으면, 잘못된 프로퍼티 타입이면 $.name, 잘못된 배열 원소면 $.list[1] 같은 경로를 담은 A2uiValidationException을 던집니다. 이 예외는 클라이언트가 되돌려 보내는, 코드가 VALIDATION_FAILEDA2uiClientError로 곧바로 대응되므로, 스키마 위반은 에이전트가 대처할 수 있는 구조화된 메시지가 됩니다. 검증기는 엔진에서 가장 완성도 높은 알고리즘이며, 손상된 UI가 화면에 닿기 전에 클라이언트가 이를 거부할 수 있게 해 줄 장치입니다.

지금 만들어진 것과 아직 뼈대뿐인 것

이 모듈의 솔직한 현주소를 짚어 보겠습니다. 이것이 앞의 모든 내용을 어떻게 읽어야 할지를 바꿔 놓기 때문입니다. a2ui는 서로 매우 다른 단계에 있는 네 개의 Gradle 모듈입니다.

  • **a2ui-model**은 가장 성숙한 모듈입니다. 프로토콜 메시지, A2uiComponentPayload, A2uiDataPath, 예외 타입, 그리고 commontypes 어휘를 갖춘 완전한 A2uiSchema 계층까지 모두 구현되고 테스트되어 있습니다. 이것이 진짜 토대입니다.
  • **a2ui-engine**은 일부만 되어 있습니다. 서피스 모델과 스키마 검증기는 구현되고 테스트되어 있습니다. 하지만 데이터 모델과 컴포넌트 레지스트리, 카탈로그, 컴포넌트 정의는 트리 안에 구체 구현이 없는 인터페이스일 뿐이고, 기본 제공 카탈로그로 딸려 오는 컴포넌트도 없으며, 오케스트레이션 계층도 없습니다. A2uiServerToClientMessage를 파싱해 그로부터 모델을 움직이는 코드는 아직 어디에도 작성돼 있지 않습니다. 값을 바꾸는 메서드들은 internal이고 그룹 모델의 생성자도 internal인데, 모듈 안에서 이들을 생성하는 것은 아무것도 없습니다.
  • **a2ui-core**에는 코틀린 소스가 아예 하나도 없습니다. 선언만 되어 있고 비어 있는 모듈입니다. A2uiCore라는 접두사에도 불구하고, 이 글 곳곳에서 본 A2uiCore 클래스들은 여기가 아니라 a2ui-engine에 살고 있습니다.
  • **a2ui-compose**는 플레이스홀더 클래스 하나가 전부입니다. 서피스와 그 레지스트리를 컴포저블로 바꿔 줄 조각인 Compose 렌더러는 아직 존재하지 않습니다.

눈에 띄는 빈틈도 있습니다. 모델에는 @Serializable 어노테이션도, JSON 코덱도 없어서, 실제 네트워크 메시지를 이 클래스들로 바꿔 줄 전송 파싱은 여기에 작성되어 있지 않습니다. 내부의 A2uiHandleActionMessage는 아직 구현되지 않은 SurfaceActor 큐를 참조합니다. 통합 테스트 앱은 빈 Activity입니다. 그래서 의도된 흐름, 곧 메시지가 도착하고, 서피스가 만들어지거나 갱신되고, 데이터 모델이 바뀌고, Compose 렌더러가 리컴포즈되는 흐름은 설계에는 보이지만 아직 처음부터 끝까지 연결되어 있지는 않습니다. 오늘 존재하는 것은 정성껏 모델링된 프로토콜과 도메인 모델 한 벌, 그리고 검증기입니다. 이 시스템을 전송 형식(wire format)부터 위로 설계한다면 가장 먼저 세울 뼈대가 바로 이것입니다.

오늘 이것으로 할 수 있는 일

렌더러와 오케스트레이션이 아직 작성되지 않았기 때문에, a2ui를 앱에 그냥 끼워 넣고 에이전트가 화면을 그리게 할 수는 아직 없습니다. 공개된 것과 그렇지 않은 것을 갈라 보면 이해에 도움이 됩니다. updateComponentsupdateDataModel 같은 서피스 변경 메서드와 A2uiCoreSchemaValidatorinternal이라, 지금 이들을 움직이는 코드는 내부 API에 접근할 수 있는 엔진 자신의 테스트 모듈입니다. 그 테스트들이 의도된 런타임을 가장 또렷하게 보여 주며, 대략 이런 모습입니다.

val surface = A2uiCoreSurfaceModel(
    id = "surface-1",
    catalog = myCatalog,
    dataModel = myDataModel,        // A2uiCoreDataModel, 예를 들어 맵이나 스냅샷 상태 기반 저장소
    componentRegistry = myRegistry, // A2uiCoreComponentRegistry
    onDispatchAction = { action -> sendToServer(action) },
    onDispatchError = { error -> sendToServer(error) },
)

surface.updateComponents(listOf(A2uiComponentPayload("btn-1", "button", mapOf("text" to "Click Me"))))
surface.updateDataModel(A2uiDataPath("/settings/volume"), 10)
surface.dispatchAction("btn-1", mapOf("type" to "click", "context" to mapOf("x" to 100, "y" to 200)))

검증기도 그 테스트들에서 같은 방식으로 시험되며, 가장 이해해 둘 만한 조각입니다. 실제 클라이언트가 잘못된 페이로드를 거부하려고 기댈 대상이 바로 이것이기 때문입니다.

val schema = A2uiObjectSchema(
    properties = mapOf("name" to A2uiStringSchema(), "age" to A2uiNumberSchema()),
    required = setOf("name"),
)
A2uiCoreSchemaValidator.validateSchema(mapOf("name" to "Alice", "age" to 30), schema)  // true
// mapOf("name" to 123)은 path "$.name"과 함께 A2uiValidationException을 던짐

여러분 자신의 모듈에서 온전히 공개된 계층은 a2ui-model입니다. 프로토콜 타입을 만들 수 있고, 컴포넌트의 계약을 스키마로 서술한 뒤 toJsonSchema()로 에이전트가 읽을 JSON Schema로 내보낼 수 있습니다. 다시 말해, 오늘의 a2ui는 프로토콜과 스키마 모델링 라이브러리로, 그리고 렌더러가 그 위에 얹힐 런타임의 미리보기로 쓸 수 있습니다. 에이전트 주도 UI를 실험하고 있다면, 지금 서버 쪽 계약과 스키마 어휘를 프로토타이핑하고, 엔진과 렌더러가 등장할 때를 대비해 두기에는 이것으로 충분합니다.

결론

이 글에서는 안드로이드 팀이 아직 만들고 있는, 에이전트 주도 UI를 위한 새 AndroidX 모듈 a2ui를 살펴보았습니다. 그 중심 발상은, 클라이언트가 JSON Schema로 자신의 컴포넌트를 언어 모델에게 설명하고, 그래서 에이전트가 한 번도 맞춰 컴파일된 적 없는 네이티브 UI를, 이름과 설명으로 컴포넌트를 고르고 정밀한 스키마에 맞춰 채워 넣어 만들어 낸다는 것입니다. 프로토콜은 서피스를 기반으로 한 순환입니다. 서버가 서피스를 만들고, 컴포넌트와 데이터 모델 델타를 스트리밍하며, 사용자 액션과 구조화된 에러를 되돌려 받되, JSON Pointer 경로를 통한 데이터 바인딩이 컴포넌트 트리를 별도의 스트리밍 데이터 모델에 이어 줍니다. 엔진은 그 메시지들을 관찰 가능한 서피스 모델로 바꾸고, 모든 페이로드를 화면에 닿기 전에 스키마와 대조해 검증합니다.

아티클 목록으로 가기