HOME INFO PROJECT BLOG ESSAY
Article Projects Lab
한국어 English
article |

A2UI - AI 에이전트가 UI를 말하는 방법

개요

2025년은 유독 에이전트 기술이 많이 등장한 해인 것 같습니다.

하지만 25년 여기서 끝이 아니다!

25년이 얼마 안남은 시점에 기세를 몰아(?) Google에서 12월 15일에 A2UI(Agent-to-User Interface)라는 기술을 공개했습니다.

먼저 소개를 살펴보면 다음과 같습니다:

A2UI는 AI 에이전트가 임의의 코드를 실행하지 않고도 웹, 모바일 및 데스크톱에서 원활하게 렌더링되는 풍부하고 상호작용적인 사용자 인터페이스를 생성할 수 있도록 한다.

에이전트에서 사용하는 일종의 규약인 것으로 보이는데 프로젝트 설명과 함께 알아봅시다.

목적과 철학

먼저 A2UI로 개선하고자 하는 점을 ‘식당 예약’을 하는 상황으로 운을 띄워봅시다:

유저: (typing) “2인용 테이블을 예약해줘.”

에이전트: “네, 몇 일부터 시작하시겠어요?”

유저: (typing) “내일.. 몇시..”

에이전트: “그 시간대에는 예약이 불가능합니다. 다른 시간대는 없을까요?”

유저: (typing) “예약 가능한 시간이 언제있지?”

에이전트: “5시, 5시 30분, 6시, 8시 30분, 9시, 9시 30분, 10시에 예약 가능합니다. 이 중 편하신 시간이 있으신가요?”

식당을 예약하는 과정에서 텍스트로만 이뤄지는 대화는 사용자와 에이전트 간 불필요한 턴을 소모하여 비효율적입니다. 필요한 정보를 한 번에 주지 않아 에이전트는 계속해서 되물어야 하기 때문이죠.

여기서 A2UI는 “에이전트가 UI라는 언어를 말하게 하자(Speak UI)“는 방향성으로 에이전트 쪽에서 식당 예약 날짜와 시간를 제출할 수 있는 버튼 양식을 직접 만들어 더 나은 사용자 경험을 제공합니다.

여기서 포인트는 화면을 어떻게 그릴지 구체적으로 명령하는 코드가 아니라 ‘선언적인 형태로 JSON으로 그 의도를 전달’ 한다는 것입니다. 즉, 에이전트가 생성하는 것이 텍스트가 아니라 UI 컴포넌트의 구조 자체를 데이터로 생성하는 것이죠.

이제 클라이언트 측에서는 선언형 JSON 데이터를 받아 웹, 모바일 등 자신의 플랫폼에 맞는 네이티브 컴포넌트로 렌더링 할 수 있게됩니다.

좀 더 구체적인 응용 사례로 살펴볼까요?

예시는 사용자가 자신의 정원이나 작업 공간의 사진을 업로드한 뒤에, 해당 공간의 조경 설계에 필요한 구체적인 요구사항을 파악하는 상황입니다.

정적 폼이 아닌 맞춤형 입력 양식을 즉석에서 생성하여 A2UI 메시지로 전송합니다.

텍스트로 일일이 설명할 필요 없이, 에이전트가 업로드한 사진을 보고 만들어준 UI를 통해 쉽고 정확하게 정보를 입력할 수 있게 된 것이죠.

철학

선언적 데이터를 다룸으로써 가장 최우선으로 얻을 수 있는 것은 ‘보안’ 입니다.

UI를 선언적으로 만듦으로써 Agent는 실행 가능한 코드를 보내지 않고, ‘스펙’만을 전달하게 되는데 이로서 임의 코드 실행을 방지할 수 있는 보안성을 가집니다.

a2ui-composer를 이용해 만든 form

또한 ‘UI 구조’와 ‘UI 구현’을 분리하여 프레임워크에 구애받지 않고 이식할 수 있습니다.

<Button
  variant="primary"
  onClick={handleSubmit}
  className="bg-blue-500 hover:bg-blue-600 rounded-lg px-4 py-2"
>
  submit
</Button>

예시는 React로 만든 ‘제출 버튼 기능’입니다.

UI를 코드로 생성하게 되면 구조와 구현이 하나로 묶여있게 되고, React 에서만 동작하게 됩니다.

// 예시
{
  "type": "a2ui",
  "surface": {
    "components": [
      {
        "id": "btn1",
        "type": "Button",
        "props": {
          "label": "제출하기",
          "variant": "primary",
          "action": "submit"
        }
      }
    ]
  }
}

반면 에이전트가 JSON 형식으로 생성한 UI 구조는 특정 플랫폼에 얽매이지 않습니다. 각 플랫폼의 렌더러(Renderer)에서 책임을 지게 되는 것이죠.

