Android Studio 없이 실기기 빌드: 최소 환경 838MB와 각 구성요소의 역할
Expo SDK 57 앱을 안드로이드 실기기에 올리려고 맥을 열었는데, 빌드에 필요한 것이 하나도 없었습니다. java -version은 "Unable to locate a Java Runtime"을 냈고 ANDROID_HOME은 비어 있었으며 adb는 명령어 자체가 없었습니다. Xcode만 들어 있는 기계였습니다.
반사적으로 Android Studio를 떠올렸습니다. 그런데 정말 필요한지 따져 보니, 실기기에 앱을 올리는 경로 어디에도 IDE가 끼는 자리가 없었습니다. 결국 설치한 것은 838MB였고 Android Studio는 받지 않았습니다.
IDE가 끼어들 자리가 없는 빌드 경로
npx expo run:android가 하는 일은 두 가지입니다. Gradle을 CLI로 돌려 APK를 만들고, adb로 그 APK를 기기에 밀어 넣습니다. 그 사이에 에디터도 GUI도 등장하지 않습니다.
Android Studio는 이 도구들을 번들해서 같이 설치해 주는 IDE입니다. IntelliJ 기반 에디터, SDK Manager GUI, 에뮬레이터 관리 화면, 프로파일러가 JDK와 Android SDK 위에 얹혀 있는 형태입니다. 아래쪽 두 개만 있으면 빌드는 돌아갑니다.
게다가 제가 다루는 앱들은 android/와 ios/를 gitignore하고 expo prebuild로 매번 생성합니다. 손으로 열어서 고칠 네이티브 코드가 애초에 저장소에 없습니다. 그렇다면 IDE가 열어 줄 것도 없습니다.
838MB로 끝나는 최소 구성
| 구성요소 | 크기 | 무엇을 하는가 |
|---|---|---|
openjdk@17 |
305MB | Gradle과 AGP가 도는 런타임, 앱 소스를 컴파일하는 javac |
cmdline-tools |
173MB | sdkmanager, avdmanager, apkanalyzer, lint, r8, retrace |
build-tools;36.0.0 |
188MB | aapt2, d8, zipalign, apksigner. APK를 실제로 만드는 것들 |
platforms;android-36 |
134MB | android.jar. 컴파일할 때만 쓰이는 API 스텁 |
platform-tools |
37MB | adb, fastboot. 기기와 말하는 통로 |
| Gradle 9.3.1 | 0MB | 설치하지 않습니다. gradlew 래퍼가 첫 빌드에 받습니다 |
설치는 Homebrew 두 줄이면 끝납니다.
brew install openjdk@17
brew install --cask android-commandlinetoolsJDK는 cask temurin@17 대신 formula openjdk@17을 골랐습니다. cask 쪽은 pkg 설치라 sudo를 요구하는데, Gradle은 JAVA_HOME이 가리키는 곳만 보면 되기 때문에 시스템 자바 위치에 등록될 이유가 없습니다. keg-only로 /opt/homebrew/opt/openjdk@17에 격리된 채 남고, 시스템 자바 래퍼는 여전히 아무것도 못 찾는 상태로 둡니다. 그 편이 나중에 JDK 버전을 갈아 끼울 때 안전합니다.
빌드 도구 자신이 자바로 짜여 있다는 사실
자바가 필요한 이유가 "앱을 자바로 짜서"가 아니라는 점이 헷갈리기 쉬운 부분입니다. React Native 앱의 화면 코드는 TypeScript고 자바는 한 줄도 안 씁니다. 그런데도 JDK 17이 먼저 필요합니다.
빌드 도구 자신이 자바로 짜여 있기 때문입니다. Gradle이 JVM 위에서 돌고, Android Gradle Plugin도 JVM 위에서 돌고, 그 위에서 Kotlin 컴파일러가 호출됩니다. 앱에 남아 있는 소량의 네이티브 브리지 코드(Expo 모듈과 React Native 자체가 Java/Kotlin으로 되어 있습니다)를 컴파일하는 것도 이 JDK에 붙은 컴파일러입니다.
버전을 17로 고정한 것도 취향이 아닙니다. 최신 JDK를 넣으면 AGP가 지원 범위 밖이라며 거부하거나, 더 나쁘게는 Gradle 데몬이 알아보기 힘든 예외를 내며 죽습니다.
android.jar이 구현 없는 껍데기인 이유
platforms;android-36에서 실제로 쓰이는 것은 사실상 android.jar 하나이고, 26MB입니다. 이게 compileSdk = 36의 정체입니다.
이 jar 안의 메서드에는 구현이 없습니다. 시그니처만 있고 본문은 전부 예외를 던지는 스텁입니다. 컴파일러가 "이 API가 존재하고 인자를 이렇게 받는다"를 확인하는 데만 쓰이고, APK에는 들어가지 않습니다. 실제 구현은 사용자 기기의 안드로이드 프레임워크 안에 있고, 앱은 실행될 때 그쪽에 연결됩니다.
그래서 compileSdk를 올리는 것과 targetSdk를 올리는 것은 성격이 전혀 다릅니다. 앞은 "어떤 API까지 이름을 알고 있는가"라 올려도 동작이 안 바뀝니다. 뒤는 "OS에게 나는 이 버전 규칙을 따르겠다고 선언"하는 것이라 올리는 순간 백그라운드 제한이나 권한 정책이 실제로 달라집니다.
APK를 실제로 만드는 네 개의 도구
188MB 중 이름을 알아 둘 가치가 있는 것은 넷입니다.
aapt2가 res/ 아래 XML과 이미지를 바이너리로 컴파일하고 링크해서 리소스 테이블과 R 클래스를 만듭니다. 안드로이드 빌드에서 첫 번째로 깨지는 지점이 대개 여기라, 아이콘 하나를 잘못 넣으면 에러 메시지에 aapt2가 찍힙니다.
d8이 JVM 바이트코드를 안드로이드 런타임이 읽는 DEX로 바꿉니다. 자바 바이트코드는 기기에서 그대로 못 돌기 때문에 반드시 거쳐야 하는 단계입니다. 릴리스 빌드에서는 r8이 같은 일을 하면서 코드 축소와 난독화를 겸합니다.
zipalign이 APK 안의 파일들을 4바이트 경계에 맞춰 정렬합니다. OS가 파일을 통째로 메모리에 복사하지 않고 mmap으로 바로 읽게 하려는 조치라, 안 하면 실행 중 메모리를 더 씁니다.
apksigner가 서명합니다. 서명 없는 APK는 기기가 설치를 거부합니다. 디버그 빌드에서는 Gradle이 자동 생성한 디버그 키를 쓰기 때문에 이 단계가 보이지 않을 뿐, 안 하는 것이 아닙니다.
기기와 말하는 유일한 통로, adb
platform-tools는 37MB로 가장 작지만 없으면 실기기 작업이 성립하지 않습니다.
adb는 설치만 하는 도구가 아닙니다. 빌드가 끝나면 adb install로 APK를 밀어 넣고, adb logcat으로 기기의 로그를 끌어오고, adb reverse tcp:8081 tcp:8081로 기기의 8081 포트를 맥의 Metro 번들러로 되돌립니다. USB로 연결한 실기기가 개발 서버에 붙는 것이 이 마지막 명령 덕분입니다. 폰과 맥이 같은 와이파이에 없어도 되는 이유가 여기 있습니다.
설치하지 않는 가장 큰 조각, Gradle
Gradle은 이 목록에서 크기가 0입니다. 프로젝트에 들어 있는 gradlew 래퍼가 gradle-wrapper.properties에 적힌 주소에서 첫 빌드 때 받아 오기 때문입니다.
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip이게 래퍼의 존재 이유입니다. Gradle 버전은 기계가 아니라 프로젝트가 정합니다. 제 맥에 어떤 Gradle이 깔려 있든, 이 앱은 9.3.1로 빌드됩니다. CI 서버에서도, 다른 사람 기계에서도 같습니다. 그래서 Gradle을 미리 설치하는 것은 도움이 되기는커녕 혼동만 만듭니다.
이미 deprecated 인 sdkmanager
SDK 구성요소를 받으려고 sdkmanager를 부르자 예상 못 한 경고가 먼저 나왔습니다.
WARNING: The SDK Manager CLI tool (sdkmanager) is deprecated. Use Android CLI instead. The 'android' binary can also be found in the cmdline-tools directory, and 'android sdk' is the replacement for 'sdkmanager'.
cmdline-tools/latest/bin을 보니 정말 android 바이너리가 sdkmanager 옆에 같이 들어 있었습니다. 이번에는 그대로 sdkmanager로 진행했고 정상 설치됐지만, 새로 문서를 쓴다면 android sdk 쪽을 적어야 할 것 같습니다. 언제 실제로 제거될지는 저도 모릅니다.
yes | sdkmanager --licenses
sdkmanager --install "platform-tools" "platforms;android-36" "build-tools;36.0.0"라이선스 동의를 먼저 받아야 합니다. 이걸 건너뛰면 나중에 Gradle이 빌드 도중 "라이선스에 동의하지 않았다"며 멈추는데, 그 시점에는 원인이 SDK 설치 단계에 있다는 게 잘 안 보입니다.
compileSdk 버전이 코드에 없는 이유
android-36을 고른 근거를 확인하려고 Expo 템플릿을 열었습니다. node_modules/expo/template.tgz 안의 android/build.gradle에 버전이 적혀 있을 줄 알았는데, 파일 전체에 숫자가 하나도 없었습니다.
apply plugin: "expo-root-project"
apply plugin: "com.facebook.react.rootproject"compileSdkVersion도 buildToolsVersion도 없고 이 두 줄이 끝이었습니다. node_modules 여기저기를 몇 번 더 grep했지만 못 찾았습니다. 버전 숫자가 Gradle 플러그인 안으로 들어가 있어서, 텍스트 검색으로는 안 나오는 자리에 있었습니다.
결국 설치된 React Native가 0.86.3이라는 사실에서 역산해 android-36으로 정했습니다. 나중에 안 것인데 이 헛수고는 애초에 안 해도 됐습니다. AGP는 필요한 platform과 build-tools가 없으면 라이선스가 동의된 상태에서 알아서 받아 오기 때문에, 실제로 손으로 깔아야 하는 것은 platform-tools뿐이었습니다. 나머지 둘은 빌드를 처음 돌릴 때 어차피 채워졌을 것입니다.
.zshrc 가 아니라 기계별 파일에 넣은 환경변수
~/.zshrc 끝에 이 줄이 이미 있었습니다.
[ -f "$HOME/.config/shell/local.zsh" ] && source "$HOME/.config/shell/local.zsh".zshrc 자체는 여러 기계에 공유되는 파일이고, 기계마다 다른 설정은 저 아래로 내려가게 만들어 둔 구조였습니다. Android SDK 경로는 정확히 그 부류입니다. 그래서 .zshrc를 건드리지 않고 local.zsh를 만들었습니다. 참조만 있고 파일은 아직 없는 상태였습니다.
export JAVA_HOME="/opt/homebrew/opt/openjdk@17"
export ANDROID_HOME="/opt/homebrew/share/android-commandlinetools"
export ANDROID_SDK_ROOT="$ANDROID_HOME"
path=("$JAVA_HOME/bin" "$ANDROID_HOME/platform-tools" "$ANDROID_HOME/emulator" $path)ANDROID_HOME과 ANDROID_SDK_ROOT를 둘 다 넣은 것은 도구마다 보는 이름이 다르기 때문입니다. 값은 같습니다. emulator 경로는 지금 존재하지 않는 디렉터리인데, 나중에 에뮬레이터를 받으면 그 자리에 생깁니다. 없는 경로가 PATH에 있어도 셸은 조용히 넘어갑니다.
Studio 가 정말 필요한 자리, 네이티브 디버거와 프로파일러
빼고 나서 무엇을 못 하게 됐는지도 확인했습니다. 대부분은 대체재가 이미 설치되어 있었습니다.
에뮬레이터는 Studio 없이도 됩니다. sdkmanager로 emulator와 system image를 받고 avdmanager로 AVD를 만들면 되는데, 둘 다 이미 있는 cmdline-tools 안에 들어 있습니다. APK 분석, lint, R8 스택트레이스 복원도 apkanalyzer, lint, retrace가 /opt/homebrew/bin에 링크되어 있어서 그대로 됩니다.
정말 Studio가 있어야 하는 것은 네이티브 디버거와 프로파일러, 그리고 Kotlin 코드 자동완성입니다. 네이티브 모듈을 직접 짜게 되면 그때 받으면 됩니다. 지금은 그럴 일이 없습니다.
아직 확인하지 못한 것
여기까지 하고 adb devices를 쳤더니 목록이 비어 있었습니다. 폰에서 개발자 옵션과 USB 디버깅을 아직 켜지 않아서입니다. 도구 문제가 아니라 기기 설정 문제입니다.
그래서 이 글은 "환경 구성이 끝났다"까지고, 실제 빌드가 처음부터 끝까지 통과하는 것은 아직 못 봤습니다. AGP가 빠진 SDK 구성요소를 정말 알아서 받아 오는지도 다음 빌드에서 확인할 부분으로 남았습니다.
함께 읽기
- Expo config plugin이 반영되지 않는 이유: prebuild와 mod 순서생명시계 앱에 Face ID 잠금을 붙였습니다. expo-local-authentication 을 설치하고 app.json 에 플러그인과 권한 문구를 적었습니다.
- Expo 로컬 릴리스 빌드: 17분 만에 죽고 3분 48초에 끝난 이유클라우드 빌드를 떠나 로컬에서 릴리스를 만들어 보기로 했습니다. 서명 자료는 다 모아 두었고 도구도 깔았습니다. 첫 빌드는 17분 51초를 돌고 나서 죽었습니다.
- Expo CNG: android 와 ios 폴더가 리포에 없는 이유Expo 로 만든 앱의 리포를 클론하면 ios/ 도 android/ 도 없습니다. .gitignore 를 열어 보면 아예 막혀 있습니다.
- EAS 없이 Expo 로컬 빌드: 서명 자료를 모으고도 남는 다섯 가지클라우드 빌드를 떠나기로 하고(왜 옮기는지는 따로 적었습니다) 서명 자료부터 모았습니다. iOS 배포 인증서와 프로비저닝 프로파일, 안드로이드 업로드 키스토어와 비밀번호 두 개, 스토어 API 키까지 한자리에 놓고 확인했습니다. 코드 서명 신원도 유효하게 잡혔습니다.
- Play 스토어 등록정보 자동화: EAS가 덮지 않는 한 칸앱에 언어 셋을 더하고 스토어에 올리려던 참이었습니다. iOS 쪽은 명령 하나로 끝났습니다.