LY Corporation Tech Blog

LY Corporation과 LY Corporation Group(LINE Plus, LINE Taiwan and LINE Vietnam)의 기술과 개발 문화를 알립니다.

AI 에이전트를 위한 Android CLI: 대규모 모바일 개발 환경에 적용하기

LINE Android 앱은 수백 명의 개발자가 하나의 Git 저장소에서 함께 만들어 가는, 수백 개의 Gradle 모듈로 구성된 대규모 Android 앱입니다. LINE Android 앱 개발 팀은 최근에 여러 AI 코딩 에이전트(주로 Claude Code, 그 외 Codex·OpenCode·Gemini in Android Studio 등)를 개발에 일상적으로 활용하고 있는데요. 이런 규모에서는 AI 에이전트가 토큰을 과도하게 소비하는 상황이 빈번하게 발생합니다. 코드가 방대해 하나의 검색 요청이 수많은 결과를 반환할 수 있으며, 그 결과가 에이전트의 컨텍스트에 포함되면 그것만으로 비용이 빠르게 증가합니다. 게다가 텍스트 검색(grep/glob)이나 에이전트가 찾은 방대한 결과로는 ‘이 심볼이 어디에서 선언 혹은 참조되는지’, ‘IDE(Integrated Development Environment)에서 이 파일에 대한 문제를 어떻게 파악하는지’와 같은 의미론적 질문에 정확히 답하기 어려워서 에이전트가 질문과 무관한 결과에 의존한 채 재시도를 거듭하며 토큰을 더 소비합니다. 모듈이 많아질수록 비효율은 점점 더 커집니다.

Android CLI는 Android 개발자에게 점차 기본 도구가 될 것입니다. 그러나 수백 개 모듈 규모의 저장소에 그대로 적용하면 토큰을 낭비하고 에이전트가 예기치 않은 오작동을 할 우려가 있습니다. 이 글은 저희가 그 한계를 메우기 위해 Android CLI 위에 쌓은 보완책(얇은 래퍼(wrapper), 스킬, 프롬프트)을 소개하고, 이 보완책을 이용해 대규모 저장소와 여러 에이전트 환경에서 토큰을 아끼며 안정적으로 쓰기 위해 어떻게 접근하고 있는지 소개하겠습니다.

도입 배경: 첫 적용처는 문서 검색

저희는 Google이 Android CLI를 공개 발표한 직후 도입해 사용하기 시작했습니다(참고: Build Android apps 3x faster using any agent). Android CLI는 AI 에이전트가 Android 개발 작업을 커맨드라인에서 수행하도록 돕는 Google의 도구입니다. 프로젝트 빌드·배포, SDK(Software Development Kit) 관리, 환경 진단부터 공식 문서 검색, 뒤에서 다룰 Android Studio 연동까지 폭넓은 기능을 제공합니다.

이 기능 중 저희가 가장 먼저 적용한 것은 문서 검색입니다. 에이전트가 적은 토큰으로 Android·Jetpack Compose·AndroidX·Firebase 등 공식 문서를 최신 상태로 참조할 수 있다면 생산성 향상에 직접적인 도움이 될 것이라고 판단했기 때문입니다. 에이전트의 사전 학습 지식에는 시점 제한이 있기 때문에 빠르게 변화하는 Android·AndroidX API에서는 더 이상 유효하지 않은 시그니처를 그럴듯하게 생성하는 환각(hallucination)이 발생하기 쉽습니다. 이럴 때 권위 있는 최신 문서를 검색해 근거로 삼게 하면 환각을 줄일 수 있을 것입니다.

문서 검색 자체는 그 전에도 Google Cloud의 Knowledge MCP(Model Context Protocol) 서버를 이용하여 사용하고 있었습니다. 다만 다음과 같은 이유로 이를 대규모 조직에 적용하는 데는 부담이 있었습니다.

  • 개발자마다 개별적으로 Google Cloud API 인증을 구성해야 하는데 수백 명 규모에서는 운영 복잡도가 너무 큰 작업입니다.
  • 운영 복잡도를 줄이기 위해 인증을 대신 처리하는 프락시를 별도로 운영했더니 이번에는 할당량 제한에 직면해 이를 처리하기 위한 로직을 추가하고 유지해야 했습니다.

결과적으로 문서 검색 하나를 위해 인증 프락시와 할당량 대응 로직이라는 부수 인프라를 계속 유지해야 했는데 Android CLI는 이 부담을 없애 주었습니다. 인증 프락시도, 할당량 제한 우회 로직도 더 이상 필요하지 않습니다. 저희는 MCP 기반 문서 검색을 Android CLI로 교체했으며, 이를 에이전트에 제공하는 것이 get-android-dev-knowledge 스킬입니다. 이 스킬은 Android CLI의 docs 서브커맨드를 두 단계로 사용합니다. 키워드로 문서를 찾는 search와, 그 결과의 본문을 가져오는 fetch입니다.

# 1. 키워드로 공식 문서를 검색
.agents/tools/android-cli/android docs search \
        'Android Jetpack Compose quickstart'

# 2. 검색 결과의 KB URL로 본문을 가져오기
.agents/tools/android-cli/android docs fetch \
        'kb://android/develop/ui/compose/documentation'

교체로 얻은 이점은 다음과 같습니다.

  • 최신 공식 문서 참조: 모델의 사전 학습 지식보다 최신 지식인 Android Knowledge Base를 직접 참조 출처로 삼습니다.
  • 토큰 절약: 범용 웹 검색보다 적은 토큰으로 문서를 가져옵니다.
  • 부수 인프라 제거: MCP 서버도, 개발자별 인증도, 인증 프락시도, 할당량 제한 대응 로직도 필요 없습니다. 저장소에 번들된 바이너리를 경로로 호출하면 됩니다.

CLI 바이너리를 Git 저장소에 포함시킨 이유

get-android-dev-knowledge를 적용하면서, 저희는 Android CLI 바이너리를 Git 저장소에 포함하기로 했습니다. 앞선 예시에서 명령을 저장소 내부 경로인 .agents/tools/android-cli/android로 직접 호출하고 있다는 점을 파악하셨을 것입니다. 전역에 설치된 android 명령을 쓰는 대신 저장소 안에 고정된 경로를 부르고 있는데, 이는 바이너리를 저장소에 포함(번들링)했기 때문입니다. 그렇게 결정한 이유는 세 가지입니다.

