개발 도구/Git・협업

.aab 손으로 올리다 사고 친 새벽, Play 배포를 GitHub Actions로 통째로 옮긴 기록

stackD 2026. 7. 18. 18:00
반응형

 

새벽 1시 반, .aab 파일을 플레이 콘솔(Play Console)에 끌어다 놓고 출시 비율 칸에 5를 쳤다고 생각했는데, 화면엔 50이 박혀있더라고요. 그 자리에서 자동화를 결심했습니다.

 

다행히 출시 직전 검토 화면에서 잡아서 사고로 이어지진 않았는데, 그 순간 식은땀이 흐르는 걸 보면서 "이건 사람 손에 맡길 일이 아니다" 싶었어요. 그 다음 주말에 깃허브 액션(GitHub Actions)으로 옮겨놓은 워크플로를 약 4개월 굴려본 기록을 풀어볼게요. 결과적으로 한 분기 동안 출시 비율 오입력 사고는 0건이었고, 릴리스 1회당 손이 가는 시간은 18분에서 30초로 줄었습니다.

 

Play 콘솔 수동 배포의 함정 — 왜 자동화가 필요했나

수동 릴리스는 PR 리뷰를 아무리 빡빡하게 돌려도 마지막 한 발이 사람 손이라 막을 방법이 없더라고요. 콘솔 UI 의 출시 비율 칸은 슬라이더가 아니라 그냥 숫자 입력 박스라서, 5 와 50 의 거리가 키보드 한 번 더 누른 거리밖에 안 됩니다.

 

거기에 더해, 새벽 시간 릴리스를 자주 했는데요. 혼자서 작업하다 보니 더블체크해줄 사람도 없었어요. 사람 개입 자체를 없애지 않으면 똑같은 사고가 언젠가 또 난다고 보고, 그 주말에 정리에 들어갔습니다.

 

 

Play Developer API 서비스 계정 발급과 권한 최소화

자동화의 시작은 인증입니다. Google Cloud 에서 서비스 계정을 새로 만들고 Play Developer API 를 활성화하는 순서로 진행했어요.

 

  1. Google Cloud Console 에서 서비스 계정 생성 → JSON 키 발급
  2. 플레이 콘솔 설정 > API 액세스 에서 해당 계정 연결
  3. 권한은 "출시 관리" 만 부여, "앱 정보 편집" 같은 건 일부러 빼둠

 

JSON 키는 받자마자 base64 -w 0 key.json | pbcopy 로 인코딩해서 GitHub Repository Secrets 에 PLAY_SERVICE_ACCOUNT_JSON 으로 저장했습니다. (참고로 -w 0 은 GNU coreutils, pbcopy 는 macOS 전용이에요. 리눅스라면 xclip -selection clipboard 정도로 바꿔주시면 됩니다.) 로컬엔 파일을 남기지 않았어요. 키 회전은 90일 주기로 캘린더 알림을 걸어뒀습니다.

 

참고로 저는 안드로이드 스튜디오(Android Studio) 의 키스토어와 별개로, 서명에 쓰는 upload-keystore.jks 도 base64 로 인코딩해서 별도 Secret 에 두고 있어요. 워크플로 안에서만 디코딩해서 쓰고 잡 종료 시 휘발되는 구조입니다.

 

장기적으로는 JSON 키 없이 깃허브 OIDC 토큰을 GCP 가 신뢰하게 하는 Workload Identity Federation 으로 갈 계획인데요. 초기 설정 복잡도가 좀 있어서 1차 마이그레이션에서는 일단 JSON 키 방식으로 갔습니다.

 

GitHub Actions로 .aab 빌드부터 internal 트랙 업로드까지

워크플로 트리거는 v* 형식 태그 push 로 잡았어요. git tag v1.4.7 && git push --tags 한 번으로 빌드부터 internal 트랙 업로드까지 이어집니다.

 

on:
  push:
    tags:
      - 'v*'

permissions:
  contents: read

concurrency:
  group: play-release
  cancel-in-progress: false

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: '17'
      - uses: gradle/actions/setup-gradle@v4
      - name: Decode keystore
        run: echo "${{ secrets.UPLOAD_KEYSTORE_BASE64 }}" | base64 -d > ${{ runner.temp }}/upload.jks
      - name: Build AAB
        env:
          KEYSTORE_PATH: ${{ runner.temp }}/upload.jks
          KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
          VERSION_CODE: ${{ github.run_number }}
        run: ./gradlew bundleRelease
      - uses: actions/upload-artifact@v4
        with:
          name: release-aab
          path: app/build/outputs/bundle/release/app-release.aab
      - uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
          packageName: com.example.app
          releaseFiles: app/build/outputs/bundle/release/app-release.aab
          track: internal
          status: completed

 

versionCodegithub.run_number 기반으로 동적 생성해서 "이미 사용된 버전 코드" 에러를 피했어요. Gradle 캐시를 붙이고 나서 빌드 시간은 7분에서 3분 40초로 줄었습니다. 3분 40초가 어느 정도냐면, 커피 한 잔 내릴 시간 안에 internal 트랙까지 자동으로 올라간다는 얘기예요.

 

