React Native 에서 Fastlane으로 배포 파이프라인 설계하기 (6. android 의 Fastfile)

eeennsu·2026년 7월 26일

React Native

목록 보기
75/92

이제 android/fastlane/Fastfile이 실제로 어떤 일을 하는지 lane 단위로 뜯어본다. 앞 편에서 만든 keystore·Service Account·.env 토대 위에 드디어 진짜 빌드·업로드 코드를 얹는 단계다.

구조는 iOS 편과 거의 대칭이다. 이 프로젝트는 iOS와 마찬가지로 환경(dev/beta/pro)을 셸 스크립트로 갈아끼우는 방식을 쓴다. (앞 설정 편에서 소개한 productFlavors 방식과는 다른 접근인데, 둘 다 현업에서 쓴다. 여기선 실제 코드에 맞춰 셸 스크립트 방식을 다룬다.)

세 lane 모두 internal 트랙에 draft(초안) 상태로만 올린다. pro(운영)조차 곧바로 Production으로 안 나간다. 업로드는 fastlane이 하되, "실제 출시" 버튼은 사람이 Play Console에서 누른다. 실수로 운영 배포가 나가는 사고를 막는 안전한 방식이다.



1. 이 Fastfile이 굴러가려면 필요한 준비물

코드를 보기 전에, 바깥에 미리 있어야 하는 것들을 정리한다.

1) 환경 변수 (.env.<name>에서 주입)

앞 편에서 만든 .env.dev / .env.beta / .env.pro가 넘겨주는 값이다. lane 안에서 ENV["..."]로 읽는다.

이름용도
PACKAGE_NAME빌드할 환경의 패키지명 (예: com.yourcompany.myapp.dev, com.yourcompany.myapp)
KEYSTORE_PATHUpload Keystore 파일 경로
SIGNING_STORE_PASSWORDkeystore 비밀번호
SIGNING_KEY_ALIAS키 별칭
SIGNING_KEY_PASSWORD키 비밀번호
PLAY_STORE_JSON_PATHPlay Console 접근용 Service Account JSON 경로

환경변수 이름은 프로젝트가 정하는 거라 정답이 있는 건 아니다. (앞 설정 편에서 든 예시와 이름이 다를 수 있는데, 실제 값만 맞으면 된다.)


2) 외부 셸 스크립트

android/fastlane/에 같이 있는 bash 스크립트들이다. 환경 이름(dev/beta/pro)을 인자로 받아 네이티브 파일을 갈아끼운다. Fastfile은 sh "bash ./..."로 부른다.

스크립트역할
change_package_name.sh <env>네이티브의 패키지명(applicationId)을 환경별 값으로 교체
change_appsflyer_name.sh <env>AppsFlyer(마케팅 분석 SDK) 앱 이름·플래그 등 환경 분기
copy_config.sh <env>google-services.json(Firebase 설정) 등을 환경별로 복사

iOS의 change_app_identifier.sh에 대응하는 게 여기선 change_package_name.sh다. (iOS는 "번들 ID", Android는 "패키지명"이라 이름만 다르고 역할은 같다.)


3) versioning 플러그인

이 Fastfile은 android_get_version_name / android_set_version_name / android_get_version_code / android_set_version_code를 쓰는데, 이건 fastlane 기본 액션이 아니라 플러그인(fastlane-plugin-versioning_android)이다. 없으면 "액션을 못 찾는다"는 에러가 나니, 먼저 설치해야 한다.

cd android
bundle exec fastlane add_plugin versioning_android

이 액션들은 android/app/build.gradleversionName·versionCode를 직접 읽고 쓴다.


4) Appfile

android/fastlane/Appfile은 Play Console 인증 정보를 담는다. 환경마다 바뀌므로 ENV로 읽게 둔다.

# android/fastlane/Appfile
json_key_file(ENV["PLAY_STORE_JSON_PATH"])
package_name(ENV["PACKAGE_NAME"])

실제 lane들은 upload_to_play_storejson_key·package_name직접 넘기기도 한다. Appfile은 그 기본값을 잡아주는 역할이라, 둘 다 있어도 무방하다.



2. 실패해도 dev로 되돌리는 안전망 — error 블록

platform :android do 바로 안에, 모든 lane에 공통으로 걸리는 에러 핸들러가 있다.