1. 환경 파편화 방지

설치를 개발자 개개인에게 맡기면 버전·설치 위치·플랫폼이 개발자마다 달라지면서 환경 차이 때문에 오류가 발생할 수 있습니다. 설치를 저장소 클론으로 대체하면 이 문제가 사라집니다. 커밋에 고정된 바이너리가 저장소와 함께 배포되므로 장비 간 버전 불일치도 없습니다. 이는 개발자 장비뿐 아니라 CI(Continuous Integration)와 에이전트 실행 호스트에도 똑같이 적용됩니다. 어디서 클론하든 동일한 버전이 포함됩니다.

2. 보안 정책과 래퍼

회사 보안 정책상 불필요한 정보가 외부로 나가는 것을 가능한 한 피해야 합니다. Android CLI는 호출 시 일부 데이터를 수집할 수 있으며, 이를 제어하려면 호출마다 인자를 지정해야 합니다(참고: Data collected).

현재 저희 시스템에서 이 도구를 실제로 호출하는 주체는 사람이 아니라 에이전트입니다. 에이전트는 호출마다 이 인자를 누락할 수 있으므로 호출을 감싸는 래퍼에서 인자를 강제하는 편이 안전합니다. 이때 래퍼가 CLI를 호출하려면 바이너리 위치를 알아야 하며, 그 위치를 보장하는 가장 단순한 방법이 저장소의 고정 경로에 두는 것입니다. 즉, 바이너리 번들링은 단순한 편의일 뿐 아니라, 보안 요구 사항을 충족하기 위한 래퍼의 전제 조건이기도 합니다.

3. git-lfs

저희는 이미 대규모 Git 저장소를 운영하고 있어 git-lfs가 적용되어 있습니다. 덕분에 바이너리를 저장소에 포함하는 비용은 크지 않았습니다.

Android CLI 1.0과 Android Studio 연동

Google I/O 26 직전 Android CLI 1.0이 출시되면서 기능이 크게 확장되었습니다(참고: Android CLI is now stable at 1.0: agent-driven Android development). 저희는 이 릴리스에서 Android Studio 연동 기능이 추가되었다는 점에 주목했습니다. Apple은 이미 지난해 WWDC에서 외부 에이전트가 MCP 서버를 통해 Xcode에 접근하도록 하는 기능을 선보였는데(참고: Giving external agents access to Xcode), Android 측에서도 이에 상응하는 기능이 제공된 것은 의미 있는 변화였습니다. 이 시점에는 세부 기능을 모두 파악한 것은 아니었으나, 개발 환경에 연동하기 위한 사전 작업을 시작했습니다.

Android CLI 래퍼와 1.0 도입 과정에서 발견한 정보 추적 수집 버그

앞서 보안 정책 때문에 호출마다 데이터 수집 제어 인자를 지정해야 한다고 언급했습니다. 그 인자가 --no-metrics이고, 이를 강제하는 지점이 모든 Android CLI 호출이 거치는 Android CLI 래퍼입니다. 이 스크립트는 플랫폼에 알맞은 바이너리를 선택한 뒤 항상 --no-metrics를 붙여 실행합니다.

exec "${REAL_BIN}" --no-metrics "$@"

저희는 --no-metrics와 관련해 Android CLI 1.0을 도입하는 과정에서 버그(issue 515098197)를 하나 발견했습니다. Android CLI의 정보 추적 수집 기능이 --no-metrics가 적용되기 전에 초기화되어, 플래그를 주더라도 여전히 ~/.android/cli 아래에 기록을 시도하는 버그입니다. 이때 에이전트의 샌드박스나 파일 시스템 권한으로 이 디렉토리에 쓰기 작업이 허용되지 않았다면 CLI는 여러 페이지에 걸친 Java 스택 트레이스를 출력합니다. 이에 따라 에이전트는 스택 트레이스 전체를 컨텍스트에 담아 해석하면서 토큰을 낭비합니다.

이를 막기 위해 Android CLI 래퍼를 수정했습니다. 실제 호출 전에 ~/.android/cli에 대한 쓰기 가능 여부를 미리 확인(probe)하고, 막혀 있으면 파싱 가능한 한 줄짜리 오류로 출력하도록 만들었습니다.

# Workaround for https://issuetracker.google.com/issues/515098197
# android-cli's metrics tracker initializes before --no-metrics is honored,
# so it still writes under ~/.android/cli. Probe up front so callers get a
# parseable error instead of a multi-page Java stack trace.

ANDROID_CLI_HOME="${HOME}/.android/cli"
mkdir -p "${ANDROID_CLI_HOME}" 2>/dev/null
PROBE="${ANDROID_CLI_HOME}/.write_probe_$$"
if ! ( : > "${PROBE}" ) 2>/dev/null; then
    echo "Error: android-cli requires write access to ${ANDROID_CLI_HOME}, but writes are blocked (sandbox or filesystem permission). Grant write access and retry." >&2
    exit 1
fi
rm -f "${PROBE}"

이제 에이전트는 장황한 스택 트레이스 대신 원인(쓰기 권한 차단)과 조치(권한 부여 후 재시도)를 한 줄로 전달받습니다. 이미 반복 관찰한 오류 내용을 에이전트가 곧바로 대응할 수 있는 형태로 바꿔 주는 것, 이 패턴은 이후 Android Studio 연동 래퍼에서도 핵심 설계 원칙이 됩니다.

Android Studio 연동이 제공하는 기능

