오늘도 공부
Genkit Dart + A2UI로 Flutter 앱 화면을 AI가 그리게 하기 본문
들어가며
앱의 화면을 미리 다 만들어 두지 않고, AI가 대화 흐름에 맞춰 그때그때 화면을 정해서 내려보내면 어떨까요? 이 글은 Dart 하나로 백엔드(Genkit Dart)와 모바일(Flutter)을 만들고, 그 사이를 A2UI로 잇는 방법을 단계별로 설명합니다.
예제로는 사용자가 몇 가지를 고르고 사진을 올리면 AI가 분석 결과 화면을 만들어 주는 간단한 앱을 씁니다. 흐름은 이렇습니다.
1. 앱을 열면 AI가 첫 화면(안내와 선택지)을 보냅니다.
2. 사용자가 버튼을 누를 때마다 AI가 다음 화면을 정합니다.
3. 사진을 내면 앱이 먼저 분석 API를 부르고, 그 결과를 보고 AI가 결과 화면을 만듭니다.
앱에는 화면이 하나도 고정돼 있지 않습니다. 어떤 카드와 버튼을 어떤 순서로 보여 줄지는 서버의 AI 에이전트가 A2UI 메시지로 정해서 보내고, 앱은 받은 메시지를 그리기만 합니다.
왜 A2UI인가
AI가 텍스트만 돌려주면, 사용자는 긴 설명을 읽고 무엇을 할지 스스로 찾아야 합니다. 하지만 화면마다 API와 위젯을 새로 짜면 흐름을 바꿀 때마다 앱을 다시 배포해야 합니다. A2UI는 그 중간 지점입니다.
• 앱은 쓸 수 있는 부품 목록(카탈로그)만 미리 등록해 둡니다.
• AI는 그 부품만 조합해서 화면을 JSON으로 설명합니다.
• 버튼을 누르면 그 행동(액션)이 다시 AI에게 다음 턴으로 전달됩니다.
사용한 스택은 다음과 같습니다.
| 영역 | 사용한 것 |
| 언어 · 저장소 | Dart 3.13, pub workspace (shared, backend, mobile) |
| 백엔드 | Genkit Dart 1.0 (genkit, genkit_shelf), genkit_a2ui 0.4, genkit_anthropic, shelf, sqlite3 |
| 모델 | Claude (Anthropic) |
| 모바일 | Flutter 3.47, genui 0.10 (A2UI 렌더러), iOS · Android |
본문의 코드는 설명을 위해 줄인 예제입니다. 패키지 API 이름은 위 버전 기준입니다.
전체 구조
구성 요소는 세 가지입니다. Flutter 앱, Dart로 짠 Genkit 서버, 그리고 서버가 부르는 Claude 모델입니다. 앱은 모델을 직접 부르지 않으므로 API 키는 서버에만 둡니다.