platform :android do
  # 빌드/배포 실패 시 작업트리를 항상 dev 상태로 복원
  error do |lane, exception, options|
    sh "bash ./change_package_name.sh dev"
    sh "bash ./change_appsflyer_name.sh dev"
    sh "bash ./copy_config.sh dev"
  end
  # ... lane 들 ...
end

이게 왜 중요한가. beta/pro 빌드는 중간에 패키지명·설정 파일을 beta/pro용으로 바꿔놓는다. 그런데 빌드가 중간에 실패하면 그 상태로 로컬이 멈춰버린다. 그러면 다음에 npm run android를 돌릴 때 엉뚱하게 beta 설정으로 켜지는 혼선이 생긴다. error 블록은 어느 lane이든 실패하는 순간 자동으로 dev로 되돌려 이 사고를 막는다. (iOS Fastfile엔 없던, Android 쪽에만 있는 안전장치다.)



3. Lane 한눈에 보기

lane빌드업로드버전 정하는 방식git commit용도
apkO (APK)XX로컬용 APK 하나 뽑기
devO (AAB)internal/draft대화형(버전 유지 + 코드 +1 제안)O개발용 배포
betaO (AAB)internal/draft대화형(patch +1 제안, 2회 확인)O내부 QA용 배포
proO (AAB)internal/draft코드에 직접 박음X운영용 빌드(이후 수동 승급)
dev_modeXXX로컬을 dev 환경으로 전환 + clean
beta_modeXXX로컬을 beta 환경으로 전환 + clean
pro_modeXXX로컬을 pro 환경으로 전환 + clean

*_mode lane은 빌드/업로드 없이 환경 전환 + 빌드 폴더 청소만 한다. 예를 들어 로컬 Android Studio에서 직접 빌드·디버깅하면서 beta API/DB를 바라보고 싶을 때 bundle exec fastlane beta_mode --env beta를 쓴다.

세 배포 lane 모두 업로드 목적지가 internal 트랙 draft라는 점을 다시 강조한다. pro도 예외가 아니다. 출시는 Play Console에서 사람이 승급(promote)한다.



4. lane별 흐름

lane :apk — 로컬용 APK 한 방

lane :apk do
  gradle(task: "clean assembleRelease")
end

가장 단순한 lane이다. 서명·업로드 없이 그냥 Release APK 하나를 만든다. 테스터에게 파일로 직접 건네거나, 스토어를 안 거치고 설치해볼 때 쓴다. (assembleRelease는 AAB가 아니라 APK를 만든다.)


lane :dev — dev 빌드 → internal/draft 업로드

lane :dev do
  package_name = ENV["PACKAGE_NAME"]
  keystore_path = ENV["KEYSTORE_PATH"]
  store_password = ENV["SIGNING_STORE_PASSWORD"]
  key_alias = ENV["SIGNING_KEY_ALIAS"]
  key_password = ENV["SIGNING_KEY_PASSWORD"]
  json_path = ENV["PLAY_STORE_JSON_PATH"]

  sh "bash ./change_package_name.sh dev"
  sh "bash ./change_appsflyer_name.sh dev"
  sh "bash ./copy_config.sh dev"

  # 버전 유지가 기본(versionCode만 +1), No 선택 시 수동 입력
  current_version = android_get_version_name()
  current_version_code = android_get_version_code()
  suggested_version = current_version
  suggested_version_code = current_version_code.to_i + 1
  # ... UI.confirm 으로 자동/수동 분기 ...

  android_set_version_name(version_name: new_version)
  android_set_version_code(version_code: new_version_code)

  gradle(task: "clean")
  gradle(
    task: "bundle",
    build_type: "Release",
    print_command: false,
    properties: {
      "android.injected.signing.store.file" => keystore_path,
      "android.injected.signing.store.password" => store_password,
      "android.injected.signing.key.alias" => key_alias,
      "android.injected.signing.key.password" => key_password,
    }
  )

  upload_to_play_store(
    track: 'internal',
    release_status: 'draft',
    aab: "app/build/outputs/bundle/release/app-release.aab",
    package_name: package_name,
    version_name: new_version,
    version_code: new_version_code,
    json_key: json_path,
    skip_upload_apk: true,
    skip_upload_metadata: true,
    skip_upload_images: true,
    skip_upload_screenshots: true
  )

  git_commit(
    path: ".",
    message: "deploy(APP-00): android dev - #{new_version}(#{new_version_code})",
    skip_git_hooks: false
  )
