안드로이드 위클리 공부 노트

#750

AI와 앱을 이어 주는 Android AppFunctions — 앱이 기기 안의 MCP 서버가 되는 방법

원문 AI, Meet App: Android AppFunctions, Katie Barnett

한 줄 요약

Android AppFunctions는 앱이 자기 기능을 기기 안의 AI 에이전트(예: Gemini 앱)가 쓸 수 있는 도구로 내놓게 하는 새 Android API다. 하는 일은 MCP 서버와 같다. 다만 인터넷 너머의 서버가 아니라 Android OS가 중간에서 앱의 함수 목록을 모아 두고, 허가받은 AI 앱이 그 목록을 보고 골라 부른다. 개발자는 함수에 @AppFunctionDeclaration을 붙이고 KDoc 주석을 AI에게 주는 설명서(프롬프트)처럼 쓰면 된다. 그러면 Jetpack 라이브러리가 서비스와 설명 파일(XML)을 자동으로 만든다. 저자는 보드게임 기록 앱 Game Night Guru로 "2시간짜리 전략 게임을 추천해 줘"에 앱 안의 데이터로 답하는 과정을 보여 준다. 2026년 10월 현재는 신뢰 테스터 대상 비공개 미리보기라 일반 Gemini 앱으로는 아직 시험할 수 없다. 그러니 지금 준비해 두라는 것이 저자의 권유다.

원문은 Medium(ProAndroidDev)에 실렸고 자동 접근이 막혀 있어(403), ProAndroidDev의 RSS 피드에 실린 전문을 읽고 정리했다. 원문 글에는 라이선스 표시가 없다. 그래서 원문의 그림 6장(표지 그림 2장, 구조 도식 2장, 테스트 앱 GIF 1개, Android 스킬 아이콘 1장)은 복사하지 않고 원문 서버(Medium 이미지 서버) 주소를 걸어 보여 주고, 그림마다 무엇이 보이는지 글로도 적었다. 원문의 코드 5개는 GitHub Gist로 붙어 있는데, 이것도 자동 접근이 막혀 있었다. 같은 코드가 담긴 저자의 샘플 앱 GameNightGuru에는 라이선스가 없어 코드를 옮기지 않았다. 대신 무엇을 하는 코드인지 글로 설명하고, 같은 역할을 하는 Google 공식 샘플 android/appfunctions(Apache-2.0) 의 코드를 인용했다(2026-10-26 기준 커밋 a3a2a89).

먼저 알아 둘 말

서론 — 앱과 AI는 한 기기에 있었지만 서로 몰랐다

밤하늘 아래 유리처럼 빛나는 Android 로봇 머리 위로 코드 조각과 별자리가 떠오르는 그림
원문 표지 그림. 무지갯빛 유리 재질의 Android 로봇 머리 위로 코드 조각과 별자리 같은 선이 빛줄기처럼 올라간다. 원문 이미지, 원문 서버에서 불러옴 (원문)

1. AppFunction이란 무엇인가

2. 왜 AppFunctions를 구현하는가

앱 안에만 있는 데이터

어떤 기능을 AppFunction으로 만들 것인가

경쟁 앱보다 먼저

가운데 반투명한 Android 로봇을 여러 고리가 둘러싸고, 고리 위에 채팅, 일정, 금융 그래프, 쇼핑 카트, 여행, 학습, 명상 아이콘이 놓인 그림
원문 그림. 가운데 선 Android 로봇을 고리가 둘러싸고, 그 위에 채팅·일정·주식 그래프·쇼핑 카트·지구본과 비행기·졸업 모자·명상하는 로봇 같은 여러 분야의 앱 아이콘이 돈다. 여러 앱이 같은 AI 주위에서 경쟁하는 모습으로 읽힌다. 원문 이미지, 원문 서버에서 불러옴 (원문)

3. 전체가 어떻게 돌아가는가