여기서 짚고 가야할 점은 A2UI는 ‘UI를 그려내는 기술”이 아니라는 것입니다. 화면을 렌더링하는 작업은 여전히 클라이언트의 책임이며, 에이전트는 무엇을 그려야 하는지 데이터만 정의만 내릴 뿐입니다.

동작 흐름이 어떻게 되는걸까?

설명은 각설하고 한 번 써봅시다.

copilotkit으로 직접 API key를 넣고 테스트해볼 수 있긴한데, 저희의 API 한도는 소중하니까 a2ui-composer로 진행해보겠습니다. (어차피 이것도 copilotkit로 돌아갑니다)

프롬프트로 항공권 조회 컴포넌트 생성을 요청했습니다.

요청 프롬프트와 결과는 위와 같습니다. (JSON은 생략했습니다.)

그런데 저희는 이미 선언적 형태의 데이터로 다룬다는 것을 알고 있기 때문에 생성된 JSON과 컴포넌트 생성물의 결과는 그다지 중요하지 않습니다.

어떤 흐름으로 동작하는지가 알아봐야합니다.

1. Server Stream

먼저 AI 에이전트가 SSE 연결을 통해 A2UI 형식의 JSONL 스트림을 전송합니다.

에이전트는 사용자 요청을 처리하면서 UI 구조와 데이터를 A2UI 규격에 맞춰 생성하는데, 이 JSON 데이터가 실시간으로 CopilotKit에게 전달됩니다.

2. Client Buffering

CopilotKit의 AG-UI 레이어가 서버에서 오는 A2UI 메시지를 수신합니다. 메시지를 파싱하면서 두 가지를 버퍼에 저장합니다.

첫 번째는 surfaceUpdate로 UI 컴포넌트 구조 정보입니다. 어떤 컴포넌트가 어떤 props를 가지고 어떻게 배치되는지 정의합니다.

두 번째로는 dataModelUpdate로 UI에 바인딩될 실제 데이터입니다. 항공편 목록, 가격, 좌석 정보 등이 여기 담깁니다.

CopilotKit은 이 단계에서 아직 화면에 그리지 않고 데이터를 모읍니다.

3. Render Signal

서버가 beginRendering 신호를 보냅니다.

이 신호는 불완전한 콘텐츠가 깜빡이며 나타나는 현상(flash of incomplete content)을 방지하기 위함인데, 클라이언트(CopilotKit)는 이 신호를 받기 전까지는 버퍼링만 하고, 신호를 받은 후에야 렌더링을 시작합니다.

4. Client-Side Rendering

beginRendering 신호를 받으면 CopilotKit의 A2UIRenderer가 실제 렌더링을 수행합니다. 이 과정은 세 단계로 이루어지는데:

  • 위젯 트리를 구축: surfaceUpdate에 정의된 컴포넌트 구조를 바탕으로 트리를 구성
  • 데이터 바인딩 해결: dataModelUpdate의 데이터를 각 컴포넌트에 연결
  • WidgetRegistry에서 위젯을 조회: 컴포넌트 타입(예: “FlightCard”)을 실제 네이티브 위젯(예: React의 FlightCard 컴포넌트)으로 매핑하여 인스턴스화합니다.

이 과정이 완료되면 사용자 화면에 UI가 표시됩니다.

5. User Interaction

사용자가 CopilotKit이 렌더링한 UI와 상호작용합니다. 버튼을 클릭하거나, 폼에 값을 입력하거나, 드롭다운을 선택하는 등의 행동을 합니다.

CopilotKit은 이 인터랙션을 감지하고 userAction 페이로드를 구성합니다. 이 페이로드에는 어떤 액션이 발생했는지, 어떤 데이터가 포함되어 있는지가 담깁니다.

6. Event Handling

구성된 userAction이 별도의 A2A 메시지를 통해 서버로 전송됩니다.

저희는 CopilotKit의 AG-UI 레이어가 userAction을 별도의 A2A 메시지로 AI 에이전트에게 전송합니다.

7. Dynamic Updates

AI 에이전트가 액션을 처리한 후, 새로운 A2UI 메시지(surfaceUpdate 또는 dataModelUpdate)를 원래 SSE 스트림으로 전송합니다.

CopilotKit은 이 업데이트를 받아 A2UIRenderer를 통해 UI를 다시 빌드합니다. 사용자가 항공편을 선택했다면 다음 단계인 승객 정보 입력 폼이 렌더링되는 것이죠.

맺음

글을 다 작성하고 알았는데 Google에서 disco라는 앱을 만들어주는 브라우저를 공개했더군요.

A2UI 프로토콜을 적극 푸시하려고 하는 것 같습니다. 또한 Gemini 3 모델에 힘입어 에이전트와 브라우저에도 신경을 쓰려는 것 같네요.

