안드로이드 CI 파이프라인 최적화를 위한 테스트 샤딩

@onseok· May 02, 2025 · 15 min read

test sharding thumbnail

모바일 애플리케이션이 복잡해지고 테스트 중요성이 증가함에 따라, 테스트 실행 시간도 늘어나 개발 생산성에 부정적인 영향을 주고 있습니다. 특히 CI(Continuous Integration) 환경에서는 빠른 피드백을 통한 개발 주기 단축이 중요한데, 테스트 시간이 길어지면 이러한 목표 달성이 어려워집니다. 본 글에서는 이런 문제를 해결하기 위한 테스트 샤딩(Test Sharding) 기법에 대해 자세히 알아보겠습니다.

테스트 샤딩이란?

테스트 샤딩(Test Sharding) 은 하나의 테스트 스위트(Test Suite)를 여러 개의 작은 그룹(샤드)으로 나누고, 이를 여러 장치에서 병렬로 실행하는 기법입니다. 'shard'라는 단어는 '작은 조각' 또는 '파편'을 의미하며, 대규모 테스트를 더 작은 단위로 나누어 처리하는 개념을 담고 있습니다.

테스트 샤딩의 장점

  1. 실행 시간 단축: 테스트를 여러 장치에서 병렬로 실행하여 전체 테스트 시간 감소
  2. 리소스 활용도 증가: CI 서버의 CPU와 메모리 자원을 효율적으로 활용
  3. 빠른 피드백: 개발자에게 더 빠른 피드백 제공으로 개발 생산성 향상
  4. 안정성 향상: 짧은 테스트 실행으로 네트워크 타임아웃 등의 문제 감소

안드로이드에서의 테스트 샤딩 구현

안드로이드에서 테스트 샤딩은 주로 AndroidJUnitRunner를 통해 구현됩니다. 기본 원리는 다음과 같습니다.

  1. 전체 테스트 스위트를 N개의 샤드로 분할
  2. 각 샤드를 식별하는 인덱스 번호 할당(0부터 N-1까지)
  3. 각 샤드를 별도의 장치나 에뮬레이터에서 병렬로 실행

ADB 명령어로 샤딩하기

가장 기본적인 방법은 ADB 명령어를 직접 사용하는 것입니다.

adb shell am instrument -w -e numShards 3 -e shardIndex 0 com.example.app.test/androidx.test.runner.AndroidJUnitRunner

이 명령어는 테스트를 3개 샤드로 나누고 첫 번째 샤드(인덱스 0)만 실행합니다. 병렬 실행을 위해서는 각 샤드를 다른 장치나 에뮬레이터에서 실행해야 합니다.

Gradle로 샤딩하기

일상적인 개발 환경에서는 Gradle을 통해 더 쉽게 구현할 수 있습니다:

./gradlew connectedAndroidTest \
    -Pandroid.testInstrumentationRunnerArguments.numShards=3 \
    -Pandroid.testInstrumentationRunnerArguments.shardIndex=0

특정 장치에서만 테스트하려면 ANDROID_SERIAL 환경 변수를 추가하면 됩니다.

ANDROID_SERIAL=emulator-5554 ./gradlew connectedAndroidTest \
    -Pandroid.testInstrumentationRunnerArguments.numShards=3 \
    -Pandroid.testInstrumentationRunnerArguments.shardIndex=0

CI 환경에서의 테스트 샤딩

실제 프로젝트에서는 CI 파이프라인에 샤딩을 통합하는 것이 중요합니다. 주요 CI 시스템별 구현 방법을 알아보겠습니다.

GitHub Actions에서의 테스트 샤딩

GitHub Actions는 매트릭스 전략으로 여러 작업을 병렬화할 수 있어 테스트 샤딩에 적합합니다. 2023년 2월부터는 Ubuntu 러너에서도 하드웨어 가속을 지원하기 시작해 안드로이드 에뮬레이터 성능이 크게 개선되었습니다. 이전에는 macOS 러너를 사용했지만, 이제는 Ubuntu 러너가 2-3배 빠르고 비용도 저렴해 권장됩니다.

larger linux runners

테스트 샤딩 구현 예시는 다음과 같습니다.

name: Android Test
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [0, 1, 2, 3]  # 4개의 샤드 생성
      fail-fast: false  # 하나의 샤드가 실패해도 다른 샤드는 계속 실행
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Set up JDK
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: '17'
      
      - name: Setup Android SDK
        uses: android-actions/setup-android@v2
      
      # KVM 권한 활성화 (Linux에서 하드웨어 가속 사용에 필요)
      - name: Enable KVM group perms
        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: Create and start emulator
        run: |
          echo "no" | avdmanager create avd -n test-device -k "system-images;android-29;google_apis;x86_64"
          emulator -avd test-device -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim &
          adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done'
      
      - name: Run tests with sharding
        run: |
          ./gradlew connectedCheck \
            -Pandroid.testInstrumentationRunnerArguments.numShards=4 \
            -Pandroid.testInstrumentationRunnerArguments.shardIndex=${{ matrix.shard }}
      
      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: test-results-${{ matrix.shard }}
          path: app/build/outputs/androidTest-results