Android Studio 연동은 android studio 서브커맨드로 제공됩니다. 연동하면 실행 중인 Android Studio 인스턴스에 연결해 IDE 수준의 의미론적 작업을 커맨드라인에서 수행할 수 있습니다. 저희가 활용하는 기능은 다음과 같습니다.

  • check: Android Studio가 실행 중이고, 대상 프로젝트가 열려 인덱싱까지 끝났는지 확인합니다. 나머지 기능의 전제 조건입니다.
  • analyze-file: 빌드 없이 단일 파일에 IDE 인스펙션을 수행하고 에러나 경고를 반환합니다. grep으로는 재현할 수 없는 ‘사용되지 않음(is never used)’과 같은 의미론적 검사를 포함합니다.
  • find-declaration: 심볼의 선언 위치를 찾습니다. 프로젝트뿐 아니라 의존성(.aar/.jar) 내부까지 대상으로 삼습니다.
  • find-usages: 심볼의 사용처를 찾습니다.
  • render-compose-preview: Compose @Preview를 PNG 이미지로 렌더링합니다.

이들 역시 CLI를 있는 그대로 에이전트에 노출하지 않았습니다. Android CLI 래퍼와 같은 원칙으로 각 기능을 얇은 래퍼와 스킬로 감쌌고, 그 출발점은 studio-check 스킬이었습니다.

연동의 출발점: studio-check 스킬

문서 검색에서 Android CLI가 기존 MCP 구현을 동등하게 대체할 수 있음을 확인한 뒤, 저희는 유사한 접근을 Android Studio 연동에도 적용하기로 했습니다. CLI의 입출력을 에이전트에게 직접 그대로 노출하는 대신 몇 가지 간단한 작업만 하는 래퍼 스크립트로 감싸 스킬로 제공하는 방식입니다.

가장 먼저 만든 것은 studio-check 스킬입니다. 이 스킬 외 나머지 studio-* 기능은 모두 ‘에이전트가 사용하려는 Android Studio 인스턴스가 실제로 이 프로젝트를 열고, 인덱싱까지 끝내서, 정상적으로 작동하는 상태’를 전제로 합니다. studio-check는 그 전제를 확인하는 역할을 맡습니다. 이 스킬의 래퍼는 android studio check를 실행한 뒤, 그 출력에서 현재 프로젝트를 연 Android Studio 인스턴스를 프로젝트 경로로 매칭합니다.

여기서 한 가지를 래퍼 안으로 감췄습니다. 바로 폴링(polling)입니다. Android Studio의 인스턴스 상태를 에이전트가 직접 관리하며 적절한 간격으로 재시도하도록 설계하는 것은 번거롭고 결과도 불안정합니다. 그래서 인덱싱이 진행 중이면 래퍼가 끝날 때까지 기다렸다가 결과를 돌려주고, 너무 오래 걸리면 실패 코드로 끝내도록 했습니다. 성공하면 인스턴스 식별자(PID)와 프로젝트 경로를 한 줄로 반환합니다. 에이전트는 단일 호출로 준비됐다는 코드나 실패 코드만 수신하면 되므로 에이전트와 CLI 연동이 단순해집니다. 동시에 폴링을 위한 반복 호출과 그 출력이 에이전트 측에서 발생하지 않으므로 토큰 소비도 줄어듭니다.

출력을 비교하면 차이가 분명합니다(아래 예시는 실제 출력을 단순화하고 경로·이름을 익명화한 것입니다). 원본 android studio check는 실행 중인 모든 인스턴스와 각 인스턴스가 연 프로젝트, 그 인덱싱 상태를 함께 나열합니다.

$ android studio check

pid: 66541
    Projects:
        READY     project-a    /Users/dev/AndroidStudioProjects/project-a
        INDEXING  project-b    /Users/dev/AndroidStudioProjects/project-b
pid: 70112
    Projects:
        READY     project-c    /Users/dev/AndroidStudioProjects/project-c

studio-check 래퍼는 이 중 현재 프로젝트를 연 인스턴스만 골라 인덱싱이 끝날 때까지 기다린 뒤 한 줄로 돌려줍니다.

$ .agents/tools/studiohelper/check

STUDIO_PID=66541 STUDIO_PROJECT_PATH=/Users/dev/AndroidStudioProjects/project-a

오류가 발생하는 경로에서는 차이가 더 큽니다. 앞서 본 정보 추적 수집 버그 때문에 ~/.android/cli 쓰기가 막히면, 이 실패는 정보 추적 수집 초기화 단계에서 일어나므로 studio check를 포함한 어떤 명령에서도 같은 스택 트레이스가 출력됩니다. 다음은 이슈 515098197에 보고된 실제 출력의 일부입니다.

Unable to initialize metrics, ensure %s is writable, details: %s
Exception in thread "main" java.lang.RuntimeException: Unable to initialize first usage tracking spool file
        at com.android.tools.analytics.JournalingUsageTracker.<init>(JournalingUsageTracker.kt:87)
        at com.android.tools.analytics.AnonymousUsageTrackerWriter.<init>(AnonymousUsageTrackerWriter.kt:24)
        at com.android.tools.analytics.UsageTracker.createAnonymousWriter(UsageTracker.kt:317)
        at com.android.tools.analytics.UsageTracker.setWriters(UsageTracker.kt:311)
        at com.android.tools.analytics.UsageTracker.modeChanged(UsageTracker.kt:300)
        at com.android.tools.analytics.UsageTracker.initialize(UsageTracker.kt:214)
        at com.android.cli.analytics.AndroidCliAnalytics.initialize(AndroidCliAnalytics.java:98)
        at com.android.cli.analytics.AndroidCliAnalytics.<init>(AndroidCliAnalytics.java:36)
        at com.android.cli.ui.AndroidCLI.<init>(AndroidCLI.kt:20)
        at com.android.cli.Android.main(Android.java:54)
Caused by: java.nio.file.FileSystemException: /Users/xxxxxx/.android/cli/analytics/metrics/spool/…​.trk: Operation not permitted
        at java.base/sun.nio.fs.UnixFileSystemProvider.newFileChannel(Unknown Source)
        at com.android.tools.analytics.JournalingUsageTracker.newTrackFile(JournalingUsageTracker.kt:97)
        ... 10 more

같은 상황에서 studio-check는 Android CLI 래퍼의 사전 확인(앞 절 참고) 덕분에 한 줄로 끝냅니다.

$ .agents/tools/studiohelper/check

Error: android-cli requires write access to ~/.android/cli, but writes are blocked (sandbox or filesystem permission). Grant write access and retry.

