이제 ios/fastlane/Fastfile이 실제로 어떤 일을 자동화하는지, lane 단위로 뜯어본다. 앞 편에서 만든 서명·환경 토대(match, .env, 인증서) 위에 드디어 진짜 빌드·업로드 코드를 얹는 단계다.
아래에서 다루는 Fastfile은 3가지 환경(dev / beta / pro) 의 iOS 빌드를 TestFlight에 올리고, 빌드 직전·직후에 환경 분기(Info.plist·AppsFlyer·Firebase 설정 파일·Google OAuth URL scheme)를 자동으로 갈아끼운다. 사람이 Xcode를 열지 않아도, 명령 한 번에 대략 다음이 일어난다.
match)update_info_plist)increment_version_number / increment_build_number).ipa (build_app)upload_to_testflight)Fastfile 코드를 보기 전에, 이 코드가 바깥에 이미 있다고 가정하는 것들부터 정리한다. 이게 없으면 코드가 돌다가 멈춘다.
.env.<name>에서 주입)앞 편에서 만든 .env.dev / .env.beta / .env.pro가 넘겨주는 값들이다. lane 안에서 ENV["..."]로 읽는다.
| 이름 | 용도 |
|---|---|
APP_IDENTIFIER | 빌드할 환경의 Bundle ID (예: com.yourcompany.myapp.dev, com.yourcompany.myapp) |
GOOGLE_CLIENT_URL_SCHEME | 현재 환경에서 쓸 Google 로그인(OAuth)용 URL scheme |
DEV_GOOGLE_CLIENT_URL_SCHEME | beta/pro 빌드가 끝난 뒤 dev로 되돌릴 때 쓰는 dev용 URL scheme |
"URL scheme"이란 앱이
com.googleusercontent.apps.123...://같은 커스텀 주소로 자기를 열 수 있게 등록해둔 값이다. Google 로그인 후 브라우저가 다시 앱으로 돌아올 때 이 주소를 쓴다. 환경마다 OAuth 클라이언트가 다르면 이 값도 달라진다.
ios/fastlane/ 폴더에 같이 들어있는 bash 스크립트들이다. 환경 이름(dev/beta/pro)을 인자로 받아 네이티브 프로젝트 파일을 갈아끼운다. Fastfile은 sh "bash ./..."로 이 스크립트를 부른다.
| 스크립트 | 역할 |
|---|---|
change_app_identifier.sh <env> | 네이티브 프로젝트의 Bundle ID를 환경별 값으로 교체 |
change_appsflyer_name.sh <env> | AppsFlyer(마케팅 분석 SDK) 앱 이름·디버그 플래그 등 환경 분기 |
copy_config.sh <env> | GoogleService-Info.plist(Firebase 설정 파일)를 환경별로 복사 |
여기서 중요한 발상 하나. 이 프로젝트는 환경을 Xcode의 scheme으로 나누지 않고, 셸 스크립트로 "그때그때 파일을 갈아끼우는" 방식을 쓴다. (그래서 뒤에 나오는 코드는 세 환경 모두 같은
MyAppscheme 하나만 쓴다.) 앞 편에서 소개한 "scheme별로 나누는 방법"과는 다른 접근인데, 둘 다 현업에서 흔히 쓴다. 이 편은 셸 스크립트 방식을 다룬다.
/usr/libexec/PlistBuddy는 macOS에 기본 내장된 plist 편집 CLI다. (plist는 Apple의 설정 파일 포맷. Info.plist가 대표적이다.) 여기서는 Google OAuth URL scheme을 Info.plist의 특정 위치에 직접 써넣는 데 쓴다.
PlistBuddy -c "Set :CFBundleURLTypes:1:CFBundleURLSchemes:0 com.googleusercontent.apps.XXX" Info.plist
:CFBundleURLTypes:1:CFBundleURLSchemes:0이라는 경로는 plist 구조를 파고드는 주소인데, "두 번째 URL Type → 그 안의 첫 번째 scheme" 을 가리킨다. (배열 인덱스가 0부터라 1이 두 번째다.) 즉 "이 자리에 Google OAuth scheme이 들어간다"고 프로젝트에서 약속해둔 위치다. 이 위치가 프로젝트마다 다를 수 있으니, 본인 Info.plist의 CFBundleURLTypes 배열을 열어 인덱스를 먼저 확인해야 한다.
lane들 위에 공통으로 쓰는 값과 함수를 정의해둔다.
xcodeproj = "MyApp.xcodeproj"
workspace = "MyApp.xcworkspace"
plist_path = "MyApp/Info.plist" # update_info_plist용 (Fastlane 기준 경로)
plist_buddy_path = "../MyApp/Info.plist" # PlistBuddy용 (셸 작업 디렉토리 기준)
ios_dir = File.expand_path(File.join(File.dirname(__FILE__), ".."))
podfile_path = File.join(ios_dir, "Podfile") # 절대 경로
여기서 초보가 헷갈리기 쉬운 게 plist_path와 plist_buddy_path가 ../ 한 칸 차이라는 점이다. 이유는 이렇다. Fastlane의 update_info_plist 액션은 경로를 알아서 보정해주지만, sh로 직접 부르는 PlistBuddy는 작업 디렉토리(ios/fastlane/)를 기준으로 경로를 푼다. 그래서 실제 Info.plist가 있는 ios/MyApp/로 가려면 한 칸 위로 올라가는 ../가 붙는다. (같은 파일을 가리키는데 도구마다 기준점이 달라서 경로가 둘이 되는 것이다.)
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}" # "1.2.11" → "1.2.12"
end
X.Y.Z 형식의 마케팅 버전을 받아 맨 끝자리(patch)를 +1 하는 헬퍼다. 뒤의 beta lane에서 "다음 버전 자동 제안"을 만들 때 쓴다.
| lane | 빌드 | 업로드 | 버전 정하는 방식 | git commit | 용도 |
|---|---|---|---|---|---|
dev | O | TestFlight | 대화형(현재 버전 유지 + 빌드 +1 제안) | O | 개발용 배포 |
beta | O | TestFlight | 대화형(patch +1 제안, 2회 확인) | O | 내부 QA용 정식 배포 |
pro | O | TestFlight | 코드에 직접 박음 | X | 정식 출시용 배포 |
dev_mode | X | X | — | X | 로컬을 dev 환경으로만 전환 |
beta_mode | X | X | — | X | 로컬을 beta 환경으로만 전환 |
pro_mode | X | X | — | X | 로컬을 pro 환경으로만 전환 |
*_mode lane들은 빌드/업로드 없이 환경 분기 파일만 갈아끼우는 용도다. 예를 들어 로컬 Xcode에서 직접 빌드·디버깅하면서 beta API/DB를 바라보고 싶을 때 bundle exec fastlane beta_mode --env beta를 쓴다.
원래 이 시리즈 초안에서는 dev lane을 "버전이 하드코딩된 가벼운 단발 빌드"로 설명했는데, 실제 코드의 dev lane은 beta처럼 대화형으로 버전을 입력받고 git commit까지 한다. 아래 흐름은 실제 코드 기준으로 정리한 것이다.
lane :dev — dev 빌드를 TestFlight에 업로드lane :dev do
app_identifier = ENV["APP_IDENTIFIER"] # com.yourcompany.myapp.dev
client_url_scheme = ENV["GOOGLE_CLIENT_URL_SCHEME"] # com.googleusercontent.apps.*
match(type: "appstore")
sh "bash ./change_app_identifier.sh dev"
sh "bash ./change_appsflyer_name.sh dev"
sh "bash ./copy_config.sh dev"
sh %Q[/usr/libexec/PlistBuddy -c "Set :CFBundleURLTypes:1:CFBundleURLSchemes:0 #{client_url_scheme}" "#{plist_buddy_path}"]
update_info_plist(
xcodeproj: xcodeproj,
plist_path: plist_path,
app_identifier: app_identifier,
display_name: "MyApp-dev"
)
# 버전 유지가 기본(빌드번호만 +1), No 선택 시 수동 입력
current_version = get_version_number(xcodeproj: xcodeproj)
current_build_number = get_build_number(xcodeproj: xcodeproj)
suggested_version = current_version
suggested_build_number = current_build_number.to_i + 1
# ... UI.confirm 으로 자동/수동 분기 ...
build_app(
workspace: workspace,
output_directory: "outputs/dev",
scheme: "MyApp",
xcargs: "-allowProvisioningUpdates",
clean: true,
disable_xcpretty: true,
suppress_xcode_output: true,
buildlog_path: "./outputs/dev"
)
upload_to_testflight(
ipa: "outputs/dev/MyApp.ipa",
skip_waiting_for_build_processing: true
)
git_commit(
path: ".",
message: "deploy(APP-00): ios dev - #{new_version}(#{new_build_number})",
skip_git_hooks: false
)
end
단계별로 풀면 이렇다.
match(type: "appstore")로 App Store 인증서·프로파일을 키체인에 설치한다.dev 인자로 호출해 Bundle ID·AppsFlyer 이름·Firebase 설정을 dev용으로 교체한다.PlistBuddy로 Info.plist의 Google OAuth scheme을 dev 값으로 덮어쓴다.update_info_plist로 Bundle ID와 표시 이름(MyApp-dev)을 한 번 더 못 박는다.UI.confirm에서 Yes면 그 값으로, No면 UI.input으로 직접 입력받는다. (마케팅 버전은 유지하고 빌드 번호만 올리므로, TestFlight의 "같은 빌드 번호 재업로드 거부"에 걸리지 않는다.).ipa 생성 — build_app으로 Release archive와 .ipa를 outputs/dev/에 만든다. xcargs: "-allowProvisioningUpdates"는 프로파일이 없을 때 Xcode가 자동으로 갱신하도록 허용하는 옵션이다.upload_to_testflight. skip_waiting_for_build_processing: true라서 Apple의 처리 완료를 기다리지 않고 업로드 직후 종료한다.deploy(APP-00): ios dev - {버전}({빌드}) 메시지로 커밋한다. (APP-00은 Jira/이슈 키 자리표시자다.)dev lane은 beta/pro와 달리 완전 클린(
rm -rf build·DerivedData 삭제)이나cocoapods재설치가 없고, 빌드 후 환경 복원도 없다. 가장 가볍게 굴리는 lane이다. (단, git commit은 한다.)
lane :beta — 패치 버전 자동 증가 + 2회 확인 + 복원 + commitbeta는 이 Fastfile에서 가장 손이 많이 가는 lane이다. 사용자 확인을 두 번 거치고, 완전 클린 빌드를 하고, 끝나면 환경을 dev로 되돌린 뒤 git commit까지 한다.
get_version_number / get_build_number로 현재 Info.plist 값을 읽는다.increment_patch_version으로 마케팅 버전 patch를 +1 하고, 빌드 번호는 새 버전 기준 1로 리셋한다.UI.confirm으로 "자동 버전으로 갈래? 아니면 직접 입력?"을 묻는다. No면 UI.input으로 버전·빌드 번호를 직접 받는다.UI.user_error!로 lane을 중단한다. (실수로 빈 값이 들어가 이상한 빌드가 올라가는 걸 막는다.)UI.confirm. 확인을 두 번 둬서 잘못된 버전이 올라가는 사고를 예방한다.rm -rf build, ~/Library/Developer/Xcode/DerivedData/MyApp-* 삭제, xcodebuild clean. dev와 달리 매번 깨끗이 지운다.cocoapods(clean_install: true)로 Pods/도 새로 받는다. 네이티브 의존성 변경이 누락 없이 반영되게 하려는 것이다.match(type: "appstore").beta, 표시 이름이 MyApp-beta.increment_version_number / increment_build_number.build_app → upload_to_testflight. 산출물은 outputs/beta/.dev 인자로 호출하고, PlistBuddy로 OAuth scheme을 DEV_GOOGLE_CLIENT_URL_SCHEME으로 되돌린다. 빌드 직후 로컬이 beta 상태로 남으면 다음 개발 작업에 혼선이 생기므로 자동 복원하는 것이다.project.pbxproj, Info.plist, 설정 파일 등)을 deploy(APP-00): ios beta - {버전}({빌드}) 메시지로 커밋한다.lane :pro — 정식 출시 빌드beta와 거의 같지만 사용자 확인·자동 버전 증가·git commit이 전부 빠진 단순화 버전이다.
match(type: "appstore").pro 인자로 셸 스크립트 3개 + PlistBuddy.com.yourcompany.myapp), 표시 이름(MyApp).version_number: "1.2.11", build_number: 1. 매 배포 전에 이 두 값을 손으로 직접 수정해야 한다. beta 같은 자동 증가/입력 분기가 없다.build_app → upload_to_testflight. 산출물은 outputs/pro/.git_commit은 pro에는 없다. pro 빌드는 보통 release 태깅·별도 커밋으로 관리하는 정책이라 그렇다.increment_version_number(
version_number: "1.2.11", # ← pro는 매 배포 전 이 값을 직접 수정
xcodeproj: xcodeproj
)
increment_build_number(
build_number: 1, # ← 이 값도 직접 관리
xcodeproj: xcodeproj
)
lane :dev_mode / :beta_mode / :pro_mode — 환경 전환 전용빌드·업로드 없이 로컬 프로젝트를 해당 환경으로 전환만 한다.
PlistBuddy + update_info_plist만 호출한다.dev_mode에는 주석 처리된 increment_version_number·increment_build_number가 들어있다. 필요할 때 주석을 풀어 수동으로 버전을 박는 용도다.| 액션 | 한 줄 설명 |
|---|---|
match | 별도 git 저장소에 암호화 저장된 인증서·프로파일을 내려받아 키체인에 설치 |
cocoapods | pod install 실행. clean_install: true면 Pods/를 통째로 재설치 |
update_info_plist | Info.plist의 Bundle ID·표시 이름 등을 일괄 갱신 |
get_version_number / get_build_number | 현재 Info.plist에 박힌 마케팅 버전 / 빌드 번호 조회 |
increment_version_number | CFBundleShortVersionString(마케팅 버전) 설정 |
increment_build_number | CFBundleVersion(빌드 번호) 설정 |
build_app (= gym) | Release archive 생성 + .ipa 추출. 내부적으로 xcodebuild 호출 |
upload_to_testflight (= pilot) | App Store Connect API로 .ipa를 TestFlight에 업로드 |
git_commit | 변경된 파일들을 묶어 git commit (Fastlane 내장 액션) |
build_app 옵션 풀이build_app(
workspace: workspace, # .xcworkspace 경로 (RN은 반드시 workspace)
output_directory: "outputs/beta", # .ipa·로그가 떨어질 폴더
scheme: "MyApp", # 빌드할 Xcode scheme
xcargs: "-allowProvisioningUpdates", # 프로파일 누락 시 Xcode가 자동 갱신
clean: true, # 빌드 전 clean
disable_xcpretty: true, # xcpretty 로그 포맷팅 끔
suppress_xcode_output: true, # Xcode raw 로그 콘솔 출력 억제
buildlog_path: "./outputs/beta" # 로그 파일 위치
)
RN 프로젝트는 CocoaPods를 쓰므로
.xcodeproj가 아니라 반드시.xcworkspace를 넘겨야 한다. (1편에서 나온 그 이유다.)
앞서 말했듯 지금 Fastfile은 dev/beta/pro가 비슷한 코드를 복붙하고 있다. 규모가 커지면 아래처럼 다듬는 걸 권장한다. (초보 단계에선 몰라도 되고, "나중에 이렇게 줄일 수 있구나" 정도만 알아두면 된다.)
1) 공통 동작을 private_lane으로 묶기. 진입 lane(dev/beta/pro)은 얇게 두고, "환경전환 → 인증서 → 빌드 → 업로드" 공통 로직을 private_lane :build_and_upload 하나로 모으는 방식이다. 이렇게 하면 새 환경 추가가 lane 한 줄로 끝나고, 수정할 때 한 곳만 고치면 된다.
private_lane :build_and_upload do |options|
env = options[:env]
match(type: "appstore")
sh "bash ./change_app_identifier.sh #{env}"
# ... 공통 빌드/업로드 ...
end
lane :pro do
build_and_upload(env: "pro")
# pro만 추가로 dSYM을 Sentry에 업로드 같은 분기는 여기서
end
2) CI에서는 match(readonly: true). CI가 인증서를 새로 만들지 않고 기존 것만 내려받게 강제한다. 안 그러면 CI가 실수로 새 인증서를 발급해 개발자 계정당 인증서 한도(보통 배포용 3개)에 걸릴 수 있다. 지금 코드에는 없으니, CI에 붙일 때 추가하면 좋다.
3) 빌드 번호를 TestFlight 기준으로 자동 계산. 지금은 로컬 Info.plist 값을 기준으로 올리는데, latest_testflight_build_number + 1을 쓰면 "TestFlight에 이미 올라간 최대 빌드 번호 + 1"로 안전하게 잡을 수 있다. 이때 환경별 번들 ID가 다르므로 app_identifier를 함께 넘겨 환경별로 독립적인 번호가 유지되게 해야 한다. (더 정교한 버저닝은 다음 편에서 다룬다.)
터미널에서 매번 cd ios && bundle exec fastlane ...을 치기 번거로우니, package.json에 단축 스크립트를 등록해둔다.
{
"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",
"set-ios-dev": "cd ios && bundle exec fastlane dev_mode --env dev",
"set-ios-beta": "cd ios && bundle exec fastlane beta_mode --env beta",
"set-ios-pro": "cd ios && bundle exec fastlane pro_mode --env pro"
}
}
deploy-ios-* → 실제 빌드·업로드까지 하는 dev/beta/pro laneset-ios-* → 로컬 환경만 전환하는 *_mode lane
fastlane을 그냥 부르지 않고bundle exec fastlane으로 부르는 이유는 1편에서 다룬 그대로다. Gemfile에 못 박은 버전으로 실행돼야 CI/로컬 간 버전 차이 사고를 막을 수 있으니,bundle exec접두사는 빼지 않는다.
build_app 전에 Pod이 최신이어야 한다. 위 beta/pro lane은 cocoapods(clean_install: true)로 매번 새로 받는데, 이게 시간이 오래 걸린다. 매 빌드마다 돌리기 싫다면 환경변수로 조건부 실행하는 방법도 있다.
cocoapods(
clean_install: true,
podfile: "./Podfile"
) if ENV["BUILD_PODS"] == "true"
React Native 0.70+ 부터 Hermes(자바스크립트 엔진)가 기본이다. build_app이 부르는 Xcode 빌드 단계에서 RN의 빌드 스크립트가 JS 번들 생성과 Hermes 바이트코드 변환을 자동으로 처리하므로, Fastfile에서 별도로 해줄 건 없다.
다만 Release 빌드 때 node가 PATH에 잡혀 있어야 한다. CI에서 가끔 node: command not found가 나면, Xcode 빌드 페이즈에서 PATH를 명시하거나 lane 안에서 ENV["PATH"]를 보강한다. (New Architecture를 쓰더라도 이 부분은 동일하다.)
dev/beta/pro를 한 폰에 동시에 깔면, 아이콘이 다 똑같아서 어느 게 어느 빌드인지 헷갈린다. 그래서 번들 ID뿐 아니라 아이콘·표시 이름도 나누는 게 좋다. (위 코드가 display_name을 MyApp-dev/MyApp-beta/MyApp로 다르게 준 것도 이 때문이다.)
update_info_plist의 display_name으로 이미 분기 중.Assets.xcassets를 지정(ASSETCATALOG_COMPILER_APPICON_NAME 빌드 설정)하면 아이콘도 분리된다. (자세한 방법은 다음 편에서 다룬다.)1) "Code signing is required for product type 'Application'"
가장 흔한 에러다. 원인은 보통 둘 중 하나다.
match가 인증서를 깔았지만, Xcode 프로젝트의 signing이 여전히 "Automatic"으로 되어 있음해결: Xcode에서 Target → Signing & Capabilities → "Automatically manage signing" 체크를 끄고, Configuration별로 match AppStore com.yourcompany.myapp.dev 같은 환경별 프로파일을 직접 선택한다.
2) .env.dev 값이 안 먹는 것 같을 때
--env dev가 정말 로드됐는지 확인한다. 실행 로그에 Loading from '.env.dev'가 보여야 한다. 안 보이면 .env.dev가 ios/fastlane/에 있는지(다른 폴더 X), 파일명 오타가 없는지 확인한다.
3) "No code signing identity found"
Keychain에 인증서가 없거나, CI의 임시 keychain이 잠겨 있는 경우다. CI라면 before_all에서 setup_ci를 호출하도록 추가한다. (setup_ci는 CI용 임시 keychain을 만들어 잠금을 풀어주는 액션이다. 지금 이 Fastfile엔 없으니 CI에 붙일 때 넣어야 한다.)
4) "...Build Number ... already been used"
빌드 번호 충돌이다. 같은 빌드 번호로 두 번 올리려 해서 그렇다. dev/beta의 "빌드 +1" 로직이 제대로 도는지, 환경을 잘못 지정해 다른 번들 ID의 번호를 가져오지 않았는지 확인한다.
5) "Invalid Provisioning Profile"
대개 새 디바이스가 추가됐는데 프로파일이 갱신 안 된 경우다. 로컬에서 아래를 실행해 프로파일을 갱신한다.
bundle exec fastlane match development --env dev --force_for_new_devices
6) Apple Silicon 맥에서 ffi 관련 에러
일부 Ruby gem이 ARM 네이티브 빌드를 요구해서 난다. Gemfile이 있는 위치에서 아래를 실행한다.
bundle config set --local force_ruby_platform true
bundle install
이번 편을 끝낸 시점의 상태는 다음과 같다.
pnpm deploy-ios-*(배포) / pnpm set-ios-*(환경 전환) 단축 스크립트private_lane으로 줄이는 개선 방향까지 파악다음 편에서는 Android 설정을 살펴본다.