Android 에뮬레이터 자동화 삽질기
Android 에뮬레이터 자동화 삽질기
Android 앱 테스트 자동화 도구를 만들면서, SDK 설치부터 에뮬레이터 실행, 앱 설치까지 전부 자동화했다. 생각보다 삽질이 많았다.
목표
사용자가 ./app run 명령 하나로:
- Android SDK 자동 설치 (없으면)
- 시스템 이미지 다운로드
- ARM64 에뮬레이터 생성
- 에뮬레이터 부팅
- 앱 설치
- 앱 실행
“설정 없이 바로 실행” 이 목표였다.
SDK 자동 설치
Android SDK를 자동으로 설치하는 건 의외로 간단했다.
func (m *DependencyManager) installAndroidSDK(ctx context.Context) error {
// 1. cmdline-tools 다운로드
url := "https://dl.google.com/android/repository/commandlinetools-mac-xxx.zip"
downloadFile(url, cmdlineToolsZip)
// 2. 압축 해제
unzip(cmdlineToolsZip, sdkRoot)
// 3. 라이선스 동의
cmd := exec.CommandContext(ctx, sdkmanager, "--licenses")
cmd.Stdin = strings.NewReader("y\ny\ny\ny\ny\n")
cmd.Run()
// 4. 필수 컴포넌트 설치
exec.CommandContext(ctx, sdkmanager,
"platform-tools",
"emulator",
"system-images;android-30;google_apis;arm64-v8a",
).Run()
return nil
}
라이선스 동의(--licenses)에 “y”를 여러 번 입력해야 하는데, stdin으로 처리했다.
에뮬레이터 생성
AVD(Android Virtual Device)를 생성하는 건 avdmanager를 사용한다.
func (m *Manager) createAVD(ctx context.Context, avdName string) error {
cmd := exec.CommandContext(ctx, avdmanager,
"create", "avd",
"--name", avdName,
"--package", "system-images;android-30;google_apis;arm64-v8a",
"--device", "pixel_4",
"--force",
)
// "Do you wish to create a custom hardware profile?" -> no
cmd.Stdin = strings.NewReader("no\n")
return cmd.Run()
}
에뮬레이터 부팅 대기
에뮬레이터를 시작하고 부팅 완료를 기다리는 게 까다로웠다.
func (m *Manager) waitForBoot(ctx context.Context) error {
// 에뮬레이터 시작 (백그라운드)
cmd := exec.Command(emulator,
"-avd", m.avdName,
"-no-audio",
"-no-window",
"-gpu", "swiftshader_indirect",
)
cmd.Start()
// 부팅 완료 대기
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
output, _ := exec.Command("adb", "shell",
"getprop", "sys.boot_completed").Output()
if strings.TrimSpace(string(output)) == "1" {
return nil
}
time.Sleep(2 * time.Second)
}
}
}
sys.boot_completed 프로퍼티가 “1”이 되면 부팅 완료. 보통 20-30초 걸린다.
APK 런타임 다운로드
처음에는 APK를 바이너리에 임베딩했다. 바이너리가 214MB가 됐다.
APKPure에서 런타임에 다운로드하도록 변경했다.
import "github.com/kyungw00k/apkpure-go/pkg/apkpure"
func DownloadAPKs() error {
opts := apkpure.DownloadOptions{
Arch: "arm64-v8a",
Language: "ko-KR",
OSVersion: "30",
}
client := apkpure.NewClient(opts)
app := apkpure.AppInfo{
PackageID: "com.example.app",
Version: "2.2.1",
}
return client.Download(app, xapkPath)
}
결과:
- 바이너리 크기: 214MB → 122MB (43% 감소)
- 최초 실행 시 APK 다운로드 (~50MB, 이후 캐시 재사용)
Split APK 설치
최신 Android 앱은 Split APK 형태다. base.apk + config.arm64.apk + config.xxhdpi.apk 등으로 분리되어 있다.
func (m *Manager) installSplitAPKs(ctx context.Context, apks ...string) error {
// install-multiple 사용
args := []string{"install-multiple", "-r"}
args = append(args, apks...)
cmd := exec.CommandContext(ctx, "adb", args...)
return cmd.Run()
}
adb install-multiple을 사용해야 한다. 일반 adb install로는 안 된다.
한글 입력 문제
에뮬레이터에서 한글 입력이 필요했는데, ADB로 한글을 직접 입력할 수 없다.
# 영어는 됨
adb shell input text "hello"
# 한글은 안 됨
adb shell input text "안녕" # 에러 또는 깨짐
해결: Gboard 자동 설치
- Gboard APK 다운로드 (APKPure에서)
- 설치
- 기본 키보드로 설정
- 한글 키보드 활성화
func (m *Manager) setupGboard(ctx context.Context) error {
// 1. Gboard 설치
m.installAPK(ctx, gboardAPK)
// 2. 기본 키보드로 설정
exec.CommandContext(ctx, "adb", "shell",
"ime", "set", "com.google.android.inputmethod.latin/.LatinIME",
).Run()
// 3. 한글 키보드 활성화 (설정 파일 직접 수정)
m.applyGboardKoreanConfig(ctx)
return nil
}
Gboard 설정은 shared_prefs에 저장된다. 직접 XML을 수정해서 한글 키보드를 활성화했다.
func (m *Manager) applyGboardKoreanConfig(ctx context.Context) error {
config := `<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="active_input_styles">ko_KR:korean_2set</string>
<boolean name="input_language_korean_ko_KR_enabled" value="true" />
</map>`
// 설정 파일 push
exec.CommandContext(ctx, "adb", "shell",
"echo", config, ">",
"/data/data/com.google.android.inputmethod.latin/shared_prefs/...",
).Run()
return nil
}
Frida Server 자동 설치
앱 분석을 위해 Frida server도 자동 설치했다.
func (m *Manager) installFridaServer(ctx context.Context) error {
// 1. 아키텍처 확인
arch, _ := exec.Command("adb", "shell", "getprop", "ro.product.cpu.abi").Output()
// arm64-v8a, armeabi-v7a, x86_64, x86
// 2. 다운로드
url := fmt.Sprintf(
"https://github.com/frida/frida/releases/download/%s/frida-server-%s-android-%s.xz",
version, version, normalizeArch(arch),
)
downloadFile(url, serverXZ)
// 3. XZ 압축 해제 (순수 Go)
extractXZ(serverXZ, serverBin)
// 4. 디바이스에 push
exec.Command("adb", "push", serverBin, "/data/local/tmp/frida-server").Run()
exec.Command("adb", "shell", "chmod", "755", "/data/local/tmp/frida-server").Run()
// 5. 실행 (root 필요)
exec.Command("adb", "root").Run()
exec.Command("adb", "shell", "/data/local/tmp/frida-server", "&").Start()
return nil
}
XZ 압축 해제
처음에는 시스템 xz 명령을 사용했다. macOS에서 brew install xz 필요.
순수 Go 라이브러리로 교체했다.
import "github.com/ulikunitz/xz"
func extractXZ(xzPath, destPath string) error {
xzFile, _ := os.Open(xzPath)
defer xzFile.Close()
xzReader, _ := xz.NewReader(xzFile)
outFile, _ := os.Create(destPath)
defer outFile.Close()
io.Copy(outFile, xzReader)
return nil
}
시스템 의존성 제거 완료.
전체 흐름
func runAutomation(ctx context.Context) error {
// 1. 의존성 확인/설치
deps := deps.NewManager()
deps.EnsureAllDependencies(ctx) // SDK, 시스템 이미지
// 2. 에뮬레이터 시작
emu := emulator.NewManager(avdName)
emu.Start(ctx) // 생성 + 부팅 대기
// 3. Gboard 설치 (한글 입력용)
emu.SetupGboard(ctx)
// 4. Frida server 설치
emu.InstallFridaServer(ctx)
// 5. 앱 설치
apks := assets.GetAPKs() // 캐시 또는 다운로드
emu.InstallSplitAPKs(ctx, apks...)
// 6. 앱 실행
emu.LaunchApp(ctx, packageName)
return nil
}
삽질 포인트 정리
| 문제 | 해결 |
|---|---|
| 라이선스 동의 자동화 | stdin으로 “y” 입력 |
| 부팅 완료 감지 |
sys.boot_completed 프로퍼티 폴링 |
| Split APK 설치 | adb install-multiple |
| 한글 입력 | Gboard 설치 + 설정 파일 수정 |
| XZ 압축 해제 | 순수 Go 라이브러리 |
| 바이너리 크기 | APK 런타임 다운로드 |
결론
“설정 없이 바로 실행” 목표는 달성했다. 사용자는 바이너리 하나만 다운받으면 된다.
대신 최초 실행 시 시간이 좀 걸린다:
- SDK 다운로드: ~5분 (1GB)
- APK 다운로드: ~1분 (50MB)
- 에뮬레이터 부팅: ~30초
두 번째 실행부터는 캐시 덕분에 30초 내로 시작.
Android 자동화는 삽질의 연속이다. 특히 한글 입력.