App Store 신청은 많은 팀에서 아직도 「PM이 독촉하고, iOS 담당이 수동 Archive, 밤새 TestFlight 업로드, 다음 날 또 메타데이터 입력」 패턴입니다. 기능은 이미 merge됐는데 진짜 병목은 공정화되지 않은 Release 체인에 있습니다. 이 글은 CI/CD 관점에서 Xcode부터 심사 통과까지를 6개의 관측 가능한 단계로 나누고, 서명·빌드·업로드·심사 제출·배포의 경계를 제시합니다. 빌드 노드 배치가 고민이라면 먼저 iOS 파이프라인 신판의 Mac 빌드 섬 레이어 분리를 읽어보세요.
읽고 나면 얻는 것: 6단계 신청 체인 다이어그램, 수동 vs 자동화 대조표, fastlane + GitHub Actions 최소 YAML, 거절 후 30분 안에 TestFlight를 재배포하는 체크리스트.
왜 「신청」을 체크리스트가 아닌 공정 체인으로 다루는가
초보자는 신청을 체크리스트로 이해합니다: 스크린샷, 개인정보 처리방침, 제출 버튼. 문제는——체크리스트는 상태를 기록하지 않고, N번째 배포가 1번째와 같은 전제로 돌아간다는 보장도 없습니다. 공정화 관점에서 신청은 방향성 있는 파이프라인이며, 각 단계가 명확한 산출물을 내고 다음 단계는 이전 출력만 소비합니다:
- 반복 가능: 동일 git tag가 빌드 섬에서, 동일 Xcode와 의존성 lock이면 로컬과 같은 IPA를 생성
- 관측 가능: 각 단계에 로그, 소요 시간, 실패 코드. Slack에는 「업로드 성공 / 처리 중 / 심사 제출 가능」이 보여야 하며 「아마 올라갔을 듯」이 아님
- 롤백 가능: 스토어에 이전 build 보존. git에 tag와 dSYM 보존, 크래시 심볼화 단절 방지
- 책임 분리: 개발이 바이너리와 버전 번호, 프로덕트/마케팅이 스토어 문구와 스크린샷——CI에서는 별도 Job이나 승인 게이트로 분리
Mac이 없는 크로스플랫폼 팀에도 같은 체인이 적용됩니다. 코딩은 Windows / Linux, Release 단계만 macOS 빌드 노드에 집중하면 됩니다. 분담 패턴은 Windows 주력 + 원격 Mac 빌드 섬을 참고하세요.
6단계: Xcode Scheme에서 App Store 판매까지
아래 표가 본문 골격입니다. 후반 Runbook은 이 순서로 Job을 배치해 「먼저 업로드하고 나서 버전 bump를 잊었다」는 사고를 막습니다.
| 단계 | 주요 작업 | 일반 소요 | 자동화 수준 |
|---|---|---|---|
| ① 사전 검증 | 버전 번호, 의존성 lock, 단위 테스트, 프라이버시 manifest 초안 | 5–15 min | 높음(Linux/Mac) |
| ② 서명 준비 | 인증서, 프로파일, 키체인 잠금 해제, ASC API Key | 최초 + 로테이션 | 중(Secret 주입) |
| ③ Archive & Export | xcodebuild archive, IPA보내기 | 10–40 min | 높음(전용 Mac) |
| ④ TestFlight | 업로드, 처리, 내부 테스트 그룹 | 15–60 min | 높음 |
| ⑤ 심사 제출 | 메타데이터, 스크린샷, 수출 컴플라이언스, submit for review | 수동 30 min + 대기 | 중(승인 게이트 필요) |
| ⑥ 배포 | 심사 통과, phased release / 전량 | 24–48 h 심사 | 중(상태 폴링) |
공정상 분수령은 ④와 ⑤ 사이입니다. TestFlight는 가능한 한 자동화(main merge나 nightly마다 빌드 배포). 심사 제출에는 수동 또는 PM 승인을 두어 문구 미확정 상태로 심사 큐에 넣지 않습니다. ③은 버전이 고정된 Mac에서 실행해야 하며——이것이 Cloud Mac을 iOS CI 노드로 쓰는 핵심 시나리오이지, 일상 Swift 코딩용이 아닙니다.
서명과 인증서: CI에서 가장 비싼 비결정성
로컬 Xcode에서는 Archive되는데 CI에서 errSecInternalComponent나 No profiles for ...가 나온다——거의 항상 아이덴티티와 환경이 고정되지 않은 탓이지, 프로젝트 자체 문제가 아닙니다.
세 종류의 자격 증명을 한 Secret에 섞지 마세요
| 자격 증명 | 용도 | CI 보관 권장 |
|---|---|---|
| Distribution 인증서(p12) | codesign으로 설치 가능 패키지 생성 | 암호화 Secret. 빌드 섬 키체인 또는 임시 keychain |
| Provisioning Profile | Bundle ID, 기능, 기기 바인딩 | UUID별 Job 전용 디렉터리. 전역 덮어쓰기 금지 |
| ASC API Key(.p8) | 업로드, build 상태 확인, deliver 심사 제출 | GitHub Encrypted Secret. 키체인 불필요 |
여러 Job이 병렬 서명하면 키체인 경합과 Profile 디렉터리 race가 장기 장애 원인입니다. Runner 단위 독립 사용자, Job 단위 임시 keychain이 정석입니다. 자세한 내용은 codesign 병행과 키체인 격리 FAQ를 보세요.
CI 키체인 최소 스크립트(Xcode 16 / macOS 14 가정)
# 在 self-hosted Mac runner 上,每个 release job 开头
KEYCHAIN=$RUNNER_TEMP/build.keychain-db
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security list-keychains -s "$KEYCHAIN" login.keychain-db
security import dist.p12 -k "$KEYCHAIN" -P "$P12_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
빌드 산출물: Archive → Export → TestFlight 업로드
Release 빌드와 Debug 빌드의 차이는 「최적화 플래그 하나」가 아닙니다. 서명 설정, Export 옵션, 버전 메타데이터가 App Store Connect에 등록된 App 레코드와 맞아야 합니다.
xcodebuild Archive(Xcode Product → Archive와 동일)
xcodebuild -scheme MyApp -configuration Release \
-destination 'generic/platform=iOS' \
-archivePath "$RUNNER_TEMP/MyApp.xcarchive" \
archive \
DEVELOPMENT_TEAM=XXXXXXXXXX \
-allowProvisioningUpdates
IPA보내기와 업로드
xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/MyApp.xcarchive" \
-exportPath "$RUNNER_TEMP/export" \
-exportOptionsPlist ExportOptions.plist
# ExportOptions.plist 中 method 应为 app-store
# 上传可用 xcrun altool(旧)或 fastlane pilot / deliver
공정화 포인트:
- CFBundleVersion(build number)은 업로드마다 증가. git tag 또는 CI run number와 연결, Runbook에 명시
- .xcarchive와 dSYM 보관. GitHub Artifacts나 S3에. 거절 대응과 프로덕션 크래시 심볼화에 필수
- DerivedData 캐시 키에 Xcode 메이저 버전 포함. Xcode 업그레이드 후 더러운 캐시로 컴파일은 통과하지만 서명이 실패하는 사고 방지
빌드 섬 디스크와 캐시 전략은 ③ 단계 소요 시간 안정성에 직결됩니다. self-hosted Runner 캐시와 디스크 FAQ를 참고하세요.
심사 제출과 심사: 인력 심사 밖에서 자동화할 수 있는 것
Apple 심사는 여전히 인력 중심이지만, 심사 전후 반복 작업 대부분은 파이프라인화할 수 있습니다:
자동화에 적합한 부분
- build 처리 상태 폴링(
processing→valid) - 「수출 컴플라이언스」「콘텐츠 저작권」「광고 식별자」 등 저장된 답변 적용 여부 확인
- fastlane
deliver또는 App Store Connect API로 메타데이터와 스크린샷 제출(전제: Git에 스크린샷 원본 보관) - 심사 통과 후 Slack 알림 / phased release 비율 자동 트리거
수동 게이트를 남겨야 할 부분
- 특정 지역 최초 출시, 결제/건강/아동 등 민감 카테고리
- 메이저 버전 마케팅 문구, 미리보기 영상, 스토어 스크린샷 최종본
- 「심사에 제출」버튼 자체——GitHub Environment
required_reviewers또는 수동workflow_dispatch
거절(Rejected)이나 메타데이터 거절(Metadata Rejected) 시 Runbook은 먼저 git 수정, bump build, ③→④ 재실행으로 정하고, Connect 웹만 고치고 새 바이너리를 내지 않습니다(순수 메타데이터 문제 제외). 흔한 거절 사유——프라이버시 manifest(Privacy Manifest) 누락, 로그인 데모 계정 미제공, 서드파티 SDK 선언 불완전——은 ① 사전 검증 Job에서 PrivacyInfo.xcprivacy와 Info.plist 키를 스크립트로 스캔해야 합니다.
CI/CD Runbook: GitHub Actions + fastlane 최소 루프
아래는 「main merge → TestFlight에서 확인 가능」의 최소 Workflow입니다. 심사 제출 Job은 기본 off. release/* tag 또는 수동 트리거로 실행.
Fastfile 조각(TestFlight 업로드)
# fastlane/Fastfile
default_platform(:ios)
platform :ios do
lane :beta do
setup_ci if ENV['CI']
match(type: "appstore", readonly: true)
build_app(
scheme: "MyApp",
export_method: "app-store",
output_directory: "./build"
)
upload_to_testflight(
skip_waiting_for_build_processing: false,
api_key_path: "asc_api_key.json"
)
end
lane :release do
upload_to_app_store(
submit_for_review: true,
automatic_release: false,
precheck_include_in_app_purchases: false
)
end
end
GitHub Actions: 사전 검증 + Release(self-hosted Mac)
name: App Store Release
on:
push:
branches: [main]
workflow_dispatch:
inputs:
submit_review:
description: 'Submit to App Review'
type: boolean
default: false
jobs:
preflight:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: swiftlint --strict
- run: swift test --parallel
- name: Check Privacy Manifest exists
run: test -f MyApp/PrivacyInfo.xcprivacy
beta:
needs: preflight
runs-on: [self-hosted, macos, ios-release]
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: ~/ci/DerivedData
key: dd-xcode16-${{ hashFiles('**/Package.resolved') }}
- name: TestFlight upload
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
run: bundle exec fastlane beta
submit-review:
if: github.event.inputs.submit_review == true
needs: beta
runs-on: ubuntu-latest
environment: app-store-review
steps:
- uses: actions/checkout@v4
- run: bundle exec fastlane release
오케스트레이션은 GitHub Actions, 실행은 self-hosted Mac 빌드 섬에 고정——혼합 파이프라인과 같은 철학: Linux로 빠른 검증, Mac은 Archive와 업로드만. 배포 주에 Runner를 임시 증설하려면 해당 글의 「burst」절을 참고하세요.
흔한 반패턴: 전자동 심사 제출을 피해야 할 때
공정화가 「모든 버튼을 로봇에 맡기기」는 아닙니다. 아래에서는 무리하게 전자동 submit_for_review하면 손해가 되기 쉽습니다:
- 스토어 문구가 아직 Google Doc에서 수정 중——Connect와 문서 불일치는 Metadata Rejected로 직결
- 백엔드 API와 App 버전이 강결합——서버 단계적 배포가 준비된 뒤 심사 제출
- ATT·결제·HealthKit 최초 도입——심사 Q&A와 데모 영상은 프로덕트 현장 확인 필요
- 공유 호스팅 macOS runner에서 서명——대기보다 환경 드리프트가 더 어렵습니다. 신청 체인은 전용 Mac으로
더 안전한 기본값: TestFlight까지 전자동, App Review는 반자동. 내부 테스트 검증 완료 + PM이 Environment에서 승인한 뒤 submit-review Job 실행.
자주 묻는 질문 FAQ
App Store 신청에 로컬 Mac이 꼭 필요한가요?
필수는 아닙니다. 일상 코딩은 Windows / Linux로 가능하지만 Archive, 서명, TestFlight 업로드는 macOS가 필요합니다. 로컬 Mac이 없는 팀은 SSH / VNC로 Cloud Mac이나 self-hosted 빌드 섬에 연결해 전체 신청 체인을 완료할 수 있습니다.
TestFlight와 App Store 심사 제출을 같은 CI 파이프라인에 넣을 수 있나요?
빌드와 서명 단계는 공유할 수 있지만 Job은 두 개로 나누세요. 먼저 upload_to_testflight로 내부 테스트용 빌드를 배포하고, 심사 제출 Job은 수동 또는 승인 게이트로 트리거하며 메타데이터, 스크린샷, 프라이버시 manifest 준비를 별도로 검증합니다.
fastlane과 네이티브 xcodebuild 중 어떻게 선택하나요?
xcodebuild가 컴파일과 Archive를 담당하고, fastlane이 서명, 업로드, 메타데이터, deliver 심사 제출을 감쌉니다. 소규모 팀은 fastlane 일원화도 가능합니다. 대규모 저장소는 CI에서 xcodebuild를 명시 호출하고 fastlane은 release 계층만 담당하는 것이 정석입니다.
CI에는 ASC API Key와 p12 인증서 중 무엇이 더 적합한가요?
바이너리 업로드와 심사 상태 확인은 ASC API Key(.p8)를 우선합니다. 키체인 임포트가 필요 없습니다. codesign에는 여전히 Distribution 인증서와 프로비저닝 프로파일이 필요합니다. 일반적으로 API Key는 Connect 측, p12는 서명 측으로 나눠 Secret에 보관합니다.
심사 거절 후 빠르게 재빌드하려면?
거절된 build의 git tag와 dSYM을 보관합니다. 수정 후 build number를 bump하고 동일 release 파이프라인으로 upload_to_testflight를 실행합니다. 검증 후 submit_for_review를 다시 실행하세요. build number 단조 증가와 메타데이터 버전 일치가 핵심입니다.
Export Compliance와 프라이버시 manifest는 반드시 수동 입력해야 하나요?
최초에는 App Store Connect 웹에서 입력해 템플릿화할 수 있습니다. CI에서는 fastlane deliver 또는 App Store Connect API로 승인된 답변을 쓸 수 있습니다. 암호화 수출 컴플라이언스에서 앱이 HTTPS만 사용한다면 대부분 팀이 표준 면제를 선택해 Info.plist에 기록합니다.
Archive부터 심사 통과까지 보통 얼마나 걸리나요?
빌드 10–40분(프로젝트 규모에 따라). 업로드와 처리 15–60분. 심사는 24–48시간이 일반적이며 연휴에는 더 깁니다. 공정화 목표는 「Apple을 기다리는」 시간 외 구간을 압축하고 예측 가능하게 만드는 것입니다.
원격 Cloud Mac이 신청 노드로 적합한가요?
적합합니다. 전용 물리 Mac, 고정 Xcode 버전, 영구 키체인과 DerivedData는 공유 호스팅 runner보다 서명 안정성과 배포 deadline에 유리합니다. 최초 TCC는 VNC, 이후 SSH 헤드리스 실행이 가능합니다.
정리
App Store 신청을 CI/CD 관점에서 보면 Xcode 안의 일회성 Archive가 아니라 6단계 Release 체인입니다. 서명, 빌드, TestFlight, 심사 제출, 배포를 나눠 관리해야 실패를 「또 신청에 실패했다」가 아니라 구체 단계로 특정할 수 있습니다.
- 사전 검증과 TestFlight는 가능한 한 전자동. 심사 제출에는 승인 게이트
- 서명 자격 증명 3종 분리 보관. 병렬 빌드 시 키체인과 Profile 격리
- build number를 git tag와 연결. dSYM과 archive는 반드시 보관
- 전용 Mac 빌드 섬이 ③④, Linux가 ① 담당
- 거절 시 동일 파이프라인으로 bump build. 웹만 고치고 패키지를 내지 않기
다음 단계: 기존 저장소에 TestFlight만 업로드하는 Workflow를 하나 추가하고, 통과하면 environment: app-store-review 심사 제출 Job을 더하세요. 빌드 노드는 일 단위 Cloud Mac으로 일주일 검증한 뒤 상주 Mac mini 필요 여부를 판단하는 것이 현실적입니다.