GitHub Actions + Fastlane 기반 iOS 배포 자동화

안녕하세요. 센디 플랫폼 스쿼드에서 iOS 앱을 개발하고 있는 박정환 (Lody)입니다.
기능을 만드는 시간보다 빌드를 기다리는 시간이 더 길게 느껴질 때가 있습니다. 코드 한 줄을 고쳐도 확인을 받으려면 앱을 새로 빌드해 전달해야 하고, 이 과정은 수정할 때마다 처음부터 다시 반복됩니다.
센디에는 별도의 QA 조직이 없습니다. 엔지니어가 먼저 기능을 확인한 뒤 PM과 디자이너에게 빌드를 공유하고, 스쿼드 안에서 피드백을 주고받습니다. iOS 개발자는 저 혼자라서 공유용 빌드를 만들어 전달하는 일도 제 몫이었습니다.
빌드가 끝나면 하던 작업을 멈추고 다음 배포 단계를 직접 시작해야 했습니다. 피드백을 받아 코드를 고친 뒤에도 같은 개입이 반복됐습니다.

이 글은 그 반복을 GitHub Actions와 Fastlane으로 걷어낸 기록입니다. 먼저 배포를 개인 Mac에서 떼어 낸 과정을 다루고, 이어서 CI로 옮긴 뒤 드러난 반복 빌드 비용을 살펴봅니다. 아래 구성은 2026년 2월의 호스티드 러너 기반 워크플로를 기준으로 정리했습니다.
시작 배경: 반복되는 수동 배포
센디는 Gitflow와 유사한 브랜치 전략을 사용합니다. feature/*에서 기능을 개발하고 develop에서 통합하며, 출시 후보는 release/*에서 관리합니다.
앱을 배포하는 환경은 두 가지입니다.
- Staging: 사내 확인용 환경. 개발 중인 기능을 Firebase App Distribution으로 공유하고 PM과 디자이너가 직접 확인
- Production: 출시 전 최종 확인용 환경. TestFlight에 빌드를 올려 검증한 뒤 App Store에 제출
| 환경 | 용도 | 배포 방식 |
|---|---|---|
| Staging | 기능 개발 및 내부 확인 | Ad Hoc + Firebase App Distribution |
| Production | 출시 전 최종 확인 | App Store 프로파일 + TestFlight |
가장 자주 배포하는 환경은 Staging이었습니다. 기능을 개발하는 동안 수정과 확인이 여러 번 반복되기 때문입니다. 당시 Staging 빌드를 한 번 공유하려면 다음을 순서대로 거쳤습니다.
- Xcode에서 Archive (로컬 Mac에서 실행)
- IPA Export
- Firebase App Distribution에 업로드
- Slack으로 PM과 디자이너에게 확인 요청
배포 절차가 복잡한 것은 아니었습니다. 다만 새로운 빌드가 필요할 때마다 같은 작업을 처음부터 다시 해야 했습니다. Staging에서는 테스트 기기가 추가되면 Ad Hoc 프로비저닝 프로파일을 갱신해야 했고, Production에서는 TestFlight에 올라간 마지막 빌드 번호를 확인해야 했습니다. 이런 작업은 평소에는 크게 신경 쓰이지 않지만, 하나라도 빠뜨리면 배포에 실패하거나 다시 업로드해야 했습니다.
빌드 시간이 줄어든다고 해결되는 문제도 아니었습니다. Archive 시간이 짧아져도 이후 배포 과정에는 여전히 사람 손이 필요했기 때문입니다.
Fastlane으로 배포 절차를 스크립트화하면 일부 작업을 줄일 수 있습니다. 하지만 스크립트를 제 Mac에서 실행하는 방식이라면 빌드가 개인 장비에 의존하는 문제는 남습니다. 배포 요청부터 결과 공유까지 개인 Mac을 거치지 않는 구조가 필요했습니다.
GitHub Actions 배포 자동화
기존 브랜치 전략을 배포 트리거로 활용
iOS 빌드를 CI에서 실행하려면 Xcode가 설치된 macOS 환경이 필요합니다. 별도의 Mac을 빌드 전용 장비로 등록해 self-hosted runner를 운영할 수도 있지만, 배포를 위해 새 장비를 관리할 필요는 없다고 판단했습니다. GitHub Actions의 호스티드 macOS 러너를 쓰면 별도 장비 없이 빌드를 실행할 수 있습니다.
이미 GitHub에서 코드를 관리하고 있었고 Fastlane으로 작성한 배포 절차도 있었기 때문에, 기존 구조를 크게 바꿀 필요가 없었습니다. 배포 트리거는 브랜치에 연결했습니다. Staging Release는 staging/*와 release/*, Production Release는 production/*와 prod 브랜치에 push할 때 실행되도록 했고, 두 환경 모두 필요할 때 수동으로 워크플로를 실행할 수도 있습니다.
배포 과정은 환경별로 다섯 개의 Job으로 나눴습니다. 프로비저닝 프로파일을 준비하고(sync-provisioning), Archive를 만들고(build), 코드 서명과 IPA를 생성합니다(sign-export). Staging에서는 Firebase에 배포하고(distribute), Production에서는 TestFlight에 업로드한 뒤(upload-testflight), Slack으로 결과를 알립니다(notify).
Staging과 Production은 프로비저닝 프로파일 종류와 배포처가 다르지만 전체 파이프라인 구조는 같습니다. 빌드에 필요한 환경 준비는 Makefile로 관리했습니다. React Native 서브모듈 동기화, JavaScript 의존성 설치, 환경 값 생성, CocoaPods 설치가 여기에 포함됩니다.
Staging에는 concurrency 설정도 적용했습니다.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true기능을 수정하다 보면 같은 브랜치에 커밋을 연달아 올리는 경우가 있습니다. 이전 빌드가 진행 중인데 새 커밋이 올라왔다면, 이미 오래된 빌드를 끝까지 생성할 필요는 없습니다. 같은 워크플로와 브랜치에서 새 실행이 시작되면 이전 실행을 취소하도록 했습니다. 불필요한 러너 사용을 줄이고, 테스터에게 여러 버전의 확인용 빌드가 전달되는 상황을 막기 위해서입니다.

CI에서의 프로비저닝 프로파일 관리
GitHub 호스티드 러너는 실행할 때마다 새 환경에서 시작합니다. 로컬 Mac에 설치된 배포 인증서와 프로비저닝 프로파일을 그대로 쓸 수 없으므로, CI에서 코드 서명에 필요한 정보를 매번 준비해야 합니다.
Fastlane에서는 cert, sigh, match로 인증서와 프로파일을 관리할 수 있습니다. 그중 match는 인증서와 프로파일을 암호화된 저장소에 보관하고 여러 개발자와 CI 환경이 공유하도록 지원합니다. iOS 개발자가 여럿이라면 유용하지만, 센디에서는 iOS를 혼자 맡고 있어 인증서를 여러 개발자 사이에서 공유할 필요가 없었습니다. 별도의 인증서 저장소를 운영하기보다는, 배포 인증서는 GitHub Secrets로 관리하고 프로비저닝 프로파일은 Fastlane sigh로 준비하는 방식을 택했습니다.
Staging에서 쓰는 Fastlane lane의 핵심 부분입니다. 아래 코드 예제의 앱 식별자와 프로젝트 이름, lane 이름은 설명용 값으로 바꿨습니다.
lane :sync_signing do
setup_api_key
sigh(
app_identifier: "com.example.app.staging",
adhoc: true,
force: true,
filename: "staging.mobileprovision",
output_path: "./fastlane/profiles"
)
sigh(
app_identifier: "com.example.app.staging.PushExtension",
adhoc: true,
force: true,
filename: "push_extension.mobileprovision",
output_path: "./fastlane/profiles"
)
end센디 iOS 앱에는 Push Notification Service Extension이 포함돼 있습니다. 메인 앱과 Extension은 서로 다른 번들 ID를 쓰기 때문에 프로비저닝 프로파일도 각각 준비해야 합니다.
여기서 force: true를 설정한 이유는 테스트 기기 관리 때문입니다. Ad Hoc 배포에서는 프로비저닝 프로파일에 등록된 기기에만 앱을 설치할 수 있습니다. 새 기기를 추가했는데 프로파일이 갱신되지 않았다면, 빌드를 정상적으로 배포하더라도 그 기기에서는 설치할 수 없습니다. 기존에는 테스트 기기가 추가될 때 사람이 직접 프로파일을 갱신해야 했지만, 이제는 배포를 시작할 때 sigh가 프로파일을 다시 생성합니다. 등록된 기기 구성이 바뀌어도 별도로 갱신 작업을 할 필요가 없습니다.
프로파일 준비를 별도 Job으로 분리한 것도 같은 이유입니다. 프로파일을 생성하거나 다운로드하는 과정에서 문제가 생기면, 긴 Archive 작업을 시작하기 전에 실패를 확인할 수 있습니다. 다만 force: true는 기존 프로파일이 유효하더라도 다시 생성합니다. 매번 갱신하는 비용은 있지만, 당시에는 프로파일 갱신 여부를 사람이 확인해야 하는 작업을 줄이는 쪽을 택했습니다.
Archive와 코드 서명 분리
빌드와 코드 서명도 별도 Job으로 분리했습니다. CI에서는 네이티브 컴파일과 코드 서명이 서로 다른 이유로 실패합니다. 빌드 설정이나 소스 코드 문제로 Archive가 실패할 수도 있고, 인증서와 프로비저닝 프로파일이 맞지 않아 Export 단계에서 실패할 수도 있습니다. 이 둘을 구분하고, 컴파일에 필요한 고사양 러너를 빌드 단계에서만 쓰도록 구성했습니다.
먼저 build Job에서는 코드 서명 없이 Archive를 생성합니다.
- name: Build Archive
run: |
xcodebuild -workspace App.xcworkspace \
-scheme App-Staging \
-configuration Debug \
-archivePath $RUNNER_TEMP/App.xcarchive \
-destination 'generic/platform=iOS' \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO \
clean archiveCODE_SIGNING_REQUIRED=NO와 CODE_SIGNING_ALLOWED=NO를 지정해 Archive 과정에서 코드 서명을 비활성화했습니다. 그런데 Job을 분리하면 한 가지 문제가 생깁니다.
GitHub Actions의 Job은 각각 독립적인 러너에서 실행되기 때문에, 앞선 Job에서 생성한 Archive를 다음 Job에서 바로 쓸 수 없습니다.
이를 해결하려고 GitHub Actions의 Artifact 기능을 썼습니다. build Job에서는 생성한 .xcarchive를 업로드합니다.
# build Job
- name: Upload Archive
uses: actions/upload-artifact@v4
with:
name: App-Archive
path: ${{ runner.temp }}/App.xcarchive
retention-days: 1이후 sign-export Job에서 그 Artifact를 다운로드합니다.
# sign-export Job
- name: Download Archive
uses: actions/download-artifact@v4
with:
name: App-Archive
path: ${{ runner.temp }}/App.xcarchive
- name: Download Provisioning Profiles
uses: actions/download-artifact@v4
with:
name: Provisioning-Profiles
path: fastlane/profilesArchive뿐 아니라 앞서 sync-provisioning Job에서 생성한 프로비저닝 프로파일도 Artifact로 전달합니다. sign-export Job은 배포 인증서를 키체인에 등록하고, 다운로드한 프로비저닝 프로파일을 설치합니다. 서명에 필요한 프로파일 이름은 .mobileprovision 파일에서 직접 추출했습니다.
PROFILE_NAME=$(
security cms -D -i fastlane/profiles/staging.mobileprovision |
plutil -extract Name raw -
)추출한 이름을 ExportOptions.plist의 provisioningProfiles에 지정합니다. 메인 앱과 Push Extension의 프로파일을 각각 매핑해야 하므로 두 번들 ID를 모두 포함했습니다. 그다음 xcodebuild -exportArchive로 IPA를 생성합니다.
xcodebuild -exportArchive \
-archivePath $RUNNER_TEMP/App.xcarchive \
-exportOptionsPlist $RUNNER_TEMP/ExportOptions.plist \
-exportPath $RUNNER_TEMP/export생성한 IPA도 Artifact로 업로드하고, 다음 배포 Job에서 이를 다운로드해 Firebase App Distribution 또는 TestFlight로 전달합니다. 결과적으로 하나의 배포 과정에서 세 종류의 산출물을 주고받습니다.
| 산출물 | 생성 Job | 사용 Job |
|---|---|---|
| 프로비저닝 프로파일 | sync-provisioning | sign-export |
.xcarchive | build | sign-export |
.ipa | sign-export | distribute / upload-testflight |
이렇게 구성하면서 컴파일에는 macos-26-xlarge, 서명과 배포에는 기본 macOS 러너를 쓸 수 있었습니다. 물론 Job을 분리하면 Archive와 IPA를 업로드하고 다운로드하는 시간이 추가됩니다. 빌드 산출물이 크다면 무시할 수 없는 비용입니다. 당시에는 빌드와 서명의 실패 지점을 구분하고 각 단계에 필요한 러너를 다르게 쓰는 쪽에 무게를 뒀고, Artifact 전달 시간과 러너 비용을 비교한 결과는 따로 측정하지 않았습니다.
TestFlight 빌드 번호 자동 관리
Production 배포에는 Staging과 다른 작업이 하나 더 필요했습니다. TestFlight에 새 빌드를 업로드하려면 기존에 등록된 빌드 번호와 충돌하지 않아야 합니다. 이전에는 마지막 빌드 번호를 확인하고 Xcode 프로젝트에서 다음 번호로 바꿨습니다.
Fastlane의 increment_build_number를 쓰면 프로젝트에 기록된 번호를 올릴 수 있습니다. 다만 CI는 매번 새 체크아웃에서 시작하기 때문에, 로컬 프로젝트 번호만 올리는 방식으로는 이미 업로드된 번호와 충돌할 수 있습니다. 그래서 get_next_build_number lane을 만들었습니다.
lane :get_next_build_number do |options|
app_id = options[:app_identifier]
setup_api_key
latest_build = latest_testflight_build_number(
app_identifier: app_id,
initial_build_number: 0
)
next_build = latest_build.to_i + 1
puts "NEXT_BUILD_NUMBER=#{next_build}"
endlatest_testflight_build_number로 마지막 빌드 번호를 조회하고 1을 더한 값을 출력합니다. GitHub Actions에서는 출력된 NEXT_BUILD_NUMBER를 파싱해 빌드 환경 변수로 설정하고, 이 값을 Archive 실행 시 CURRENT_PROJECT_VERSION에 전달합니다.
xcodebuild ... \
CURRENT_PROJECT_VERSION=$BUILD_NUMBER \
clean archive이제 Production을 배포할 때마다 빌드 번호를 직접 확인하거나 수정할 필요가 없습니다. 다만 조회한 번호를 기준으로 올리는 방식은 여러 Production 배포가 동시에 실행되면 충돌할 가능성이 있습니다. 당시 설정한 concurrency는 같은 브랜치의 중복 실행을 취소하지만, 서로 다른 Production 브랜치의 동시 실행까지 막지는 않습니다. 이 부분은 빌드 번호 할당을 직렬화하는 방식으로 보완할 여지가 있습니다.
배포 결과 Slack 알림
배포가 끝난 뒤 Slack에 직접 메시지를 남기던 과정도 워크플로에 포함했습니다. 빌드 Job이 시작되면 Started를 보내고, 앞선 Job이 끝나면 notify Job에서 Completed를 보냅니다. notify에는 if: always()를 지정해 앞선 단계의 실패 여부와 관계없이 실행하도록 했습니다. 완료 알림에는 소요 시간과 각 Job의 성공과 실패 여부를 담습니다.
예전에는 빌드가 끝나면 Firebase나 TestFlight에서 업로드 결과를 확인하고 PM과 디자이너에게 따로 알려야 했습니다. 지금은 배포 브랜치에 push한 뒤 Slack에서 결과를 확인하면 됩니다. 빌드에 실패했을 때도 어느 Job에서 문제가 생겼는지 바로 보입니다. 프로비저닝 프로파일 준비인지, Archive 실패인지, 코드 서명이나 업로드 문제인지 구분하기 쉬워졌습니다. 배포를 시작하고 완료를 알리는 과정까지 CI에서 처리하면서 Xcode Organizer를 직접 열 일이 크게 줄었습니다.
CI 반복 빌드 비용 점검
배포를 GitHub Actions로 옮기면서 수동으로 처리하던 작업은 대부분 사라졌습니다. 하지만 빌드 자체가 빨라진 것은 아니었습니다. Staging Release를 실행한 한 사례에서는 전체 12분 17초 중 9분 53초가 빌드 Job에 쓰이고 있었습니다.
| Job | 실행 시간 |
|---|---|
| build | 9분 53초 |
| sign-export | 1분 |
| distribute | 1분 3초 |
| notify | 5초 |
| 전체 실행 시간 | 12분 17초 |

이 캡처는 프로파일 준비를 별도 Job으로 분리하기 전의 실행입니다. 수치는 해당 실행의 결과이며, 위의 다섯 Job 구성 전체를 측정한 값은 아닙니다. 개인 Mac에서의 Archive와도 실행 환경과 측정 범위가 달라 직접 비교하기는 어렵습니다. 다만 전체 실행 시간에서 빌드가 차지하는 비중이 크다는 점은 확인할 수 있었습니다.
센디 iOS 프로젝트에는 React Native 모듈이 포함돼 있습니다. 빌드 전에 React Native 서브모듈을 동기화하고 JavaScript 의존성을 설치한 뒤 환경 값을 생성하고 pod install을 실행하며, 이후 Xcode에서 네이티브 코드를 컴파일합니다. GitHub 호스티드 러너는 매번 새 환경에서 시작하기 때문에 이전 실행에서 설치한 의존성이나 컴파일 결과가 기본적으로 남아 있지 않습니다. 의존성 구성이 바뀌지 않았어도 설치 과정을 다시 거치고, 이미 컴파일한 코드도 다시 빌드하는 경우가 생겼습니다.
CocoaPods 캐시에서 놓친 부분
먼저 actions/cache로 Pods/와 ~/Library/Caches/CocoaPods를 캐싱하도록 설정했습니다. 캐시 키에는 Podfile.lock의 해시를 썼습니다. 의존성 구성이 같다면 이전에 설치한 파일을 재사용하려는 목적이었습니다.
- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: |
Pods
~/Library/Caches/CocoaPods
key: pods-${{ runner.os }}-${{ hashFiles('**/Podfile.lock') }}
restore-keys: |
pods-${{ runner.os }}-그런데 워크플로를 다시 확인해 보니 문제가 있었습니다. 캐시를 복원한 뒤 make clean을 실행하고 있었는데, Makefile에는 다음 작업이 들어 있었습니다.
clean:
rm -rf Pods Podfile.lock
RUBYOPT='-rlogger' pod cache clean --all
rm -rf ~/Library/Developer/Xcode/DerivedData캐시에서 복원한 Pods/를 지우고 CocoaPods 다운로드 캐시까지 비우는 구조였습니다. 캐시를 복원하더라도 이후 클린 빌드 과정에서 모두 지워지기 때문에, 이 캐시의 이점을 얻을 수 없었습니다. 의존성 충돌을 피하려고 추가한 클린 빌드 절차가 캐시 재사용과 충돌한 것입니다.
CocoaPods 캐시를 제대로 쓰려면 의존성이 바뀌었을 때만 다시 설치하거나, 클린 빌드에서도 다운로드 캐시는 유지하도록 빌드 준비 과정을 조정해야 합니다. 따라서 당시 구성에서는 CocoaPods 캐시가 CI 시간을 줄였다고 보기 어렵습니다.
ccache를 통한 컴파일 결과 재사용
다음으로 살펴본 것은 네이티브 컴파일이었습니다. React Native에는 C/C++로 작성된 네이티브 코드가 포함돼 있고, 코드가 바뀌지 않았더라도 CI에서는 새 러너가 실행될 때마다 같은 컴파일을 반복합니다. 이를 줄이려고 ccache를 적용했습니다. 이전 컴파일 결과를 저장해 두었다가 같은 조건의 컴파일 요청이 들어오면 재사용하는 방식입니다.
React Native의 ccache_enabled 옵션을 켜고, Xcode 26 환경에서는 별도의 Clang 래퍼 스크립트를 연결했습니다. Podfile의 핵심 설정입니다.
react_native_post_install(
installer,
config[:reactNativePath],
:mac_catalyst_enabled => false,
:ccache_enabled => true
)
ccache_clang = File.join(
project_root, "scripts", "xcode", "ccache-clang.sh"
)
ccache_clangpp = File.join(
project_root, "scripts", "xcode", "ccache-clang++.sh"
)
installer.pods_project.build_configurations.each do |config|
config.build_settings['CLANG_ENABLE_EXPLICIT_MODULES'] = 'NO'
config.build_settings['CC'] = ccache_clang
config.build_settings['CXX'] = ccache_clangpp
end래퍼 스크립트는 ccache 실행 파일을 확인한 뒤 Clang 컴파일을 전달하고, ccache를 찾지 못하면 일반 Clang으로 실행합니다.
exec "$CCACHE_BINARY" clang "$@"이렇게 컴파일러 설정을 연결해야 실제 빌드에서 ccache가 동작합니다. GitHub Actions에서 ccache를 설치하고 캐시 디렉터리를 복원하는 것만으로는 충분하지 않습니다.
당시 최적화 커밋에는 캐시가 없는 실행과 캐시가 적중한 로컬 실행의 시간이 기록돼 있습니다. 두 실행은 최적화 도입 전후를 비교한 실험이 아니므로, 이를 CI 개선율로 제시하지 않았습니다. CI에는 같은 컴파일 캐시 구성을 반영했으며, 이전 실행의 결과를 복원하도록 설정했습니다.
- name: Cache ccache
uses: actions/cache@v4
with:
path: ~/Library/Caches/ccache
key: ccache-${{ runner.os }}-${{ hashFiles('**/Podfile.lock') }}
restore-keys: |
ccache-${{ runner.os }}-빌드 Job에는 macos-26-xlarge 러너를 썼습니다. 네이티브 컴파일에 충분한 자원을 할당하면서, ccache가 적중하는 컴파일은 기존 결과를 재사용하도록 한 것입니다. CI 전체 실행 시간의 개선 폭과 실제 캐시 적중률은 따로 측정하지 않았습니다.
캐시의 실제 효과는 적중률에 따라 달라집니다. 특히 GitHub Actions는 같은 키로 저장된 캐시를 덮어쓰지 않기 때문에, 이후 실행에서 생성된 컴파일 결과를 계속 활용하려면 캐시 키 정책을 더 다듬을 필요가 있습니다.
남은 측정과 운영 과제
배포를 자동화하기 전에는 내부 확인용 빌드를 공유할 때마다 Xcode에서 Archive를 돌리고, IPA를 Export한 뒤 Firebase에 업로드해야 했습니다. Production 배포에서는 빌드 번호도 직접 확인했습니다. 지금은 배포 브랜치에 코드를 반영하면 GitHub Actions가 빌드부터 배포, Slack 알림까지 처리합니다. 프로비저닝 프로파일 갱신과 빌드 번호 관리도 자동화하면서 배포 과정에서 직접 챙길 작업이 크게 줄었습니다.
빌드와 서명을 별도 Job으로 나눈 덕분에 실패 지점을 구분하기 쉬워졌고, 고사양 러너는 컴파일이 집중되는 Job에만 쓰도록 구성했습니다.
배포 자동화 이후에는 빌드 성능 개선도 진행했습니다. 반복되는 의존성 설치와 네이티브 컴파일을 살펴보고 CocoaPods 캐시와 ccache를 적용했습니다. CocoaPods 캐시는 기존 클린 빌드 절차와 충돌해 복원 효과를 얻기 어려웠습니다. ccache는 컴파일러 래퍼와 CI 캐시 복원을 연결했지만, 실제 CI 개선 폭은 추가 측정이 필요합니다.
앞으로는 캐시 적중률과 러너 비용을 함께 보면서 빌드 시간을 더 줄여 볼 생각입니다. 기존에는 배포를 한 번 진행하려면 Archive가 끝나는 시점에 맞춰 Export와 업로드를 직접 처리해야 했지만, 이제는 배포를 시작해 두고 다른 개발 작업을 이어 갈 수 있습니다.