internal 트랙은 status: completed 로 두고, 단계적 출시는 production 트랙 전용 워크플로에서 따로 다뤘습니다.

 

userFraction으로 단계적 출시(Staged Rollout) 자동화

production 으로 승격하는 워크플로는 일부러 태그 push 가 아니라 workflow_dispatch 수동 실행으로 분리했습니다. cron 으로 자동 승격하는 구조도 고민했는데, 24시간 모니터링은 사람이 직접 봐야 의미가 있다고 봐서 한 박자 쉬는 구조를 택했어요.

 

입력값은 fraction 하나로 받습니다. 단, 0.05 / 0.2 / 0.5 / 1.0 중 하나로 강제 검증을 거는 게 핵심이에요.

 

on:
  workflow_dispatch:
    inputs:
      fraction:
        description: '출시 비율 (0.05 / 0.2 / 0.5 / 1.0)'
        required: true
        type: choice
        options: ['0.05', '0.2', '0.5', '1.0']

jobs:
  promote:
    runs-on: ubuntu-latest
    environment: production-approval
    steps:
      - name: Validate fraction
        run: |
          case "${{ inputs.fraction }}" in
            0.05|0.2|0.5|1.0) echo "ok" ;;
            *) echo "invalid fraction"; exit 1 ;;
          esac
      - uses: actions/download-artifact@v4
        with:
          name: release-aab
          path: app/build/outputs/bundle/release/
      - name: Promote (staged)
        if: inputs.fraction != '1.0'
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
          packageName: com.example.app
          track: production
          status: inProgress
          userFraction: ${{ inputs.fraction }}
          releaseFiles: app/build/outputs/bundle/release/app-release.aab
      - name: Promote (full)
        if: inputs.fraction == '1.0'
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
          packageName: com.example.app
          track: production
          status: completed
          releaseFiles: app/build/outputs/bundle/release/app-release.aab

 

여기서 가장 조심해야 할 자리가 userFraction 미지정 사고입니다. r0adkll/upload-google-play Issue #200 에 따르면, userFraction 을 비우고 production 트랙에 올리면 의도와 달리 1% 사용자에게 자동 배포되는 동작이 보고돼 있어요. 그래서 입력값 검증 단계를 따로 두고, 1.0 일 때와 미만일 때를 각각 별도 step 으로 분기해 status: completed / status: inProgress 를 명시적으로 지정했습니다.

 

100% 직전 단계는 environment: production-approval 로 묶어서 옆자리 동료의 수동 승인을 강제하고 있어요. 혼자 새벽에 충동적으로 100% 누르는 사고를 막으려는 안전핀입니다.

 

 

crash-free 지표 기반 자동 halt 와 50% 단계의 의미

단계 사이는 최소 24시간 간격을 지키고 있습니다. 그 사이에 지표가 무너지면 사람 손을 거치지 않고 즉시 status: halted 로 신규 배포를 멈춰야 해요. 이 부분은 Play Developer Reporting API 로 따로 폴링 스크립트를 돌렸습니다.

 

임계치는 세 가지로 잡았어요.

 

  1. crash-free 세션 비율이 직전 안정 버전 대비 0.3%p 이상 떨어지면 halt
  2. ANR 비율이 0.2%p 이상 올라가면 halt
  3. 두 지표 모두 1시간 간격으로 폴링, 위반 2회 연속 시 트리거

 

Play Console Help 문서에 따르면 halted 로 전환되면 신규 사용자에게만 배포가 멈추고 이미 받은 사용자는 그대로 유지돼요. 그래서 50% 단계에서 충분히 관찰하는 게 진짜 중요한 자리입니다. 100% 배포 이후에도 fully-rolled-out halt 기능이 따로 있긴 한데, 이미 업데이트받은 사용자엔 효과가 없다는 점에서 '진짜 롤백'은 아닙니다. 그래서 50% 단계의 관찰이 사실상 마지막 안전 구간이에요.

 

저는 100% 승격 PR 에 crash-free 세션 비율과 ANR 차트 스크린샷 첨부를 의무로 걸어뒀어요. 환경 승인자 입장에서, 숫자 없이는 Approve를 못 누르게 만드는 안전 장치예요.

 

한 가지 더 주의할 자리가 있는데요. r0adkll/upload-google-play Issue #179 에 보고된 바로는, 새 draft 릴리스를 만드는 순간 이전 버전의 진행 중인 단계적 출시가 자동으로 halt 된다고 합니다. 핫픽스 올리면서 기존 50% 출시가 끊기는 일이 생길 수 있으니, 동시 배포 시 순서를 신중히 잡으셔야 합니다.

 

 

4개월 굴려본 자리에서 남은 한 줄

자동화 옮긴 다음 분기에 출시 비율 오입력 사고는 0건이었고, halt 가 자동으로 걸린 적이 한 번 있었습니다. 그날 새벽 폰에 알림이 울려서 깨긴 했는데, 어쨌든 신규 사용자에게는 이미 배포가 멈춰있어서 차분히 원인만 찾으면 되는 상황이었어요. 사람이 깨어있어야 막을 수 있는 사고를 기계가 자고 있을 때 막아준다는 게, 1인 개발자에게 자동화가 주는 가장 큰 값어치라고 봅니다.

 

반응형
LIST