
2026 Google I/O Extended의 Android AppFunctions 핸즈온에 참여했다.
발표 자료는 다음 한 문장으로 시작했다.
@AppFunction하나로 내 앱의 기능을 AI Agent에게 제공할 수 있다.
Gemini 기반 Agent 앱에서 ChatApp의 Alice에게 메시지를 보내는 첫 시연까지는 생각보다 덜 신기했다. Agent가 한 앱의 함수를 대신 호출하는 모습은 어느 정도 예상할 수 있었다.
그다음 시연은 달랐다.
가계부 앱에서 소비 내역을 가져와 ChatApp의 Alice에게 보내줘.
Agent는 AppFunctions가 적용된 가계부 앱에서 소비 내역을 가져온 뒤, 그 결과를 ChatApp으로 넘겨 Alice에게 메시지를 보냈다.
가계부 앱과 ChatApp을 미리 연결하지 않았는데도 하나의 요청이 두 앱을 거쳤다. AppFunctions가 단일 앱의 기능을 호출하는 데서 끝나지 않고 여러 앱의 기능을 이어 붙일 수 있다는 점이 가장 신기했다.
시연을 마친 뒤에는 각자 프로젝트에서 “사용자가 한 문장으로 시킬 만한 행동” 하나를 AppFunction으로 열어봤다.
나는 여비의 여행 생성 기능을 골랐다.
여비에서 여행을 만들려면 앱을 열고 제목, 일정, 국가를 차례대로 입력해야 한다.
그런데 이 과정은 한 문장으로도 충분히 표현된다.
8월 3일부터 7일까지 일본으로 가는
"도쿄 여름휴가" 여행을 여비에 만들어줘.
그래서 기존 여행 생성 Repository를 AppFunction으로 연결해봤다.
@AppFunction(isDescribedByKDoc = true)
suspend fun createTrip(
params: CreateTripParams,
): CreateTripResult
코드만 보면 어노테이션이 붙은 suspend 함수 하나다. 조금 생각해보면 이상하다.
에이전트는 여비의 코드를 모르는데, 이 함수가 있다는 사실과 파라미터의 의미를 어떻게 알아낼까?
함수는 앱 프로세스 안에 있는데 다른 앱인 에이전트는 어떻게 실행할까?
Manifest에는 exported="true"인 Service를 등록하는데, 아무 앱에서나 호출할 수 있는 것은 아닐까?
자연어로 들어온 요청이 실패했을 때 에이전트는 그 이유를 어떻게 구분할까?
AppFunctions가 무엇인지, Kotlin 함수가 어떻게 에이전트의 도구가 되는지부터 살펴보자.
I/O Extended 핸즈온에서 본 흐름과 여비의 여행 생성 기능을 실제 브랜치에 적용한 과정도 함께 정리해보려 한다.
우선, 결론부터 말하면 AppFunctions는 앱의 함수를 LLM에 직접 넘기는 API가 아니다.
함수의 구조와 설명을 Android OS에 등록해두면 권한을 가진 에이전트가 이를 발견하고 실행한다. AppFunctions는 이 과정을 담당하는 Android 플랫폼 API다.
Android 공식 문서는 이를 앱이 온디바이스 MCP 서버처럼 동작하는 구조라고 설명한다.
앱의 Kotlin 함수
↓
KSP가 함수 Metadata와 호출 코드 생성
↓
Android OS가 설치된 AppFunction을 색인
↓
권한 있는 Agent가 함수 목록 조회
↓
사용자 요청과 함수 설명을 바탕으로 함수 선택
↓
앱 프로세스에서 기존 비즈니스 로직 실행
기존 Android 앱은 대부분 UI로 기능을 제공했다. 사용자는 화면을 열고 버튼을 눌러 원하는 기능에 도달한다.
AppFunctions는 여기에 에이전트가 호출하는 두 번째 표면을 추가한다.
사람을 위한 표면
사용자 -> Screen -> ViewModel -> Repository
에이전트를 위한 표면
Agent -> AppFunctionService -> Repository
두 경로의 진입점은 다르지만 실제 기능까지 다시 만들 필요는 없다.
AppFunction에서도 기존 Repository와 Database, 로그인 상태를 그대로 사용할 수 있다.
AppFunctions의 호출 경로와 함수 실행은 기기 안에 있다.
AppFunction 내부에서 기존 Repository가 서버 API를 호출한다면 네트워크 통신은 여전히 발생한다.
별도의 원격 MCP 서버를 운영하지 않아도 된다고 해서 앱의 모든 작업이 오프라인이 되는 것은 아니다.
AppFunctions는 MCP의 tool과 비슷한 역할을 Android 앱 안에서 제공한다.
다만 실행 위치와 발견 방식이 다르다.
| 구분 | Android AppFunctions | 일반적인 원격 MCP |
|---|---|---|
| 실행 위치 | 설치된 Android 앱 프로세스 | 별도 원격 서버 |
| 함수 발견 | Android OS의 AppFunction Registry | MCP Server가 제공하는 tool 목록 |
| 기존 앱 상태 | 로그인, DB, Repository를 직접 사용 | 앱 로컬 상태 대신 서버 측 상태·인증 사용 |
| 네트워크 | 호출 자체는 기기 안에서 중계 | 보통 Client와 Server 사이 통신 필요 |
| 접근 제어 | Android 권한과 AppFunction 활성화 상태 | MCP Server의 인증과 정책 |
| 현재 상태 | Android 16+ Experimental Preview | 플랫폼과 구현에 따라 다름 |
위 표는 별도 원격 서버로 운영하는 MCP와 비교한 일반적인 차이다. MCP의 실행 위치와 상태·인증 방식은 구성에 따라 달라질 수 있다.
AppFunctions와 원격 MCP는 서로를 완전히 대체하지 않는다. 에이전트는 요청에 따라 서버의 MCP tool과 기기에 설치된 AppFunction을 함께 고려할 수 있다.
이 과정에는 Provider, Android Platform, Consumer가 등장한다.
[Provider App]
@AppFunction + KDoc + Serializable Type
↓
[Android Platform]
Function Registry + AppFunctionManager
↓
[Consumer]
권한을 가진 Agent 또는 System App
우리가 구현하는 것은 Provider 쪽이다.
@AppFunctionServiceEntryPoint가 붙은 추상 Service에 공개할 함수를 작성한다.
@RequiresApi(36)
@AppFunctionServiceEntryPoint(
serviceName = "YeoBeeAppFunctionService",
appFunctionXmlFileName = "yeobee_app_function_service",
)
abstract class BaseYeoBeeAppFunctionService : AppFunctionService()
이 클래스가 abstract인 이유는 KSP가 실제 Service 구현체를 생성하기 때문이다.
serviceName은 생성할 클래스 이름이고, appFunctionXmlFileName은 함수 Metadata XML의 이름이다.
KSP는 @AppFunction 함수의 이름과 파라미터, 반환 타입, 설명을 읽어 Metadata와 호출 Dispatcher를 만든다.
앱이 설치되면 Android OS가 Manifest와 생성된 XML을 읽어 사용 가능한 함수를 색인한다.
권한 있는 에이전트는 AppFunctionManager를 통해 Metadata를 조회한다.
에이전트는 이 Metadata를 보고 사용자 요청에 맞는 함수와 인자를 고른다. Android Platform은 실행 요청을 Provider 앱의 Service로 넘긴다.
여비의 createTrip에는 다음과 같은 KDoc을 작성했다.
/**
* Creates a solo trip and attempts to issue
* an invitation code and link for it.
*
* An existing trip with the same title, dates,
* and countries is reused to make retries safer.
*
* @param params The trip title, travel dates,
* and destination country codes.
* @return The created trip ID and invitation details.
*/
@AppFunction(isDescribedByKDoc = true)
suspend fun createTrip(params: CreateTripParams): CreateTripResult
isDescribedByKDoc = true를 사용하면 KDoc 본문과 @param, @return 설명이 AppFunction Metadata에 포함된다.
이제 이 주석은 개발자만 읽는 문서가 아니다.
에이전트가 이 함수를 언제 선택하고 각 값에 무엇을 넣어야 하는지 판단하는 명세가 된다.
KDoc 본문 -> 함수 설명
@param -> 파라미터 설명
@return -> 반환값 설명
Property KDoc -> 필드 설명
복구 방법이나 운영 규칙은 함수 설명이나 @AppFunctionInstruction에 명시하면 된다.
런타임에서는 원인에 맞는 AppFunctionException을 반환한다. 그래야 호출자가 실패 종류를 구분한다.
함수 하나의 설명만으로 부족한 규칙은 앱 수준의 app_metadata.xml에 적을 수 있다.
여비의 앱 Metadata에는 동일한 여행을 재사용한다는 규칙을 적었다. 초대 발급만 실패하면 여행을 다시 만들지 않는다는 점과 날짜·국가 코드 형식도 함께 기록했다.
Function KDoc
-> 이 함수의 목적, 파라미터, 반환값
App Metadata
-> 여러 함수에 공통인 앱의 제약과 운영 규칙
여행 생성에는 제목, 시작일, 종료일, 국가 목록이 필요하다.
결과에는 여행 ID와 초대 코드 등 여러 값이 들어간다.
이런 커스텀 타입에는 @AppFunctionSerializable을 사용한다.
@AppFunctionSerializable(isDescribedByKDoc = true)
data class CreateTripParams(
/** A non-blank trip title of at most 15 characters. */
val title: String,
/** The first travel date in ISO-8601 yyyy-MM-dd format. */
val startDate: String,
/** The last travel date in ISO-8601 yyyy-MM-dd format. */
val endDate: String,
/** ISO 3166-1 alpha-3 country codes. */
val countryCodes: List<String>,
)
@AppFunctionSerializable(isDescribedByKDoc = true)
data class CreateTripResult(
val tripId: Long,
val tripCreated: Boolean,
val invitationCreated: Boolean,
val invitationCode: String?,
val invitationUrl: String?,
val invitationExpiresAt: String?,
val message: String,
)
KSP는 이 구조를 직렬화 가능한 Schema로 만든다. 덕분에 에이전트는 문자열 하나를 억지로 해석하지 않고, 이름과 타입, 설명이 정해진 구조화된 입력과 결과를 다룬다.
핸즈온은 세 단계로 진행됐다. Android CLI로 Android 16, API 36 에뮬레이터를 준비하고, 공식 AppFunctions 저장소의 ChatApp과 Testing Agent를 실행했다.
ChatApp
-> AppFunction을 제공하는 Provider
Testing Agent
-> 함수 목록을 조회하고 실행하는 Consumer
ChatApp에는 연락처를 찾는 searchContacts와 메시지를 보내는 sendMessage가 있다.
사람은 “Alice에게 안녕이라고 보내줘”라고 말하지만, 실제 전송 함수에는 내부 식별자인 endpointValue가 필요하다.
Testing Agent의 수동 모드에서는 각 함수와 파라미터를 직접 골라 호출한다.
LLM 모드에서는 에이전트가 필요한 경우 다음처럼 두 함수를 연결한다.
"Alice에게 안녕이라고 보내줘"
↓
searchContacts(query = "Alice")
↓
endpointValue 획득
↓
sendMessage(endpointValue, "안녕")
여기까지는 ChatApp 안의 두 함수를 연결한 흐름이다.
내가 더 흥미롭게 본 것은 다음 시연이었다. Agent에게 가계부 앱의 소비 내역을 가져와 Alice에게 보내달라고 요청했다.
"가계부 앱의 소비 내역을 가져와
Alice에게 메시지로 보내줘"
↓
가계부 앱의 소비 내역 조회 AppFunction
↓
소비 내역 반환
↓
ChatApp.searchContacts(query = "Alice")
↓
ChatApp.sendMessage(endpointValue, 소비 내역)
가계부 앱은 ChatApp을 알지 못하고, ChatApp도 가계부 앱을 알지 못한다.
Gemini 기반 Agent가 Android에 등록된 각 앱의 AppFunction Metadata를 보고 함수를 고른 뒤, 앞선 함수의 결과를 다음 함수의 입력으로 넘겼다.
AppFunctions가 앱끼리 직접 통신하게 만든 것은 아니다. Android의 Registry에 각 앱의 기능을 올려두고 Agent가 실행 시점에 조합하면서 앱 사이의 상호작용이 만들어졌다.
직접 해보니 어떤 기능을 AppFunction으로 열어야 할지도 조금 선명해졌다.
마지막에는 각자의 앱에서 이 기준에 맞는 함수 하나를 열어봤다. 여비에서는 여행 생성이 가장 잘 맞았다.
2026년 7월과 1.0.0-alpha10 기준으로 AppFunctions를 사용하려면 compileSdk 36 이상과 KSP가 필요하다.
dependencies {
implementation("androidx.appfunctions:appfunctions:1.0.0-alpha10")
ksp("androidx.appfunctions:appfunctions-compiler:1.0.0-alpha10")
}
여비 브랜치에서는 AppFunctions를 붙이는 과정에서 AGP와 Gradle을 올리고 compileSdk도 37로 조정했다.
공식 요구사항은 compileSdk 36 이상이므로 다른 프로젝트도 무조건 37까지 올려야 하는 것은 아니다.
AppFunction Service에서는 Metro의 기존 AppGraph를 가져와 네 개의 Repository를 그대로 사용했다.
TokenRepository
-> 로그인 여부 확인
CountryRepository
-> 지원하는 국가 코드 확인
TripRepository
-> 기존 여행 조회, 기간 중복 검사, 여행 생성
InvitationRepository
-> 초대 코드와 링크 생성
여행 생성 API Client나 AppFunctions 전용 Domain 계층은 따로 만들지 않았다.
기존 Repository API를 재사용하고, AppFunction Service에는 에이전트 호출에 필요한 흐름만 새로 작성했다. 입력 검증부터 중복 여행 조회, 기간 충돌 확인, 초대 발급과 부분 성공 처리까지 이 Service가 순서대로 조율한다.
Agent
↓
YeoBeeAppFunctionService
↓
AppGraph
↓
기존 Repository
↓
여비 Server API
createTrip은 Repository 함수 하나만 호출하고 끝나지 않는다. 자연어 요청에서 만든 값이 실제 앱의 제약을 만족하는지부터 확인한다.
로그인 확인
↓
제목, 날짜, 국가 코드 형식 검증
↓
여비가 지원하는 국가인지 확인
↓
동일한 여행이 이미 있는지 조회
↓
기간이 겹치는 다른 여행 확인
↓
동행자 없는 여행 생성
↓
초대 코드와 링크 발급
↓
구조화된 결과 반환
네트워크와 저장소 작업이 있어 함수는 suspend로 선언하고 Dispatchers.IO에서 실행했다. AppFunction은 기본적으로 Main Thread에서 시작되므로 blocking 작업을 그대로 두면 안 된다.
입력값도 앱 화면과 같은 수준으로 검증했다.
| 입력 | 여비에서 적용한 규칙 |
|---|---|
| 제목 | 공백 불가, 최대 15자 |
| 시작일·종료일 | ISO-8601 yyyy-MM-dd |
| 날짜 순서 | 종료일은 시작일보다 빠를 수 없음 |
| 국가 코드 | ISO 3166-1 alpha-3, 1~20개 |
| 국가 지원 여부 | 여비 서버의 국가 목록과 다시 비교 |
에이전트가 Schema에 맞는 타입을 보냈다고 해서 비즈니스 규칙까지 만족하는 것은 아니다. UI 경로에서 하던 검증은 AppFunction 경로에서도 Provider 앱이 책임져야 한다.
에이전트가 응답을 받지 못하거나 결과를 잘못 해석하면 같은 요청을 다시 보낼 수 있다. 여행 생성처럼 상태를 바꾸는 함수에서는 중복 생성 가능성을 먼저 생각해야 했다.
여비에서는 제목, 날짜, 국가 집합이 모두 같은 여행이 이미 있으면 새로 만들지 않고 기존 여행을 재사용했다.
같은 제목 + 같은 날짜 + 같은 국가
-> 기존 여행 ID 반환
다른 여행과 날짜가 겹침
-> 자동 생성 중단
-> 여비 앱에서 확인하도록 안내
물론 완전한 멱등성을 보장하지는 않는다. 현재 Mutex는 같은 Service 인스턴스 안의 동시 실행만 직렬화하고, 기존 여행을 재사용한 뒤에도 초대 발급은 다시 시도한다.
이번 구현은 “중복 생성을 막았다”보다 “중복 생성 가능성을 줄였다”가 정확하다.
초대 발급이 실패했을 때는 여행 생성까지 실패로 돌리지 않았다.
여행 생성 성공
↓
초대 발급 실패
↓
tripId 반환
invitationCreated = false
앱에서 초대를 만들도록 안내
하나의 AppFunction 안에서도 작업이 일부만 성공하는 경우가 있다. 그래서 Boolean 하나가 아니라 에이전트가 다음 행동을 정하는 데 필요한 구조화된 결과가 필요했다.
잘못된 입력과 로그인 문제, 서버 장애를 같은 Exception으로 반환하면 호출자는 무엇을 다시 물어봐야 할지 알기 어렵다.
여비에서는 실패 원인에 따라 AppFunctions의 예외 타입을 나눴다.
| 상황 | 반환하는 예외 또는 결과 |
|---|---|
| 로그인하지 않음 | 현재 브랜치는 AppFunctionPermissionRequiredException 사용 |
| 제목·날짜·국가가 잘못됨 | AppFunctionInvalidArgumentException |
| 다른 여행과 기간이 겹침 | AppFunctionElementAlreadyExistsException |
| 국가·여행 조회 또는 생성 실패 | AppFunctionAppUnknownException |
| 여행 생성 후 초대만 실패 | 예외가 아닌 부분 성공 결과 |
예외 타입을 보고 에이전트는 다음 행동을 선택할 수 있다. 인자가 잘못됐다면 값을 다시 물어보고, 로그인이 필요하다면 앱을 열도록 안내하는 식이다.
다만 현재 브랜치에서 미로그인에 사용한 예외는 다시 살펴봐야 한다.
공식 문서에서 AppFunctionPermissionRequiredException은 Calendar처럼 사용자 승인이 필요한 Android 권한을 Provider 앱이 받지 못했을 때 쓰는 예외다. 계정 로그인 상태와는 의미가 다르다.
공식 가이드대로라면 로그인 전에는 함수를 기본 비활성화하고, 로그인 상태가 확인된 뒤 setAppFunctionEnabled()로 노출하는 방식이 더 정확하다.
exported="true"인 Service는 안전할까?KSP가 생성한 Service는 Manifest에 등록해야 Android Platform이 발견한다.
<service
android:name=".appfunctions.YeoBeeAppFunctionService"
android:exported="true"
android:permission="android.permission.BIND_APP_FUNCTION_SERVICE"
tools:targetApi="36">
<intent-filter>
<action android:name=
"android.app.appfunctions.AppFunctionService" />
</intent-filter>
<property
android:name="android.app.appfunctions.schema"
android:value="app_functions_schema.xsd" />
<property
android:name="android.app.appfunctions.v2"
android:value="yeobee_app_function_service.xml" />
</service>
exported="true"만 보면 외부 앱이 자유롭게 접근할 수 있는 Service처럼 보이지만, 이 Service는 BIND_APP_FUNCTION_SERVICE 권한을 요구한다.
다른 앱의 AppFunction을 발견하고 실행하는 호출자도 EXECUTE_APP_FUNCTIONS 권한이 필요하다.
이 권한은 일반 앱에 자유롭게 주어지지 않는다. 시스템과 허용된 에이전트를 위한 관문이다.
그렇다고 Provider가 모든 기능을 안심하고 열어도 되는 것은 아니다. 공식 문서는 필요한 데이터와 행동만 좁게 공개하고, 민감한 정보는 피하라고 권장한다. 삭제나 결제처럼 중요한 행동에는 앱 자체의 명확한 확인 단계를 두라고도 권한다.
로그인이나 구독 상태에 따라 사용할 수 없는 함수라면 기본값을 비활성화한 뒤 런타임에 활성화하는 방법도 있다.
AppFunction의 등록 상태와 실행은 ADB로 확인할 수 있다.
adb shell cmd app_function list-app-functions
adb shell "cmd app_function execute-app-function \
--package com.yeobee.dev \
--function 'com.yeobee.appfunctions.BaseYeoBeeAppFunctionService#createTrip' \
--parameters '{
\"params\": {
\"title\": \"도쿄 여름휴가\",
\"startDate\": \"2026-08-03\",
\"endDate\": \"2026-08-07\",
\"countryCodes\": [\"JPN\"]
}
}'"
위 예시는 debug variant의 Application ID다. release 앱을 설치했다면 --package를 com.yeobee로 바꿔야 한다.
이 방식은 LLM의 함수 선택을 빼고 Service 등록과 직렬화, 함수 호출 경로를 직접 확인한다. 함수 ID와 파라미터를 이미 알고 직접 채워 넣기 때문에 LLM이 자연어에서 적절한 함수를 골랐는지는 검증하지 못한다.
ADB 직접 호출
-> 등록과 실행 검증
Testing Agent의 수동 모드
-> 권한 있는 조회와 결정적 실행 검증
Testing Agent의 LLM 모드
-> 자연어에서 함수와 인자를 고르는 과정 검증
Gemini 전체 연동
-> 실제 사용자 자연어 경험 검증
Testing Agent에는 수동 입력 모드와 LLM 기반 Agent 모드가 모두 있다. Testing Agent로 실행했다는 사실만으로 자연어 함수 선택까지 검증했다고 보기는 어렵다. 어떤 모드를 사용했는지 구분해야 한다.
2026년 7월 현재 AppFunctions는 Experimental Preview다.
AppFunctions와 Gemini의 전체 연동도 제한된 앱과 시스템 에이전트를 대상으로 한 Private Preview 단계다.
지금 구현하고 ADB와 Testing Agent로 준비할 수는 있지만, 모든 배포 앱이 곧바로 Gemini에서 호출되는 것은 아니다.
처음 질문으로 돌아가보자.
에이전트는 여비의 코드를 모르는데 어떻게
createTrip을 발견하고 실행할까?
KSP가 @AppFunction과 타입, KDoc을 읽어 함수 Metadata와 호출 코드를 만든다. Android OS는 앱이 설치될 때 이 정보를 Registry에 색인한다.
권한을 가진 에이전트는 Metadata를 조회하고 사용자 요청과 함수 설명을 비교해 함수를 선택한다.
플랫폼이 호출을 여비의 Service로 넘기면 함수는 앱 프로세스 안에서 기존 Repository를 사용해 여행을 만든다.
사용자의 자연어
↓
권한 있는 Agent
↓
Android AppFunction Registry
↓
YeoBeeAppFunctionService
↓
기존 Repository와 Server API
↓
여행 ID, 초대 코드, 결과 메시지
핸즈온 전에는 AppFunctions를 “함수에 어노테이션을 붙여 AI가 호출하게 만드는 기능” 정도로 생각했다.
ChatApp에서 Alice에게 메시지를 보내는 시연만 봤을 때는 앱을 조작하는 새로운 입력 방식처럼 느껴졌다.
가계부 앱의 소비 내역을 가져와 Alice에게 보내는 흐름을 보고 나서 생각이 달라졌다. AppFunction 하나는 단일 앱의 기능이지만, Agent는 여러 앱의 함수를 이어 하나의 작업으로 만들 수 있었다.
AppFunctions가 직접 앱들을 연결하는 것은 아니다. 각 앱이 기능과 설명을 Android Registry에 공개하고, Agent가 사용자의 요청에 맞춰 실행 순서를 조합한다.
여비에 직접 적용해보니 어노테이션보다 어떤 기능을 열 것인지, 에이전트가 오해하지 않도록 어떻게 설명할 것인지 정하는 일이 더 중요했다.
KDoc은 Schema의 일부가 됐고, Exception은 복구 전략이 됐다.
UI에서는 사용자가 화면을 보며 잘못된 값을 바로 고친다. AppFunction에서는 입력 검증과 구조화된 실패가 그 역할을 대신한다.
재시도 가능한 환경에서는 중복 생성과 부분 성공도 함수 계약에 포함해야 했다.
아직 AppFunctions는 Experimental Preview이고 Gemini와의 전체 연동도 제한적이다.
그래도 기존 앱의 아키텍처를 갈아엎지 않고 Repository 앞에 에이전트용 진입점을 하나 더 만들 수 있다는 점은 분명했다.
앱이 더 이상 화면으로만 기능을 제공하지 않는다면, 앞으로의 API 설계에는 사람뿐 아니라 에이전트가 읽을 함수 설명도 함께 들어가게 될 것 같다.