세 층 구조 도식. 맨 위 Clients 층에 LLM을 가진 Agent app, 가운데 Tools 층에 Android Platform과 Query game, Add play, Create note, Edit note 도구, 맨 아래 Servers 층에 Game Night Guru와 Notes app이 있다
AppFunctions의 구조. 맨 아래 Servers 층의 Game Night Guru와 메모 앱이 위쪽으로 "Register AppFunctions"(함수 등록) 화살표를 보내, 가운데 Tools 층에 Query game·Add play·Create note·Edit note 도구가 생긴다. 맨 위 Clients 층의 Agent app(LLM을 가짐)은 가운데 Android Platform에 "Look up Metadata"(설명 조회)를 하고, 고른 도구에 "Execute AppFunction"(실행)을 보낸다. 원문 이미지, 원문 서버에서 불러옴 (원문)
같은 세 층 도식에서 Agent app에서 Android Platform, Query game, Game Night Guru로 이어지는 분홍색 화살표만 강조되고 나머지는 흐리게 처리된 그림
게임 질문이 흘러가는 길. 앞의 도식에서 나머지는 흐리게 하고, 분홍 화살표로 Agent app → Android Platform → Query game → Game Night Guru 경로만 강조했다. 앱의 대답은 같은 길을 거꾸로 올라간다. 원문 이미지, 원문 서버에서 불러옴 (원문)

4. AppFunctions 구현하기

예제에서 가장 쉬운 함수인 게임 찾기를 구현한다. 원문의 코드는 단계마다 Gist로 붙어 있다. 아래에서는 단계마다 원문 코드가 무엇을 하는지 글로 설명하고, 같은 역할을 하는 Google 공식 샘플(ChatApp, 채팅 앱 예제)의 코드를 인용한다.

① 의존성

공식 샘플의 버전 목록과 빌드 설정은 아래와 같다.

[versions]
agp = "9.3.1"
appfunctions = "1.0.0-beta01"
kotlin = "2.4.10"

[libraries]
androidx-appfunctions = { module = "androidx.appfunctions:appfunctions", version.ref = "appfunctions" }
androidx-appfunctions-compiler = { module = "androidx.appfunctions:appfunctions-compiler", version.ref = "appfunctions" }

출처: ChatApp/gradle/libs.versions.toml 일부 (android/appfunctions@a3a2a89, Apache-2.0)

implementation(libs.androidx.appfunctions)
ksp(libs.androidx.appfunctions.compiler)

// AppFunctions ksp option
ksp { arg("appfunctions:aggregateAppFunctions", "true") }

출처: ChatApp/app/build.gradle.kts 일부 (android/appfunctions@a3a2a89, Apache-2.0)

② 서비스

공식 샘플의 같은 부분은 아래와 같다.

/**
 * Service entry point for chat-related AppFunctions such as searching contacts, sending messages, and making calls.
 */
@RequiresApi(36)
@AndroidEntryPoint
@AppFunctionServiceEntryPoint(
    serviceName = "ChatAppFunctionService",
    appFunctionXmlFileName = "chat_app_function_service",
)
abstract class BaseChatAppFunctionService : AppFunctionService() {
    @Inject lateinit var messageRepository: MessageRepository