end

단계별로 풀면 이렇다.

  1. 환경 전환 — 셸 스크립트 3개를 dev 인자로 호출해 패키지명·AppsFlyer·Firebase 설정을 dev용으로 교체한다.
  2. 버전 결정 (대화형) — 현재 versionName/versionCode를 읽어 "마케팅 버전은 그대로, versionCode만 +1"을 제안한다. UI.confirm에서 Yes면 그 값, No면 UI.input으로 직접 입력받는다. (versionCode를 매번 올리므로 Play의 "같은 버전 코드 거부"에 안 걸린다.)
  3. 버전 적용android_set_version_name / android_set_version_codebuild.gradle에 기록한다.
  4. 클린 후 AAB 빌드gradle(task: "clean")으로 한번 청소하고, gradle(task: "bundle", build_type: "Release")로 AAB를 만든다. 서명 정보는 android.injected.signing.* 프로퍼티로 빌드 시점에 주입한다.
  5. Play Store 업로드upload_to_play_storeinternal 트랙 draft에 올린다. (자세한 옵션은 5번에서)
  6. git commit — 바뀐 파일들을 deploy(APP-00): android dev - {버전}({코드}) 메시지로 커밋한다. (APP-00은 이슈 키 자리표시자다.)

android.injected.signing.*가 핵심이다. 이걸 쓰면 build.gradlesigningConfigs를 안 넣어도, fastlane이 빌드할 때 keystore를 그 자리에서 꽂아준다. print_command: false는 이 비밀번호가 콘솔·CI 로그에 찍히지 않게 가리는 옵션이다.


lane :beta — patch 자동 증가 + 2회 확인 + dev 복원 + commit

beta는 dev와 비슷하되 확인을 두 번 받고, 끝나면 dev로 되돌린 뒤 커밋한다.

  1. 버전 제안increment_patch_version으로 마케팅 버전 맨 끝자리를 +1, versionCode는 +1 제안.
  2. 사용자 확인 1차 — 자동/수동 분기. 수동이면 입력받고, 빈 값이면 UI.user_error!로 중단.
  3. 사용자 확인 2차 — 한 번 더 UI.confirm. 두 번 확인으로 잘못된 버전 배포를 예방한다.
  4. 환경 전환 — 셸 스크립트 beta.
  5. 버전 적용 → AAB 빌드 → 업로드 — dev와 같은 패턴(단, beta는 앞의 별도 gradle clean이 없다).
  6. dev 복원 — 셸 스크립트 3개를 다시 dev로 호출해 로컬을 되돌린다. (빌드 직후 beta 상태로 남는 걸 방지. 실패 시엔 2번의 error 블록이 대신 처리한다.)
  7. git commitdeploy(APP-00): android beta - ....

lane :pro — 운영용 빌드

beta와 거의 같지만 대화형·자동 증가·git commit이 전부 빠지고, 버전을 코드에 직접 박는다.

lane :pro do
  # ... ENV 읽기 ...
  version_name = "2.0.3"   # ← pro는 매 배포 전 이 두 값을 직접 수정
  version_code = 37

  sh "bash ./change_package_name.sh pro"
  sh "bash ./change_appsflyer_name.sh pro"
  sh "bash ./copy_config.sh pro"

  android_set_version_name(version_name: version_name)
  android_set_version_code(version_code: version_code)

  gradle(task: "bundle", build_type: "Release", print_command: false, properties: { ... })

  upload_to_play_store(
    track: 'internal',        # ← pro도 internal/draft로 올림 (곧바로 운영 배포 X)
    release_status: 'draft',
    aab: "app/build/outputs/bundle/release/app-release.aab",
    package_name: package_name,
    version_name: version_name,
    version_code: version_code,
    json_key: json_path,
    skip_upload_apk: true, skip_upload_metadata: true,
    skip_upload_images: true, skip_upload_screenshots: true
  )

  # pro -> dev 복원 (pro는 git_commit 없음)
  sh "bash ./change_package_name.sh dev"
  sh "bash ./change_appsflyer_name.sh dev"
  sh "bash ./copy_config.sh dev"
