본문 바로가기

안드로이드

GitHub Actions로 Android CI/CD 구축하기

반응형

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를 사용한다

 

참고:

반응형