아직 기술 도입에 드는 비용이나 안정성을 논하기에는 일주일 밖에 되지 않은 기술이지만, 프론트엔드에서 어떤 변화점을 가져올 지 궁금합니다.

Google에서 만들어서 그런지 Angular를 먼저 지원하는 도그 푸딩을 선보였지만, 조만간 React나 다른 프레임워크에서도 지원하는 모습을 볼 수 있을 것 같습니다.

조금 갖고 놀면서 사용해본 프론트엔드 개발자들의 후기와 의견을 기다려봐야겠습니다.

출처

article |

Java Null 안전성의 새로운 표준, JSpecify

개요

흔히 Java에서 null 참조를 10억 달러짜리 실수라고 하곤 합니다.

public class User {
    private String name;      // null이어서는 안 되지만 컴파일러는 모르고
    private String nickname;  // null 가능하다고 의도했지만, 타입상 구분이 없음

    public User(String name, String nickname) {
        this.name = name; // null이 들어와도 컴파일 에러 X
        this.nickname = nickname;
    }

    public int getNameLength() {
        return name.length();  // name이 null이라면 NPE
    }
}

// 예시
User user1 = new User("Alice", null);     // nickname은 null 의도
User user2 = new User(null, "Bobby");     // name이 null - 런타임 에러 발생 가능
User user3 = null;                         // User 객체 자체도 null 가능

public void processUser(User user) {  // user가 null일 수 있는지 알 수가 없다..!
    user.getNameLength();  // user가 null이면 NPE 발생
}

Java는 null 값 처리에 있어서 항상 모호함이 있었습니다. 개발자가 매번 명시적으로 지정하지않고도 동작이나 컨텍스트에 따라 변수가 null 허용 가능 or 불가능으로 가정되기 때문이죠.

public class User {
    @Nonnull
    private String name;      // null이 되어서는 안됨

    @Nullable
    private String nickname;  // null 가능

    public User(@Nonnull String name, @Nullable String nickname) {
        this.name = name;
        this.nickname = nickname;
    }
    ...

Java는 이 null의 불안전성을 개선하기 위해서 주로 어노테이션 형태로 null 허용 여부를 명시하여, API 사용자가 이 메서드가 null을 반환할 수 있는지 혹은 매개변수가 null을 허용할 수 있는지 즉시 알 수 있도록 하는 방식을 택했는데요:

  • javax.annotation.Nullable (JSR-305)
  • org.jetbrains.annotations.Nullable
  • org.eclipse.jdt.annotation.Nullable

문제는 위처럼 어노테이션들이 표준화되지 않아 각각 미묘하게 다른 의미를 가지기도 하고, 도구 간 호환성이 부족하다는 맹점이 있었습니다.

이를 주시하고 있었는지(?) 25년 11월 13일에 release된 Spring 7에서는 JSpecify를 표준으로 채택하여 하나의 통일된 표준을 제공하게 되었는데 한 번 살펴봅시다.

JSpecify

JSpecify는 크게 네 가지 키워드로 null 가능성을 나타냅니다.

@NonNull

이름 그대로 해당 타입이 null을 아님을 나타내는 어노테이션입니다.

// @NullMarked 없는 레거시 코드
public class LegacyService {

    // 이 반환값만 non-null임을 명시
    public @NonNull String getName() {
        return "name";
    }
}

후술할 @NullMarked가 아닌 코드에서 전체 패키지나 클래스에 @NullMarked를 적용하지 않은 상태에서, 특정 위치만 non-null임을 표시할 때 사용합니다.

다음으로 문서에서 설명하는 핵심 용도는 ‘non-null projection’인데 직역하면 non-null 투영인데 이 ‘투영’, ‘프로젝션’이라는 단어가 잘 이해가 되질 않았습니다.

그래서 문서에서 나온 설명과 코드를 살펴보자면:

@NullMarked
class MyOptional<T> {
    // T는 non-null 타입만 허용 (기본 상한이 Object)
}

@NullMarked
interface MyList<E extends @Nullable Object> {
    // E는 nullable 타입도 허용

    // ❌ 문제 발생!
    MyOptional<E> firstNonNull();
}

MyList<@Nullable String> 을 사용하면

