우테코 8기/Android

Android AppFunctions - 내 프로젝트에 연결하기

Reddish 2026. 7. 26. 01:20
반응형

GDG행사인 Google I/O Extended 에서 핸즈온 세션에 운빨로 참여할 수 있는 기회가 생겼다. 내용은 “Android App Functions: 내 앱을 에이전트에게 연결하기” 였다.

What's new in Android

 

2026 Google I/O 에서 발표한 내용으로, App Functions를 Android의 온디바이스 MCP 방식으로 나왔다고 한다. 이전부터 AppFunctions는 존재했지만, Gemini와 연결하는 실제 개발 경험을 이번에 본격적으로 공개했다.

해당 영상을 보면, 간단하게 첫 번째 앱인 채팅 앱을 구현하고, 사용자가 AI에게 부탁을 하는 두 번째 앱에, Gemini API Key를 넣어 실행하면, 두 번째 앱에서 간단하게 프롬프트로 “~~에게 ~~라고 보내줘” 라고 말하면 실제 앱에서 보내주는 모습이다.

GitHub - android/appfunctions: Android AppFunctions samples · GitHub

안드로이드에서 샘플로 개발한 앱인데, 해당 앱을 다운 받아 실행할 수 있다.

AppFunctions가 그래서 뭔데?

앱이 온디바이스 MCP 서버처럼 동작해 Gemini 같은 에이전트와 함께 사용할 도구 역할을 하는 기능을 제공한다. AppFunctions를 정의하면, 앱에서 제공하는 기능들을 에이전트가 실행할 권한을 가져 사용자 ↔ 에이전트 상호작용을 통해 앱에서 자동으로 실행할 수 있다.

AppFunctions는 Android 16 이상을 실행하는 기기에서 사용할 수 있다. (웬만하면 16보단 17에서 실행하는 걸 추천한다) 나는 Android Studio Panda 버전은 안되고, Quail 2버전을 했을 때 되었다.

AppFunctions를 최적화하고 테스트, 디버깅을 위한 ADB 명령어 스킬을 제공한다. 아래 링크에서 그 스킬을 받을 수 있다. https://github.com/android/skills/tree/main/device-ai/appfunctions

아래 세 방법을 적용해 AppFunctions를 쓸 수 있다

  1. 기존의 함수에서 @AppFunction 어노테이션만 붙여준다면 그대로 그 함수의 기능을 사용할 수 있다.
  2. isDescribedByKDoc = true 를 통해, KDoc 주석을 LLM에게 줄 수 있다
  3. 앱 프로세스 안에서 내 권한을 통해 실행된다. 화면을 띄우지 않고 백그라운드 실행도 가능하다.

android 공식 문서에서 다양한 예시를 들어주는데, 주로 아래와 같은 앱에서 쓸 수 있다

  • 할 일 관리 및 생산성 앱
    • 오늘 오후 5시에 회사에서 택배를 찾아오라고 알려줘
  • 미디어 및 엔터테인먼트
    • 올해의 인기 락 메탈 앨범으로 새 재생목록을 만들어줘
  • 앱 간 워크플로우
    • 이메일에서 국수 레시피를 찾아 재료를 내 쇼핑 목록에 추가해줘
  • 캘린더 및 일정
    • 다음 주 월요일 오후 6시에 엄마 생일파티를 내 캘린더에 추가해줘
/**
 * 제목, 기한, 위치를 포함한 새로운 할일이나 알림을 만든다.
 * Create a new task or reminder with a title, due time, and location.
 *
 * @param title The descriptive title of the task (e.g., "Pick up my package").
 * @param dueDateTime The specific date and time when the task should be completed.
 * @param location The physical location associated with the task (e.g., "Work").
 * @return The created Task
 */
@AppFunction(isDescribedByKDoc = true)
suspend fun createTask(
    title: String,
    dueDateTime: LocalDateTime? = null,
    location: String? = null,
): Task = TODO()
  • 주석의 내부는 KDoc으로, 함수에 대한 설명과 각각의 파라미터에 대한 설명, 그리고 Return 값에 대한 설명을 나타낸다
  • 대략적으로 투두를 만드는 함수이다.
  • 호출자인 LLM앱이 관련 작업 관리 앱을 식별하고 함수를 호출해 작업을 만든다. 제목, 시간, 위치 필드는 사용자의 프롬프트에 따라 자동으로 채워진다

