Google Play 스토어에 Android 배포 자동화
Gradle 구성부터 CI/CD를 통한 Play 스토어 게시까지, Android 빌드 및 배포 자동화 설정.

Play 스토어에 Android 앱을 배포하는 것은 간단해 보입니다. APK를 빌드하고 업로드하면 끝이라고 생각할 수 있습니다. 하지만 실제로는 서명 구성, 빌드 변형, 번들 형식, Play 스토어 트랙, 단계별 출시, 그리고 누구라도 직업 선택을 다시 생각하게 만들 만큼 복잡한 Gradle 구성이 필요합니다. iOS 및 Android 파이프라인이 모두 필요한 Flutter 앱을 포함하여 여러 프로젝트에 걸쳐 Android 배포를 자동화한 후, 저는 커밋부터 프로덕션 출시까지 수동 개입 없이 모든 것을 처리하는 설정을 갖추게 되었습니다.
Android 빌드 시스템
무엇이든 자동화하기 전에 Android 빌드가 어떻게 작동하는지 확실히 이해해야 합니다. Xcode가 GUI 뒤에서 대부분의 복잡성을 처리하는 iOS와 달리, Android의 빌드 시스템은 전적으로 Gradle 기반이며 코드를 통해 구성됩니다. 이는 자동화에 있어 실제로 장점입니다. 모든 것이 명시적이고 버전 관리됩니다.
릴리스 빌드를 위한 Gradle 구성
귀하의 android/app/build.gradle (Kotlin DSL로 마이그레이션했다면 build.gradle.kts)은 중앙 구성 파일입니다. 릴리스 빌드의 경우 서명, 축소 및 최적화를 구성해야 합니다.
android {
compileSdkVersion 35
defaultConfig {
applicationId "com.yourcompany.yourapp"
minSdkVersion 24
targetSdkVersion 35
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
signingConfigs {
release {
keyAlias System.getenv("KEY_ALIAS") ?: properties["keyAlias"]
keyPassword System.getenv("KEY_PASSWORD") ?: properties["keyPassword"]
storeFile file(System.getenv("KEYSTORE_PATH") ?: properties["keystorePath"] ?: "keystore.jks")
storePassword System.getenv("STORE_PASSWORD") ?: properties["storePassword"]
}
}
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
서명 구성은 환경 변수에서 먼저 읽고, 그 다음 로컬 속성 파일로 대체됩니다. 이 패턴을 통해 개발자는 key.properties 파일을 사용하여 로컬에서 빌드에 서명하고, CI는 환경 변수를 사용할 수 있습니다. 동일한 Gradle 파일이 수정 없이 두 컨텍스트 모두에서 작동합니다.
로컬 속성 파일
로컬 개발을 위해 android/ 디렉토리에 key.properties 파일을 생성하고 (즉시 .gitignore에 추가하세요):
storePassword=your_store_password
keyPassword=your_key_password
keyAlias=your_key_alias
keystorePath=../keys/release-keystore.jks
그런 다음 build.gradle에서 참조하세요:
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
서명 구성
Android 앱 서명은 iOS 코드 서명보다 간단하지만, 영구적인 위험을 안고 있습니다. 업로드 키를 잃어버리면 앱을 업데이트할 수 없게 됩니다. Google은 이를 완화하기 위해 Play 앱 서명을 도입했으며, 저는 이를 강력히 권장합니다.
Play 앱 서명
Play 앱 서명을 활성화하면 Google이 실제 배포 키를 보유합니다. 귀하는 업로드 키로 업로드에 서명하고, Google은 사용자에게 배포하기 전에 배포 키로 다시 서명합니다. 이점은 다음과 같습니다:
- 키 분실 복구: 업로드 키를 잃어버리면 Google이 재설정할 수 있습니다. Play 앱 서명이 없으면 키를 잃어버리는 것은 새 앱 목록을 생성해야 함을 의미합니다.
- 더 작은 APK: Google은 배포 키를 사용하여 각 장치 구성에 맞게 앱을 최적화할 수 있습니다.
- 키 순환: 설치된 사용자에게 영향을 주지 않고 업로드 키를 순환할 수 있습니다.
Play Console의 설정 > 앱 서명에서 활성화하세요. 새 앱의 경우 기본적으로 활성화되어 있습니다.
키스토어 생성
새 키스토어를 생성해야 하는 경우:
keytool -genkey -v \
-keystore release-keystore.jks \
-keyalg RSA \
-keysize 2048 \
-validity 10000 \
-alias your_key_alias
이 키스토어 파일을 안전하게 보관하세요. CI의 경우, 저는 이를 base64로 인코딩하여 GitHub Actions 비밀로 저장합니다:
base64 -i release-keystore.jks | pbcopy
그런 다음 빌드하기 전에 CI 워크플로에서 디코딩합니다:
- name: Decode keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks
AAB vs APK
Google은 이제 Play 스토어의 모든 새 앱에 Android App Bundle(AAB)을 요구합니다. 이 차이는 빌드 파이프라인에 중요합니다.
**APK (Android Package)**는 모든 장치 구성에 필요한 모든 것을 포함하는 단일 파일입니다. 크기는 더 크지만 보편적입니다. 모든 Android 장치는 모든 APK를 설치할 수 있습니다. APK는 여전히 내부 배포, 실제 장치 테스트 및 Play 스토어 외부 배포에 유용합니다.
**AAB (Android App Bundle)**는 모든 코드와 리소스를 포함하지만, Google Play가 각 장치에 최적화된 APK를 생성하도록 합니다. Pixel 폰을 사용하는 사용자는 모든 화면 밀도 및 CPU 아키텍처에 대한 자산이 아닌, 필요한 리소스만 다운로드합니다. 번들은 일반적으로 범용 APK보다 15-30% 더 작습니다.
CI 파이프라인의 경우, 둘 다 빌드하세요:
# For Play Store
flutter build appbundle --release
# For internal testing / direct installation
flutter build apk --release --split-per-abi
--split-per-abi 플래그는 각 CPU 아키텍처(arm64-v8a, armeabi-v7a, x86_64)에 대해 별도의 APK를 생성하여 직접 배포를 위한 파일 크기를 줄입니다.
Play 스토어를 위한 Fastlane Supply
Fastlane의 supply 액션은 Play 스토어 업로드 및 메타데이터 관리를 처리하며, iOS의 deliver와 유사한 역할을 합니다.
Supply 설정
먼저 올바른 권한을 가진 Google Play 서비스 계정이 필요합니다.
- Google Cloud Console로 이동하여 서비스 계정을 생성합니다.
- JSON 키 파일을 다운로드합니다.
- Play Console에서 설정 > API 액세스로 이동하여 서비스 계정을 연결합니다.
- 앱에 "Release manager" 권한을 부여합니다.
# android/fastlane/Appfile
json_key_file(ENV["PLAY_STORE_JSON_KEY"] || "path/to/service-account.json")
package_name("com.yourcompany.yourapp")
기본 업로드
default_platform(:android)
platform :android do
desc "Deploy to Play Store Internal Testing"
lane :internal do
gradle(
task: "bundle",
build_type: "Release",
project_dir: "./"
)
supply(
track: "internal",
aab: "../build/app/outputs/bundle/release/app-release.aab",
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
end
Flutter 프로젝트의 경우, Fastlane을 통해 Gradle 작업을 사용하는 것보다 Flutter 빌드 명령을 직접 실행하는 것을 선호합니다. Flutter의 빌드 프로세스에는 Gradle 단독으로는 올바르게 처리하지 못하는 Dart 컴파일 단계가 포함되어 있기 때문입니다:
lane :internal do
Dir.chdir("..") do
sh("flutter build appbundle --release")
end
supply(
track: "internal",
aab: "../build/app/outputs/bundle/release/app-release.aab",
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
Play 스토어 트랙
Play 스토어에는 네 가지 트랙이 있으며, 각각을 언제 사용해야 하는지 이해하는 것이 출시 전략에 중요합니다.
내부 테스트
- 최대 100명의 테스터.
- 검토 불필요 — 빌드가 거의 즉시 제공됩니다.
- 가장 적합한 용도: 개발팀 테스트, QA 주기, 승격 전 빌드 확인.
비공개 테스트 (알파)
- 무제한 테스터, 하지만 목록은 귀하가 관리합니다.
- Google의 간략한 검토 필요 (일반적으로 몇 시간).
- 가장 적합한 용도: 초대된 사용자와 함께하는 베타 프로그램, 클라이언트 미리보기.
공개 테스트 (베타)
- Play 스토어 목록에서 누구나 참여할 수 있습니다.
- 검토 필요.
- 가장 적합한 용도: 공개 베타 프로그램, 광범위한 출시 전 피드백 수집.
프로덕션
- 모든 사용자에게 제공됩니다.
- 전체 검토 필요.
- 단계별 출시를 지원합니다.
트랙 간 승격
Fastlane은 트랙 승격을 간단하게 만듭니다:
lane :promote_to_beta do
supply(
track: "internal",
track_promote_to: "beta",
skip_upload_changelogs: false,
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
lane :promote_to_production do
supply(
track: "beta",
track_promote_to: "production",
rollout: "0.1", # 10% staged rollout
skip_upload_changelogs: false,
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
단계별 출시
단계별 출시는 iOS에 비해 Android의 가장 큰 장점 중 하나입니다. 사용자 중 일부에게 출시하고 점진적으로 늘려가면서 각 단계에서 충돌률과 사용자 피드백을 모니터링할 수 있습니다.
lane :staged_rollout do |options|
percentage = options[:percentage] || "0.1"
supply(
track: "production",
rollout: percentage,
aab: "../build/app/outputs/bundle/release/app-release.aab"
)
end
lane :increase_rollout do |options|
percentage = options[:percentage] || "0.5"
supply(
track: "production",
rollout: percentage,
skip_upload_aab: true,
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
lane :complete_rollout do
supply(
track: "production",
rollout: "1.0",
skip_upload_aab: true,
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
일반적인 출시 일정:
- 1일차: 10% — Firebase Crashlytics에서 충돌률을 모니터링합니다.
- 2-3일차: 25% — 사용자 피드백 및 지원 티켓을 확인합니다.
- 4-5일차: 50% — 대규모 환경에서 성능 저하가 없는지 확인합니다.
- 6-7일차: 100% — 전체 출시.
어떤 단계에서든 문제가 발생하면 모든 사용자에게 영향을 주지 않고 출시를 중단하고 수정 사항을 푸시할 수 있습니다.
GitHub Actions 워크플로
다음은 Android용 완전한 CI/CD 워크플로입니다:
name: Deploy to Play Store
on:
push:
branches: [main]
workflow_dispatch:
inputs:
track:
description: 'Play Store track'
required: true
default: 'internal'
type: choice
options:
- internal
- alpha
- beta
- production
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
cache: 'gradle'
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.27.x'
channel: stable
cache: true
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
working-directory: android
- name: Flutter dependencies
run: flutter pub get
- name: Decode keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks
- name: Create service account file
run: echo '${{ secrets.PLAY_STORE_SERVICE_ACCOUNT }}' > android/play-store-key.json
- name: Build and deploy
working-directory: android
env:
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
KEYSTORE_PATH: app/keystore.jks
PLAY_STORE_JSON_KEY: play-store-key.json
run: bundle exec fastlane ${{ github.event.inputs.track || 'internal' }}
- name: Cleanup secrets
if: always()
run: |
rm -f android/app/keystore.jks
rm -f android/play-store-key.json
이 워크플로에 대해 몇 가지 주목할 점:
Ubuntu 러너는 Android에 잘 작동합니다. macOS가 필요한 iOS와 달리 Android 빌드는 Linux에서 실행됩니다. Ubuntu 러너는 GitHub Actions에서 더 저렴하고 더 빠르게 시작됩니다.
현재 버전의 Android Gradle Plugin에는 Java 17이 필요합니다. 프로젝트에서 이전 AGP 버전을 사용하는 경우 Java 11이 필요할 수 있지만, 이는 점점 드물어지고 있습니다.
정리 단계는 빌드가 실패하더라도 디코딩된 비밀을 제거합니다. 이는 심층 방어 조치입니다. GitHub Actions 러너는 일시적이지만, 좋은 관행입니다.
버전 관리
Android는 두 가지 버전 식별자를 사용합니다: versionCode (모든 업로드마다 증가해야 하는 정수)와 versionName (사람이 읽을 수 있는 버전 문자열).
Flutter 프로젝트의 경우, 둘 다 pubspec.yaml에서 가져옵니다:
version: 1.2.3+45
# 1.2.3 = versionName
# 45 = versionCode
저는 CI 실행 번호를 사용하여 버전 코드 증가를 자동화합니다:
lane :set_version_code do
build_number = ENV["GITHUB_RUN_NUMBER"] || "1"
Dir.chdir("..") do
current_version = sh("grep '^version:' pubspec.yaml | sed 's/version: //' | sed 's/+.*//'").strip
sh("sed -i 's/^version: .*/version: #{current_version}+#{build_number}/' pubspec.yaml")
end
end
이렇게 하면 브랜치 간에도 버전 코드가 항상 증가합니다. 더 많은 제어가 필요한 경우 Play 스토어에서 최신 버전 코드를 가져올 수 있습니다:
lane :smart_version_code do
current = google_play_track_version_codes(track: "internal").max || 0
new_code = current + 1
Dir.chdir("..") do
current_version = sh("grep '^version:' pubspec.yaml | sed 's/version: //' | sed 's/+.*//'").strip
sh("sed -i 's/^version: .*/version: #{current_version}+#{new_code}/' pubspec.yaml")
end
end
Play 스토어 목록 관리
Supply는 버전 관리되는 파일에서 전체 Play 스토어 목록을 관리할 수 있습니다:
android/fastlane/metadata/android/
├── en-US/
│ ├── title.txt
│ ├── short_description.txt
│ ├── full_description.txt
│ ├── changelogs/
│ │ ├── default.txt
│ │ └── 45.txt # Changelog for versionCode 45
│ └── images/
│ ├── phoneScreenshots/
│ │ ├── 1_home.png
│ │ └── 2_detail.png
│ ├── featureGraphic.png
│ └── icon.png
└── ru-RU/
├── title.txt
├── short_description.txt
└── full_description.txt
현재 목록을 시작점으로 다운로드하려면:
fastlane supply init
그런 다음 파일을 업데이트하고 변경 사항을 푸시합니다:
lane :update_listing do
supply(
skip_upload_aab: true,
skip_upload_apk: true
)
end
ProGuard 및 R8 구성
minifyEnabled가 true일 때, R8 (ProGuard의 후속)은 코드를 축소, 난독화 및 최적화합니다. 이는 프로덕션 빌드에 필수적입니다. APK 크기를 줄이고 역공학을 어렵게 만들지만, 제대로 구성되지 않으면 문제를 일으킬 수도 있습니다.
일반적인 문제와 해당 ProGuard 규칙:
# Keep Flutter's classes
-keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; }
# Keep Firebase classes
-keep class com.google.firebase.** { *; }
# Keep models used with JSON serialization
-keep class com.yourcompany.yourapp.models.** { *; }
# Keep classes referenced via reflection
-keepattributes *Annotation*
-keepattributes Signature
-keepattributes InnerClasses
# Common crash fix: OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
릴리스 빌드를 철저히 테스트하세요. R8 문제는 디버그 빌드에서는 나타나지 않는 런타임 충돌로 나타나는 경우가 많습니다. Flutter의 --obfuscate 및 --split-debug-info 플래그는 추가 최적화를 가능하게 합니다:
flutter build appbundle --release --obfuscate --split-debug-info=build/symbols
build/symbols 디렉토리를 보관하세요. 충돌 보고서에서 스택 트레이스를 심볼화하는 데 필요합니다.
전체 Fastfile
default_platform(:android)
platform :android do
desc "Deploy to internal testing"
lane :internal do
set_version_code
build_release
supply(
track: "internal",
aab: "../build/app/outputs/bundle/release/app-release.aab",
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
desc "Deploy to closed beta"
lane :beta do
set_version_code
build_release
supply(
track: "beta",
aab: "../build/app/outputs/bundle/release/app-release.aab",
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
desc "Deploy to production with staged rollout"
lane :production do
set_version_code
build_release
supply(
track: "production",
rollout: "0.1",
aab: "../build/app/outputs/bundle/release/app-release.aab"
)
end
desc "Promote internal to beta"
lane :promote_to_beta do
supply(
track: "internal",
track_promote_to: "beta"
)
end
desc "Increase production rollout"
lane :increase_rollout do |options|
supply(
track: "production",
rollout: options[:percentage] || "0.5",
skip_upload_aab: true
)
end
private_lane :build_release do
Dir.chdir("..") do
sh("flutter build appbundle --release --obfuscate --split-debug-info=build/symbols")
end
end
private_lane :set_version_code do
build_number = ENV["GITHUB_RUN_NUMBER"] || Time.now.strftime("%Y%m%d%H%M")
Dir.chdir("..") do
current_version = sh("grep '^version:' pubspec.yaml | sed 's/version: //' | sed 's/+.*//'").strip
sh("sed -i '' 's/^version: .*/version: #{current_version}+#{build_number}/' pubspec.yaml")
end
end
end
iOS 자동화와의 차이점
두 플랫폼을 모두 자동화해 본 결과, 주목할 만한 몇 가지 주요 차이점이 있습니다.
Android의 서명은 더 간단합니다. iOS의 인증서 및 프로비저닝 프로파일 복잡성 대신 단일 키스토어 파일로 처리됩니다. match나 다른 인증서 관리 도구가 필요 없습니다.
필수 macOS 요구 사항이 없습니다. Android는 Linux에서 빌드되므로 더 저렴하고 빠른 CI 러너를 사용할 수 있습니다.
더 빠른 검토 시간. Google의 검토 프로세스는 일반적으로 Apple보다 빠르며, 내부 트랙 업로드는 전혀 검토가 필요하지 않습니다.
단계별 출시는 기본 기능입니다. iOS에서는 단계별 출시를 할 수 있지만, 동일한 세분성으로 비율을 제어하거나 출시 도중에 중단할 수는 없습니다.
Play 스토어 API는 더 관대합니다. 스토어 목록 실험 및 가격 책정을 포함하여 거의 모든 것을 프로그래밍 방식으로 관리할 수 있습니다. Apple의 App Store Connect API는 더 많은 제한이 있습니다.
주요 단점은 Gradle 빌드 시간입니다. R8 최적화가 적용된 클린 Android 빌드는 동등한 iOS 빌드보다 훨씬 오래 걸릴 수 있습니다. 캐싱은 도움이 됩니다. Gradle의 내장 캐시와 .gradle 디렉토리의 CI 수준 캐싱 모두.
성숙한 파이프라인의 모습
모든 설정이 완료되면 일상적인 경험은 눈에 띄지 않아야 합니다. main 브랜치에 푸시하면 빌드가 실행되고, 몇 분 후 테스터는 내부 트랙에서 새 버전을 받게 됩니다. 릴리스 태그를 지정하면 단계별 출시와 함께 프로덕션 빌드가 진행됩니다.
이 파이프라인은 "내 컴퓨터에서는 되는데"와 같은 문제 범주를 완전히 제거하고, 모든 빌드가 재현 가능하도록 보장하며, 팀에게 배포가 아무것도 아닌 일이라는 확신을 줍니다. 마지막 부분이 진정한 목표입니다. 배포를 너무나 일상적인 일로 만들어 아무도 신경 쓰지 않게 하는 것입니다.