end
  • 버전 하드코딩version_name·version_code를 매 배포 전에 직접 수정해야 한다. dev/beta 같은 자동 증가/입력 분기가 없다.
  • 업로드 목적지도 internal/draft — pro라고 바로 Production으로 안 간다. Play Console에서 사람이 검토 후 Production으로 승급한다.
  • git commit 없음 — pro 빌드는 보통 release 태깅·별도 커밋으로 관리하는 정책이라 그렇다.

lane :dev_mode / :beta_mode / :pro_mode — 환경 전환 전용

빌드·업로드 없이 로컬을 해당 환경으로 전환하고 빌드 폴더를 청소만 한다.

lane :dev_mode do
  # ... (주석 처리된 android_set_version_* 있음: 필요 시 수동 버전 고정용)
  sh "bash ./change_package_name.sh dev"
  sh "bash ./change_appsflyer_name.sh dev"
  sh "bash ./copy_config.sh dev"

  sh "rm -rf ../app/build ../build"   # 빌드 산출물 청소
  gradle(task: "clean")
end

용도: 로컬 Android Studio에서 직접 빌드·디버깅하면서 특정 환경(예: beta DB·beta API)을 바라보게 하고 싶을 때.



5. 핵심 액션 참조

액션한 줄 설명
gradle./gradlew <task> 실행. task: "bundle" + build_type: "Release"bundleRelease(AAB)
android_get_version_name / _codebuild.gradle의 현재 versionName / versionCode 조회 (플러그인)
android_set_version_name / _codeversionName / versionCode 설정 (플러그인)
upload_to_play_store (= supply)Service Account JSON으로 AAB를 Play Console 트랙에 업로드
git_commit변경 파일들을 묶어 git commit (Fastlane 내장)

gradle 서명 주입

gradle(
  task: "bundle",             # bundle=AAB, assemble=APK
  build_type: "Release",
  print_command: false,       # 비밀번호가 인자로 들어가니 로그 숨김
  properties: {
    "android.injected.signing.store.file"     => keystore_path,
    "android.injected.signing.store.password" => store_password,
    "android.injected.signing.key.alias"      => key_alias,
    "android.injected.signing.key.password"   => key_password,
  }
)

android.injected.signing.*는 gradle 표준 주입 프로퍼티다. build.gradle을 안 건드리고도 빌드 시점에 서명 정보를 넣을 수 있다.

upload_to_play_store 옵션 풀이

upload_to_play_store(
  track: 'internal',          # 업로드할 트랙 (여기선 항상 internal)
  release_status: 'draft',    # draft=초안. 사람이 Play Console에서 최종 출시
  aab: "app/build/outputs/bundle/release/app-release.aab",  # 빌드된 AAB 경로
  package_name: package_name, # 어느 앱(패키지)에 올릴지
  version_name: new_version,
  version_code: new_version_code,
  json_key: json_path,        # Service Account JSON
  skip_upload_apk: true,          # APK는 안 올림 (AAB만)
  skip_upload_metadata: true,     # 스토어 설명 등 메타데이터 건드리지 않음
  skip_upload_images: true,       # 아이콘/피처 그래픽 안 건드림
  skip_upload_screenshots: true   # 스크린샷 안 건드림
)

aab 경로가 app/build/outputs/bundle/release/app-release.aab고정돼 있는 건, 이 프로젝트가 flavor 없이 셸 스크립트로 환경을 바꾸기 때문이다. 그래서 빌드 변형이 하나뿐이라 출력 경로가 항상 같다. (flavor를 쓰면 경로가 .../devRelease/app-dev-release.aab처럼 달라져서, 뒤 "개선 방향"의 lane_context 방식이 필요해진다.)



6. 이 코드의 개선 방향 (선택)

지금은 몰라도 되고, 규모가 커지면 이렇게 다듬을 수 있다는 정도만 알아두면 된다.

1) versionCode를 Play 기준으로 자동 계산. 지금은 로컬 build.gradle 값을 +1 하는데, google_play_track_version_codes로 "이미 올라간 최대 코드 + 1"을 가져오면 여러 명이 배포해도 충돌이 안 난다. iOS의 latest_testflight_build_number + 1과 같은 발상이다.

latest = google_play_track_version_codes(
  package_name: ENV["PACKAGE_NAME"], track: "internal", json_key: ENV["PLAY_STORE_JSON_PATH"]
).max || 0
new_version_code = latest + 1