이 경우 에이전트가 받는 것은 파싱하기 쉬운 한 줄입니다. 멀티 인스턴스 목록을 직접 해석하거나, 스택 트레이스 전체를 읽을 필요가 없습니다.

스킬 구성과 에이전트별 실행 경로

studio-check를 시작으로 나머지 기능 스킬도 같은 방식으로 구현해 나갔습니다. 이들은 문서 검색 때와 마찬가지로 바이너리·래퍼·스킬을 모두 저장소 안에 함께 번들링합니다. 디렉토리 구조는 다음과 같이 정리됩니다(render-compose-preview 스킬은 번들된 바이너리를 직접 호출하고, 나머지 네 개는 studiohelper 래퍼를 거칩니다).

<repo-root>/
└── .agents/
        ├── skills/                              # 에이전트가 보는 스킬 문서 (기능당 하나)
        │   ├── studio-check/SKILL.md
        │   ├── studio-analyze-file/SKILL.md
        │   ├── studio-find-declaration/SKILL.md
        │   ├── studio-find-usages/SKILL.md
        │   └── studio-render-compose-preview/SKILL.md
        └── tools/
                ├── studiohelper/                    # 얇은 래퍼: 출력 정리 + 오류 변환
                │   ├── check
                │   ├── analyze_file
                │   ├── find_declaration
                │   └── find_usages
                └── android-cli/
                        └── android                      # 저장소에 번들된 CLI 바이너리 (커밋에 버전 고정)

이 구성에서 의도한 바는 각 스킬을 가능한 한 Gemini in Android Studio의 빌트인 툴과 1:1로 대응시키는 것입니다. 같은 작업이라도 에이전트 종류에 따라 다음과 같이 실행 경로가 달라지기 때문입니다.

  • Gemini in Android Studio처럼 IDE 안에서 작동하는 에이전트는 동일 기능의 빌트인 툴을 직접 호출하면 됩니다. 이때 저희 래퍼는 불필요하므로 스킬은 필요하지 않습니다.
  • Claude Code, Codex, OpenCode처럼 IDE 외부에서 작동하는 에이전트에게는 이 스킬–래퍼–CLI 경로가 해당 기능에 접근하는 유일한 수단입니다.

스킬을 빌트인 툴과 1:1로 맞춰 두면, "이 파일을 분석", "이 프리뷰를 렌더링", "선언 찾기" 같은 동일한 요청이 에이전트 종류와 무관하게 각자에게 가장 효율적인 경로로 처리됩니다.

이와 같이 에이전트에 종속되지 않도록 구성한 것은 의도적인 설계입니다. 가격 정책을 비롯한 여러 이유로 어떤 에이전트가 주력이 될지는 앞으로 바뀔 수 있습니다. 특정 에이전트에 종속되지 않도록 설계해 두면, 주력이 바뀌더라도 연동을 재구축할 필요가 없습니다.

또 다른 이유는 이 위에 쌓을 스킬에 있습니다. 복잡한 고수준 도메인 지식(high-level domain knowledge)이 필요한 스킬일수록 이런 기반 스킬을 가져다 쓰는 경우가 자주 발생합니다. 그런데 기반 스킬이 에이전트마다 다르게 작동하면, 그 위에 구현된 도메인 지식 스킬도 에이전트별로 작동이 분기되어 제작과 유지 보수가 크게 복잡해집니다. 기반 스킬이 에이전트와 무관하게 동일한 작동을 보장하면서도 각 에이전트에게 최적의 경로로 실행되도록 해 두는 것은, 그 위에 도메인 지식 스킬을 안정적으로 쌓기 위한 토대이기도 합니다.

대규모 프로젝트에서 특히 유용한 find-declaration과 find-usages 기능

앞서 소개한 기능 중 저희처럼 대규모 프로젝트에 특히 잘 맞는 것은 find-declaration과 find-usages입니다.

대규모 프로젝트에서는 코드가 방대하고 서브프로젝트가 많다 보니 같은 이름의 심볼이 여러 모듈에서 자주 등장합니다. 그러나 이름만 같을 뿐, 컨텍스트에 따라 서로 다른 선언을 가리키고 다른 역할을 하는 경우가 많습니다.

텍스트 검색은 이름 문자열만 비교하므로 이런 동명 심볼을 구분하지 못합니다. 검색 결과에 무관한 매치가 포함되고, 정작 찾던 선언이나 사용처는 해당 결과 안에서 식별하기 어려워집니다.

반면 find-declaration과 find-usages는 IDE가 인덱싱한 정보를 바탕으로 심볼을 해석합니다. 이름이 같아도 실제 선언·타입·스코프를 구분해 의도한 대상만 짚어 줍니다. 코드베이스가 클수록 이 정확도 차이는 더욱 커집니다.

이는 Android Studio IDE에서 이미 검증이 되어 일상적으로 쓰는 기능이라, 저희는 CLI도 같은 수준을 제공하리라 기대했습니다(다만 find-usages는 현재 CLI에서 그 기대에 못 미치는 부분이 있으며, 자세한 내용은 뒤의 '알려진 문제와 래퍼의 보완' 섹션에서 다룹니다).

이 정확도를 살리려면 두 기능을 ‘있으면 좋은 추가 도구’ 정도로 두는 것만으로는 부족합니다. 에이전트의 기본 도구인 grep/glob을 심볼 검색에 한해 대체하도록 유도해야 합니다. 심볼 선언이나 사용처를 찾을 때는 텍스트 검색이 아니라 find-declaration/find-usages를 먼저 쓰도록 스킬에서 안내하는 것입니다. 그래야 에이전트가 대규모 트리에서 효과적으로 작동합니다.

이 안내는 스킬의 description에 명시됩니다. 각 스킬 설명은 자신을 ‘grep/glob 문자열 매칭보다 정확한’ 도구로 규정하고, 심볼 검색에 해당하는 트리거 문구를 함께 나열합니다. 예를 들어 find-usages 스킬의 설명은 다음과 같습니다.

description: >
    Locate usages of a symbol via Android Studio's semantic index —
    more accurate than Grep/Glob string matching for finding where a
    class, method, field, or Android resource is referenced.

    Triggers on `find usages of`, `who calls <symbol>`, `find references`.