AppFunctions 작동 방식

앱이 AppFunctions를 에이전트와 공유하고 실행하는 흐름을 보여준다.

  1. 앱에서 AppFunctions를 사용해 ‘메모 작성’ 또는 ‘메세지 보내기’와 같은 기능을 사용할 수 있도록 빌드한다.
  2. AppFunctions 라이브러리가 앱에 선언된 모든 AppFunctions를 나열하는 XML 파일을 생성한다. OS는 이 파일을 사용해 사용가능한 AppFunctions를 색인한다
  3. 에이전트가 쿼리를 통해 AppFunction 메타데이터를 검색할 수 있다. 함수별 KDoc 외에도 개발자는 앱 메타데이터에 운영 패턴과 제약 조건을 정의해 여러 도구에서 에이전트 오케스트레이션을 안내할 수 있다.
  4. 사용자 프롬프트를 기반으로 에이전트가 적절한 파라미터를 사용해 적절한 AppFunction을 선택하고 실행한다.

우리가 만든 앱(에이전트가 아닌 앱)에서는 어노테이션과 XML을 통해서 기능한다. 리플렉션과 런타임 등록 없이, 앱 설치만으로 시스템이 인지하게 되게 된다.

호출자(에이전트)가 AppFunctionManager를 사용해 이런 색인 함수를 검색하고 호출할 수 있다. 호출자는 isAppFunctionEnabled(packagename, functionId)를 사용해 타겟 앱 내에서 특정 함수가 사용 설정 되어있는지 확인할 수 있다. 호출자의 앱에서 AppFunctions를 사용하려면 EXECUTE_APP_FUNCTIONSpermission 권한이 필요하다. 이 권한은 함수 목록 조회, 실행 등을 포함한 권한이다.

다만 현재는 실험용 기능이여서 제한된 사용이 가능하다. 이를 위해선 특수한 권한을 가진 채로 실행해야한다.

cd agent

./run_privileged.sh --build --flavor retail --api-key (API Key 넣기)

만약 공식 샘플 앱을 받아온다면, 위와 같은 방식을 통해서 실행할 수 있다. (Mac 기준) 위에 링크가 있다. run_privileged.sh를 통해 권한을 부여해 실행할 수 있다.

실제 프로젝트에 적용

// build.gradle.kts
plugins {
    id("com.google.devtools.ksp")
}

android {
    compileSdk = 36          // 36 이상 필수
}

dependencies {
    implementation("androidx.appfunctions:appfunctions:1.0.0-alpha10")

    // Kotlin 소스를 쓴다면 KSP로 컴파일러를 추가
    ksp("androidx.appfunctions:appfunctions-compiler:1.0.0-alpha10")
}

위와 같은 의존성을 추가해야 한다.

/**
 * 누렁이의 서현역 실내 길 안내를 시작하는 App Function 서비스입니다.
 */