2) AAB 경로를 하드코딩하지 않기. aab: lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH]를 쓰면 직전 gradle이 만든 AAB 경로를 그대로 받아온다. flavor를 도입해 경로가 바뀌어도 안전하다.

3) 복붙을 private_lane으로 묶기. dev/beta/pro의 겹치는 "환경전환 → 빌드 → 업로드"를 private_lane :build_and_upload 하나로 모으면, 새 환경 추가와 수정이 한 곳에서 끝난다.



7. package.json 스크립트

터미널에서 매번 길게 치기 번거로우니 단축 스크립트를 등록한다. iOS 것과 합쳐 정리하면 이렇다.

{
  "scripts": {
    "deploy-ios-dev": "cd ios && bundle exec fastlane dev --env dev",
    "deploy-ios-beta": "cd ios && bundle exec fastlane beta --env beta",
    "deploy-ios-pro": "cd ios && bundle exec fastlane pro --env pro",

    "deploy-android-dev": "cd android && bundle exec fastlane dev --env dev",
    "deploy-android-beta": "cd android && bundle exec fastlane beta --env beta",
    "deploy-android-pro": "cd android && bundle exec fastlane pro --env pro",

    "set-android-dev": "cd android && bundle exec fastlane dev_mode --env dev",
    "set-android-beta": "cd android && bundle exec fastlane beta_mode --env beta",
    "set-android-pro": "cd android && bundle exec fastlane pro_mode --env pro"
  }
}
  • deploy-android-* → 실제 빌드·업로드까지 하는 dev/beta/pro lane
  • set-android-* → 로컬 환경만 전환(+clean)하는 *_mode lane

초안에서는 set-android-dev가 곧 "빌드 & 업로드"인 것처럼 적혀 있었는데, 실제로는 *_mode가 환경 전환만 한다. 실제 배포는 dev/beta/pro lane(deploy-android-*)이다. 이 둘을 헷갈리면 "업로드했는데 스토어에 안 뜬다" 같은 상황이 생긴다.



8. 첫 배포 실행

dev 환경으로 먼저 검증한다.

pnpm deploy-android-dev
# 또는: cd android && bundle exec fastlane dev --env dev

대략 이런 흐름이 뜬다.

[fastlane] Driving the lane 'android dev' 🚀
[fastlane] Loading from '.env.dev'
(패키지명·설정 dev로 전환)
(버전/버전코드 확인 프롬프트 → Yes 또는 직접 입력)
[gradle]  $ ./gradlew clean
[gradle]  $ ./gradlew bundle -Pandroid.injected.version.code=... Release
[gradle]  BUILD SUCCESSFUL
[supply]  Uploading AAB to internal (draft)...
[supply]  Successfully uploaded ✅

Play Console의 Internal testing 트랙에서 draft 상태로 빌드가 보이면 성공이다. 실제 테스터에게 풀거나 운영으로 올리는 건, 거기서 사람이 검토 후 승급하면 된다.

dev/beta는 중간에 버전 확인 프롬프트가 떠서 사람이 입력해줘야 한다. 완전 무인 CI에 넣으려면 이 대화형 부분을 환경변수로 우회하도록 고쳐야 한다. (이 프로젝트는 로컬에서 사람이 돌리는 걸 전제로 짜여 있다.)



9. React Native 특화 주의사항

1) Hermes는 자동 동작

iOS와 마찬가지로 Hermes(JS 엔진)는 RN 0.70+ 기본이고, gradle 빌드 때 자동 활성화된다. fastlane에서 추가 설정할 건 없다. (New Architecture를 쓰더라도 동일하다.)

2) JS 번들이 누락된 채 빌드되는 문제

가끔 release 빌드인데 JS 번들이 안 들어가는 경우가 있다. android/app/build.gradle에 RN gradle 플러그인이 적용돼 있는지 확인한다. (RN 0.71+ 템플릿엔 기본 포함이다.)

apply from: file("../../node_modules/@react-native/gradle-plugin/react-native-gradle-plugin/build.gradle")

이 플러그인이 bundleRelease 때 JS 번들을 만들어 assets에 넣어준다.

3) ABI splits를 켜지 말 것