    @Inject lateinit var recipientsRepository: RecipientsRepository

출처: ChatApp/shared/.../appfunctions/BaseChatAppFunctionService.kt 43–55행 (android/appfunctions@a3a2a89, Apache-2.0)

③ AndroidManifest.xml

<service
    android:name="com.example.chatapp.appfunctions.ChatAppFunctionService"
    android:permission="android.permission.BIND_APP_FUNCTION_SERVICE"
    android:exported="true"
    tools:targetApi="36">
    <property
        android:name="android.app.appfunctions.schema"
        android:value="app_functions_schema.xsd" />
    <property
        android:name="android.app.appfunctions.v2"
        android:value="chat_app_function_service.xml" />
    <intent-filter>
        <action android:name="android.app.appfunctions.AppFunctionService" />
    </intent-filter>
</service>

출처: ChatApp/app/src/main/AndroidManifest.xml 53–67행 (android/appfunctions@a3a2a89, Apache-2.0)

④ AppFunction 쓰기

공식 샘플의 함수와 출력 객체는 아래와 같다. 원문보다 한 걸음 더 나가, 잘못된 입력일 때 AI에게 다음 행동을 알려 주는 예외까지 쓴다.

/**
 * Search for message recipients or chat groups by name or email.
 * Required workflow: Call this before "send" or "makeCall" to obtain a valid endpointValue (unique ID).
 *
 * @param query Search string for contact name, email, or group name. Throws [AppFunctionInvalidArgumentException] if empty.
 * @param contactType Filter results by entity type. Accepts "INDIVIDUAL", "GROUP", or "ANY".
 * @return List of [ContactSearchResult] objects matching the query.
 * @throws AppFunctionInvalidArgumentException If query is empty or blank, an invalid contactType is provided, or if no matching contact or group is found. If thrown, suggest the user clarify the recipient or group name.
 */
@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun searchContacts(
    query: String,
    @AppFunctionStringValueConstraint(enumValues = ["INDIVIDUAL", "GROUP", "ANY"])
    contactType: String,
): List<ContactSearchResult> {
    if (query.isBlank()) {
        throw AppFunctionInvalidArgumentException("Query cannot be empty")
    }
    val recipients =
        when (contactType) {
            "INDIVIDUAL" -> {
                recipientsRepository.searchRecipients(query, 3)
            }
            "GROUP" -> {
                recipientsRepository.searchGroups(query, 3).map {
                    ContactSearchResult(
                        contactDisplayName = it.name,
                        contactType = "GROUP",
                        endpointValue = it.id,
                        endpointDisplayName = it.name,
                    )
                }
            }
            "ANY" -> {
                recipientsRepository.searchAny(query, maxCount = 3)
            }
            else -> {
                throw AppFunctionInvalidArgumentException(
                    "Invalid contactType: $contactType. Must be INDIVIDUAL, GROUP, or ANY.",
                )
            }
        }

    if (recipients.isEmpty()) {
        throw AppFunctionInvalidArgumentException(
            "$contactType with name $query not found. Ask the user to clarify the name",
        )
    }
    return recipients
}

출처: ChatApp/shared/.../appfunctions/BaseChatAppFunctionService.kt 61–110행 (android/appfunctions@a3a2a89, Apache-2.0)

/**
 * Represents a result from a contact or group search.
 */
@AppFunctionSerializable(isDescribedByKDoc = true)
data class ContactSearchResult(
    /** The human-readable name of the contact or group. */
    val contactDisplayName: String,
    /** The type of the found entity, either "INDIVIDUAL" or "GROUP". */
    val contactType: String,
    /** The unique identifier of the endpoint. */
    val endpointValue: String,
    /** The human-readable label/display name of the endpoint. */
    val endpointDisplayName: String,
)

출처: ChatApp/shared/.../appfunctions/Util.kt 23–36행 (android/appfunctions@a3a2a89, Apache-2.0)

5. 시험해 보기

AppFunctions Testing Agent 앱 화면 녹화. Debug 탭에서 Game Night Guru의 addPlay와 queryGames 입력 칸이 보이고, Agent Demo 탭에서 질문을 넣으면 Game Night Guru에 연결한 뒤 전략 게임 다섯 개를 추천한다
AppFunctions Testing Agent로 시험하는 모습(약 25초 GIF). 처음 Debug 탭에는 Game Night Guru의 addPlay(gameId·numPlayers·playTimeMinutes·rating 필수 입력)와 queryGames(maxDurationMinutes 등) 입력 칸과 Run 버튼이 보인다. 이어 Agent Demo 탭(모델 gemini-3-flash-preview)에 "친구 4명이 2시간 동안 놀러 오는데 전략 게임을 좋아한다. 뭘 하면 좋을까?"를 넣는다. 그러면 "Game Night Guru — Connecting…"이 뜬 뒤 Terraforming Mars, Dead of Winter, SHASN, Mosaic, Wingspan을 이유와 함께 추천한다. 원문 이미지, 원문 서버에서 불러옴 (원문)

6. Android 스킬 — 지름길

보라색에서 분홍색으로 물드는 배경 위에 망토를 두른 검은 Android 로봇 실루엣
망토를 두른 Android 로봇 그림(작은 아이콘). "지름길"을 슈퍼히어로에 빗댄 것으로 보인다. 원문 이미지, 원문 서버에서 불러옴 (원문)

7. 지금이 준비할 때다

원문을 읽을 때 주의할 점

코드로 보기 — Aide 구조로 옮긴 스케치

Aide는 「업무 비서」 앱이라 AppFunctions와 잘 맞는다. 사용자가 이미 Gemini 앱을 쓰고 있다면, Aide에 AI 키를 넣지 않아도 "오늘 남은 일정이 뭐야?"를 Gemini가 Aide에 물어 답할 수 있다. 결정 기록(2026-10-08)도 이것을 "키 없이 쓸 수 있는 보조 경로"로 적어 두고 나중에 검토하기로 했다.

아래는 그때 쓸 모습을 보여 주는 직접 쓴 스케치다. 지금 Aide에는 넣을 수 없다(compileSdk 36 — 아래 "Aide에 적용하면" 1번). 실제 코드는 사용자가 쓴다. EventRepository·TaskRepository는 1-a 계획의 Task 5·7 인터페이스를 가정했다.

// app/src/main/kotlin/com/giwon/aide/appfunctions/AideAppFunctions.kt — 직접 쓴 스케치
@RequiresApi(36)
@AndroidEntryPoint
@AppFunctionServiceEntryPoint(
    serviceName = "AideAppFunctionService",
    appFunctionXmlFileName = "aide_app_functions",
)
abstract class AideAppFunctions : AppFunctionService() {
    @Inject lateinit var eventRepository: EventRepository
    @Inject lateinit var taskRepository: TaskRepository
    @Inject lateinit var clock: Clock