전체 구조 · 앱, Genkit Dart 서버, Claude, 공유 계약
화면을 만드는 길(/api/uiAgent)과 무거운 데이터를 처리하는 길(/api/analyzePhoto)을 나누는 것이 핵심입니다. 두 길이 같은 이름을 쓰도록 아래의 shared 패키지가 묶어 줍니다.
저장소는 pub workspace 하나에 패키지 세 개를 둡니다.
my-a2ui-app/
├── shared/ # 계약: 카탈로그 ID, 컴포넌트, 액션, 분석 결과 모델
├── backend/ # Genkit Dart + shelf 서버
└── mobile/ # Flutter 앱 (genui)
1단계: 서버와 앱이 공유하는 ‘계약’부터 만들기
A2UI에서 가장 흔한 실패는 이름이 어긋나는 것입니다. 서버는 InfoCard를 보냈는데 앱에 그 이름의 위젯이 없으면 화면이 비어 보이고, 버튼의 액션 이름이 다르면 눌러도 아무 일이 없습니다. 에러도 없이요.
그래서 백엔드와 모바일이 함께 의존하는 shared 패키지를 두고, 양쪽이 맞춰야 하는 이름을 여기에만 적습니다. 백엔드와 앱이 모두 Dart라서 가능한 구성입니다.
/// 에이전트가 알리고 앱이 등록하는 A2UI 카탈로그 ID
const appCatalogId = 'com.example.myapp.catalog.v1';
/// 에이전트가 화면에 놓을 수 있는 앱 전용 컴포넌트
enum AppComponent {
infoCard('InfoCard', ['title', 'description']),
optionPicker('OptionPicker', ['questionId', 'question', 'options']),
photoInput('PhotoInput', ['requestId', 'guide']),
analysisResult('AnalysisResult', ['requestId']);
const AppComponent(this.wireName, this.requiredProps);
final String wireName; // A2UI 메시지에 쓰는 이름
final List<String> requiredProps; // 반드시 채워야 하는 속성
}
액션에는 이름뿐 아니라 누가 처리하는지도 같이 적습니다. 설계에서 가장 쓸모 있는 결정입니다.
enum ActionHandler {
agent, // 그대로 에이전트의 다음 턴으로 보냄
app, // 앱 안에서만 처리 (에이전트는 모름)
appThenAgent, // 앱이 먼저 처리하고 그 결과를 붙여 에이전트에 전달
}
enum AppAction {
selectOption('selectOption', ActionHandler.agent),
submitPhoto('submitPhoto', ActionHandler.appThenAgent),
reset('reset', ActionHandler.app);
const AppAction(this.wireName, this.handledBy);
final String wireName;
final ActionHandler handledBy;
}
submitPhoto가 appThenAgent인 이유는 데이터 크기 때문입니다. 사진 같은 큰 데이터를 대화 기록에 넣으면 매 턴마다 다시 전송됩니다. 그래서 앱이 먼저 전용 분석 API를 호출하고, 에이전트에게는 작은 분석 결과(JSON)만 붙여 보냅니다.
이 파일 하나는 양쪽에서 이렇게 쓰입니다.
• 백엔드: 카탈로그 설명과 프롬프트에 AppComponent.infoCard.wireName, AppAction.selectOption.wireName을 그대로 넣습니다.
• 모바일: AppComponent.values를 돌면서 위젯을 등록하고, 액션을 받으면 handledBy로 분기합니다.
• 테스트: “계약의 모든 컴포넌트가 백엔드 카탈로그에도, 앱 카탈로그에도 있는가”를 양쪽에서 검사합니다. 이름이 어긋나면 배포 전에 테스트가 깨집니다.
2단계: 백엔드 — Genkit Dart 에이전트에 A2UI 미들웨어 달기
백엔드의 핵심은 코드 10줄 남짓입니다. Genkit의 에이전트(defineAgent)를 만들고, genkit_a2ui의 a2ui() 미들웨어를 끼우면 됩니다.
Future<Agent<void>> defineUiAgent<Options>(
Genkit ai, {
required ModelRef<Options> model,
Options? config,
}) async {
await loadCatalog(ai, id: appCatalogId, catalog: appCatalog);
return ai.defineAgent<void, void, Options>(
name: 'uiAgent',
model: model,
config: config,
system: systemPrompt,
use: [a2ui(catalog: appCatalogId, validate: 'strict')],
);
}
Genkit을 만들 때 플러그인 목록에 모델 플러그인과 함께 A2uiPlugin()을 넣어야 a2ui()가 동작합니다.
final ai = Genkit(plugins: [
anthropic(apiKey: apiKey),
A2uiPlugin(),
]);
a2ui() 미들웨어가 하는 일은 두 가지입니다.
• 카탈로그를 프롬프트에 주입: 등록된 컴포넌트의 이름, 설명, 속성을 시스템 프롬프트에 넣어 모델이 화면을 만들 수 있게 합니다.
• 엄격한 검증(validate: 'strict'): 카탈로그에 없는 컴포넌트나 필수 속성이 빠진 화면은 앱에 도달하기 전에 걸러냅니다.
카탈로그: 모델이 읽는 ‘부품 설명서’
카탈로그는 genui의 기본 컴포넌트(Column, Text, Button 등)에 앱 전용 컴포넌트를 더한 것입니다. 설명문은 매 턴 프롬프트에 들어가므로 짧고 구체적으로 씁니다. 특히 어떤 버튼이 어떤 액션을 보내는지를 적어 두면 모델이 다음 턴을 예측하기 쉬워집니다.
final A2uiCatalog appCatalog = A2uiCatalog(
id: appCatalogId,
components: [...basicCatalog.components, ..._appComponents],
);
A2uiCatalogComponent(
name: AppComponent.optionPicker.wireName,
description:
'One question with tappable options. Tapping an option sends '
'${AppAction.selectOption.wireName} with context {questionId, option}.',
props:
'questionId: string (required); question: string (required); '
'options: string[] of 2-5 short options (required).',
),
이름을 문자열로 직접 쓰지 않고 shared의 enum에서 가져오는 것이 포인트입니다.
프롬프트: 화면 규칙은 말로 정한다
카탈로그가 ‘무엇을 놓을 수 있는가’라면, 시스템 프롬프트는 ‘언제 무엇을 놓는가’입니다. 실제로 돌려 보면서 필요한 규칙을 하나씩 더하는 것이 좋습니다. 예를 들면 이런 것들입니다.
• 특정 액션 다음 화면에는 반드시 특정 컴포넌트를 넣는다 (예: submitPhoto 다음에는 AnalysisResult).
• 질문은 한 화면에 하나씩, 선택지는 2~5개의 짧은 말로 낸다.
• 목록에 없는 액션 이름은 쓰지 않는다. 앱이 알아듣지 못해 아무 일도 일어나지 않기 때문이다.
• 버튼에 이미 적힌 문구를 별도 Text로 또 쓰지 않는다.
shelf로 서빙하기
genkit_shelf의 GenkitRouter가 에이전트를 HTTP 엔드포인트로 바꿔 줍니다. 일반 API(사진 분석 flow, 로그 조회)는 shelf_router에, 에이전트는 GenkitRouter에 두고 Cascade로 합칩니다.
final router = Router()
..post('/api/analyzePhoto', shelfHandler(analyzeFlow));
final agents = GenkitRouter()
..addAgent(uiAgent, path: '/api/uiAgent');
final handler = Cascade()
.add(router.call)
.add(agents.asShelfHandler())
.handler;
에이전트는 대화 기록을 클라이언트가 들고 다니는 방식으로 쓸 수 있어서, 서버는 세션을 저장하지 않습니다. 서버를 여러 대로 늘리거나 재시작해도 대화가 끊기지 않습니다.
API 키는 소스에 넣지 말고 git에서 제외한 .env 파일이나 환경 변수로 읽습니다. 앱은 모델을 직접 부르지 않으므로 키는 서버에만 있습니다.
3단계: 모바일 — genui로 받은 화면 그리기
Flutter 쪽은 세 가지만 있으면 됩니다. 에이전트와 대화하는 클라이언트, 컴포넌트 이름을 위젯으로 바꾸는 카탈로그, 그리고 메시지를 받아 화면을 관리하는 SurfaceController입니다.
클라이언트: remoteAgent와 chat
Genkit은 Dart 클라이언트(remoteAgent)도 제공합니다. chat()이 대화 기록을 들고 있다가 매 턴 함께 보내므로, 앱은 텍스트나 액션을 send하기만 하면 됩니다.
final api = remoteAgent<void>(url: '$baseUrl/api/uiAgent');
var chat = api.chat();
// 첫 화면 요청
await chat.send(text: '첫 화면을 보여 주세요.');
// 버튼을 누른 뒤
final response = await chat.send(message: actionToMessage(action));
for (final envelope in a2uiEnvelopesFromParts(response.message?.content)) {
render(envelope); // createSurface, updateComponents ...
}
actionToMessage와 a2uiEnvelopesFromParts는 genkit_a2ui/client.dart가 제공합니다. 버튼 이벤트를 모델이 읽을 메시지로 바꾸고, 모델 응답에서 A2UI 메시지만 꺼냅니다.
카탈로그: 이름 → 위젯
앱 카탈로그도 genui 기본 카탈로그에 계약의 컴포넌트를 덧붙입니다. 필수 속성은 계약의 requiredProps를 그대로 씁니다.
Catalog buildAppCatalog(LocalStore store) {
final builders = _buildersFor(store);
return BasicCatalogItems.asCatalog().copyWith(
catalogId: appCatalogId,
newItems: [
for (final component in AppComponent.values)
CatalogItem(
name: component.wireName,
dataSchema: S.object(
properties: _propertiesOf[component]!,
required: component.requiredProps,
),
widgetBuilder: builders[component]!,
),
],
);
}
위젯 빌더는 평범한 Flutter 위젯입니다. 다른 점은 버튼을 눌렀을 때 직접 화면을 바꾸지 않고, 액션을 발생시키기만 한다는 것입니다.
Widget buildOptionPicker(CatalogItemContext context) {
final props = propsOf(context);
final questionId = '${props['questionId']}';
return Column(children: [
Text('${props['question']}'),
for (final option in props['options'] as List)
OutlinedButton(
onPressed: () => dispatchAction(
context,
AppAction.selectOption,
{'questionId': questionId, 'option': option},
),
child: Text('$option'),
),
]);
}
여기서 dispatchAction은 genui의 context.dispatchEvent(UserActionEvent(...))를 감싼 작은 도우미 함수입니다.
사진이나 분석 결과처럼 큰 데이터는 A2UI 메시지에 싣지 않습니다. 에이전트는 AnalysisResult(requestId: 'r-1')처럼 무엇을 보여 줄지만 정하고, 위젯은 앱의 LocalStore에서 실제 데이터를 읽습니다.
SurfaceController와 액션 라우팅
SurfaceController가 A2UI 메시지를 받아 화면을 만들고, 버튼 이벤트는 onSubmit으로 내보냅니다. 앱은 이 이벤트를 계약의 handledBy에 따라 나눕니다.
surfaces = SurfaceController(catalogs: [buildAppCatalog(store)]);
surfaces.onSubmit.listen((message) {
final action = actionFromSubmit(message);
if (action != null) _onAction(action);
});
Future<void> _onAction(A2uiClientAction action) async {
switch (AppAction.fromWireName(action.name)) {
case AppAction.submitPhoto:
await _submitPhoto(action); // 분석 API → 결과를 붙여 에이전트로
case AppAction.reset:
await reset(); // 앱 안에서만 처리
default:
await _runTurn(() => _agent.sendAction(action)); // 그대로 에이전트로
}
}
void _render(Map<String, Object?> envelope) {
surfaces.handleMessage(
A2uiMessage.fromJson({'version': 'v0.9', ...envelope}),
);
}
actionFromSubmit과 fromWireName도 앱에서 만든 작은 변환 함수입니다. 화면에 그리는 쪽은 Surface(surfaceContext: surfaces.contextFor(surfaceId)) 한 줄이면 됩니다.
4단계: 한 턴을 따라가 보기
가장 복잡한 턴은 사용자가 사진을 내는 턴입니다. 앱이 먼저 분석 API를 부르고(2~5), 그 결과를 붙여서야 에이전트의 다음 턴이 시작됩니다(6~9).