기본 RN 설정은 모든 ABI(arm64-v8a 등)를 한 파일에 담은 universal AAB를 만든다. Play가 AAB에서 디바이스별로 잘라 배포하므로, 별도 ABI splits 설정은 불필요하다. (APK 배포하던 시절 가이드를 따라 splits를 켜면 오히려 문제가 생기니 주의.)

4) gradle 캐시 청소

캐시가 꼬여 빌드가 깨질 때가 있다. *_mode lane이 rm -rf ../app/build ../build + gradle clean을 하는 이유가 이거다. 배포 lane에서 강제 클린을 옵션화하려면:

gradle(task: "clean") if ENV["CLEAN_BUILD"] == "true"


10. 자주 만나는 에러와 해결

Package name xxx not found
Play Console에 그 패키지명 앱이 등록돼 있지 않다. 환경별 앱을 따로 만들었는지, 패키지명 오타가 없는지 확인한다.

Only releases with status draft may be created on draft app
그 앱에 아직 한 번도 수동 업로드를 안 한 상태다. Play Console에서 최소 1회 수동 업로드를 해야 API 업로드가 열린다. (앞 편에서 강조한 그 전제다.)

Version code XX has already been used
versionCode 충돌이다. 코드 +1이 제대로 도는지, 환경을 잘못 지정해 다른 패키지의 코드를 가져오지 않았는지 확인한다.

keystore 비밀번호 인증 실패
환경변수가 안 들어온 경우가 많다.

cd android
echo $SIGNING_STORE_PASSWORD   # 빈 값이면 .env 로딩 실패

.env 위치가 android/fastlane/.env인지 확인한다 (android/.env 아님).

The caller does not have permission
Service Account 권한 부족이다. Play Console → Setup → API access에서 권한과, 특히 App permissions에 해당 앱이 등록됐는지 재확인한다.

aapt2 ... daemon / 메모리 부족
android/gradle.properties에서 JVM 메모리를 늘린다.

org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m

Could not find action 'android_get_version_name'
versioning 플러그인이 안 깔린 것이다. 1번의 add_plugin versioning_android를 실행한다.



11. 전체 Fastfile

지금까지 뜯어본 코드의 전체 모습이다. 프로젝트에 맞게 커밋 스코프(APP-00)와 pro 버전 값만 바꿔 쓰면 된다.

# This file contains the fastlane.tools configuration
# 문서: https://docs.fastlane.tools

# update_fastlane

default_platform(:android)

# 패치 버전 자동 증가 함수 ("1.2.11" → "1.2.12")
def increment_patch_version(current_version)
  version_parts = current_version.split('.')
  major = version_parts[0].to_i
  minor = version_parts[1].to_i
  patch = version_parts[2].to_i + 1
  "#{major}.#{minor}.#{patch}"
end