    /**
     * Summarize the user's agenda for today: upcoming timed events and open tasks due today or overdue.
     * Use this when the user asks what is left today, what is next, or whether they are free.
     * Returns titles and times only. Event descriptions, attendees and locations are never returned.
     *
     * @param includeTasks Whether to include open tasks. Defaults to true when not given.
     * @return Today's remaining agenda. Times are local wall-clock times like "15:00".
     */
    @AppFunctionDeclaration(isDescribedByKDoc = true)
    suspend fun getTodayAgenda(includeTasks: Boolean? = null): TodayAgenda {
        val now = clock.now()
        val today = now.toLocalDate()                          // 기기 시간대 기준 오늘
        val events = eventRepository.observeEvents(from = now, to = today.endOfDay()).first()
            .filter { !it.allDay }
            .take(MAX_ITEMS)
            .map { AgendaEvent(title = it.title.ifBlank { "(제목 없음)" }, start = it.start.toLocalTimeText()) }
        val tasks = if (includeTasks == false) emptyList() else
            taskRepository.observeOpenTasks().first()
                .filter { task -> task.due?.let { it <= today } == true }
                .take(MAX_ITEMS)
                .map { AgendaTask(title = it.title, overdue = it.due!! < today) }
        return TodayAgenda(events, tasks)
    }

    private companion object { const val MAX_ITEMS = 10 }
}

/** The user's remaining agenda for today. */
@AppFunctionSerializable(isDescribedByKDoc = true)
data class TodayAgenda(
    /** Timed events that have not ended yet, earliest first. */
    val events: List<AgendaEvent>,
    /** Open tasks due today or already overdue. */
    val tasks: List<AgendaTask>,
)
// AgendaEvent(title, start) · AgendaTask(title, overdue) 도 같은 방식으로 정의한다

배운 점

Aide에 적용하면

Aide는 AI 비서 앱이다. AI 기능은 1-b에서 사용자가 고른 제공자를 사용자 키로 앱이 직접 부르는 방식으로 정했다(2026-10-08 결정). 같은 결정의 제약 항목에 "Android AppFunctions로 사용자의 AI가 Aide를 부르게 하기"는 키 없이 쓸 수 있는 보조 경로로 나중에 검토한다고 적혀 있다. 이 글은 그 "나중"에 필요한 조건을 미리 확인한 셈이다. 「서버 없음」(2026-10-08), 「시킨 일은 바로 하고 되돌릴 수 있게, 시키지 않은 일은 묻는다」(2026-10-08), 「최신화는 Task 4 뒤 별도 PR」(2026-10-20)과 맞춰 썼다.