android emulator runner 또는 더 간단하게 android-emulator-runner 액션을 사용할 수도 있습니다.

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [0, 1, 2, 3]
      fail-fast: false

    steps:
      - uses: actions/checkout@v3
      
      # KVM 활성화 (Ubuntu 러너에서 하드웨어 가속에 필요)
      - 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 tests with sharding
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 29
          script: |
            ./gradlew connectedCheck \
              -Pandroid.testInstrumentationRunnerArguments.numShards=4 \
              -Pandroid.testInstrumentationRunnerArguments.shardIndex=${{ matrix.shard }}

CircleCI에서의 테스트 샤딩

CircleCI는 기본적으로 병렬 작업을 지원하며, 테스트를 시간 기반으로 분할할 수 있는 기능을 제공합니다.

version: 2.1
jobs:
  test:
    docker:
      - image: cimg/android:2023.06
    parallelism: 4  # 4개의 병렬 컨테이너 실행
    steps:
      - checkout
      - run:
          name: Run Android Tests
          command: |
            SHARD_INDEX=$((CIRCLE_NODE_INDEX))
            TOTAL_SHARDS=$((CIRCLE_NODE_TOTAL))
            
            echo "Running shard $SHARD_INDEX of $TOTAL_SHARDS"
            
            ./gradlew connectedAndroidTest \
              -Pandroid.testInstrumentationRunnerArguments.numShards=$TOTAL_SHARDS \
              -Pandroid.testInstrumentationRunnerArguments.shardIndex=$SHARD_INDEX
      
      - store_artifacts:
          path: app/build/outputs/androidTest-results
          destination: test-results

workflows:
  version: 2
  build_and_test:
    jobs:
      - test

CircleCI는 CIRCLE_NODE_INDEXCIRCLE_NODE_TOTAL 환경 변수를 통해 현재 컨테이너의 인덱스와 총 컨테이너 수를 알 수 있어 테스트 샤딩 구현이 간편합니다.

이와 관련해서, Test splitting and parallelism 문서에서 더 자세하게 확인하실 수 있습니다.

Bitrise에서의 테스트 샤딩

bitrise에서의 테스트 샤딩은 직접 사용해보지는 못했지만, Test sharding: elevate code quality without slowing down your team 문서에서 자세히 설명하고 있어, 관심있는 분들은 참고해주시길 바랍니다.

효율적인 샤딩 전략

optimal sharding

대규모 테스트 스위트에서는 단순히 테스트 개수를 균등하게 나누는 것보다 더 효율적인 샤딩 전략이 필요합니다. 왜냐하면 테스트마다 실행 시간이 크게 다를 수 있어 단순 개수 기반 분할은 비효율적인 자원 사용으로 이어질 수 있기 때문입니다. 예를 들어, 하나의 샤드에 실행 시간이 긴 테스트들이 몰리면 전체 파이프라인 시간은 가장 느린 샤드에 의해 결정됩니다. 또한 테스트 환경 준비 시간(에뮬레이터 부팅, 앱 설치 등)이 상당한 오버헤드를 차지하므로, 테스트 자체 실행 시간 외에도 이런 준비 과정의 비용을 고려한 샤딩 전략이 필요합니다. 여러 테스트 간의 상호 의존성이나 공유 자원 경쟁도 샤딩 효율성에 영향을 미칠 수 있어 단순한 나눗셈보다 더 정교한 샤딩 방법이 요구됩니다.

시간 기반 샤딩

가장 효과적인 방법은 이전 실행 시간 데이터를 활용하는 것입니다. CircleCI는 이를 기본으로 지원합니다.

CLASSES=$(find . -name "*Test.java" | circleci tests split --split-by=timings)
./gradlew connectedCheck -Pandroid.testInstrumentationRunnerArguments.class=$CLASSES

GitHub Actions에서는 이런 기능을 직접 구현해야 하기 때문에, 팀 자체적으로 테스트 실행 시간을 JUnit 리포트에서 추출해 다음 실행 시 샤드를 균등하게 분배하는 스크립트를 개발하는 방법 등으로 적용할 수 있습니다.

테스트 유형별 샤딩

또 다른 효과적인 전략은 테스트 유형에 따라 분리하는 것입니다. 특히 단위 테스트와 UI 테스트는 실행 특성이 크게 달라 분리하는 것이 유리합니다. JUnit의 Category를 활용하면 테스트를 유형별로 표시하고 선택적으로 실행할 수 있습니다.