platform :android do
  # 빌드/배포 실패 시 작업트리를 항상 dev 상태로 복원 (pro/beta 찌꺼기 방지)
  error do |lane, exception, options|
    sh "bash ./change_package_name.sh dev"
    sh "bash ./change_appsflyer_name.sh dev"
    sh "bash ./copy_config.sh dev"
  end

  desc "create apk"
  lane :apk do
    gradle(task: "clean assembleRelease")
  end

  desc "dev build"
  lane :dev do
    package_name = ENV["PACKAGE_NAME"]
    keystore_path = ENV["KEYSTORE_PATH"]
    store_password = ENV["SIGNING_STORE_PASSWORD"]
    key_alias = ENV["SIGNING_KEY_ALIAS"]
    key_password = ENV["SIGNING_KEY_PASSWORD"]
    json_path = ENV["PLAY_STORE_JSON_PATH"]

    sh "bash ./change_package_name.sh dev"
    sh "bash ./change_appsflyer_name.sh dev"
    sh "bash ./copy_config.sh dev"

    # 버전 유지가 기본(versionCode만 +1), No 선택 시 수동 입력
    current_version = android_get_version_name()
    current_version_code = android_get_version_code()
    suggested_version = current_version
    suggested_version_code = current_version_code.to_i + 1

    UI.message("========================================")
    UI.message("현재 버전: #{current_version} (#{current_version_code})")
    UI.message("자동 배포 버전: #{suggested_version} (#{suggested_version_code})")
    UI.message("========================================")

    if UI.confirm("자동 버전(#{suggested_version})으로 배포하시겠습니까? (No 선택시 수동 입력)")
      new_version = suggested_version
      new_version_code = suggested_version_code
    else
      new_version = UI.input("배포할 버전을 입력하세요 (예: 1.2.12): ")
      if new_version.empty?
        UI.user_error!("버전이 입력되지 않았습니다. 배포가 취소되었습니다.")
      end
      version_code_input = UI.input("배포할 버전 코드를 입력하세요 (예: 108): ")
      if version_code_input.empty?
        UI.user_error!("버전 코드가 입력되지 않았습니다. 배포가 취소되었습니다.")
      end
      new_version_code = version_code_input.to_i
    end

    android_set_version_name(version_name: new_version)
    android_set_version_code(version_code: new_version_code)

    gradle(task: "clean")
    gradle(
      task: "bundle",
      build_type: "Release",
      print_command: false,
      properties: {
        "android.injected.signing.store.file" => keystore_path,
        "android.injected.signing.store.password" => store_password,
        "android.injected.signing.key.alias" => key_alias,
        "android.injected.signing.key.password" => key_password,
      }
    )

    upload_to_play_store(
      track: 'internal',
      release_status: 'draft',
      aab: "app/build/outputs/bundle/release/app-release.aab",
      package_name: package_name,
      version_name: new_version,
      version_code: new_version_code,
      json_key: json_path,
      skip_upload_apk: true,
      skip_upload_metadata: true,
      skip_upload_images: true,
      skip_upload_screenshots: true
    )

    git_commit(
      path: ".",
      message: "deploy(APP-00): android dev - #{new_version}(#{new_version_code})",
      skip_git_hooks: false
    )
  end

  desc "beta build"
  lane :beta do
    package_name = ENV["PACKAGE_NAME"]
    keystore_path = ENV["KEYSTORE_PATH"]
    store_password = ENV["SIGNING_STORE_PASSWORD"]
    key_alias = ENV["SIGNING_KEY_ALIAS"]
    key_password = ENV["SIGNING_KEY_PASSWORD"]
    json_path = ENV["PLAY_STORE_JSON_PATH"]

    # 현재 버전 조회 + 패치 버전 증가
    current_version = android_get_version_name()
    current_version_code = android_get_version_code()
    suggested_version = increment_patch_version(current_version)
    suggested_version_code = current_version_code.to_i + 1

    UI.message("========================================")
    UI.message("현재 버전: #{current_version} (#{current_version_code})")
    UI.message("자동 배포 버전: #{suggested_version} (#{suggested_version_code})")
    UI.message("========================================")

    if UI.confirm("자동 버전(#{suggested_version})으로 배포하시겠습니까? (No 선택시 수동 입력)")
      new_version = suggested_version
      new_version_code = suggested_version_code
    else
      new_version = UI.input("배포할 버전을 입력하세요 (예: 1.2.12): ")
      if new_version.empty?
        UI.user_error!("버전이 입력되지 않았습니다. 배포가 취소되었습니다.")
      end
      version_code_input = UI.input("배포할 버전 코드를 입력하세요 (예: 108): ")
      if version_code_input.empty?
        UI.user_error!("버전 코드가 입력되지 않았습니다. 배포가 취소되었습니다.")
      end
      new_version_code = version_code_input.to_i
    end

    UI.message("최종 배포 버전: #{new_version} (#{new_version_code})")

    unless UI.confirm("버전 #{new_version} (#{new_version_code})으로 배포를 진행하시겠습니까?")
      UI.user_error!("배포가 취소되었습니다.")
    end

    sh "bash ./change_package_name.sh beta"
    sh "bash ./change_appsflyer_name.sh beta"
    sh "bash ./copy_config.sh beta"

    android_set_version_name(version_name: new_version)
    android_set_version_code(version_code: new_version_code)

    gradle(
      task: "bundle",
      build_type: "Release",
      print_command: false,
      properties: {
        "android.injected.signing.store.file" => keystore_path,
        "android.injected.signing.store.password" => store_password,
        "android.injected.signing.key.alias" => key_alias,
        "android.injected.signing.key.password" => key_password
      }
    )

    upload_to_play_store(
      track: 'internal',
      release_status: 'draft',
      aab: "app/build/outputs/bundle/release/app-release.aab",
      package_name: package_name,
      version_name: new_version,
      version_code: new_version_code,
      json_key: json_path,
      skip_upload_apk: true,
      skip_upload_metadata: true,
      skip_upload_images: true,
      skip_upload_screenshots: true
    )

    # beta -> dev 복원
    sh "bash ./change_package_name.sh dev"
    sh "bash ./change_appsflyer_name.sh dev"
    sh "bash ./copy_config.sh dev"

    git_commit(
      path: ".",
      message: "deploy(APP-00): android beta - #{new_version}(#{new_version_code})",
      skip_git_hooks: false
    )
  end

  desc "pro build"
  lane :pro do
    package_name = ENV["PACKAGE_NAME"]
    keystore_path = ENV["KEYSTORE_PATH"]
    store_password = ENV["SIGNING_STORE_PASSWORD"]
    key_alias = ENV["SIGNING_KEY_ALIAS"]
    key_password = ENV["SIGNING_KEY_PASSWORD"]
    json_path = ENV["PLAY_STORE_JSON_PATH"]

    # pro는 매 배포 전 아래 두 값을 직접 수정
    version_name = "2.0.3"
    version_code = 37

    sh "bash ./change_package_name.sh pro"
    sh "bash ./change_appsflyer_name.sh pro"
    sh "bash ./copy_config.sh pro"

    android_set_version_name(version_name: version_name)
    android_set_version_code(version_code: version_code)

    gradle(
      task: "bundle",
      build_type: "Release",
      print_command: false,
      properties: {
        "android.injected.signing.store.file" => keystore_path,
        "android.injected.signing.store.password" => store_password,
        "android.injected.signing.key.alias" => key_alias,
        "android.injected.signing.key.password" => key_password,
      }
    )

    upload_to_play_store(
      track: 'internal',
      release_status: 'draft',
      aab: "app/build/outputs/bundle/release/app-release.aab",
      package_name: package_name,
      version_name: version_name,
      version_code: version_code,
      json_key: json_path,
      skip_upload_apk: true,
      skip_upload_metadata: true,
      skip_upload_images: true,
      skip_upload_screenshots: true
    )

    # pro -> dev 복원 (pro는 git_commit 없음)
    sh "bash ./change_package_name.sh dev"
    sh "bash ./change_appsflyer_name.sh dev"
    sh "bash ./copy_config.sh dev"
  end

  desc "dev mode (환경 전환 + clean)"
  lane :dev_mode do
    # 필요 시 주석 해제해 수동으로 버전 고정
    # android_set_version_name(version_name: "0.1.0")
    # android_set_version_code(version_code: 1)

    sh "bash ./change_package_name.sh dev"
    sh "bash ./change_appsflyer_name.sh dev"
    sh "bash ./copy_config.sh dev"

    sh "rm -rf ../app/build ../build"
    gradle(task: "clean")
  end

  desc "beta mode (환경 전환 + clean)"
  lane :beta_mode do
    sh "bash ./change_package_name.sh beta"
    sh "bash ./change_appsflyer_name.sh beta"
    sh "bash ./copy_config.sh beta"

    sh "rm -rf ../app/build ../build"
    gradle(task: "clean")
  end

  desc "pro mode (환경 전환 + clean)"
  lane :pro_mode do
    sh "bash ./change_package_name.sh pro"
    sh "bash ./change_appsflyer_name.sh pro"
    sh "bash ./copy_config.sh pro"

    sh "rm -rf ../app/build ../build"
    gradle(task: "clean")
  end
end


12. 이번 편 정리

이번 편을 끝낸 시점의 상태는 다음과 같다.

  • ✅ dev/beta/pro 세 환경의 Android AAB를 Play Console internal/draft까지 자동 업로드
  • ✅ 셸 스크립트로 패키지명·설정을 환경별 전환, 실패 시 error 블록이 dev로 자동 복원
  • ✅ dev/beta는 대화형 버전 관리 + git commit, pro는 버전 하드코딩(수동)
  • android.injected.signing.*build.gradle 수정 없이 서명 주입
  • ✅ 업로드는 항상 draft → Play Console에서 사람이 최종 승급하는 안전한 운영
  • pnpm deploy-android-*(배포) / pnpm set-android-*(환경 전환) 단축 스크립트
profile
이력서 https://resume.eunsu.pro

0개의 댓글