iOS 모노레포 생존기 (4) — CI/CD 전략
강민석
2026년 3월 6일7분 분량
배경
모노레포에 세 개의 앱이 있으면, PR 하나를 올릴 때마다 세 앱을 모두 빌드해야 할까요?
ProfileFeature의 UI를 수정하는 PR인데, BirdiePT와 KimcaddieOwner까지 빌드하는 건 시간과 리소스 낭비입니다. 하지만 Common 모듈을 수정한 PR이라면 세 앱 모두 영향을 받으므로 전부 빌드해야 합니다.
김캐디는 PR 라벨 기반의 선택적 빌드로 이 문제를 해결했습니다. 이 글에서는 CI/CD 파이프라인 전체 — 선택적 빌드, 배포 자동화, 앱스토어 심사 모니터링, 자동 포맷팅 — 를 다룹니다.
환경 설정
전체 파이프라인
| 워크플로우 | 트리거 | 역할 |
|---|---|---|
ci-auto-label | PR 생성/동기화 | 변경 파일 분석 → CI 라벨 자동 부착 |
ci | PR 라벨 부착 | 라벨에 해당하는 앱만 빌드 |
cd | 수동 (workflow_dispatch) | 선택한 앱·환경으로 빌드 + 배포 |
appstore_check | CD에서 자동 트리거 + 10분 cron | 앱스토어 심사 상태 모니터링 |
format | develop push | swift-format 자동 적용 |
releasenote | tag push | GitHub Release 자동 생성 |
실전 비교: CI 파이프라인
도입 전: 모든 PR에 모든 앱 빌드
PR 생성 → Kimcaddie 빌드 (5분) + KimcaddieOwner 빌드 (3분) + BirdiePT 빌드 (3분)
= 총 11분 (병렬 시 5분)ProfileFeature 수정 PR에서 BirdiePT가 빌드에 실패하면? 내 변경과 무관한 실패 때문에 머지가 막힙니다.
도입 후: 변경된 앱만 빌드
CI 라벨 시스템은 두 단계로 작동합니다.
1단계: 자동 라벨 부착 (ci-auto-label)
PR이 생성되거나 커밋이 추가되면, 변경된 파일의 경로를 분석해서 해당 앱의 라벨을 자동으로 부착합니다:
# ci-auto-label.yml
-uses: dorny/paths-filter@v3
id: filter
with:
filters:|
kimcaddie:
- 'Projects/Kimcaddie/**'
- 'XCConfig/Kimcaddie/**'
kimcaddieowner:
- 'Projects/KimcaddieOwner/**'
- 'XCConfig/KimcaddieOwner/**'
birdiept:
- 'Projects/BirdiePT/**'
- 'XCConfig/BirdiePT/**'Projects/Kimcaddie/Features/ProfileFeature/ 파일이 변경되면 → ci:kimcaddie 라벨이 붙습니다.
한 가지 트릭이 있습니다. 같은 라벨을 다시 추가해도 GitHub의 labeled 이벤트가 발생하지 않아서, 커밋을 push할 때마다 CI가 재트리거되지 않는 문제가 있습니다. 이를 해결하기 위해 라벨을 제거했다가 다시 추가합니다:
# 라벨 제거 후 재추가 → labeled 이벤트 강제 발생
gh pr edit "$PR" --remove-label "ci:kimcaddie" 2>/dev/null || true
gh pr edit "$PR" --add-label "ci:kimcaddie"2단계: 선택적 빌드 (ci)
labeled 이벤트로 트리거되는 CI 워크플로우에서, 라벨을 확인해 해당 앱만 빌드합니다:
# ci.yml
build-kimcaddie:
if:|
contains(github.event.pull_request.labels.*.name, 'ci:kimcaddie') ||
contains(github.event.pull_request.labels.*.name, 'ci:all')
steps:
-uses: actions/checkout@v6
-uses: chetan/git-restore-mtime-action@v2 # 증분 빌드를 위한 mtime 복원
-run: mise install && make install
-run: make kimcaddie config=dev
-run:|
xcodebuild build \
-workspace Kimcaddie.xcworkspace \
-scheme Kimcaddie_DEV \
-derivedDataPath ~/.derivedData/Kimcaddie \
| xcbeautify라벨 시스템:
| 라벨 | 빌드 대상 |
|---|---|
ci:kimcaddie | Kimcaddie만 |
ci:kimcaddieowner | KimcaddieOwner만 |
ci:birdiept | BirdiePT만 |
ci:all | 세 앱 모두 |
ci:pass | 빌드 건너뜀 (인프라/문서 변경) |
CI Gate: 필수 체크
GitHub의 Branch Protection에 등록된 필수 체크는 ci-gate 하나입니다. 이 job이 개별 빌드 결과를 종합 판단합니다:
ci-gate:
needs:[build-kimcaddie, build-kimcaddieowner, build-birdiept]
if: always() # 스킵된 job이 있어도 실행
steps:
-run:|
# ci:pass → 무조건 통과
if echo "$LABELS" | grep -q '"ci:pass"'; then
exit 0
fi
# CI 라벨이 없으면 → 실패
if ! echo "$LABELS" | grep -qE '"ci:(kimcaddie|kimcaddieowner|birdiept|all)"'; then
exit 1
fi
# 실행된 빌드가 모두 성공이거나 스킵이면 → 통과
is_bad() { [ "$1" != "success" ] && [ "$1" != "skipped" ]; }
if is_bad "$KIMCADDIE" || is_bad "$KIMCADDIEOWNER" || is_bad "$BIRDIEPT"; then
exit 1
fiif: always()가 핵심입니다. 기본적으로 GitHub Actions는 needs에 있는 job이 스킵되면 자신도 스킵하는데, if: always()를 붙이면 무조건 실행됩니다. skipped를 success와 동일하게 취급하므로, ci:kimcaddie만 라벨이 붙었을 때 나머지 두 빌드가 스킵되어도 Gate가 통과합니다.
증분 빌드 최적화
self-hosted runner에서 ~/.derivedData/Kimcaddie를 공유하여 빌드 간 캐시를 재사용합니다. 다만, Git checkout 시 파일의 수정 시간(mtime)이 현재 시간으로 초기화되는 문제가 있습니다. Xcode는 mtime을 기반으로 재빌드 여부를 판단하므로, mtime이 바뀌면 모든 파일을 다시 빌드합니다.
git-restore-mtime-action이 이 문제를 해결합니다. Git 히스토리에서 각 파일의 마지막 커밋 시간을 읽어 mtime을 복원합니다. 변경되지 않은 파일은 이전 빌드와 같은 mtime을 가지게 되어, Xcode가 캐시된 빌드 결과를 재사용합니다.
배포 파이프라인 (CD)
배포는 GitHub Actions의 workflow_dispatch로 수동 트리거합니다. UI에서 앱, 환경, 옵션을 선택합니다:
# cd.yml
on:
workflow_dispatch:
inputs:
target:
description:'배포할 앱 선택'
type: choice
options:[kimcaddie, kimcaddieowner, birdiept]
environment:
description:'배포 환경 선택'
type: choice
options:[development, production]
required_appstore_upload:
description:'앱스토어 심사 제출 여부'
type: boolean
default:false
phased_release:
description:'점진적 배포 활성화'
type: boolean
default:true
release_version:
description:'배포할 버전'
type: string[이미지: workflow_dispatch UI — 앱·환경·옵션 선택 화면]
배포 경로
세 가지 Fastlane lane이 환경과 옵션에 따라 호출됩니다:
| 조건 | Fastlane lane | 배포 대상 |
|---|---|---|
| development | deploy_dev | Firebase App Distribution |
| production + 앱스토어 X | deploy_prod_testflight | TestFlight |
| production + 앱스토어 O | deploy_prod_appstore | App Store Connect |
빌드 번호 형식
김캐디의 빌드 번호는 {버전접두사}.{날짜}.{카운터} 형식입니다:
5.8.7 버전의 3월 6일 첫 번째 빌드 → 587.306.1
5.8.7 버전의 3월 6일 두 번째 빌드 → 587.306.2Fastlane에서 자동으로 계산합니다:
version_prefix = marketing_version.delete(".") # "5.8.7" → "587"
current_date = Time.now.strftime("%-m%d") # "306"
counter = same_day? ? previous_counter + 1 : 1
new_build = "#{version_prefix}.#{current_date}.#{counter}"날짜가 바뀌면 카운터가 자동으로 리셋되므로, 빌드 번호만 보고 “이 빌드가 언제 만들어졌는지” 바로 알 수 있습니다.
앱스토어 제출 → 심사 모니터 자동 시작
App Store에 제출하면, CD 워크플로우가 자동으로 appstore_check 워크플로우를 활성화하고 트리거합니다:
-name: Trigger App Store Review Monitor
if: github.event.inputs.required_appstore_upload == 'true'
run:|
gh workflow enable appstore_check.yml 2>/dev/null || true
gh workflow run appstore_check.yml \
-f target=${{ env.TARGET }} \
-f version=${{ env.RELEASE_VERSION }}앱스토어 심사 모니터
앱을 제출한 후 가장 궁금한 건 “지금 심사 어디까지 진행됐지?”입니다. App Store Connect에 매번 접속해서 확인하는 대신, 슬랙으로 알림을 받으면 편합니다.
동작 방식
워크플로우는 10분마다 cron으로 실행되며, 이전 상태와 현재 상태를 비교해서 변경이 있을 때만 슬랙에 알림을 보냅니다.
상태 저장: GitHub Actions 캐시
상태 비교를 하려면 “이전 실행의 상태”를 어딘가에 저장해야 합니다. 데이터베이스를 쓸 정도는 아니고, GitHub Actions의 캐시를 간이 저장소로 활용합니다:
{
"target": "kimcaddie",
"version": "5.8.7",
"previous_status": "IN_REVIEW",
"previous_phased_state": null,
"active": true
}매 실행마다 새 캐시 키(appstore-monitor-{run_id})로 저장하고, restore-keys 패턴으로 가장 최근 캐시를 불러옵니다.
상태 조회: Fastlane Spaceship
App Store Connect API를 직접 호출하는 대신, Fastlane의 Spaceship 라이브러리를 활용합니다. ASC API의 500 에러에 대비한 재시도 로직도 포함되어 있습니다:
max_retries = 3
retry_count = 0
begin
# ASC API로 버전 상태 조회
rescue => e
retry_count += 1
if retry_count <= max_retries && e.message.include?("500")
sleep(retry_count * 30) # 30초, 60초, 90초 대기
retry
end
raise
end슬랙 알림
상태가 변경되면 한국어로 번역된 슬랙 메시지를 보냅니다:
STATUS_KR = {
"WAITING_FOR_REVIEW": "심사 대기중",
"IN_REVIEW": "심사중",
"READY_FOR_SALE": "출시 완료",
"REJECTED": "심사 거부",
"PENDING_DEVELOPER_RELEASE": "개발자 배포 대기중",
}
PHASED_PROGRESS = {
1: "1%", 2: "2%", 3: "5%", 4: "10%",
5: "20%", 6: "50%", 7: "100%"
}점진적 배포(Phased Release) 중에는 진행률 변경도 알림으로 보냅니다. “Kimcaddie 5.8.7 — 점진적 배포 20%” 같은 메시지가 옵니다.
자동 비활성화
가장 재미있는 부분입니다. 심사가 끝나면(출시 완료 또는 거부) 워크플로우가 스스로를 비활성화합니다:
-name: Disable schedule
if: steps.compare.outputs.should_deactivate == 'true'
run: gh workflow disable "${{ github.workflow }}"비활성화 조건:
- REJECTED — 심사 거부
- READY_FOR_SALE + 점진적 배포 100% 완료
- READY_FOR_SALE + 점진적 배포 없음 (즉시 출시)
- PENDING_DEVELOPER_RELEASE — 개발자 수동 출시 대기
이렇게 하지 않으면 10분마다 무의미한 cron이 계속 돌아가면서 CI 분을 낭비합니다. 다음 배포 시 CD 워크플로우가 다시 gh workflow enable로 활성화하므로, 수동 개입이 필요 없습니다.
자동 포맷팅
코드 스타일은 PR 리뷰에서 논의할 주제가 아닙니다. develop 브랜치에 push되면 자동으로 swift-format을 실행하고, 변경이 있으면 커밋합니다:
# format.yml
on:
push:
branches:[develop]
jobs:
swift_format:
steps:
-uses: actions/checkout@v6
-run: make format
-uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: Run swift-format포맷 변경이 없으면 커밋도 생기지 않습니다. 릴리즈 노트 생성 시에는 Run swift-format 커밋을 자동으로 필터링하여 변경 로그에 포함되지 않습니다.
결론
모노레포 CI/CD의 핵심은 “필요한 것만, 자동으로”입니다.
- 빌드: 변경된 앱만 빌드 (라벨 기반)
- 배포: UI에서 앱·환경 선택 후 원클릭
- 모니터링: 제출하면 알아서 시작, 끝나면 알아서 종료
- 포맷팅: 머지되면 알아서 정리
이 시리즈를 통해 김캐디의 모노레포 구축 과정을 공유했습니다. 모듈 구조 설계부터 타입세이프 의존성, 서드파티 격리, CI/CD까지 — 완벽한 정답은 아니지만, 106개 모듈과 3개 앱을 운영하면서 실제로 동작하는 방식입니다.
모노레포를 고민하고 있다면, 이 시리즈가 의사결정에 도움이 되길 바랍니다.