Android 프로젝트에서 Claude Code가 직접 빌드 및 테스트를 실행하도록 설정하기
요약
본 글은 Claude Code가 Android 프로젝트 수정 후 빌드 및 테스트를 자동으로 실행하고 그 결과를 보고하도록 설정하는 방법을 다룹니다. 이를 위해 `CLAUDE.md`에 명시적인 Gradle 태스크 절차를 작성하고, `.claude/settings.json` 권한 설정을 통해 명령을 확인 없이 실행하게 하는 것이 핵심입니다.
핵심 포인트
- Claude Code가 빌드 및 테스트 결과를 직접 보고하도록 설정할 수 있습니다.
- `CLAUDE.md`에 필요한 Gradle 태스크와 절차를 명시적으로 작성해야 합니다.
- 권한 설정을 통해 긴 빌드/테스트 명령이 타임아웃되지 않도록 처리하는 것이 중요합니다.
Claude Code에게 Android 수정을 요청하면, 대부분 '구현했습니다'에서 멈춰버립니다. 빌드가 통과하는지, 기존 테스트가 깨지지 않았는지 확인하는 작업은 여전히 제 몫으로 남습니다. 차이점을 읽기 전에 터미널로 돌아가 ./gradlew를 실행하고, 실패하면 에러를 붙여넣고 다시 요청해야 합니다. 이 왕복 과정이 가장 많은 시간을 잡아먹습니다.
이 글에서는 Claude Code가 수정을 마친 후 직접 빌드와 테스트를 실행하고, 그 결과를 첨부하여 보고하는 상태를 만드는 방법을 다룹니다. 설정할 내용은 다음 세 가지입니다.
CLAUDE.md에 Android 프로젝트의 빌드 및 테스트 절차 작성하기 -.claude/settings.json의 권한 설정을 통해 지정된 Gradle 태스크로 시작하는 명령을 확인 없이 실행하도록 하기 - hooks를 사용하여 Kotlin 파일을 편집할 때마다 포맷팅 적용하기
설정 형식은 2026-09-25 시점의 Claude Code 공식 문서를 참고했습니다. 기사 중 소요 시간과 세션의 모습은 같은 날 다음 환경에서 실제로 작동시킨 결과입니다.
| 항목 | 값 |
|---|---|
| 프로젝트 | 자체 Android 앱 MyntPack (:app 단일 모듈, main Kotlin 파일 62개, 유닛 테스트 9개. Google Play에 공개된 v1.2.2와 동일한 의존성) |
| ... |
모듈이 하나뿐인 작은 앱이므로, 멀티모듈이나 KMP 프로젝트에서는 아래 시간이 훨씬 길어질 것입니다.
Android에서 '방치'가 멈추는 이유
Gradle의 소요 시간은 데몬과 캐시 상태에 따라 한 자릿수 이상 차이가 납니다. MyntPack에서 :app:compileDebugKotlin와 :app:testDebugUnitTest의 소요 시간을 측정했습니다. 표의 위 2행은 파일을 수정한 직후 각각 3번 측정한 값입니다. 3행은 Gradle 캐시(GRADLE_USER_HOME)를 비운 상태에서 빌드하고, compile과 test를 각각 2번 측정한 값입니다. 모두 외부 time으로 측정하여 데몬 시작 시간을 포함했습니다.
| 상태 | compileDebugKotlin | testDebugUnitTest |
|---|---|
| 데몬 시작 완료 | 1.72.2초 | 2.22.7초 |
| ... |
문제가 되는 것은 마지막 행입니다. Claude Code의 Bash 도구는 기본적으로 2분(BASH_DEFAULT_TIMEOUT_MS의 기본값 120000)으로 명령을 중단합니다. 캐시가 비어있는 상태에서는 Gradle 본체와 의존 라이브러리 다운로드가 실행되어, 4번 중 2번이 2분을 초과했습니다. 395초가 걸린 경우의 원인을 분리할 수는 없지만, Gradle 본체와 의존 다운로드를 포함하는 만큼 회선 상태에 따라 크게 변동합니다. 새로운 기기에서 처음 요청한 수정이나, 의존성을 추가한 직후의 빌드가 이 경계에 걸립니다.
또 다른 사정은 Gradle 태스크가 많다는 것입니다. ./gradlew test는 모든 바리언트의 테스트를 실행하고, ./gradlew build는 lint까지 포함합니다. 무엇을 실행해야 할지 지정해주지 않으면, 어떤 태스크를 선택할지는 Claude Code에게 맡기게 됩니다. 무거운 태스크를 선택할 수도 있고, 컴파일만 확인하고 끝날 수도 있습니다.
CLAUDE.md에 실행할 태스크를 명시적으로 작성하기
프로젝트의 CLAUDE.md는 세션 시작 시마다 읽히는 지침서입니다. 리포지토리 루트에 CLAUDE.md를 배치합니다 (.claude/CLAUDE.md에서도 읽힙니다).
/init을 실행하면, Claude Code가 코드베이스를 조사하여 빌드 명령어와 테스트 절차를 포함하는 CLAUDE.md 초안을 만듭니다. 여기서부터 시작해도 좋습니다. 어떤 변경 시점에 어떤 태스크를 실행할지, 완료로 간주하는 조건은 무엇인지 등 코드로 읽어낼 수 없는 결정적인 사항은 직접 추가합니다.
MyntPack의 CLAUDE.md도 빌드 섹션에 ./gradlew :app:assembleDebug와 ./gradlew :app:testDebugUnitTest를 포함했습니다.
단지 2줄밖에 없었습니다. 명령어는 작성되어 있지만, 언제 실행하고 어떻게 완료되는지에 대한 내용은 적혀 있지 않습니다. 이 글을 위해 다음 섹션을 추가했습니다 (--tests
[예시의 클래스명은 독자 친화적인 일반 이름으로 변경했습니다].
<!-- CLAUDE.md -->
# 빌드 및 테스트
## 변경할 때마다 실행하기
...
공식 문서에서는 지침을 구체적으로 작성할수록 따르기 쉬우므로, CLAUDE.md는 파일당 200줄 이내를 권장합니다. '테스트를 실행하는 것'보다 './gradlew :app:testDebugUnitTest'를 실행하는 것이 더 확실하게 효과적입니다.
완료 조건에 '기존 테스트를 삭제하거나 @Ignore 처리하여 통과하지 못함', '3번 수정해도 통과하지 못하면 중지'라고 적는 것은, 테스트를 통과시키는 것 자체를 목적으로 삼지 않게 하기 위함입니다. 테스트를 삭제하면 언제든지 테스트는 통과할 수 있습니다. 이를 금지하는 문구가 없으면, 차이점을 읽는 사람은 매번 그 부분을 의심해야 합니다. 위의 예시는 기대값 수정까지는 금지하지 않았으므로, 신경 쓰인다면 '기존 테스트의 기대값을 변경하지 않음'도 추가합니다.
세션 중에 /context를 실행하면, 로드된 CLAUDE.md가 'Memory files' 목록에 표시됩니다. 작성했는데 효과가 없는 것 같다고 느껴지면, 먼저 이곳을 확인해야 합니다.
권한으로 결정된 Gradle 태스크만 통과시키기
CLAUDE.md에서 지시해도, 명령어를 실행할 때마다 확인을 요청하게 되면 결국 화면에 붙어 계속 승인해야 합니다. 그래서 .claude/settings.json의 permissions에 확인 없이 실행해도 되는 명령어들을 나열합니다.
// .claude/settings.json
{
"permissions": {
...
끝의 * (공백과 별표)는 해당 태스크에 --tests와 같은 인수가 붙은 호출에도, 인수 없는 호출에도 일치합니다. 다만 *는 뒤따르는 문자열을 무엇이든 받아들이기 때문에, ./gradlew :app:compileDebugKotlin clean처럼 다른 태스크를 뒤에 추가한 호출도 일치합니다. 공식 문서에서도 인수를 좁히려는 패턴은 취약하다고 주의하고 있습니다. 이 규칙으로 제한할 수 있는 것은 '지정된 태스크로 시작하는 명령어'까지만입니다. &&나 |로 연결된 복합 명령어는, 연결된 각 명령어가 허가되지 않으면 통과하지 못합니다. 실제 세션에서는 Claude Code가 ./gradlew :app:compileDebugKotlin -q 2>&1 | tail -20 && ./gradlew :app:testDebugUnitTest 2>&1 | tail -20와 같이 연결하여 실행했습니다. tail은 Claude Code가 읽기 전용으로 처음부터 허용한 명령어(공식 문서에 목록이 있습니다)이므로, 이 형태로도 확인 없이 통과합니다.
여기서 Bash(./gradlew *)라고 적어 모든 태스크를 통과시키고 싶겠지만, 추천하지 않습니다. 이유는 두 가지가 있습니다.
첫 번째는, ./gradlew *를 지정하면 publish나 clean, 플러그인이 추가한 임의의 태스크까지 맨 앞에 놓인 채로 통과시켜 버린다는 것입니다. 위에서 쓴 것처럼 개별적으로 태스크를 명시해도, 뒤에 태스크를 붙이는 빠져나갈 구멍은 남아있지만, 적어도 첫 번째 태스크는 제한할 수 있습니다.
두 번째는 Android 특유의 사정으로, 이쪽이 더 심각합니다. Gradle 빌드 스크립트는 Kotlin 코드입니다. 만약 build.gradle.kts를 수정한 후에 ./gradlew를 확인 없이 실행할 수 있다면, 임의의 코드를 확인 없이 실행할 수 있는 것과 같습니다. 위의 설정에서 *.gradle.kts와 libs.versions.toml의 편집을 ask로 한 것은, 이 경로를 인간의 확인으로 한 번 멈추기 위함입니다. 다만, ask가 효과를 발휘하는 것은 Claude Code의 편집 도구나, sed나 리디렉션처럼 Claude Code가 인식할 수 있는 쓰기까지입니다. Python 같은 스크립트가 파일을 수정하는 경로는 막을 수 없습니다.
deny의 Read(...)
또한, Claude Code가 인식하는 명령어인 Bash의 cat이나 head 같은 명령에도 적용됩니다. 다만, Gradle처럼 프로세스 자체가 파일을 여는 동작까지 막을 수는 없습니다. 빌드 스크립트가 서명 파일(signing file)을 읽도록 설정되어 있다면, ./gradlew assembleRelease를 실행할 때 Gradle이 해당 파일을 읽게 됩니다. 서명 정보는 리포지토리 외부나 환경 변수에 두고, release 빌드의 태스크를 allow에 포함시키지 않습니다.
샌드박스와 Gradle 데몬 조합 시 주의사항
프로세스 단위로 파일 접근을 막고 싶다면 Claude Code의 샌드박스를 사용합니다. macOS에서는 Seatbelt 덕분에 Bash에서 시작된 명령어와 그 자식 프로세스에 제한이 걸립니다. 그런데 Gradle은 이 '자식 프로세스'라는 전제와 맞지 않습니다. 빌드를 실행하는 것은 ./gradlew의 자식 프로세스가 아니라, 백그라운드에서 상주하는(常駐する) Gradle 데몬이기 때문입니다.
sandbox.filesystem.denyRead에 파일을 하나 지정하고, 그 파일만 읽는 Gradle 태스크를 준비하여 테스트해 보았습니다.
| 조건 | 결과 |
|---|---|
sandbox.network.allowLocalBinding을 지정하지 않음 | Gradle이 시작되지 않음 (java.net.SocketException: Operation not permitted) |
sandbox.network.allowLocalBinding: true, 데몬 없음 | 시작함. 대상 파일의 읽기는 거부됨 |
| 위와 같고, 샌드박스 외부에서 이미 실행된 데몬 있음 | 같은 데몬이 사용되어 대상 파일을 읽을 수 있었음 |
위와 같고, --no-daemon 추가 | 대상 파일의 읽기는 거부됨 |
샌드박스 외부에서 시작된 데몬이 남아있으면, 샌드박스 내부의 ./gradlew가 그 데몬에게 처리를 넘겨버립니다. 데몬은 샌드박스 외부에 있기 때문에 denyRead는 효과가 없습니다. Gradle 데몬은 기본적으로 3시간 동안 사용되지 않으면 남아 있습니다. 만약 같은 JDK와 JVM 인수로 얼마 전에 터미널에서 빌드를 했다면, 재사용될 조건이 갖춰져 있을 수 있습니다.
역방향 문제도 있었습니다. 샌드박스 내에서 시작된 데몬은 Claude Code 세션이 끝나도 남아있습니다. 그 후 터미널에서 일반적인 ./gradlew를 실행하면, 이 데몬이 재사용되어 샌드박스의 제한을 그대로 물고 빌드가 실패했습니다.
샌드박스에서 Gradle을 구동하려면 --no-daemon을 붙여서, 빌드할 때마다 샌드박스 내에서 일회용(使い捨て) 데몬을 시작해야 합니다. 그만큼 매번의 빌드 시간은 '데몬 종료 후' 줄(MyntPack 기준 13~17초)과 비슷하게 걸릴 것으로 예상합니다 (--no-daemon 사용 시 소요 시간은 측정하지 않았습니다).
타임아웃을 Gradle에 맞추기
앞서 언급된 2분 제한은 설정 파일의 env에서 늘릴 수 있습니다.
// .claude/settings.json
{
"env": {
...
MyntPack 기준으로 캐시가 비어있을 때 최장 시간은 395초였습니다. 5분으로도 부족했던 경우가 있었기 때문에, Claude Code가 명령어별로 지정할 수 있는 상한의 기본값과 같은 10분으로 설정했습니다. 멀티 모듈 프로젝트라면, 본인의 환경에서 캐시가 비어있는 빌드를 몇 번 측정하여 결정해 주세요.
BASH_MAX_TIMEOUT_MS (기본값 600000)는 Claude Code가 명령어별로 지정할 수 있는 타임아웃의 상한입니다. 이번 세션에서도 Claude Code는 Gradle 실행에 600000을 지정하고 있었습니다. 기본값을 늘려두면, 지정하는 것을 잊었을 때도 중단되지 않고 진행될 수 있습니다.
hooks로 편집할 때마다 포맷 적용하기
hooks는 Claude Code의 특정 타이밍에 명령어를 실행하는 메커니즘입니다. PostToolUse는 도구(tool) 실행이 성공한 직후에 작동합니다. Edit와 Write 실행 후에, 수정된 파일이 .kt
그렇다면 ktlint로 포맷을 적용합니다.
// .claude/settings.json
{
"hooks": {
...
#!/bin/bash
# .claude/hooks/ktlint-format.sh
FILE_PATH=$(jq -r '.tool_input.file_path')
...
스크립트에는 실행 권한(chmod +x)을 부여하고, jq와 ktlint의 CLI에 경로를 설정합니다. Spotless 설정과는 별개로, CLI를 설치해 두어야 합니다. 프로젝트 경로에 공백이 포함되는 경우에는, command의 경로를 따옴표로 묶습니다.
hook에는 실행된 도구의 정보가 JSON 형식으로 표준 입력에 전달됩니다. tool_input.file_path는 수정된 파일의 경로입니다. 1개 파일을 처리하는 데는 0.9초가 걸렸습니다.
ktlint가 자동으로 고칠 수 없는 위반 사항을 발견하면, 스크립트는 위반 내용을 표준 에러로 출력하고 종료 코드 2로 종료합니다. PostToolUse에서는 도구가 이미 실행되었기 때문에, 종료 코드 2여도 수정이 취소되지 않습니다. 대신, 표준 에러의 내용이 Claude Code에 전달됩니다.
시험 삼아 'PeriodTest.kt'에서 java.time의 import를 와일드카드 한 줄로 모아줘'라고 요청해 보았습니다. 와일드카드 import는 ktlint의 standard:no-wildcard-imports 위반 사항이라 자동으로 고칠 수 없습니다. Claude Code는 수정 직후 hook의 출력을 받았습니다. 그 후 컴파일과 PeriodTest를 실행하여 통과하는 것을 확인한 후에, import를 원래대로 되돌렸습니다. 보고서에는 ktlint가 출력한 에러 라인('Wildcard import (cannot be auto-corrected) (standard:no-wildcard-imports)')과 함께, 이 리포지토리의 ktlint 설정에서는 와일드카드 import가 금지되어 있어 이 상태로는 lint가 실패할 수 있다는 내용이 적혀 있었습니다. 여기서 말하는 lint는 ktlint의 검사이며, Android lint(:app:lintDebug)과는 별개입니다.
여기에 ./gradlew의 컴파일도 넣고 싶어졌습니다. 데몬이 실행 중이라면 2초 전후이므로 시간만으로는 허용할 수 있습니다. 넣지 않는 이유는 수정 도중에 있는 상태이기 때문입니다. 후술하는 개선 사항에서, Claude Code는 먼저 sealed class에 object ThisMonth를 추가하고, 다음 수정에서 when의 분기를 추가했습니다. 1차 수정 직후에는 when이 모두 포함되지 않아 컴파일은 반드시 실패합니다. hook으로 컴파일하면, 이 도중 과정의 에러까지 매번 Claude Code에게 반환됩니다. 컴파일과 테스트는 CLAUDE.md의 완료 조건에 맡기고, hook은 파일 단독으로 완결되는 처리만 하도록 합니다.
실제로 작동시켜 보기
설정을 적용한 상태에서 작은 수정 사항을 1건 요청했습니다.
기간 필터 Period에 '이번 달'(당월 1일 00:00부터 당월 말일 23:59:59.999까지)를 나타내는 ThisMonth를 추가해 주세요. UI 측의 대응은 필요 없습니다.
비대화 모드(claude -p)로, 파일 수정을 자동으로 승인하는 모드(--permission-mode acceptEdits)로 설정했습니다. 새로 clone한 디렉터리이므로, 위의 settings.json은 --settings로 전달했습니다. Gradle 실행을 허용할지 여부는 그 allow만으로 결정됩니다.
Claude Code가 거친 과정은 다음과 같습니다. 소요 시간은 약 54초였습니다.
Period를 참조하는 파일을 검색하고, Period.kt・테스트・관련 파일을 읽습니다. 외에 when으로 포함된 곳이 없는지 확인합니다 -
Period.kt에 object ThisMonth와 rangeMillis의 분기를 추가합니다 -
PeriodTest.kt
테스트를 2개 더 추가합니다. 월의 첫날과 마지막 날 경계(전후 1밀리초 포함)와, 윤년의 2월-컴파일 및 유닛 테스트를 확인 없이 실행하여 성공 여부를 확인합니다.
- 테스트 결과의 XML을
grep으로 읽어,PeriodTest가 6건(신규 2건)이고 실패가 0건임을 확인합니다.
완료 보고에는 변경한 파일과 그 내용, 추가한 테스트의 내용, 그리고 '컴파일 및 새로운 2건을 포함한 모든 유닛 테스트가 통과했다'는 결과가 나열되어 있었습니다. 세션 후에 Spotless의 spotlessCheck를 실행해 보았지만, 위반 사항은 없었습니다.
한편, CLAUDE.md의 '변경할 때마다 실행하기' 섹션에 적었던 'lint는 개수정 마지막에 한 번만'이라는 지시는 실행되지 않았습니다. 의존성을 최신화하기 전 MyntPack에서 같은 설정을 두고 시도했을 때는 마지막에 :app:lintDebug를 1회 실행했습니다. 같은 지시라도 실행되는 경우와 그렇지 않은 경우가 있습니다. lint를 반드시 통과시키고 싶을 때의 작성 방법은 다음 글에서 지시문 옆에 적어 테스트해 보겠습니다.
비교를 위해 --settings를 붙이지 않고 동일한 요청을 했을 때는, allow가 무시되어 Gradle을 실행할 수 없었습니다. 이때 Claude Code는 구현과 테스트를 작성한 후, '컴파일도 테스트도 실행하지 못했으므로 아직 완료가 아니다'라고 보고했습니다. 확인하지 않은 변경 사항을 '완료'로 보고하지 않은 것은, CLAUDE.md의 완료 조건이 작동했기 때문이라고 생각합니다.
다음에 읽을 것
빌드와 테스트가 통과된 상태에서 차이점이 전달되게 되면, 이 작업은 '작동하는지(working)' 확인에서 '올바른지(correct)' 판단으로 바뀝니다. 테스트가 통과했더라도, 테스트가 구현을 그대로 따라 하는 경우도 있습니다. 다음 글에서는 개수정 지시를 완료 조건으로부터 작성하는 방법을 다룹니다.
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기