Android 프로젝트에서 CI/CD는 선택이 아닌 필수다. PR마다 수동으로 빌드하고 테스트를 돌리는 것은 비효율적이며, 휴먼 에러를 유발한다. GitHub Actions를 사용하면 별도 서버 없이 코드 푸시만으로 빌드, 테스트, 배포까지 자동화할 수 있다.
1. 왜 CI/CD인가
위반 사례
개발자 A: "PR 올렸어요"
개발자 B: "로컬에서 빌드 돌려봤어요?"
개발자 A: "아... 잠깐만요" (5분 후) "빌드 깨졌네요 ㅎㅎ"
수동 검증의 문제점:
- 빌드 실패를 머지 후에 발견
- 테스트 실행을 까먹음
- "내 로컬에선 됐는데?" 현상
개선안
PR 생성 → 자동 빌드 → 자동 테스트 → 자동 Lint → 결과 리포트
CI/CD 도입 후:
- PR 생성 즉시 빌드/테스트 실행
- 실패 시 머지 불가
- 코드 리뷰 전에 기본 품질 검증 완료
2. GitHub Actions 기본 구조
구성 요소
Workflow → Job → Step → Action
| 요소 | 설명 | 예시 |
|---|---|---|
| Workflow | YAML로 정의된 자동화 프로세스 | .github/workflows/ci.yml |
| Job | 독립 실행 단위 (병렬 가능) | build, test, lint |
| Step | Job 내 순차 실행 명령 | checkout, gradle build |
| Action | 재사용 가능한 작업 | actions/checkout@v4 |
실행 환경
runs-on: ubuntu-latest # GitHub 호스팅 러너
GitHub이 제공하는 가상 머신에서 실행된다. 별도 서버 구축 없이 바로 사용 가능하다.
3. 기본 CI Workflow
전체 구성
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
env:
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -Dorg.gradle.daemon=false"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: Build Debug APK
run: ./gradlew assembleDebug --stacktrace
- name: Run Unit Tests
run: ./gradlew testDebugUnitTest --stacktrace
핵심 포인트
on: 워크플로우 트리거 조건 정의env: 전역 환경 변수 설정jobs: 실행할 작업 목록steps: 각 작업의 세부 단계
4. 트리거 조건 설정
기본 트리거
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
main, develop 브랜치에 푸시하거나 PR을 생성하면 실행된다.
경로 필터링
on:
push:
paths:
- 'app/**'
- 'core/**'
- '*.gradle.kts'
paths-ignore:
- '**.md'
- 'docs/**'
코드 변경만 빌드를 트리거한다. README 수정으로 빌드가 돌지 않는다.
PR 이벤트 세분화
on:
pull_request:
types: [ opened, synchronize, reopened ]
PR 생성, 커밋 추가, 재오픈 시에만 실행한다.
5. Gradle 캐싱 전략
빌드 시간의 대부분은 의존성 다운로드다. 캐싱으로 이를 최적화한다.
공식 Gradle Action 사용 (권장)
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
gradle/actions/setup-gradle은 자동으로 Gradle 의존성을 캐싱한다. PR에서는 cache-read-only로 캐시 오염을 방지한다.
수동 캐싱
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-
hashFiles로 gradle 파일이 변경될 때만 캐시를 갱신한다.
캐싱 효과
| 상태 | 빌드 시간 |
|---|---|
| 캐시 없음 | 8-12분 |
| 캐시 있음 | 3-5분 |
6. 시크릿 관리
API 키, 서명 키 같은 민감 정보는 GitHub Secrets에 저장한다.
설정 방법
1. GitHub 레포 → Settings → Secrets and variables → Actions
2. New repository secret 클릭
3. Name과 Value 입력
API 키 사용
- name: Create local.properties
run: |
echo "TMDB_API_KEY=${{ secrets.TMDB_API_KEY }}" >> local.properties
서명 키스토어 사용
- name: Decode Keystore
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > app/keystore.jks
- name: Create keystore.properties
run: |
echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}" >> keystore.properties
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> keystore.properties
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> keystore.properties
키스토어 파일은 Base64로 인코딩하여 저장한다.
# 로컬에서 Base64 인코딩
base64 -i release.keystore | pbcopy # macOS
base64 release.keystore | xclip # Linux
7. 병렬 Job 실행
빌드, 테스트, Lint를 병렬로 실행하면 전체 시간이 단축된다.
위반 사례
# 순차 실행: 총 15분
steps:
- run: ./gradlew assembleDebug # 5분
- run: ./gradlew testDebugUnitTest # 5분
- run: ./gradlew lintDebug # 5분
개선안
# 병렬 실행: 총 5분
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- run: ./gradlew assembleDebug
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- run: ./gradlew testDebugUnitTest
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- run: ./gradlew lintDebug
세 Job이 동시에 실행되어 가장 긴 작업 시간만큼만 소요된다.
Job 의존성 설정
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: ./gradlew assembleRelease
deploy:
runs-on: ubuntu-latest
needs: build # build 완료 후 실행
if: github.ref == 'refs/heads/main'
steps:
- run: echo "Deploying..."
needs로 의존성을 정의하고, if로 조건부 실행을 설정한다.
8. 테스트 결과 리포트
테스트 결과를 PR 화면에 표시하면 리뷰가 편해진다.
- name: Run Unit Tests
run: ./gradlew testDebugUnitTest --continue
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: '**/build/test-results/test*/TEST-*.xml'
retention-days: 7
- name: Publish Test Report
uses: mikepenz/action-junit-report@v4
if: always()
with:
report_paths: '**/build/test-results/test*/TEST-*.xml'
check_name: 'Unit Test Results'
핵심 포인트:
--continue: 테스트 실패해도 끝까지 실행if: always(): 실패해도 리포트 업로드mikepenz/action-junit-report: PR에 테스트 결과 코멘트 추가
9. Instrumented Test (에뮬레이터)
UI 테스트는 에뮬레이터에서 실행해야 한다.
instrumented-test:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Run Instrumented Tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
arch: x86_64
profile: pixel_6
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
script: ./gradlew connectedDebugAndroidTest
주의사항:
- KVM 활성화 필수 (하드웨어 가속)
timeout-minutes설정 권장 (에뮬레이터 행 방지)- 에뮬레이터 옵션으로 헤드리스 실행
10. 배포 자동화
Firebase App Distribution (테스터 배포)
deploy-firebase:
runs-on: ubuntu-latest
needs: [ build, unit-test, lint ]
if: github.ref == 'refs/heads/develop'
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Build Release APK
run: ./gradlew assembleRelease
- name: Upload to Firebase
uses: wzieba/Firebase-Distribution-Github-Action@v1
with:
appId: ${{ secrets.FIREBASE_APP_ID }}
serviceCredentialsFileContent: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }}
groups: testers
file: app/build/outputs/apk/release/app-release.apk
releaseNotes: |
Branch: ${{ github.ref_name }}
Commit: ${{ github.sha }}
develop 브랜치 푸시 시 테스터에게 자동 배포된다.
Play Store 배포 (프로덕션)
deploy-playstore:
runs-on: ubuntu-latest
needs: [ build, unit-test, lint ]
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Build Release Bundle
run: ./gradlew bundleRelease
- name: Upload to Play Store
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT }}
packageName: com.example.app
releaseFiles: app/build/outputs/bundle/release/app-release.aab
track: internal
status: completed
main 브랜치 머지 시 내부 테스트 트랙에 자동 배포된다.
11. Branch Protection 설정
PR 머지 전 필수 체크를 강제한다.
설정 경로: Settings → Branches → Branch protection rules
✅ Require a pull request before merging
✅ Require status checks to pass before merging
✅ build
✅ unit-test
✅ lint
✅ Require branches to be up to date before merging
빌드, 테스트, Lint가 모두 통과해야 머지할 수 있다.
12. 실무 최적화 팁
gradle.properties 설정
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configureondemand=true
org.gradle.jvmargs=-Xmx4096m -XX:+UseParallelGC
멀티 모듈 빌드 최적화
- name: Build Changed Modules Only
run: |
CHANGED_FILES=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }})
if echo "$CHANGED_FILES" | grep -q "^feature/home/"; then
./gradlew :feature:home:assembleDebug
fi
변경된 모듈만 빌드하여 시간을 단축한다.
매트릭스 빌드
strategy:
fail-fast: false
matrix:
api-level: [ 28, 34 ]
include:
- api-level: 28
target: default
- api-level: 34
target: google_apis
steps:
- uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ matrix.api-level }}
target: ${{ matrix.target }}
여러 API 레벨에서 동시에 테스트를 실행한다.
13. 트러블슈팅
문제: Out of Memory
FAILURE: Build failed with an exception.
* What went wrong:
Gradle build daemon disappeared unexpectedly
해결:
env:
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m"
문제: 캐시 무효화 안 됨
해결:
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*') }}-${{ github.run_id }}
github.run_id를 추가하여 강제 갱신한다.
문제: 에뮬레이터 타임아웃
해결:
- uses: reactivecircus/android-emulator-runner@v2
with:
emulator-boot-timeout: 600 # 10분으로 증가
disable-animations: true
14. 비용 최적화
GitHub Actions 무료 한도
| 플랜 | 월 무료 분 | 스토리지 |
|---|---|---|
| Free | 2,000분 | 500MB |
| Team | 3,000분 | 2GB |
주의: macOS 러너는 10배로 계산된다. Instrumented Test를 macOS에서 돌리면 비용이 급격히 올라간다.
중복 실행 방지
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
같은 브랜치에서 새 커밋을 푸시하면 이전 실행을 취소한다. 불필요한 러너 시간을 절약할 수 있다.
면접 예상 질문
1. CI/CD 파이프라인을 어떻게 구성했는가? 각 단계의 역할은?
2. Gradle 캐싱 전략을 설명하라. cache-read-only는 왜 사용하는가?
3. 시크릿 관리를 어떻게 했는가? keystore 파일은?
4. CI 빌드 시간을 어떻게 최적화했는가?
5. Branch Protection과 Required Status Checks의 차이는?
정리
- GitHub Actions는 별도 서버 없이 Android CI/CD를 구축할 수 있다
- Gradle 캐싱으로 빌드 시간을 50% 이상 단축할 수 있다
- 병렬 Job 실행으로 전체 파이프라인 시간을 최적화한다
- Branch Protection으로 품질 게이트를 강제한다
- concurrency 설정으로 중복 실행을 방지해 비용을 절약한다
- 민감 정보는 반드시 GitHub Secrets를 사용한다
참고:
'안드로이드' 카테고리의 다른 글
| Android 멀티 모듈 아키텍처 설계 (0) | 2026.03.28 |
|---|---|
| Clean Architecture 실전 적용 (0) | 2026.03.24 |
| 안드로이드 테스트 UnitTest, UI Test (1) | 2025.12.12 |
| Compose에서 MVI 아키텍처 적용하기 (0) | 2025.11.29 |
| 안드로이드로이드에서 Constants를 효율적으로 사용하려 어떻게 해야할까? (1) | 2025.02.15 |