  1. Aide를 AppFunctions 서버로 만드는 것 — 지금은 안 한다.
    • 빌드가 막힌다. appfunctions 1.0.0-beta01은 compileSdk 37을 요구하는데 Aide는 36이다. compileSdk 37은 2026-10-20 결정의 「최신화 PR」(Kotlin 2.4 · compileSdk 37)에 들어 있는 일이라, 그 PR이 먼저 끝나야 한다. AGP 조건(9.1.0 이상)은 Aide의 9.2.1이 이미 채운다. minSdk 26도 문제없다(라이브러리 minSdk 24).
    • 쓸 사람이 아직 없다. 비공개 미리보기라 일반 Gemini 앱이 부르지 않고, Android 16 이상 기기에서만 동작한다.
    • 1-a 범위가 아니다. 1-a는 AI 없이 캘린더·리마인더·할 일·대시보드다.
    • 다시 볼 때: 최신화 PR이 끝나고, AppFunctions가 일반에 풀렸을 때. 1-b의 AI 함수가 생긴 뒤면 더 좋다(아래 2번).
  2. 1-b의 AI 함수(function calling)를 원문의 설계 원칙으로 짠다 — 가져온다. 시점은 1-b 착수 때다.
    • 결정 기록은 이미 AI 함수를 두 종류로 나눴다. 바로 실행하는 것(create_event)과 제안만 하는 것(propose_event·propose_task·ask_completion)이다. 이것은 원문의 "확인" 원칙과 같은 생각이다.
    • 여기에 원문 4장의 요령을 더한다. 함수 설명에 "언제 쓰는지", "먼저 부를 함수", "실패 시 사용자에게 물을 것" 을 적는다. 파라미터는 되도록 null 허용으로 둔다. 결과는 적게(상위 N개) 돌려준다.
    • 이렇게 짜 두면, 나중에 같은 함수를 @AppFunctionDeclaration으로 바깥에 내놓을 때 설명을 다시 쓸 필요가 없다. Aide 안의 LLM이 읽는 설명과 Gemini가 읽는 KDoc이 같은 글이 된다.
  3. AI가 쓰는 함수의 몸통을 저장소 위의 순수 함수로 둔다 — 바꿔서 가져온다. 시점은 Task 8과 1-b다.
    • 원문과 공식 샘플 모두 AppFunction 서비스는 저장소를 부르는 얇은 껍데기다. Aide도 같은 모양이 되려면, "오늘 남은 일정", "마감 지난 할 일" 같은 계산이 화면(ViewModel) 안이 아니라 순수 함수에 있어야 한다.
    • Task 8 계획이 이미 sortOpenTasks·nextEvent를 순수 함수로 두기로 했으니 방향이 맞다. 1-b에서 AI 함수를 만들 때도 같은 함수를 부르게 하면, 화면·내부 AI·AppFunctions가 같은 규칙(종일 일정 제외, "(제목 없음)")으로 대답한다.
  4. 내보낼 데이터의 범위를 먼저 정한다 — 가져온다. AppFunctions를 실제로 넣을 때 정한다.
    • Aide가 읽는 캘린더에는 회사 일정도 섞여 있을 수 있다. 원문 GIF처럼 AI는 받은 값을 웹 검색에 쓸 수 있다.
    • 그래서 위 스케치처럼 제목과 시간만 내보내는 것으로 시작하는 안을 둔다. 어디까지 내보낼지(제목도 숨길지, 할 일만 내보낼지)는 사용자가 정한다. 정하면 decisions.md에 남긴다.
  5. 쓰기 함수(일정·할 일 만들기)를 AppFunction으로 내놓는 것 — 지금은 안 한다.
    • 원문 저자도 쓰기 함수 요령은 "다음 글"로 미뤘다. 공식 샘플은 메시지를 보내기 전에 확인 카드를 돌려주는 방식(fetchSendMessageDetails)을 쓴다.
    • Aide의 「시킨 일은 바로 하고 되돌릴 수 있게」를 바깥 AI에도 그대로 적용할지는 따로 정할 문제다. 바깥 AI는 Aide의 되돌리기 카드를 보여 주지 않기 때문이다. 읽기 함수가 자리 잡은 뒤에 다시 본다.

출처