  • E = @Nullable String이 되버리고
  • firstNonNull()의 반환 타입은 MyOptional<@Nullable String> 됩니다.
  • 하지만 MyOptional은 `non-null 타입만 허용하므로 타입 에러가 발생하게 되지요.
@NullMarked
interface MyList<E extends @Nullable Object> {

    // E의 non-null 버전으로 projection
    MyOptional<@NonNull E> firstNonNull();
}

하지만 E를 @NonNull로 명시한다면?

MyList<@Nullable String> maybeNulls = ...;
MyList<String> nonNulls = ...;

// 둘 다 MyOptional<String> 반환
MyOptional<String> a = maybeNulls.firstNonNull();  // ✅
MyOptional<String> b = nonNulls.firstNonNull();    // ✅

E의 실제 타입이 @Nullable String이라면 @NonNull E 결과는 String가 되고, 실제 타입이 String이어도 String이 되겠지요!

아마 여러 입력이 같은 결과로 수렴한다는 의미에서 ‘변환’이 아니라 ‘투영’이라는 말을 쓴게 아닐지 추측을 해봅니다..

@Nullable

역시나 이름 그대로 해당 타입이 null을 포함할 수 있음을 나타냅니다.

어노테이션 위치별 의미를 살펴보자면:

// 패러미터 타입
void setField(@Nullable String value) { ... }

당연하지만(?) null을 전달해도 됩니다. 하지만? 런타임에 예외가 발생하지 않는다는 보장은 없다는거..

// 반환 타입
@Nullable String getField() { return field; }

null을 반환할 수 있으며, 호출자는 null 가능성을 처리해야 합니다.

// 필드 타입
@Nullable String field;

필드도 null을 가질 수 있습니다. 모든 참조 타입 필드는 초기에 null이지만, 클래스가 초기화되지 않은 상태를 노출하지 않는다면 무시해도 됩니다.

// 타입 인자
List<@Nullable String> getList() { ... }

코드는 List의 요소가 null일 수 있음을 나타냅니다.

만약 리스트 자체도 null일 수 있다면? @Nullable List<@Nullable String> 요런 식으로 작성하면 됩니다.

// 타입 패러미터의 upper bound
class Container<E extends @Nullable Object> { ... }

E 자리에 nullable 타입 인자를 허용합니다.

// 타입 변수 사용 (Nullable Projection)
class Foo<E extends @Nullable Object> {

    @Nullable E getOrNull() { ... }
}

@Nullable E는 타입 인자가 뭐든 항상 nullable로 투영합니다.

투영은 뭔지 위에서 언급했으니 패스하겠습니다.

// 중첩 타입
Map.@Nullable Entry<String, String> entry;

Entry가 null일 수 있음을 나타냅니다. Java 문법상 @Nullable은 바로 뒤에 오는 타입에 적용되겠지요

// Record 컴포넌트
record User(@Nullable String nickname, String name) {
    public User {
        Objects.requireNonNull(name);  // non-null 컴포넌트는 검증 필요
    }
}

JSR-305에도 있었는지는 모르겠는데 레코드에도 명시가 가능해졌는데 @Nullable이 생성자 파라미터와 접근자 메서드 반환 타입에 모두 적용됩니다.

문서에서는 non-null 컴포넌트를 compact constructor(파라미터와 할당을 생략하는 Record 전용 생성자)에서 Objects.requireNonNull()로 검증하는 것을 권장하고 있습니다.

@NullMarked

해당 범위 내에서 어노테이션이 없는 타입은 기본적으로 non-null로 취급됩니다.

이러면 @NonNull을 반복해서 쓸 필요가 없어지게 되지 않을까요?

@NullMarked
public class UserService {

    public String getName() { ... }           // non-null (기본값)
    public @Nullable String getNickname() { ... }  // nullable (명시)
}

원칙으로는 어노테이션 명시되어있지 않으면 non-null을, null 가능한 곳에만 @Nullable 표시하는 것입니다.

// src/main/java/com/example/service/package-info.java
@NullMarked
package com.example.service;

import org.jspecify.annotations.NullMarked;

모듈, 패키지, 클래스, 인터페이스, 메서드, 생성자까지 모두 명시가 가능하지만 문서에서 권장하는 방식은 패키지 적용을 권장합니다.

이렇게 되면 새로 만드는 클래스가 자동으로 null-marked가 됩니다.

@NullUnmarked

@NullMarked의 효과를 취소하여, 해당 범위를 null-unmarked 상태로 되돌리며, 이 때 어노테이션이 없는 타입은 nullness 미지정 상태가 됩니다.

어노테이션의 목적은 점진적인 마이그레이션을 염두에 두었는데:

    1. 패키지에 @NullMarked 적용 (새 코드는 자동으로 null-safe)
    1. 아직 마이그레이션 못한 클래스/메서드는 @NullUnmarked로 제외
    1. 점진적으로 @NullUnmarked 제거
    1. 완료 후 @NullUnmarked 사용 금지

이런 식으로 사용한다고 합니다.. 이건 나중에 쓸일이 있을 때 참고하면 좋을 듯 하네요.

맺음

JSpecify 외에도 Spring 7에 추가된 다른 기능도 한 번 써봐야겠습니다.

읽어주셔서 감사합니다.

출처