@RequiresApi(36)
@AppFunctionServiceEntryPoint(
    serviceName = "NureongiAppFunctionService",
    appFunctionXmlFileName = "nureongi_navigation_app_functions",
)
abstract class BaseNureongiAppFunctionService : AppFunctionService() {
    /**
     * 서현역 안의 출발지에서 목적지까지 접근성 길 안내를 시작하고 현재 단계를 알림에 표시합니다.
     * 사용자가 "3번 출구에서 여자 화장실까지 안내해 줘"처럼 출발지와 목적지를 말하면 호출합니다.
     *
     * @param startLocation 사용자가 출발하는 서현역 내 장소 이름입니다.
     * @param destination 사용자가 도착하려는 서현역 내 장소 이름입니다.
     * @return 실제로 확인된 출발지, 목적지와 첫 안내 정보입니다.
     */
    @AppFunction(isDescribedByKDoc = true)
    fun startGuidance(
        startLocation: String,
        destination: String,
    ): NavigationStartResult {
        if (!NavigationNotificationController.canPostNotifications(this)) {
            throw AppFunctionPermissionRequiredException(
                "누렁이 앱을 열고 알림 권한을 허용한 뒤 다시 안내를 요청해 주세요.",
            )
        }

        val route = try {
            NavigationRouteResolver().resolve(startLocation, destination)
        } catch (exception: IllegalArgumentException) {
            throw AppFunctionInvalidArgumentException(exception.message)
        }
        // 시작점과 목적지를 설정했을 때, 문제가 없다면 네비게이션 경로를 생성한다
        
        NavigationNotificationController.start(this, route)  
        // 네비게이션을 시작하고, 상단바에 알림을 생성한다
        val firstStep = route.steps.first()
        // 첫 시작을 설정하고, 결과를 반환한다        
        return NavigationStartResult(
            startLocation = route.startName,
            destination = route.destinationName,
            currentInstruction = firstStep.guideMessage,
            remainingDistance = firstStep.remainingDistanceText,
            totalSteps = route.steps.size,
        )
    }
}

// 반환 값인 NavigationStartResult는 아래에 있다.
  • AppFunctionServiceEntryPoint 는 AppFunction를 담을 서비스들을 선언한다.
    • KSP가 이 클래스를 상속한 구현서비스(NureongiAppFunctionService)를 생성하게 된다.
    • 주석(KDoc)을 통해서 에이전트에게 해당 내용을 전달한다.
    • appFunctionXmlFileName 은 생성될 XML 파일명이다.
  • AppFunction은 실제로 에이전트가 실행할 수 있는 함수이다.
    • isDescribedByKDoc 을 통해 에이전트에게 해당 내용을 전달할 수 있다.
    • 예외도 복구 지시로 가능하다. @throws 에 “if thrown, ask the user to …” 를 적어서 유저에게 질문할 수 있도록 한다. (예를 들면, 목적지를 빼먹었다거나, 목적지와 출발지가 같아 예외가 발생하면 다시 설정하라고 할 수 있다.)

추가적으로 DI(Hilt, Dagger 등)를 그대로 활용할 수 있다.

/**
 * 시작된 길 안내의 현재 상태입니다.
 */
@AppFunctionSerializable(isDescribedByKDoc = true)
data class NavigationStartResult(
    /** 확인된 출발지 이름입니다. */
    val startLocation: String,
    /** 확인된 목적지 이름입니다. */
    val destination: String,
    /** 지금 따라야 할 첫 번째 안내입니다. */
    val currentInstruction: String,
    /** 목적지까지 남은 거리입니다. */
    val remainingDistance: String,
    /** 목적지까지의 전체 안내 단계 수입니다. */
    val totalSteps: Int,
)
  • 프로퍼티의 KDoc도 읽는다. 각 필드 위의 주석이 그대로 필드 설명으로 XML에 들어가게 된다.
  • 지원하는 타입은 기본형(Primitive, String, Int 등), List<T>에다가 Uri, PendingIndent와 같은 Android 타입도 지원해준다.
<service
    android:name=".appfunctions.NureongiAppFunctionService"
    android:exported="true"
    android:permission="android.permission.BIND_APP_FUNCTION_SERVICE"
    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="nureongi_navigation_app_functions.xml" />
    <intent-filter>
        <action android:name="android.app.appfunctions.AppFunctionService" />
    </intent-filter>
</service>

그리고 KSP가 생성한 서비스를 Manifest에 등록해야한다.

내가 사용하던 앱을 사용해 세부적인 부분은 이해가 안되도, abstract class 를 선언한 선언부와 어노테이션, suspend 함수만 이해해도 어느 정도는 쓸 수 있다고 생각한다.

실제 동작하는 화면은 아래와 같다. (한국어 설정을 따로 안해서 영어로 보이는점...)

https://youtube.com/shorts/zzk-mJrD_wo?si=9YSEWskdQ1H_AJu-

 

GDG에서 핸즈온 발표 해주신 기민님 감사합니다..!

13따봉도 받았다. skydove님의 Compose 책도 노려볼만했는데 내 codex는 왜 느린거야 ㅠ

 

반응형