[Android] 안드로이드 앱 CI / CD 적용하기 with Github Actions - (1) CI 적용
요즘 채용 공고를 보면 'CI / CD 를 적용해본적이 있는 분' 이 자주 명시되어있다. 또, 앱을 플레이 스토어에 배포하고 나면 자연스레 CI / CD를 접하게 되는데, 대체 무엇을 의미하는걸까 ? 또 어떤
sxunea.tistory.com
위의 글과 이어지는 글로, 저번에 CI 를 적용했다면, 이번엔 CD를 적용해보자. CD에 대한 설명은 이전에 했으니, 이번엔 바로 어떻게 적용하는 지로 넘어가자
CD 적용하기
CI 를 통해서 프로젝트가 성공적으로 빌드되고 테스트를 통과하면, 커밋한 변경사항을 메인으로 머지해 프로덕션 환경에 배포해 사용자에게 새로운 버전을 제공해야한다. 이때, CD 즉 지속적 배포를 통해 배포 과정에서 일어날 수 있는 오류를 최소화 하고, 테스터들에게 apk 를 제공해 피드백 루프 또한 줄일 수 있다.
아래의 내용은 내 프로젝트에 적용한 워크플로우이고, 필요에 따라 다른 이벤트를 추가하거나 불필요한 부분은 제거하면 된다.
워크플로우 트리거 환경 설정
on:
pull_request:
branches:
- develop
- main
먼저 이전처럼, 어떤 브랜치에 PR을 올릴 때 CD가 트리거 될지를 지정해준다. 테스트를 위해 처음에는 develop으로의 PR도 트리거되게했다.
JDK 환경 설정
jobs:
cd-build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: 17
CI와 마찬가지로, 같은 JDK 환경을 설정해준다.
KeyStore 파일 생성
- name: Generate winey.keystore.jks
run: echo '${{ secrets.WINEY_KEYSTORE }}' | base64 -d > ./app/winey.jks
그 다음, 키스토어 jks 파일을 생성한다. 여기서 키스토어란 개발자가 앱을 배포할때, 앱 개발자에 대한 정보가 포함되어있는 암호화된 파일을 의미한다. Google Play에 앱을 배포할 때 이 키스토어로 서명한 apk / app bundle이 필요하다.
이 keystore를 생성하는 방법은 안드로이드 스튜디오에서 build-generate signed bundle/apk 에서 가능하며, 이때 키스토어를 생성할 때 key alias, key pw, keystore pw 등을 설정하고, 이를 꼭 기억해두어야 한다. 이를 완료하면 키스토어가 지정한 경로에 생성되고, 이 키스토어 jks 파일이 필요하다. keystore 생성 방법은 안드로이드 앱 배포 방법 만 검색해도 잘 나와있고, CD 는 보통 앱 배포 이후에 적용하니 여기서는 자세한 설명 없이 넘어가겠다.
그럼 여기서 궁금증이 생길 것이다. 코드를 보면 키스토어 또한 secrets에서 가져오는데, 시크릿에서는 파일 첨부가 불가하지 않은가? 바로 그래서 옆에 base64 -d가 필요한 것이다. jks 파일 같은 바이너리 파일은 텍스트 기반 형식으로 저장할 수 없으므로, 텍스트 형태로 전환하기 위해 base64로 인코딩한다.
이 인코딩된 텍스트를 GitHub Secrets나 환경 변수로 저장할 수 있게 되어, 워크플로우에서 쉽게 사용할 수 있다. 그렇다면 인코딩은 어떻게할까 ? 검색해보면 다양한 방법이 있지만 터미널에서 쉽게 가능하다. (참고로 내 환경은 macOS이다)
keystore base64로 인코딩하기
먼저 생성한 키스토어가 있는 디렉토리로 cd 명령어를 통해 이동한다. 그다음 해당 위치에서 아래와 같이 입력해주면 된다.
base64 -i 키스토어이름.jks -o 키스토어이름.jks.base64
나의 경우, 위와 같았다. 이렇게 되면 해당 디렉토리에 base64로 인코딩된 jks가 생성된다. 그렇다면 이 텍스트를 복사해 secrets에 붙여넣기 하면된다. 인코딩된 파일은 단순히 더블클릭으로 열리지 않는데, 이때도 터미널을 이용하면 된다.
cat은 파일 내용을 화면에 출력하는 터미널 명령어로, 생성된 파일 앞에 cat을 붙여주면 터미널에 바로 텍스트 형식의 키스토어가 출력된다. 그러면 이를 복사하고, 이전에 local properties를 등록한 방법과 같이 github secret에 해당 내용을 붙여넣기 하면 된다.
이렇게 인코딩한 파일을 secrets에 넣으면, | base64 -d > ./app/winey.jks 를 통해 이를 디코딩해 winey.jks라는 이름으로 디코딩해 저장하고, 결국 키스토어 파일을 복구 활용할 수 있다.
Local Properties
- name: Generate local.properties
env:
AUTH_BASE_URL: ${{ secrets.AUTH_BASE_URL }}
KAKAO_NATIVE_KEY: ${{ secrets.KAKAO_NATIVE_KEY }}
AMPLITUDE_API_KEY: ${{ secrets.AMPLITUDE_API_KEY }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
run: |
echo auth.base.url=\"$AUTH_BASE_URL\" >> ./local.properties
echo kakao.native.key=\"$KAKAO_NATIVE_KEY\" >> ./local.properties
echo amplitude.api.key=\"$AMPLITUDE_API_KEY\" >> ./local.properties
echo kakaoNativeKey=$KAKAO_NATIVE_KEY >> ./local.properties
echo keyAlias=$KEY_ALIAS >> ./local.properties
echo keyPassword=$KEY_PASSWORD >> ./local.properties
echo storePassword=$STORE_PASSWORD >> ./local.properties
- name: Create Google Services JSON File
env:
GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }}
run: echo $GOOGLE_SERVICES_JSON > ./app/google-services.json
이 부분은 CI와 동일하므로 넘어가겠다.
이때 하나 주의할 점은 local.properties 파일에 환경 변수를 추가할 때, auth.base.url="$AUTH_BASE_URL" 같은 라인의 큰따옴표 안에 변수 값을 넣어야 할 경우가 있는데, 큰따옴표(")가 쉘에서 해석되지 않고 텍스트로 포함되도록 백슬래시를 사용한다. 이를 넣지 않고 cd 스크립트를 작성했다가, 계속해서 오류가 났어서 애먹었다. 간단하지만 놓치기 쉬우니 참고하자 !
버전 이름 추출
# PR 제목에서 release v*.*.* 형식의 버전을 추출
- name: Extract Version Name
run: echo "##[set-output name=version;]v$(echo '${{ github.event.pull_request.title }}' | grep -oP 'release v\K[0-9]+\.[0-9]+\.[0-9]+')"
id: extract_version
- name: Build Release APK
run: |
./gradlew :app:assembleRelease
이 단계에서는 PR 제목에서 버전 번호를 추출하고, Release APK 빌드를 진행한다. PR 제목에서 release v*.*.* 형식의 버전이름을 추출하고, 이후 ./gradlew :app:assembleRelease 로 릴리즈 버전을 빌드한다.
APK GitHub Actions의 Artifacts 업로드
# GitHub Actions Artifacts에 릴리즈 apk 추출
- name: Upload Release Build to Artifacts
uses: actions/upload-artifact@v3
with:
name: release-artifacts
path: app/build/outputs/apk/release/
if-no-files-found: error
- name: Create Github Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.extract_version.outputs.version }}
release_name: ${{ steps.extract_version.outputs.version }}
generate_release_notes: true
files: |
app/build/outputs/apk/release/app-release.apk
Github Actions의 upload-artifact 액션을 사용해 빌드한 파일을 Artifacts로 업로드 한다. 성공하면, 빌드된 apk파일을 Artifacts 탭에서 다운로드 할 수 있다. 그다음엔 위 단계에서 추출한 버전 정보를 태그 및 릴리즈 이름으로 지정하고, 릴리즈 노트를 생성한다.
이를 통해 빌드가 완료되면 자동으로 GitHub Release에 게시되어 최신 버전 APK를 관리하거나 공유할 수 있다 !
APK를 Firebase App Distribution에 업로드해 테스터에게 공유
- name: Upload artifact to Firebase App Distribution
uses: wzieba/Firebase-Distribution-Github-Action@v1
with:
appId: ${{secrets.FIREBASE_APP_ID}}
serviceCredentialsFileContent: ${{ secrets.CREDENTIAL_FILE_CONTENT }}
groups: testers
file: app/build/outputs/apk/release/app-release.apk
apk 빌드까지 완료되면, 위 설정을 통해 apk파일을 Firebase App Distribution에 업로드하고, 이를 테스터에게 공유할 수 있다. 여기서 이 스크립트만 작성하면 끝나는게 아니라, 다음의 과정이 필요하다.
1. Firebase App Distribution Get Started
선수 과정으로, 앱의 파이어베이스 콘솔에 들어간 후, App Distribution을 클릭하면, Get Started가 보인다. 이를 클릭해 App Distribution을 활성화한다. 나는 이 설정이 당연히 켜진줄알고, 스크립트를 실행했다가 오류를 겪었다. 다른 블로그에서는 딱히 명시가 없어서 추가해둔다 !
2. Firebase APP ID
스크립트를 보면 Github Secrets에서 두가지 값이 필요하다. 먼저 Firebase App ID이다. 이는, 마찬가지로 Firebase console에 들어가서 프로젝트 설정 - 아래로 스크롤 하면 앱 ID가 보인다. 이를 바로 복사하고, 같은 방법으로 Github Secrets에 추가해주면 된다.
3. Firebase Service Credentials
이를 얻기 위해서는 Google Cloud Console에 들어간다. 그 다음 IAM 및 관리자 > 서비스계정 에서 자신의 프로젝트를 클릭하자. 그 다음 Firebase 앱 배포 관리자 라는 권한으로 계정을 하나 만들면 된다. 계정이 생성되면 JSON 키를 만들면 되는데, 만들고 나면 자동으로 다운로드 된다.
이때, JSON 형식으로 다운받았으니 바로 파일을 열어 내부 JSON을 복사한 뒤, 같은 방법으로 Github Secrets에 붙여넣기해 추가해주면 된다. 자세한 과정은 아래 블로그에 캡쳐와 함께 잘 나와있어 첨부하겠다.
[Android] Firebase 배포 자동화 (Github Actions)
어제는 Github Actions를 이용하여 Github Release 를 자동화하였습니다. 2023.11.19 - [AOS] - [Android] “캐치 테이프” CD 구축 - 2. Github Actions 을 이용한 배포 자동화 [Android] “캐치 테이프” CD 구축 - 2. Githu
tral-lalala.tistory.com
4. 테스터 그룹 생성
마지막으로, 다시 Firebase App Distribution 으로 들어가 테스터를 지정한다. 스크립트에서 testers 라는 그룹으로 지정했으므로, 같은 이름으로 그룹을 생성하고 테스터들을 초대하면 된다.
이 네 단계를 통해 테스터는 Firebase App Distribution을 통해 최신 APK를 테스트할 수 있으며, GitHub Actions 워크플로우 내에서 CI/CD 과정이 Firebase 배포까지 자동화된다.
트러블슈팅
이 세팅을 거치면서 3-4 번의 CD 실패가 있었다. 그 실패 이유와 경험을 공유해 이 글을 읽는 사람들에게 빠른 해결책을 제공하고자 한다.
No key with alias '***' found in keystore
58 actionable tasks: 58 executed
Execution failed for task ':app:packageRelease'.
> A failure occurred while executing com.android.build.gradle.tasks.PackageAndroidArtifact$IncrementalSplitterRunnable
No key with alias '***' found in keystore
마주쳤던 오류로, 스토어 파일이 제대로 로드되지 않았기 때문에 발생한다. 이 문제의 원인은 보통
- jks 파일의 부재 혹은 오류
- keyAlias 또는 PW의 오류
두 가지이다. 둘 중 어디에 해당하는지에 대해 알아보기 위해 다음 스크립트를 추가할 수 있다.
jks 파일이 존재하는지 확인
- name: Check if winey.jks exists
run: |
if [ -f "./app/winey.jks" ]; then
echo "winey.jks file exists in the app directory."
else
echo "Error: winey.jks file is missing in the app directory." >&2
exit 1
fi
키스토어 jks 파일이 secrets로 부터 가져와, 디코딩 되어 디렉토리에 잘 존재하는 지 확인한다. 여기서 실패하면 Github Secrets가 잘 설정되어있는지 확인해야 한다.
키스토어가 잘 디코딩 되었는지 확인
- name: List keys in winey.jks
run: |
keytool -list -v -keystore ./app/winey.jks -storepass "${{ secrets.STORE_PASSWORD }}"
위의 인코딩 과정에서 키스토어가 혹시 잘못되었는지, 혹은 디코딩이 잘 되었는지 확인하는 방법이다. 이를 스크립트에 추가하면 디코딩한 키스토어의 내용을 Github CD 로그에서 확인할 수 있다. key alias 와 certificate chain 등이 제대로 출력되면 옳게 된 것이다.
위의 두가지가 제대로 수행되었다면, 답은 아마도 오타이다 ... 이게 내 경우였다.
Github Secrets를 추가할 때 key 값을 붙여넣기 하면서 오타를 냈다. 시크릿은 클릭하면 현재 값을 볼 수 없기 때문에 맞겠지 ~ 하고 바로 cd를 돌리다가 오류가 발생했다. 다시 키 값과 비밀번호 등을 붙여넣기 하니 제대로 동작했다. 위의 스크립트가 별 의미는 없었지만, 누군가의 다른 케이스를 위해 작성했던 것을 공유한다.
Warning: Unexpected input(s) 'release_name', valid inputs are : Run softprops/action-gh-release@v1 👩🏭 Creating new GitHub release for tag v... ⚠️ GitHub release failed with status: 403
위의 오류 또한 발생했는데, 잘 알려진 오류가 아니라서 해결하는데 힘들었다. 서치하다가 다른 사람이 보고한 이슈를 통해 해결했는데,
# 전체 워크플로우에 대한 권한 설정
permissions:
contents: write
딱 이 두 줄을 추가하면 해결된다. 나는 on: 키와 같은 수준에 추가해줬다. 저 오류를 자세히 보면 깃허브 토큰을 제시하라고 하는데, 권한 설정과 관련되어 나타나는 오류였다. 최상단에 다음과 같이 권한을 허용해주면 깃허브 릴리즈 생성에 대한 권한이 생겨 오류가 사라진다.
위의 과정을 다 마쳐 나는 아래와 같은 CD 워크플로우를 작성하게 되었다.
# JVM 옵션을 설정, 최대 힙 메모리를 4GB로
env:
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -Dorg.gradle.daemon=false"
GRADLE_BUILD_ACTION_CACHE_DEBUG_ENABLED: true
# main, develop 브랜치에 PR을 올릴 시 트리거
on:
pull_request:
branches:
- develop
- main
# 전체 워크플로우에 대한 권한 설정
permissions:
contents: write
jobs:
cd-build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: 17
- name: Generate winey.keystore.jks
run: echo '${{ secrets.WINEY_KEYSTORE }}' | base64 -d > ./app/winey.jks
- name: Check if winey.jks exists
run: |
if [ -f "./app/winey.jks" ]; then
echo "winey.jks file exists in the app directory."
else
echo "Error: winey.jks file is missing in the app directory." >&2
exit 1
fi
- name: List keys in winey.jks
run: |
keytool -list -v -keystore ./app/winey.jks -storepass "${{ secrets.STORE_PASSWORD }}"
- name: Generate local.properties
env:
AUTH_BASE_URL: ${{ secrets.AUTH_BASE_URL }}
KAKAO_NATIVE_KEY: ${{ secrets.KAKAO_NATIVE_KEY }}
AMPLITUDE_API_KEY: ${{ secrets.AMPLITUDE_API_KEY }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
run: |
echo auth.base.url=\"$AUTH_BASE_URL\" >> ./local.properties
echo kakao.native.key=\"$KAKAO_NATIVE_KEY\" >> ./local.properties
echo amplitude.api.key=\"$AMPLITUDE_API_KEY\" >> ./local.properties
echo kakaoNativeKey=$KAKAO_NATIVE_KEY >> ./local.properties
echo keyAlias=$KEY_ALIAS >> ./local.properties
echo keyPassword=$KEY_PASSWORD >> ./local.properties
echo storePassword=$STORE_PASSWORD >> ./local.properties
- name: Create Google Services JSON File
env:
GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }}
run: echo $GOOGLE_SERVICES_JSON > ./app/google-services.json
# PR 제목에서 release v*.*.* 형식의 버전을 추출
- name: Extract Version Name
run: echo "##[set-output name=version;]v$(echo '${{ github.event.pull_request.title }}' | grep -oP 'release v\K[0-9]+\.[0-9]+\.[0-9]+')"
id: extract_version
- name: Build Release APK
run: |
./gradlew :app:assembleRelease
# GitHub Actions Artifacts에 릴리즈 apk 추출
- name: Upload Release Build to Artifacts
uses: actions/upload-artifact@v3
with:
name: release-artifacts
path: app/build/outputs/apk/release/
if-no-files-found: error
- name: Create Github Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.extract_version.outputs.version }}
release_name: ${{ steps.extract_version.outputs.version }}
generate_release_notes: true
files: |
app/build/outputs/apk/release/app-release.apk
- name: Upload artifact to Firebase App Distribution
uses: wzieba/Firebase-Distribution-Github-Action@v1
with:
appId: ${{secrets.FIREBASE_APP_ID}}
serviceCredentialsFileContent: ${{ secrets.CREDENTIAL_FILE_CONTENT }}
groups: testers
file: app/build/outputs/apk/release/app-release.apk
다시 한번 요약하면, 위의 워크플로우를 통해 다음과 같은 것들이 가능하다.
- PR 자동 트리거: develop 및 main 브랜치에 PR이 생성될 때마다 자동으로 워크플로우를 실행하여 변경 사항을 검토하고 테스트할 수 있다
- 비밀 정보 관리: Firebase와 같은 외부 서비스에 접근하기 위해 비밀 키 및 인증 정보를 안전하게 관리하고, 필요할 때마다 해당 정보를 사용하여 환경을 설정한다
- APK 빌드: Gradle을 사용하여 애플리케이션의 릴리스 APK를 생성하고, 빌드 결과물을 GitHub Actions의 아티팩트로 업로드하여 팀원들이 쉽게 다운로드하고 검토할 수 있도록 한다
- GitHub 릴리스 생성: PR 제목에서 버전 정보를 추출하여 GitHub 릴리스를 자동으로 생성하고, APK 파일을 릴리스와 함께 배포하여 사용자나 테스터가 접근할 수 있게 한다
- Firebase App Distribution에 업로드: 빌드된 APK를 Firebase App Distribution에 업로드하여 지정된 테스터 그룹에게 배포한다
CD 워크플로우를 모두 통과하면, 다음과 같이 버전이 생성되고 테스터들을 정상적으로 초대하는 걸 확인할 수 있다. 이를 통해 개발 프로세스의 효율성을 크게 향상시키며, 팀원 간의 협업을 원활하게 할 수 있었다. 디자이너, 기획 과의 빠른 테스트 + 피드백 공유에 큰 힘이 될 수 있었다 ! 위니에는 아직 crashlytics 도입이 안되어있는데, 도입하면 오류 보고 또한 추가해 자동화 해보자.
이렇게 CI / CD 도입을 마친다 ! 안드로이드 CI / CD 를 검색하면 처음부터 끝까지 자세히 나와있는 블로그가 없어서 한번 A-Z까지 써서 적용을 시작하려는 사람들에게 도움이 되고싶었다. 이 글을 마주친 개발자들에게 친절한 블로그가 되길 !! : )
참고자료
[Android] Github Release 자동화 (Github Actions)
캐치 테이프를 개발하며 CI/CD 를 구축하게 되었습니다. CI 는 다른 팀원 분이 맡아주셨고, 저는 Github Action 을 이용하여 태그를 푸쉬하여 apk 파일을 빌드하고 파일을 release 하는 워크플로우 를 만
tral-lalala.tistory.com
Github) Github actions에서 Secrets로 환경변수 관리하기
github actions는 github에서 제공하는 기능으로, 특정 트리거가 발동되었을 때 미리 설정한 워크플로를 실행시키는 자동화 툴이다. github actions를 활용하면 main 브랜치에 코드가 푸시되었을 때 자동으
velog.io