에이전트가 ‘사용처 찾기’와 같은 요청을 만나면 이 트리거 문구와 ‘grep/glob보다 정확하다’는 규정이 텍스트 검색 대신 이 스킬을 고르도록 유도합니다.

JetBrains에서 비슷하게 접근하고 있습니다. JetBrains의 AI 가이드라인은 표준 에이전트 도구를 IDE 기반 도구로 매핑합니다. 예를 들어 Grepsearch_in_files_*로, Globfind_files_*로 대체합니다. 근거는 ‘표준 도구는 파일 시스템을 직접 다루지만, IDE 도구는 IDE가 보는 파일 뷰를 다룬다’는 것으로, IDE의 인덱싱을 활용해 대규모 코드베이스에서 더 정확하고 빠른 검색을 얻습니다(JetBrains Kotlin AI guidelines — Tool mapping).

토큰을 아끼는 출력 설계

대규모 저장소에서 의미론 도구의 실제 비용은 CLI 호출 자체가 아니라, 그 결과를 에이전트의 컨텍스트에 싣는 데서 발생합니다. 예를 들어 흔한 심볼에 대한 find-usages는 코드 컨텍스트가 붙은 수백 개의 결과를 돌려줄 수 있습니다. 이를 그대로 컨텍스트에 넣으면 답을 계산하는 비용보다 답을 표시하는 비용이 더 커집니다.

이는 에이전트가 셸(shell) 명령의 출력을 다루는 전형적인 패턴과 맞닿아 있습니다. 출력이 적으면 그대로 처리할 수 있지만, 분량이 커지면 흔히 다음과 같은 패턴이 반복됩니다.

  1. 명령이 대량의 출력을 생성
  2. 에이전트가 이를 한꺼번에 처리하지 못해 head·tail·grep 같은 필터를 붙여 같은 명령을 여러 번 다시 실행
  3. 그래도 원하는 부분을 못 찾으면 즉시 필터링 스크립트를 작성
  4. 그 과정에서 토큰과 시간을 크게 소모하며, 최악의 경우 본래 의도했던 작업을 완수하지 못한 채 종료

여기서 2번의 '같은 명령 재실행'이 특히 까다롭습니다. 명령이 매번 동일한 출력을 낸다면 그나마 낫지만, 실행할 때마다 내용이 달라질 수 있는(불안정한) 출력이라면 에이전트가 매번 다른 데이터를 보며 판단이 흔들립니다.

find-usages 출력이 바로 이 함정에 빠지기 쉬운 형태입니다. 그래서 래퍼는 다음 원칙을 따릅니다. 명령은 한 번만 실행하고, 정규화된 요약은 표준 출력(stdout)으로 내보내고, 상세 데이터는 파일로 저장한 뒤 그 위치만 알려 줍니다. 구체적으로 살펴보면 다음과 같습니다.

  1. 기본 출력은 간결한 형식입니다. find-usages는 매치당 경로:줄 한 줄만 표준 출력에 출력합니다. 경로는 저장소 기준 상대 경로로 줄이고, 외부 jar만 절대 경로로 남깁니다. 코드 컨텍스트가 붙은 자세한 출력은 파일로 저장하고, 표준 출력에는 그 파일을 가리키는 한 줄만 남깁니다.
  2. 상세 정보는 필요한 경우에만 조회합니다. 특정 매치의 줄 번호나 주변 코드가 필요하면, 에이전트는 저장된 파일을 grep합니다. CLI를 다시 호출하지 않습니다. 명령을 재실행하면 추가 비용이 발생할 뿐 아니라, 출력이 실행 때마다 달라질 수 있어 에이전트가 일관되지 않은 데이터를 보게 됩니다. 저장된 파일은 그 시점의 결과를 고정한 스냅샷이므로, 동일한 데이터를 여러 번 안정적으로 참조할 수 있습니다.
  3. 중복을 줄이고 요약합니다. analyze-file은 파일의 분석 결과를 Severity | Count | Message 요약 테이블로 접어 표준 출력에 보여주고, 줄 단위 상세 테이블은 파일로 저장합니다. 에이전트는 문제 전체의 개요를 적은 토큰으로 파악하고, 실제로 수정이 필요한 부분만 선별해 상세를 확인합니다.

find-usages를 예로 들어 보겠습니다. 원본 CLI는 매치마다 절대 경로와 Line:, 주변 코드 블록을 함께 출력합니다. 사용처가 수백 곳이면 출력은 수천 줄에 달합니다(아래 두 예시는 이해를 돕기 위해 실제 출력을 단순화하고, 경로·심볼을 익명화한 것입니다).

$ android studio find-usages <symbol> --pid=… --project=…