// 마커 인터페이스 정의
interface FastTests {}
interface SlowTests {}

// 카테고리 적용
@Category(FastTests::class)
class LoginTest {
    // 빠른 테스트들...
}

@Category(SlowTests::class)
class EndToEndTest {
    // 느린 테스트들...
}

그 다음, 빌드 스크립트에서 특정 카테고리만 선택해 실행할 수 있습니다.

// build.gradle
android {
    testOptions {
        unitTests {
            includeCategories 'com.example.FastTests'
        }
    }
}

이런 방식으로 테스트 샤딩을 도입하고자 하는 안드로이드 팀에서는 빠른 테스트와 느린 테스트를 별도의 작업으로 실행해 전체 파이프라인 효율성을 크게 높일 수 있음을 기대할 수 있습니다.

Android Studio와 Gradle의 테스트 샤딩 지원

Android Gradle Plugin 7.2.0부터는 Gradle Managed Devices(GMD)를 통한 테스트 샤딩을 공식 지원합니다. GMD를 통해 빌드 설정에서 가상 장치를 정의하고 Gradle이 관리하도록 할 수 있습니다.

gmd sharding

// build.gradle
android {
    testOptions {
        devices {
            pixel2(com.android.build.api.dsl.ManagedVirtualDevice) {
                device = "Pixel 2"
                apiLevel = 30
                systemImageSource = "google"
                abi = "x86"
            }
        }
    }
}

그리고 다음 명령어로 테스트 샤딩을 활성화할 수 있습니다.

./gradlew -Pandroid.experimental.androidTest.numManagedDeviceShards=2 pixel2DebugAndroidTest

Firebase Test Lab과 Flank

대규모 테스트는 클라우드 기반 솔루션이 유리할 수 있습니다. Firebase Test Lab은 다양한 실제 기기에서 테스트를 실행할 수 있으며 샤딩도 지원합니다.

gcloud firebase test android run \
    --app=app-debug.apk \
    --test=app-debug-androidTest.apk \
    --device=model=Pixel3,version=30 \
    --num-uniform-shards=5

Firebase Test Lab의 기본 샤딩은 단순하지만, Flank라는 오픈소스 테스트 러너를 활용하면 스마트 샤딩이 가능합니다. Flank는 이전 실행 결과를 분석해 최적의 샤드 구성을 자동으로 계산합니다.

# flank.yml
gcloud:
  app: app-debug.apk
  test: app-androidTest.apk
  device:
    - model: Pixel_3
      version: 30
  use-orchestrator: true
  timeout: 30m
flank:
  max-test-shards: 5
  shard-time: 120
  smart-flank-gcs-path: gs://your-bucket-path

이와 관련하여 더 자세한 도큐먼트는 flank.github.io/flank에서 확인하실 수 있습니다.

실제로 팀 내에서 일상적인 개발은 CI 파이프라인에서, 릴리즈 검증은 Test Lab에서 하는 전략을 취할 수 있을 것 같습니다.

결론

테스트 샤딩은 CI 파이프라인의 실행 시간을 크게 단축하는 강력한 기법이라고 생각합니다. 특히 대규모 테스트 스위트에서 개발 생산성과 피드백 주기를 획기적으로 개선할 수 있습니다.

효과적인 테스트 샤딩을 위해서는 적절한 샤딩 전략 선택(시간 기반 샤딩과 테스트 유형별 분리), 하드웨어 가속 활성화, 독립적인 테스트 설계가 중요합니다.

테스트 샤딩의 진정한 가치는 개발자 경험 향상에 있다고 생각합니다. 2024년 드로이드나이츠의 차영호님께서 발표해주신 "당신의 앱 빌드는 안녕하십니까?" 세션에서 "개발자의 생산성은 빌드시간과 반복횟수에 영향을 받고, 빌드시간 및 반복횟수는 개발환경에도 상당히 의존적이기 때문에, 팀 및 조직에서 반드시 케어해줘야 한다."라는 말씀에 크게 공감했던 기억이 납니다.

결국, CI 파이프라인이 빨리 실행되면 개발자는 더 자주 코드를 통합하고 빠른 피드백을 받을 수 있어 전체 개발 주기가 가속화될 것이고, 테스트 커버리지와 개발 속도 사이의 균형을 유지하는 데 테스트 샤딩은 필수적인 도구라고 생각합니다.

참고 자료

  1. Android Developer Documentation - Test Sharding
  2. Firebase Test Lab Documentation
  3. Flank Documentation
  4. CircleCI Blog: How Bolt optimized their CI pipeline
  5. Droidcon: Accelerating Android UI Testing Through Parallelization
  6. GitHub: Android Emulator Runner
  7. GitHub Changelog: Hardware accelerated Android virtualization
  8. GitHub Marketplace: Android Emulator Runner
@onseok
배움을 배포하기