들어가며
Android 앱 릴리스 작업을 매번 Android Studio에서 Generate Signed Bundle 버튼을 눌러 빌드가 완료되면 Play Console을 열어 AAB를 드래그&드롭하는 방식으로 진행하고 계신가요?
저도 한동안 이렇게 운영했는데, 릴리스 횟수가 늘어날수록 다음과 같은 문제가 생겼습니다.
- 로컬 환경 차이로 인한 빌드 실패 (JDK 버전 불일치, Gradle 캐시 문제)
- Keystore 관리의 고립화 (특정 개발자의 Mac에만 존재하는 상태)
- 릴리스 절차가 문서에 의존하여 휴먼 에러가 발생하기 쉬움
이 문제들을 한 번에 해결하기 위해 GitHub Actions 상에서 빌드·서명·Google Play Console 업로드까지 자동화했고, 그 절차를 정리합니다.
전체 흐름
최종적으로 구성하는 파이프라인은 다음과 같습니다.
flowchart TD
A["git tag v1.0.0 push"]
B["GitHub Actions 실행"]
C["JDK 설정 및 Gradle 캐시 복원"]
D["Secrets에서 Keystore 복원"]
E["./gradlew bundleRelease로 AAB 빌드 및 서명"]
F["r0adkll/upload-google-play로 내부 테스트에 배포"]
G["Google이 수신 후 앱 서명 키로 재서명하여 사용자에게 배포"]
A --> B --> C --> D --> E --> F --> G"AAB"와 "업로드 키"라는 키워드는 다음 섹션에서 설명합니다.
미리 파악해 둘 3가지 개념
실제 절차에 들어가기 전에 이 글에서 등장하는 키워드를 정리해 두겠습니다. 이 부분이 흐릿하면 나중에 Play App Signing 주변에서 혼란스러워지기 쉽습니다.
APK와 AAB의 차이
| APK (Android Package Kit) | AAB (Android App Bundle) | |
|---|---|---|
| 내용 | 모든 ABI·화면 밀도·언어 리소스를 1개 파일에 담은 유니버설 패키지 | 기기에 맞게 최적화하기 이전의 빌드 산출물 |
| 파일 크기 | 모든 것을 포함하므로 커지는 경향 | Google Play 측에서 Split APK를 생성하여 배포하므로, 사용자 다운로드 크기가 평균 15% 정도 작음 |
| 주요 용도 | 직접 배포 / 사내 배포 / 서드파티 스토어 | Google Play Store 배포 (2021년 8월 이후 신규 앱은 필수) |
| Gradle 태스크 | ./gradlew assembleRelease |
./gradlew bundleRelease |
간단히 말하면 "APK = 사용자 기기에 설치할 수 있는 완성품", "AAB = Google Play에 제출하는 설계도" 같은 관계입니다. Google Play는 AAB를 받아서 사용자의 기기에 맞는 APK를 매번 조립하여 배포합니다.
이 글은 Play Store 배포가 목적이므로 AAB(bundleRelease)를 사용합니다. 사내 배포 등 다른 용도로 APK가 필요한 경우에는 assembleRelease로 APK 빌드도 가능합니다.
업로드 키와 앱 서명 키
Play App Signing에서는 서명 키가 2종류 존재합니다.
| 키 종류 | 누가 관리? | 용도 |
|---|---|---|
| 업로드 키 (Upload Key) | 개발자 | AAB를 Play Console에 업로드할 때의 서명 |
| 앱 서명 키 (App Signing Key) | Play Store에서 사용자에게 배포하는 최종 APK의 서명 |
즉 Play Console은 "올바른 업로드 키로 서명된 AAB인가?"를 검증하므로, 개발자는 자신이 보유한 업로드 키 = Keystore로 서명해야 합니다. Google이 대신 해주는 것은 사용자 배포 시에 앱 서명 키로 재서명하는 부분뿐입니다.
flowchart TD
Dev["개발자"] -->|"업로드 키로 서명"| AAB["AAB"]
AAB --> Play["Play Console"]
Play --> Verify["Google이 업로드 키를 검증"]
Verify --> Resign["Google이 앱 서명 키로 재서명"]
Resign --> User["사용자"]Play App Signing이 되어도 "직접 Keystore를 만들어야 한다"는 사실 자체는 변하지 않습니다. 변한 것은 "업로드 키를 잃어버려도 재발급 신청을 할 수 있다"는 안전성뿐이라고 이해하면 됩니다.
디버그 서명과 release 서명의 구분
Android Studio에서 처음으로 Run 버튼을 눌렀을 때, 아무 설정도 하지 않았는데 APK가 만들어져서 기기에서 실행된 것은, AGP (Android Gradle Plugin)가 ~/.android/debug.keystore를 자동 생성하여 디버그 빌드에 자동 서명하기 때문입니다.
| 디버그 서명 | release 서명 | |
|---|---|---|
| Keystore | ~/.android/debug.keystore (AGP가 자동 생성) |
직접 생성·관리 (이 글의 release.keystore) |
| 패스워드 | android 고정 |
직접 설정 |
| 별칭 | androiddebugkey 고정 |
직접 설정 |
| 유효기간 | 약 30년 | 직접 설정 (이 글은 약 27년) |
| 용도 | 개발 중 동작 확인 / ./gradlew assembleDebug |
Play Store 배포 / ./gradlew bundleRelease |
| Git 관리 | 불필요 (기기마다 있으면 OK) | 엄중히 보관 (Play App Signing이라면 잃어버려도 재발급 가능) |
중요한 특성이 2가지 있습니다.
- Play Console은 디버그 서명된 AAB를 절대 받지 않는다
→ CI에서는release.keystore를 별도로 준비해야 함 - 디버그 서명과 release 서명은 완전히 독립적이다
→ CI용 release 서명 설정을 추가해도 로컬의./gradlew assembleDebug에는 전혀 영향 없음
섹션 5·6에서 signingConfigs.create("release")를 조건 분기하는 것은 바로 이 2가지 특성을 활용하여 "키가 없는 개발자도 디버그 빌드만큼은 정상적으로 실행 가능한" 상태를 만들기 위함입니다.
전제 조건
- Android 앱 (
build.gradle또는build.gradle.kts사용) - GitHub 리포지토리
- Google Play Console 개발자 계정
- 이미 Play Console에서 대상 앱의 첫 번째 릴리스가 수동으로 완료되어 있을 것 (API 경유 최초 생성은 불가)
- 대상 앱에서 Play App Signing이 활성화되어 있을 것
2021년 8월 이후에 생성된 신규 앱은 Play App Signing이 자동으로 활성화되므로 특별한 조작은 불필요합니다. 그 이전에 생성된 구방식 앱의 경우에는 먼저 Play App Signing으로의 마이그레이션이 필요합니다 (섹션 7-4에서 다룹니다).
1. 업로드 키 (Keystore) 생성
AAB 서명에 사용할 Keystore를 로컬에서 생성합니다. Play App Signing 구성에서 이 키는 "업로드 키"로 기능합니다 (= Play Console에 AAB를 제출할 때의 본인 증명). 사용자에게 최종 배포 시의 서명은 Google이 별도로 보유한 "앱 서명 키"로 이루어지므로, 설령 이 키를 잃어버려도 Play Console에서 리셋을 신청할 수 있습니다 (자세한 내용은 7-3).
일상 운영에서 분실하면 리셋 완료까지 릴리스가 중단되므로, 안전하게 보관하는 것이 좋습니다.
# macOS / Linux
keytool -genkey -v \
-keystore release.keystore \
-alias my-release-key \
-keyalg RSA \
-keysize 2048 \
-validity 10000
Windows (PowerShell / 명령 프롬프트)에서는 백슬래시 줄 연속이 지원되지 않으므로 한 줄로 실행하거나, PowerShell에서는 백틱 `으로 줄 바꿈합니다.
keytool -genkey -v `
-keystore release.keystore `
-alias my-release-key `
-keyalg RSA `
-keysize 2048 `
-validity 10000
대화형으로 패스워드·이름·조직명 등을 입력합니다.
2. Keystore를 Base64로 인코딩
GitHub Secrets는 바이너리를 직접 저장할 수 없으므로, Base64로 변환하여 문자열로 만듭니다. 워크플로 측에서 디코딩 시 tr -d '\n\r'로 줄 바꿈을 제거하므로 출력에 줄 바꿈이 포함되어 있어도 문제없지만, 각 OS에서 줄 바꿈 없이 출력하는 것이 더 안전합니다.
macOS
base64 -i release.keystore -o release.keystore.base64
cat release.keystore.base64 | pbcopy
Linux
base64 -w 0 release.keystore > release.keystore.base64
cat release.keystore.base64 | xclip -selection clipboard
Windows (PowerShell)
[Convert]::ToBase64String([IO.File]::ReadAllBytes("release.keystore")) `
| Set-Clipboard
파일에 저장하려면 이쪽을 사용합니다.
[Convert]::ToBase64String([IO.File]::ReadAllBytes("release.keystore")) `
| Out-File -Encoding ascii -NoNewline release.keystore.base64
Windows (명령 프롬프트)
certutil도 가능하지만 헤더·푸터와 줄 바꿈이 붙으므로 제거가 필요합니다.
certutil -encode release.keystore release.keystore.base64
출력 파일에서 -----BEGIN CERTIFICATE----- 행과 -----END CERTIFICATE----- 행, 그리고 줄 바꿈을 제거하고 사용합니다. PowerShell을 사용하는 것을 권장합니다.
클립보드에 복사한 문자열을 다음 단계에서 GitHub Secrets에 붙여넣습니다.
3. Google Play Console용 서비스 계정 만들기
Play Console에 자동 업로드하려면 Google Cloud 서비스 계정이 필요합니다.
3-1. Google Play Android Developer API 활성화
먼저 GCP 측에서 API를 활성화합니다. 이 단계를 건너뛰면 나중에 403 에러가 발생하므로 먼저 완료해 둡시다.
- Google Play Android Developer API에 접근
- 사용 설정을 클릭
3-2. Google Cloud Console에서 서비스 계정 생성
- Google Cloud Console에 접근
- 프로젝트를 생성 (또는 기존 프로젝트 선택)
- IAM 및 관리 → 서비스 계정에서 신규 생성. IAM 역할은 부여하지 않음 (Play Console 측에서 권한을 관리하므로)
- 생성 후 키 추가 → 새 키 만들기 → JSON을 선택하여 JSON 파일 다운로드
3-3. Play Console 측에서 권한 부여
- Play Console 열기
- 사용자 및 권한을 열고 사용자 초대를 클릭
- 3-2에서 생성한 서비스 계정의 이메일 주소 입력
- 앱 권한 탭에서 대상 앱을 추가하고 다음 권한 부여:
- 테스트 트랙에 릴리스 — 내부 테스트 트랙 배포에 필요
- 앱 정보 보기 및 대량 보고서 다운로드 — 읽기 권한
4. GitHub Secrets에 값 등록
리포지토리의 Settings → Secrets and variables → Actions에서 다음을 등록합니다.
| Secret 이름 | 내용 |
|---|---|
KEYSTORE_BASE64 |
2단계에서 얻은 Base64 문자열 |
KEYSTORE_PASSWORD |
Keystore 패스워드 |
KEY_ALIAS |
예: my-release-key |
KEY_PASSWORD |
키 패스워드 |
SERVICE_ACCOUNT_JSON |
3-2 단계에서 다운로드한 JSON의 내용을 그대로 붙여넣기 |
5. build.gradle에서 환경 변수로 서명 정보 읽기
CI에서는 환경 변수 경유, 로컬 개발에서는 keystore.properties 경유로 읽을 수 있도록 하고, Keystore가 없는 환경에서도 디버그 빌드만큼은 정상적으로 실행 가능한 형태로 구성합니다.
app/build.gradle.kts를 다음과 같이 편집합니다.
import java.util.Properties
import java.io.FileInputStream
// --- 서명 정보 해결 ---
// 우선순위: 환경 변수(CI) > keystore.properties(로컬) > 없음(디버그 빌드만 가능)
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties().apply {
if (keystorePropertiesFile.exists()) {
load(FileInputStream(keystorePropertiesFile))
}
}
fun resolveSigning(key: String, envKey: String): String? =
System.getenv(envKey) ?: keystoreProperties.getProperty(key)
val releaseStoreFile = resolveSigning("storeFile", "KEYSTORE_FILE")
val releaseStorePassword = resolveSigning("storePassword", "KEYSTORE_PASSWORD")
val releaseKeyAlias = resolveSigning("keyAlias", "KEY_ALIAS")
val releaseKeyPassword = resolveSigning("keyPassword", "KEY_PASSWORD")
val hasReleaseSigning = listOf(
releaseStoreFile, releaseStorePassword, releaseKeyAlias, releaseKeyPassword
).all { !it.isNullOrBlank() }
android {
signingConfigs {
if (hasReleaseSigning) {
create("release") {
storeFile = file(releaseStoreFile!!)
storePassword = releaseStorePassword
keyAlias = releaseKeyAlias
keyPassword = releaseKeyPassword
}
}
}
buildTypes {
getByName("debug") {
// applicationIdSuffix를 붙여 release 빌드와 기기에서 공존 가능하도록 함
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
// signingConfig는 AGP가 자동 생성하는 debug.keystore가 그대로 사용됨
}
getByName("release") {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
// 서명 정보가 있는 경우에만 release용 signingConfig를 할당
// (= 키가 없는 개발자도 assembleDebug는 실행 가능)
if (hasReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
}
}
}
}
이 코드의 포인트는 다음과 같습니다.
signingConfigs.create("release")를 가드하고 있으므로 서명 정보가 없는 환경에서는 release 설정 자체가 생성되지 않음- **
debug빌드 타입은 AGP 자동 생성의debug.keystore**가 그대로 사용되어 release 서명 설정의 유무에 전혀 영향받지 않음 - 환경 변수를 최우선으로 하므로, CI에서는
keystore.properties를 둘 필요가 없음
6. 로컬 개발 환경을 망가뜨리지 않기 위한 추가 설정
"CI를 위해 build.gradle을 수정했더니, 로컬에서 디버그 빌드가 안 된다"는 것은 Android × CI에서 흔한 사고입니다. 이를 방지하기 위해 local.properties 이외도 포함하여 다음을 정비합니다.
6-1. keystore.properties 만들기 (로컬 전용)
local.properties는 원래 SDK 경로를 쓰는 파일이며, 여기에 서명 정보를 혼재하면 가독성이 나빠집니다. 서명 정보 전용으로 별도 파일 keystore.properties를 만드는 것이 Android 공식 문서에서도 권장하는 방식입니다.
프로젝트 루트 (app/이 아닌 그 상위)에 keystore.properties를 생성합니다.
storeFile=/Users/yourname/keys/release.keystore
storePassword=your-store-password
keyAlias=my-release-key
keyPassword=your-key-password
이 파일은 Git에 절대 넣지 않으므로 .gitignore에 추가합니다 (후술).
6-2. .gitignore에 추가해야 할 것들
의외로 놓치기 쉬운 것이 .gitignore 정비입니다. Android Studio가 생성하는 .gitignore에는 서명 관련 항목이 없으므로 명시적으로 추가합니다.
# 서명 관련 - 절대로 커밋하지 않기
*.keystore
*.jks
keystore.properties
release.keystore.base64
# 서비스 계정 JSON
*-service-account*.json
play-publisher.json
# google-github-actions/auth가 생성하는 임시 인증 파일 (WIF 사용 시)
gha-creds-*.json
# Android Studio가 만들지만 혹시 모르니
local.properties
local.properties는 Android Studio가 기본적으로 gitignore에 추가해 주지만, 오래된 프로젝트에서는 누락되어 있을 수 있으니 확인해 두세요.
6-3. applicationIdSuffix로 release / debug 공존
위의 build.gradle.kts에서
debug {
applicationIdSuffix = ".debug"
}
를 넣는 것은, 기기에 release 버전 (Play Store에서 설치한 것)과 로컬에서 빌드한 debug 버전을 동시에 설치할 수 있도록 하기 위함입니다. 이것이 없으면 Play Store에서 배포 중인 앱을 사용하는 사용자(= 자신)가 개발 기기에 디버그 빌드를 설치하는 순간 덮어쓰는 사고가 발생합니다.
CI와 직접 관련 없는 이야기처럼 보이지만, 릴리스 자동화를 시작하면 Play Store 배포 버전을 기기에 유지한 채 개발하는 기회가 늘어나므로 함께 설정해 두는 것을 강력히 권장합니다.
6-4. ~/.gradle/gradle.properties를 사용하는 선택지
또 다른 선택지로 홈 디렉터리의 Gradle 프로퍼티에 써두는 방법이 있습니다.
# macOS/Linux: ~/.gradle/gradle.properties
# Windows: %USERPROFILE%\.gradle\gradle.properties
MYAPP_KEYSTORE_FILE=/Users/yourname/keys/release.keystore
MYAPP_KEYSTORE_PASSWORD=xxxx
MYAPP_KEY_ALIAS=my-release-key
MYAPP_KEY_PASSWORD=xxxx
build.gradle.kts 측에서는 findProperty("MYAPP_KEYSTORE_FILE") as String?처럼 읽습니다.
장점은 프로젝트 디렉터리에 비밀 정보를 전혀 두지 않아도 된다는 것. 단점은 새로운 개발자에게 설정 순서를 전달하는 것이 약간 번거롭다는 것. 개인 개발이라면 ~/.gradle/gradle.properties 방식, 팀 개발이라면 keystore.properties를 .gitignore로 보호하는 방식 정도의 사용 구분이 제 추천입니다.
6-5. Android Studio에서 서명 없는 release 빌드를 시험해 보고 싶을 때
./gradlew assembleDebug는 그대로 실행 가능하지만, ./gradlew assembleRelease를 서명 없이 실행하고 싶은 경우 (release 빌드의 ProGuard 설정 확인 등)도 있습니다.
이 경우, hasReleaseSigning이 false일 때 release 빌드 타입에서 signingConfig가 제거되므로, assembleRelease를 실행하면 서명되지 않은 APK가 그대로 생성됩니다 (기기에 설치는 불가하지만, 크기나 mapping.txt 확인은 가능).
7. Play App Signing 운영상의 보충
여기까지의 1~6단계로, 실은 Play App Signing을 사용한 구성이 이미 완성되어 있습니다. 1단계에서 생성한 release.keystore는 Play App Signing 문맥에서 업로드 키로 기능합니다.
이 섹션에서는 운영하면서 알아두면 편리한 보충 사항을 정리합니다.
7-1. 첫 번째 업로드 시 무슨 일이 일어나는가
신규 앱의 경우, 최초 AAB를 Play Console에 업로드한 시점에:
- 그 AAB에 사용된 인증서가 "업로드 키 인증서"로 자동 등록됨
- Google 측에서 새로운 "앱 서명 키"가 자동 생성됨
- Play App Signing이 활성화됨
즉 추가적인 Play Console 설정은 불필요하며, 이 글의 순서를 그대로 실행하면 Play App Signing 구성이 완성됩니다.
flowchart TD
CI["CI"] -->|"release.keystore로 서명"| AAB["AAB"]
AAB --> Play["Play Console"]
Play --> Verify["Google이 업로드 키 인증서를 검증"]
Verify --> Resign["Google이 앱 서명 키로 재서명"]
Resign --> Device["사용자 기기"]7-2. 한 단계 더 안전하게: 업로드 키를 전용으로 만들기
신규 앱이어도 Play Console에서 "업로드 키를 별도로 등록하는" 구성을 선택할 수 있습니다.
- 최초 업로드 시 Play Console에서 "Google이 생성한 앱 서명 키를 사용"을 선택
- 같은 화면에서 업로드 키 인증서를 별도로 업로드
이 경우, release.keystore에 넣는 것은 "완전히 업로드 전용 키"가 됩니다.
| 일반 패턴 (7-1) | 키를 분리하는 방식 (7-2) | |
|---|---|---|
release.keystore의 내용 |
업로드 키 = 최초 AAB에 서명한 키 | 순수한 업로드 키 |
| 만약 유출되었을 경우 | 리셋 신청으로 복구 가능 | 리셋 신청으로 복구 가능 |
| 보안 수준 | 충분 | 더 엄격 |
CI 측의 순서는 어느 패턴이든 동일합니다. release.keystore를 어떻게 만드느냐가 다를 뿐이라고 이해하면 됩니다.
7-3. 업로드 키를 분실했을 경우
Play App Signing 최대의 장점이 여기 있습니다. Play Console에서 리셋 신청을 할 수 있습니다.
-
새로운 Keystore를 로컬에서 생성
-
인증서를 PEM 형식으로 내보내기
keytool -export -rfc \ -keystore release.keystore \ -alias my-release-key \ -file upload_certificate.pem -
Play Console → 설정 → 앱 무결성 → 앱 서명 → 업로드 키 리셋 요청에서 인증서 업로드
-
Google 지원팀이 확인 (보통 1~2 영업일)
-
승인 후 새로운 키로 서명한 AAB를 받아줌
구방식에서는 "키를 분실하면 같은 패키지명으로는 영원히 업데이트 불가"라는 막다른 상황이 있었으므로, 이 구제 수단만으로도 Play App Signing을 사용할 가치가 있습니다.
7-4. 기존 (구방식) 앱을 마이그레이션하는 경우
2021년 8월 이전에 생성되어 아직 Play App Signing으로 마이그레이션되지 않은 앱의 경우, 현재 사용 중인 서명 키 자체를 Google에 맡기는 과정이 필요합니다.
-
Play Console → 해당 앱 → 설정 → 앱 무결성 → 앱 서명
-
Google이 제공하는 PEPK (Play Encrypt Private Key) 도구 다운로드
-
PEPK 도구로 현재 Keystore를 암호화된 형태로 내보내기
java -jar pepk.jar \ --keystore=existing-release.keystore \ --alias=my-release-key \ --output=encrypted-key.zip \ --include-cert \ --rsa-aes-encryption \ --encryption-key-path=public-key.pem -
출력 파일 (
encrypted-key.zip)을 Play Console에 업로드 -
마이그레이션 후 필요에 따라 새로운 업로드 키를 생성·등록 (기존 키를 그대로 업로드 키로 계속 사용도 가능)
public-key.pem은 Play Console 마이그레이션 화면에 표시되는 공개 키를 저장한 것입니다.
마이그레이션 후에는 이 글의 1~6단계를 그대로 사용할 수 있게 됩니다.
8. GitHub Actions 워크플로 작성
드디어 본론인 워크플로입니다. .github/workflows/release.yml을 생성합니다.
name: Android Release
on:
push:
tags:
- 'v*'
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Cache Gradle packages
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
- name: Decode Keystore
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | tr -d '\n\r' | base64 --decode > ${{ github.workspace }}/release.keystore
- name: Build Release AAB
env:
KEYSTORE_FILE: ${{ github.workspace }}/release.keystore
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: ./gradlew bundleRelease --no-daemon
- name: Upload AAB as artifact
uses: actions/upload-artifact@v4
with:
name: release-aab
path: app/build/outputs/bundle/release/app-release.aab
- name: Deploy to Play Store (Internal Track)
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
packageName: com.example.myapp
releaseFiles: app/build/outputs/bundle/release/app-release.aab
tracks: internal
status: completed
whatsNewDirectory: distribution/whatsnew
포인트 해설
- 태그 트리거 (
v*)로 기동하도록 하여 실수로 main push 시 배포되는 것을 방지 - Gradle 캐시를 활용하여 빌드 시간 단축 (첫 번째 실행 약 6분 → 이후 약 2분)
- Artifact 업로드를 넣어두면, Play Console 업로드에 실패해도 Actions 화면에서 AAB를 수동으로 다운로드할 수 있으므로 보험이 됨
tracks: internal은alpha/beta/production등으로 변경 가능whatsNewDirectory는 릴리스 노트를 언어별로 두는 디렉터리. 예를 들어distribution/whatsnew/whatsnew-ja-JP라는 파일명으로 배치
8-2. 더 안전한 구성: Workload Identity Federation 사용
GitHub Secrets에 장기 유효한 JSON 키를 저장하는 대신, **Workload Identity Federation (WIF)**을 사용하면 GitHub Actions가 임시 인증 정보를 취득하여 Play Console에 접근할 수 있습니다. JSON 키 유출 위험이 없어지므로, 프로덕션 운용에서는 WIF가 권장됩니다.
사전 준비 (GCP 측)
WIF 설정은 한 번만 하면 이후에는 불필요합니다. google-github-actions/auth의 순서에 따라 설정합니다. 이 방식에서는 서비스 계정의 JSON 키를 발행하지 않으므로, 섹션 3-2의 4번째 단계 (키 생성·다운로드)와 섹션 4의 SERVICE_ACCOUNT_JSON 등록은 불필요합니다.
다음 gcloud 명령어로 설정합니다. 각 플레이스홀더는 실제 값으로 교체하세요.
| 플레이스홀더 | 설명 |
|---|---|
${PROJECT_ID} |
GCP 프로젝트 ID |
${GITHUB_ORG} |
GitHub 조직명 또는 사용자명 |
${REPO} |
org/repo 형식의 리포지토리명 |
${SERVICE_ACCOUNT} |
3-2 단계에서 생성한 서비스 계정명 (이메일 @ 왼쪽 부분) |
${WORKLOAD_IDENTITY_POOL_ID} |
2번 단계에서 취득한 Pool의 완전한 ID |
# 1. Workload Identity Pool 생성
gcloud iam workload-identity-pools create "github" \
--project="${PROJECT_ID}" \
--location="global" \
--display-name="GitHub Actions Pool"
# 2. Pool의 완전한 ID 취득
gcloud iam workload-identity-pools describe "github" \
--project="${PROJECT_ID}" \
--location="global" \
--format="value(name)"
# 예: projects/123456789/locations/global/workloadIdentityPools/github
# 3. Workload Identity Provider 생성
# --attribute-condition으로 "이 조직으로부터의 토큰만 Pool에 들어올 수 있도록" 제한
gcloud iam workload-identity-pools providers create-oidc "my-repo" \
--project="${PROJECT_ID}" \
--location="global" \
--workload-identity-pool="github" \
--issuer-uri="https://token.actions.githubusercontent.com" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner" \
--attribute-condition="assertion.repository_owner == '${GITHUB_ORG}'"
# 4. 서비스 계정에 roles/iam.workloadIdentityUser 부여
# principalSet의 attribute.repository로 "이 특정 리포지토리만"으로 제한
# ${WORKLOAD_IDENTITY_POOL_ID} = 2번 단계에서 취득한 완전한 ID
gcloud iam service-accounts add-iam-policy-binding \
"${SERVICE_ACCOUNT}@${PROJECT_ID}.iam.gserviceaccount.com" \
--project="${PROJECT_ID}" \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/${WORKLOAD_IDENTITY_POOL_ID}/attribute.repository/${REPO}"
설정 후 Provider의 완전한 ID(projects/.../providers/...)를 메모해 둡니다. GitHub Actions YAML의 workload_identity_provider에 사용합니다.
워크플로 YAML (WIF 버전)
리포지토리에 id-token: write 권한과 google-github-actions/auth 스텝을 추가합니다.
name: Android Release
on:
push:
tags:
- 'v*'
permissions:
contents: read
id-token: write # WIF 토큰 취득에 필요
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Cache Gradle packages
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
- name: Decode Keystore
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | tr -d '\n\r' | base64 --decode > ${{ github.workspace }}/release.keystore
- name: Build Release AAB
env:
KEYSTORE_FILE: ${{ github.workspace }}/release.keystore
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: ./gradlew bundleRelease --no-daemon
- name: Upload AAB as artifact
uses: actions/upload-artifact@v4
with:
name: release-aab
path: app/build/outputs/bundle/release/app-release.aab
- name: Authenticate to Google Cloud (WIF)
uses: google-github-actions/auth@v3
id: auth
with:
workload_identity_provider: projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID
service_account: SERVICE_ACCOUNT@PROJECT_ID.iam.gserviceaccount.com
- name: Deploy to Play Store (Internal Track)
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJson: ${{ steps.auth.outputs.credentials_file_path }}
packageName: com.example.myapp
releaseFiles: app/build/outputs/bundle/release/app-release.aab
tracks: internal
status: completed
whatsNewDirectory: distribution/whatsnew
workload_identity_provider와 service_account는 WIF 설정 후 GCP 콘솔에서 확인한 값으로 교체해 주세요. JSON 키 버전과의 차이는 serviceAccountJsonPlainText 대신 serviceAccountJson에 인증 파일 경로를 전달하는 것뿐이며, 다른 순서는 동일합니다.
9. 릴리스 노트용 디렉터리 만들기
리포지토리 루트에 distribution/whatsnew/를 만들고 다음 파일들을 놓습니다.
distribution/whatsnew/
├── whatsnew-en-US
└── whatsnew-ja-JP
각 파일의 내용은 해당 언어의 릴리스 노트 (500자 이내)입니다.
10. 실행해 보기
여기까지 왔다면, 이제 태그를 push하기만 하면 됩니다.
git tag v1.0.0
git push origin v1.0.0
GitHub의 Actions 탭을 열면 워크플로가 실행되기 시작합니다. 성공하면 Play Console의 내부 테스트에 새로운 릴리스가 추가되어 있을 것입니다.
11. 자주 빠지는 함정과 대책
실제 운영에서 겪은 함정을 몇 가지 공유합니다.
versionCode 올리는 것을 잊어버리기
Play Console은 versionCode 중복을 허용하지 않습니다. GitHub Actions의 실행 번호 (GITHUB_RUN_NUMBER)를 사용하여 자동으로 채번하는 구조를 넣어두면 사고가 줄어듭니다. versionName은 태그명에서 v를 제거한 값을 사용합니다.
android {
defaultConfig {
// CI에서는 GITHUB_RUN_NUMBER를 versionCode에 사용 (로컬은 1로 폴백)
versionCode = (System.getenv("GITHUB_RUN_NUMBER")?.toInt() ?: 1) + 1000
// CI에서는 git tag (v1.0.0 → 1.0.0)를 versionName에 사용
versionName = System.getenv("GITHUB_REF_NAME")?.removePrefix("v") ?: "1.0.0-local"
}
}
서비스 계정 권한 반영 대기
권한을 부여한 직후에 Actions를 실행하면 403 The caller does not have permission으로 실패하는 경우가 있습니다. 설정 변경이 반영되는 데 다소 시간이 걸리므로, 첫 번째 실행은 느긋하게 기다려봅시다.
Base64 디코드 시 줄 바꿈이 섞이기
base64 명령어의 출력은 OS에 따라 동작이 다릅니다.
- macOS: 76자마다 자동으로 줄 바꿈이 삽입됨 →
-i/-o로 완전한 형태로 출력 - Linux: 기본적으로 줄 바꿈이 삽입됨 →
-w 0을 지정하면 줄 바꿈 없음 - Windows (
certutil): 헤더·푸터·줄 바꿈 모두 포함됨 → 후처리가 필요; PowerShell의[Convert]::ToBase64String이 더 안전
GitHub Secrets에 붙여넣기 전에 줄 바꿈이 섞여 있지 않은지 반드시 확인하세요. CI 측에서 디코딩할 때 안전을 위해 tr -d '\n\r'을 추가하는 것이 정석입니다.
- name: Decode Keystore
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | tr -d '\n\r' | base64 --decode > release.keystore
마치며
GitHub Actions를 도입함으로써 릴리스 작업이
"태그를 잘라서 push만 하면 된다"
로 변하여, 릴리스 빈도를 높여도 작업 부하가 거의 제로로 운영할 수 있게 되었습니다.
특히 여러 명이 개발하는 팀에서는 "그 Mac이 아니면 릴리스가 안 된다" 문제가 해소되는 것만으로도 도입할 가치가 있습니다. Android 앱 개발에서 같은 고민을 가진 분들은 꼭 시도해 보세요.
참고 링크
Android / 서명 관련
- 앱 서명 | Android Developers
- Android App Bundle 정보 | Android Developers
- APK와 AAB 비교 | Android Developers
- 빌드 구성 | Android Developers
- Gradle 팁과 레시피 (서명 정보를 Git에서 제외하기) | Android Developers
Google Play Console
- Play 앱 서명 사용 | Play Console 도움말
- Google Play Developer API
- Google Play Developer API 시작하기 | Android Developers
GitHub Actions
- actions/checkout
- actions/setup-java
- actions/cache
- actions/upload-artifact
- r0adkll/upload-google-play
- 암호화된 시크릿 | GitHub Docs