사진 제출 한 턴 · 10단계
실선은 요청, 점선은 응답입니다. 단계별로 보면 다음과 같습니다.
• 1에서 5까지: 앱의 일입니다. 사진을 분석 API로 보내고, 돌아온 결과를 LocalStore에 둡니다.
• 6: 앱이 액션을 다시 만들어 보냅니다. context에는 사진이 아니라 분석 결과 JSON만 들어갑니다.
• 7에서 9까지: 에이전트의 턴입니다. 모델은 프롬프트 규칙에 따라 AnalysisResult를 넣은 화면을 만들고, a2ui() 미들웨어가 카탈로그와 맞는지 검사합니다.
• 10: 앱이 메시지를 그리면서 AnalysisResult(requestId) 위젯이 LocalStore에서 결과를 읽어 표시합니다.
선택지를 고르는 것 같은 나머지 턴은 2~5가 없는 단순한 형태입니다. 버튼을 누르면 액션이 곧바로 에이전트로 가고, 새 화면이 돌아옵니다.
5단계: AI가 무엇을 보냈는지 보이게 하기
화면을 AI가 만들면 디버깅이 어렵습니다. 화면이 이상할 때 모델이 잘못 보낸 것인지, 앱이 잘못 그린 것인지부터 가려야 하기 때문입니다. 두 가지 도구가 크게 도움이 됩니다.
SQLite 로그: 요청 · A2UI · 응답 원문
서버에 shelf 미들웨어를 하나 두고, AI 관련 API의 요청과 응답을 통째로 SQLite에 저장합니다. 응답은 버퍼에 모았다가 그대로 다시 내보내므로 앱 동작은 바뀌지 않습니다. 앱에는 /api/logs를 읽는 로그 화면을 두고, 항목마다 한 줄 요약을 보여 줍니다.
uiAgent · 14:05:12 · 2.7초
▶ selectOption {"questionId":"q1","option":"실내"}
◀ Column, Text, Text, OptionPicker
항목을 누르면 탭 세 개가 나옵니다.
| 탭 | 보여 주는 것 |
| 요청 | 앱이 보낸 메시지 JSON |
| A2UI | 이번 턴에 에이전트가 보낸 A2UI 메시지만 뽑은 것 |
| 응답 원문 | 서버가 보낸 SSE 프레임 전체 |
A2UI 탭을 따로 두는 이유가 있습니다. 응답 원문에는 모델이 토큰 단위로 보낸 조각이 수십 개 이어지고, 실제 화면 정의는 맨 끝의 최종 결과에 나옵니다. 최종 결과의 state에는 지난 대화 전체가 다시 들어 있어서, 이것을 건너뛰고 이번 턴의 메시지만 뽑습니다. 뽑아 낸 화면은 이런 모양입니다.
[
{
"createSurface": {
"surfaceId": "a54ab538-...",
"catalogId": "com.example.myapp.catalog.v1"
},
"version": "v0.9"
},
{
"updateComponents": {
"surfaceId": "a54ab538-...",
"components": [
{ "id": "root", "component": "Column", "children": ["title", "desc", "pick"] },
{ "id": "title", "component": "Text", "text": "어디에서 찍을까요?", "variant": "h2" },
{ "id": "desc", "component": "Text", "text": "하나를 골라 주세요.", "variant": "caption" },
{ "id": "pick", "component": "OptionPicker", "questionId": "q1",
"question": "촬영 장소", "options": ["실내", "야외"] }
]
},
"version": "v0.9"
}
]
컴포넌트는 평평한 목록이고, children이 id로 서로를 가리킵니다. 중첩 JSON보다 모델이 실수할 여지가 적고, 일부만 바꾸는 업데이트도 쉽습니다.
테스트 화면: 채팅으로 화면 확인하기
프롬프트를 고칠 때마다 처음부터 흐름을 다시 따라가기는 번거롭습니다. 그래서 앱에 채팅식 테스트 화면을 두면 좋습니다.
• 아무 문장이나 보내면 에이전트가 만든 화면이 말풍선 안에 그대로 그려집니다.
• 말풍선 안의 버튼도 실제로 동작해서, 누르면 그 액션이 새 턴으로 쌓입니다.
• 본 화면과는 대화(chat())를 따로 쓰므로 서로 섞이지 않습니다.
테스트 화면에서 하나를 누르고 로그 화면으로 넘어가면, 방금 턴의 요청과 A2UI를 바로 볼 수 있습니다.
주의할 점
대부분은 에러 없이 ‘조용히’ 틀리는 문제들입니다. 위의 로그 화면이 있으면 원인을 훨씬 빨리 찾을 수 있습니다.
| 증상 | 원인 | 해결 |
| 버전 표기가 없는 메시지를 genui가 받지 않음 | A2uiMessage.fromJson이 version: 'v0.9'를 요구함 | 렌더링 전에 {'version': 'v0.9', ...envelope}로 채워 넣어 방어 |
| 버튼을 눌러도 모델이 액션의 세부 내용(context)을 모름 | 모델 플러그인에 따라 메시지의 data 파트가 모델에 전달되지 않을 수 있음 | actionToMessage가 만드는 텍스트 파트에 액션 이름과 context가 함께 들어 있는지 확인 |
| Android 에뮬레이터에서 턴이 중간에 끊김 | 에뮬레이터의 호스트 프록시가 오래 열린 SSE 스트림을 끊을 수 있음 | 화면은 어차피 한 덩어리로 오므로, 스트리밍 대신 chat.send()로 턴 전체를 받음 |
| 꼭 보여야 할 화면을 모델이 건너뜀 | 프롬프트에 ‘반드시’가 없음 | “이 액션 다음 화면은 반드시 이 컴포넌트로 시작”처럼 규칙을 명시 |
| 버튼 글자가 화면에 두 번 보임 | 모델이 버튼의 child Text를 Column에도 넣음 | 프롬프트에 ‘버튼 글자를 Text로 또 쓰지 않기’ 추가 |
| 로그 요약에 컴포넌트가 여러 번 세어짐 | 응답에 스트리밍 조각, 최종 결과, state의 지난 대화까지 들어 있음 | 최종 결과를 우선하고 state는 건너뜀 |
| 어느 날 갑자기 앱이 응답이 없음 | 개발 노트북의 Wi-Fi IP가 바뀜 (서버 로그에 요청이 아예 없음) | 서버 주소를 설정 파일 한 곳에 두고 화면에 표시. iOS 시뮬레이터는 localhost로도 접속 가능 |
하나 더: 모델 호출은 몇 초씩 걸리고 가끔 실패합니다. 턴마다 시간 제한을 두고, 연속으로 실패하면 AI 없이 쓸 수 있는 기본 화면으로 안내하세요. 화면을 AI에 맡길수록, 모델이 없을 때의 화면도 필요합니다.
마치며
Genkit Dart와 A2UI를 쓰면 ‘화면을 어떤 순서로, 어떤 문구로 보여 줄지’를 프롬프트로 바꿀 수 있습니다. 흐름을 고칠 때 앱을 다시 배포하지 않고 서버만 고치면 됩니다.
정리하면 네 가지입니다.
1. 이름은 한 곳에만. 컴포넌트와 액션 이름을 공유 패키지에 두고 양쪽을 테스트로 묶으면, ‘조용히 안 되는’ 문제가 크게 줄어듭니다. 서버와 앱이 같은 언어(Dart)인 것이 여기서 큰 장점입니다.
2. AI는 구성을, 앱은 데이터를. 사진처럼 크고 민감한 데이터는 앱에 두고, 에이전트에는 ‘무엇을 보여 줄지’만 맡깁니다.
3. 보이게 만들기. 요청과 A2UI를 바로 볼 수 있는 로그 화면과 채팅식 테스트 화면이 프롬프트 수정 속도를 크게 올려 줍니다.
4. 실패를 전제로. 시간 제한, 다시 시도, AI 없는 기본 화면을 처음부터 넣어 두세요.
Genkit의 에이전트 API는 아직 실험 단계(experimental)입니다. 도입할 때는 버전을 고정하고 변경 내역을 따라가는 것을 권합니다.
'AI' 카테고리의 다른 글
| 새로 나온 구글 임베딩 모델 EmbeddingGemma 2 직접 뜯어보기 (0) | 2026.10.07 |
|---|---|
| AI 시대의 개발자의 다음 역할인 AI Builder? (0) | 2026.10.06 |
| AI가 코드를 다 짜는 시대, 엔지니어는 무엇을 공부해야 할까 (0) | 2026.09.15 |
| Microsoft가 말하는 ‘에이전트 시대 개발자의 새로운 규칙’ (0) | 2026.09.14 |
| GPT-6 Astra 프롬프트 작성법 (0) | 2026.09.09 |