/Users/dev/app/lib/aaa-ui/src/main/java/…/ContactRow.kt
Line: 88
     86        Row(...) {
     87            …
     88            ProfileImage(userId = id)
     89            …

/Users/dev/app/lib/bbb-impl/src/main/java/…/MemberItem.kt
Line: 134
     132        …
     133        …
     134        ProfileImage(userId = member.id)
     …  (위와 같은 블록이 매치 수만큼 반복)

래퍼는 같은 결과를 매치당 한 줄(경로:줄)로 줄이고, 코드 컨텍스트가 붙은 전체 출력은 파일로 저장한 뒤 그 경로만 한 줄 덧붙입니다.

$ .agents/tools/studiohelper/find_usages <symbol> --pid=… --project=…

lib/aaa-ui/src/main/java/…/ContactRow.kt:88
lib/bbb-impl/src/main/java/…/MemberItem.kt:134
…  (매치당 한 줄)

Verbose output (with surrounding code context) saved to: build/studio-find/20260625T0000Z-usages-<symbol>.txt

에이전트는 이 간결한 목록만으로 영향받는 모듈을 즉시 파악하고, 특정 위치의 주변 코드가 필요한 경우에만 저장된 파일을 grep합니다. 위 예제는 지면상 일치하는 결과를 두 건만 보여주고 있지만, 실제로는 검색 결과가 수백 건을 넘는 일이 자주 발생합니다. 그쯤 되면 원본 출력은 앞서 설명한 패턴처럼 에이전트가 감당하기 어려운 분량이 되는데, 대규모 프로젝트에서는 이것이 예외적인 상황이 아니라 일상입니다.

결과적으로 가장 일반적인 작업 흐름, 즉 결과를 검토하여 수정할 지점을 결정하는 과정에서 소비되는 토큰은 원본 결과 집합의 크기와 무관하게 낮게 유지됩니다. 앞서 정보 추적 수집 버그에서 여러 페이지에 걸친 스택 트레이스를 한 줄로 압축했던 것과 같은 원칙을, 정상 출력에도 동일하게 적용합니다.

환경에 따라 달라지는 작동, 그리고 프롬프트

지금까지의 스킬은 하나의 전제 위에서 작동합니다. ‘IDE 외부의 에이전트가, 실행 중이고 인덱싱이 끝난 Android Studio에 연결한다’는 것입니다. 이 전제가 성립하지 않는 환경이 두 가지 있는데요. 이 두 가지 환경은 각각 프롬프트로 처리합니다.

Android Studio가 작동하지 않을 때

Android Studio가 실행 중이지 않거나 인덱싱이 완료되지 않은 상태에서도 심볼 검색을 무조건 find-declaration/find-usages로 수행하도록 설정하면, 에이전트는 사용할 수 없는 도구를 계속 호출하며 실패한 호출을 반복하거나 작업을 중단합니다. 그래서 studio-check 실패 시의 대응 지침을 프로젝트의 AGENTS.md/CLAUDE.md에 명시했습니다. 이 파일들은 Claude Code와 Codex 등 모든 에이전트가 공통으로 참조합니다.

When `studio-check` does not succeed, do not run the `studio-` skills:

- For symbol search, fall back to the builtin Grep / Glob tools.
- Otherwise, there is no builtin substitute: relay what `studio-check`
    reported and let the user resolve it (start Studio, open this workspace,
    or wait for indexing), then retry.

Re-check on the user's signal; once it succeeds, use the skills normally
without asking again.

명시한 내용은, 심볼 검색은 기본 grep/glob으로 되돌리고, IDE 전용 기능(인스펙션·preview 렌더링)은 추측으로 진행하지 않고 실패 원인과 조치를 전달한 뒤 기다리라는 것입니다. 연동 가능 여부를 studio-check 스킬 한곳에서 판정하므로, 이 분기는 모든 기능 스킬에 동일하게 적용됩니다.

Gemini in Android Studio일 때

반대 상황도 있습니다. 에이전트가 이미 Android Studio 안에서 작동하는 경우(Gemini in Android Studio)입니다. 이때는 IDE의 빌트인 툴을 직접 호출할 수 있으므로, 저희가 제작한 studio-* 스킬과 래퍼를 사용할 시 동일한 작업을 이중으로 처리하게 됩니다.

중복 처리를 막기 위해, 앞서 각 스킬을 빌트인 툴과 1:1로 대응시켰다고 했는데 그 대응을 Gemini in Android Studio의 Prompt Library 기능으로 프로젝트 범위(project scope) 프롬프트로 등록합니다. 이렇게 등록한 프롬프트는 프로젝트 설정 파일 .idea/project.prompts.xml에 저장되고, Gemini in Android Studio가 이를 기본 지침으로 삼아서 스킬 대신 빌트인 툴을 호출합니다.

## Skill/Tool mapping

Inside Android Studio, the `studio-` skills duplicate Android Studio builtin
tools. When one of these skills is requested, run the matching builtin tool
instead of the skill:

| Project skills                  | Builtin tools           |
| ------------------------------- | ----------------------- |
| `studio-analyze-file`           | `analyze_file`          |
| `studio-find-declaration`       | `find_declaration`      |
| `studio-find-usages`            | `find_usages`           |
| `studio-render-compose-preview` | `render_compose_preview`|

The `studio-check` skill is unnecessary here: Android Studio is already
running with this project open, so skip it and call the builtin tools directly.

이 상황에서는 studio-check조차 필요 없습니다. 이미 IDE가 이 프로젝트를 열고 있으니, 곧바로 빌트인 툴을 호출하면 됩니다.

render-compose-preview: 간단한 명령, 까다로운 성공 판정

render-compose-preview는 Compose @Preview를 PNG 이미지로 렌더링하는 기능입니다. 에이전트는 통상 Compose UI를 코드 분석으로만 파악하지만, 이 기능을 사용하면 실제 렌더링 결과를 직접 확인할 수 있습니다. UI를 수정한 뒤 의도대로 렌더링되는지 시각적으로 검증하고(--print-semantics를 붙이면 각 노드의 위치·접근성 텍스트까지), 시각적 회귀를 잡아낼 수 있습니다. 에이전트는 자신이 작성한 UI 변경을 코드 추론이 아니라 실제 렌더 결과로 검증할 수 있기 때문에 개발자에게 반복해서 확인 요청하는 과정을 줄일 수 있습니다. 개발 생산성에 직접적인 영향을 미치는 기능입니다.

명령 자체는 간단합니다. 대상 파일과 함수 이름, 출력 경로를 넘기면 됩니다.

.agents/tools/android-cli/android studio render-compose-preview \
        --pid=<STUDIO_PID> \
        --project=<STUDIO_PROJECT_PATH> \
        --output-image-file=build/studio-render/<composable>.png \
        <path> <composable>

우선 대상 함수는 @Preview(또는 @PreviewLightDark처럼 @Preview를 묶어 만든 멀티 프리뷰 어노테이션)를 단 함수여야 합니다. 성공 여부는 표준 출력의 단 한 줄, Wrote rendering to <path>로 판정합니다.

여기서 까다로운 점은, 렌더링에 실패해도 CLI가 Error: Failed to find preview element.만 출력하고 exit code 0으로 끝나는 경우가 있다는 것입니다. exit code만으로는 성공을 알 수 없습니다. 따라서 스킬은 Wrote rendering to 줄의 유무로만 성공을 판정하고, 그 줄이 없으면 실패로 보고 원인 진단에 들어갑니다.

또한 스킬은 실패를 단순 재시도로 처리하지 않습니다. 대신 가능한 원인을 순서대로 검토하여 첫 번째로 해당하는 항목에서 중단하도록 다음과 같은 진단 절차를 스킬에 적어 두었습니다(가독성을 위해 일부 표현을 다듬었습니다).

## Step 3 — On failure, diagnose before retrying

A failure surfaces as an `Error: …` line on stdout (often with exit 0),
or as a non-zero exit with no rendering. Stop at the first cause that
matches.

1. **Stale PID** — connection error or non-zero exit with no
     preview-specific message. Invoke `studio-check` to refresh, then
     retry rendering the preview.
2. **Not a renderable preview** — run
     `grep -nE -B 10 'fun <composable>' <path>`. The target must be a
     `fun` carrying `@Preview` or a `@Preview…` multi-preview. If not,
     run `grep -n -C 8 '@Composable' <path>` to collect the functions
     whose block carries a `@Preview`(-like) annotation. Re-run this
     skill with the sole candidate as `<composable>`; if several, pick
     the one closest to `<composable>` by name, otherwise ask the user
     which to render.
3. **File does not compile** — use the `studio-analyze-file` skill on
     `<path>`. Report ERROR findings. But widespread `Unresolved
     reference` on standard symbols (e.g. `Column`, `Text`) means a
     stale model, not real errors — go to 4.
4. **Gradle Sync needed** — if 1–3 are clean, tell the user to run
     "Sync Project with Gradle Files" in Android Studio, then retry.
     (`studio-check` confirms open + indexed, not Sync success.)

각 지시어의 의미는 다음과 같습니다.

  1. 오래된 PID: Android Studio의 연결 오류처럼 보이거나, 프리뷰 관련 메시지 없이 비정상 종료한 경우. studio-check로 PID 핸들을 갱신하고 재시도합니다.
  2. 렌더 대상이 아님: grep으로 대상 함수가 @Preview 또는 같은 종류의 어노테이션을 포함하는지 확인합니다. 아니라면 같은 파일에서 @Preview 어노테이션을 포함한 함수 후보를 모아 하나면 그것으로, 여럿이면 이름이 가장 가까운 것으로 다시 시도하거나 사용자에게 묻습니다.
  3. 컴파일 오류: Composable 함수의 Preview 렌더링은 컴파일이 가능해야 수행할 수 있습니다. 컴파일 오류는 본래 해당 코드를 컴파일하는 Gradle 태스크를 직접 실행해야 드러납니다. 그런데 대규모 멀티 모듈 프로젝트에서는 그 파일에 맞는 태스크를 찾는 것부터 쉽지 않고, 태스크 실행에도 꽤 많은 시간이 걸릴 수 있습니다. 대신 studio-analyze-file 스킬을 사용하면 빌드 없이도 그 파일의 컴파일 가능 여부를 빠르게 확인할 수 있습니다. 진짜 컴파일 오류가 있다면 ERROR로 보고합니다. 다만 Column·Text처럼 Jetpack Compose의 기본 API에서 Unresolved reference가 광범위하게 나타난다면 이야기가 다릅니다. 항상 존재하는 표준 심볼조차 해석되지 않는다는 것은 코드 자체의 문제가 아니라 Android Studio의 프로젝트 모델이 최신 상태와 일치하지 않는다는(프로젝트 동기화에 문제가 있는) 신호입니다. 이때는 컴파일 오류로 보고하지 않고 다음 단계로 넘어갑니다.
  4. Gradle Sync 필요: 앞선 단계를 거치며 코드 자체의 문제가 아닌 것으로 추측되면, IDE에서 Sync Project with Gradle Files를 실행한 뒤 재시도하도록 안내합니다. studio-check가 보장하는 것은 프로젝트가 열려 인덱싱이 끝났다는 사실뿐입니다. 인덱싱은 동기화 이후의 작동이라 과거 어느 시점엔가 동기화가 성공했다는 뜻이기는 하지만, 그 이후 빌드 구성이 바뀌었다면 최신 프로젝트 구성(의존성 포함)이 모델에 반영되었는지는 알 수 없기 때문입니다.

이처럼 실패 원인을 항목별 점검으로 명시해 두면, 에이전트가 같은 실패를 반복하지 않고 사람의 개입 없이도 다음 행동을 결정할 수 있습니다.

알려진 문제와 이를 해결하기 위한 래퍼 보완

Android Studio 연동을 적용하는 과정에서 여러 가지 예기치 않은 작동이 확인되었습니다. 대부분은 래퍼와 스킬에서 보완하여, 에이전트가 오류 없이 작업을 계속 진행할 수 있도록 했습니다(앞 절에서 다룬 render-compose-preview의 실패 진단도 그 한 예입니다).

  • 최상위(top-level) Kotlin·Compose 심볼의 까다로운 표기: find-declaration과 find-usages에서 최상위 함수·프로퍼티는 일반 FQN(Fully Qualified Name, 예: pkg.bar)이 아니라 합성 클래스 <FileName>Kt로 지정해야 합니다(pkg.FooKt#bar). 게다가 이 이름은 선언이 아니라 파일명에서 나오기 때문에 호출자가 추측하기 어렵습니다. 스킬에 이 규칙을 명시해, 에이전트가 처음부터 올바른 형식으로 질의하도록 했습니다(issue 524395182).
  • 범위 지정 없이 흔한 이름의 심볼 선언 검색이 오래 걸리는 경우: --context-file= 없이 짧고 흔한 이름을 검색하면, 의존성(Jetpack Compose)과 수많은 서브프로젝트를 훑느라 매우 오래 걸릴 수 있습니다. 스킬은 이렇게 흔하거나 모호한 이름을 다룰 때 사용처 파일을 --context-file=로 함께 넘겨 범위를 줄입니다.
  • 오래된 프로세스 핸들: Android Studio를 재시작하면 캐시해 둔 PID가 무효가 됩니다. 래퍼는 비정상 종료를 감지해 studio-check로 핸들을 한 번 다시 확인한 뒤 재시도하도록 합니다. 무한 반복에 빠지지 않습니다.

다만 모든 문제를 클라이언트에서 막을 수 있는 것은 아닙니다. 가장 큰 미해결 과제는 Gradle sync입니다. 인덱스에 의존하는 명령(analyze-file, find-declaration, find-usages, render-compose-preview)은 모두 Gradle sync가 끝난 프로젝트 모델을 전제로 합니다. 그런데 동기화한 뒤 변동이 발생하면(stale) 이들 명령은 경고 없이 불완전하거나 잘못된 결과를 돌려줍니다. CLI에는 동기화를 트리거하는 서브커맨드도, 동기화가 필요하다는 경고도 없습니다. studio-check 역시 프로젝트가 열려 인덱싱이 끝났다는 것까지만 확인할 수 있을 뿐, 그 이후 변경된 빌드 구성(의존성 포함)이 프로젝트 모델에 반영되었는지는 알 수 없습니다. 현재로서 유일한 대응은 IDE에서 수동으로 동기화하는 것입니다. 이 부분은 동기화 서브커맨드와 staleness 경고를 요청하는 버그 리포트로 남겨 두었습니다(issue 525093271, P1).

또 하나의 미해결 문제는 find-usages의 신뢰성입니다. 같은 기능이 Android Studio IDE에서는 문제없이 작동하지만 CLI에서는 기대만큼 작동하지 않았습니다. find-usages는 비FQN(bare) 심볼 이름을 정확히 하나의 선언으로 해석하는데, 호출자가 어느 선언을 대상으로 할지 지정할 방법도, 동명 후보가 여럿이라는 신호도 없습니다. 그래서 이름이 충돌하면 결과가 다음 두 방향으로 어긋납니다.

  • 과다 보고(over-reporting): getBoolean처럼 흔한 멤버 이름은 모든 타입의 동명 메소드를 한꺼번에 매칭해 수천 줄을 반환합니다. 게다가 멤버에는 사용 가능한 FQN이 없어(android.os.Bundle#getBoolean은 ‘failed to identify the target declaration’으로 실패) 범위를 좁힐 수단조차 없습니다.
  • 조용한 누락(under-reporting): 지역 멤버의 이름이 널리 쓰이는 함수와 동일하면, 다른 선언의 사용처가 경고 없이 누락됩니다. 공개 샘플 프로젝트 nowinandroid로 재현해 보면, 한 파일이 androidx 확장 updateLayoutParams만 사용할 때 find-usages updateLayoutParams는 5건(사용처·import·core-ktx 선언)을 정확히 반환합니다. 그런데 같은 파일에 이름이 같은 지역 private 함수를 하나 추가하면, 결과는 그 지역 멤버 호출 1건으로 줄고, 확장 함수의 사용처는 동명 후보가 있다는 신호도 없이 사라집니다.

우회 수단은 최상위 함수에 한해서만 존재합니다(합성 클래스 FQN …Kt#name, issue 524395182). 클래스 멤버에는 사용할 수 있는 FQN이 없어 이름 충돌을 구분할 수 없습니다. 이처럼 결과를 신뢰하기 어려워 현재는 studio-find-usages 스킬을 비활성화하고 사용처 검색은 잠정적으로 grep으로 대체하고 있으며, 재현 케이스와 함께 issue 528094657로 검증과 개선을 요청했습니다.

활용 구상: 미사용 코드 자동 정리에 활용

이렇게 구성한 스킬을 대규모 앱 소스에서 어떻게 활용할지는 현재 검토 중입니다. 검토하는 활용처 중 한 가지는 미사용 코드(dead code)를 자동으로 정리하는 기능입니다.

studio-analyze-file 스킬은 ‘사용되지 않음(is never used)’과 같은 검사를 수행해 단순한 미사용 심볼을 식별합니다. 이 경우 해당 심볼을 제거하는 작업은 그 자체로는 어렵지 않습니다. 문제는 그다음입니다.

많은 코드는 테스트 커버리지를 위해 단위 테스트에서 참조하는데 이런 심볼은 analyze-file 관점에서는 ‘사용됨’으로 보입니다. 그러나 앱 런타임에서 참조되지 않는 코드는 단위 테스트에서 참조하고 있더라도 미사용 코드일 확률이 매우 높습니다. 사실상 테스트를 위해서만 존재하는 코드라 할 수 있습니다. 따라서 studio-find-usages 스킬로 그 심볼이 테스트에서만 사용되는지 식별할 수 있습니다. 다만 이를 코드 전반에서 자동으로 가려내는 일은 현재로서는 매우 어렵습니다.

여기에 더해, DI나 @AutoService처럼 직접 호출 없이 런타임에 연결되는 코드도 함께 식별해야 합니다. 이런 코드는 사용처 검색에 잡히지 않아 미사용으로 오인되기 쉽지만, 실제로는 런타임에 살아 있는 코드입니다.

이처럼 아직 초기 단계의 아이디어이며 해결해야 할 과제도 남아 있지만, 실현 가능성은 충분하다고 판단합니다.

마치며

이 글 전반을 아우르는 패턴은 CLI 출력을 있는 그대로 에이전트에게 전달하지 않고, 얇은 래퍼를 넣어 오류를 파싱 가능한 형태로 바꾸고, 출력을 정규화된 요약으로 줄이고, 스킬의 description으로 호출 트리거를 명시하는 것입니다. 도구나 규모에 관계없이 적용할 수 있는 방식입니다.

Android CLI의 Android Studio 연동 기능은 곧 출시될 Android Studio 2026.1.1(Quail)부터 사용할 수 있습니다. 이 글에서 언급한 문제들, 특히 Gradle sync 관련 과제가 해결된다면 더 나은 개발 환경을 갖출 수 있을 것이고, 대규모 앱 개발 환경의 생산성을 끌어올리는 데에도 큰 도움이 될 것으로 기대합니다.

참고 자료

Android CLI

Google Knowledge MCP

이슈 트래커(관련 버그·요청)

  • b/515098197: 샌드박스에서 ~/.android/cli 쓰기 실패 시 복잡한 스택 트레이스 출력
  • b/524395182: 최상위 Kotlin/Compose 심볼의 import 이름 조회 지원 요청
  • b/525093271: Gradle sync 인지 및 동기화 서브커맨드 요청
  • b/528094657: find-usages가 비FQN(bare) 심볼을 단일 선언으로 해석(흔한 이름 과다 보고·이름 충돌 시 누락, 멤버는 FQN 구분 불가)

기타