시작하며
iOS 앱 릴리스 작업, 매번 로컬 Xcode에서 Archive → Distribute... 를 수동으로 진행하고 있지 않으신가요?
이 작업은 시간이 많이 걸릴 뿐만 아니라 빌드 환경이 특정 개인에게 종속되기 쉬워, "내 Mac에서는 빌드되는데 다른 Mac에서는 실패한다"는 문제의 원인이 됩니다.
이 글에서는 GitHub Actions를 사용해 iOS 앱을 빌드·서명하고 TestFlight에 자동 업로드하는 전체 파이프라인 구축 방법을 설명합니다.
많은 튜토리얼에서 fastlane을 사용한 구성을 소개하지만, 이 글에서는 fastlane 없이 Apple 순정 xcodebuild와 xcrun altool만으로 완결되는 구성을 채택합니다. Ruby 환경을 도입할 필요가 없어 워크플로가 단순해지고, Xcode 업데이트 시 fastlane 호환성 문제에서도 해방됩니다.
인증 방식은 Apple ID + 비밀번호 대신, 더 안전하고 2FA의 영향을 받지 않는 App Store Connect API 키를 사용합니다.
사전 조건
- Apple Developer Program 등록 완료 (유료)
- App Store Connect에 앱 레코드 생성 완료
- GitHub 리포지토리에 iOS 프로젝트 push 완료
- macOS / Xcode로 로컬 빌드가 통과됨을 확인
전체 흐름
구축할 파이프라인의 전체 개요는 다음과 같습니다.
main브랜치 push 또는 수동 트리거로 워크플로 시작- GitHub Actions의 macOS runner에서 Xcode 준비
- 인증서와 프로비저닝 프로파일을 임시로 키체인에 임포트
- 빌드 번호 업데이트
xcodebuild archive로.xcarchive생성xcodebuild -exportArchive로.ipa로 내보내기xcrun altool로 TestFlight에 업로드- 임시 키체인 삭제 및 정리
push → Actions 시작 → 인증서 설치 → archive → export → altool → TestFlight
Step 1: 인증서와 프로비저닝 프로파일 준비하기
CI에서 코드 서명을 수행하려면, 다음 두 파일을 Base64 인코딩하여 GitHub Secrets에 저장합니다.
1-1. 배포용 인증서(.p12) 내보내기
로컬의 "키체인 접근.app"을 열고, Apple Distribution 또는 iPhone Distribution 인증서를 우클릭하여 .p12 형식으로 내보냅니다. 내보낼 때 설정한 비밀번호는 나중에 사용하므로 메모해 두세요.
인증서가 없는 경우 Apple Developer 사이트의 Certificates에서 새로 생성하세요.
1-2. 프로비저닝 프로파일(.mobileprovision) 다운로드
Apple Developer 사이트의 Profiles에서 App Store 배포용 프로비저닝 프로파일을 다운로드합니다. 이때 프로파일 이름(예: YourApp AppStore)도 반드시 메모해 두세요. 나중에 ExportOptions.plist에 기재해야 합니다.
1-3. Base64로 인코딩
터미널에서 다음 명령을 실행하여 파일을 Base64 문자열로 변환합니다.
# 인증서
base64 -i Certificates.p12 | pbcopy
# 프로비저닝 프로파일
base64 -i YourApp_AppStore.mobileprovision | pbcopy
pbcopy로 클립보드에 복사되므로, 그대로 GitHub Secrets에 붙여넣을 수 있습니다.
Step 2: App Store Connect API 키 발급하기
이중 인증에 영향 받지 않기 위해 API 키를 사용합니다.
- App Store Connect에 로그인
- "사용자 및 접근 권한" → "Integrations" → "App Store Connect API" 선택
- "+" 버튼으로 키 생성 (역할은 App Manager 이상)
- 다운로드한
AuthKey_XXXXXXXXXX.p8파일 저장 (재다운로드 불가이므로 주의) - 발급 시 표시되는 Key ID와 Issuer ID 메모
.p8 파일도 동일하게 Base64로 변환합니다.
base64 -i AuthKey_XXXXXXXXXX.p8 | pbcopy
Step 3: GitHub Secrets에 등록하기
리포지토리의 Settings → Secrets and variables → Actions에서 다음 Secrets를 등록합니다.
| Secret 이름 | 내용 |
|---|---|
BUILD_CERTIFICATE_BASE64 |
.p12 인증서의 Base64 문자열 |
P12_PASSWORD |
.p12 내보내기 시 설정한 비밀번호 |
BUILD_PROVISION_PROFILE_BASE64 |
.mobileprovision의 Base64 문자열 |
KEYCHAIN_PASSWORD |
임시 키체인용 임의의 비밀번호 |
APP_STORE_CONNECT_API_KEY_ID |
API 키의 Key ID |
APP_STORE_CONNECT_API_ISSUER_ID |
Issuer ID |
APP_STORE_CONNECT_API_KEY_BASE64 |
.p8의 Base64 문자열 |
Step 4: ExportOptions.plist 만들기
.xcarchive에서 .ipa를 생성할 때 필요한 설정 파일입니다. 리포지토리에 ios/ExportOptions.plist로 커밋해 둡니다 (민감한 정보는 포함되지 않습니다).
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store</string>
<key>teamID</key>
<string>ABCD123456</string>
<key>signingStyle</key>
<string>manual</string>
<key>signingCertificate</key>
<string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict>
<key>com.example.yourapp</key>
<string>YourApp AppStore</string>
</dict>
<key>uploadSymbols</key>
<true/>
<key>uploadBitcode</key>
<false/>
</dict>
</plist>
Step 5: GitHub Actions 워크플로 만들기
드디어 핵심입니다. .github/workflows/ios-testflight.yml을 만듭니다.
name: iOS TestFlight Deploy
on:
push:
branches: [main]
workflow_dispatch:
env:
SCHEME: YourApp
WORKSPACE: YourApp.xcworkspace # .xcodeproj의 경우 PROJECT 사용
CONFIGURATION: Release
EXPORT_OPTIONS_PLIST: ios/ExportOptions.plist
jobs:
deploy:
runs-on: macos-14
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_15.4.app
- name: Install the Apple certificate and provisioning profile
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
# Base64 디코드
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH
# 임시 키체인 생성
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# 인증서 임포트
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" \
-A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: \
-k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
# 프로비저닝 프로파일 배치
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles
- name: Set build number
run: |
# GitHub Actions 실행 번호를 빌드 번호로 사용 (유일성 보장)
BUILD_NUMBER=${{ github.run_number }}
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" \
YourApp/Info.plist
echo "Build number set to $BUILD_NUMBER"
- name: Build archive
run: |
xcodebuild archive \
-workspace "$WORKSPACE" \
-scheme "$SCHEME" \
-configuration "$CONFIGURATION" \
-archivePath "$RUNNER_TEMP/YourApp.xcarchive" \
-destination "generic/platform=iOS" \
-allowProvisioningUpdates \
CODE_SIGNING_ALLOWED=YES \
| xcbeautify
- name: Export IPA
run: |
xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/YourApp.xcarchive" \
-exportOptionsPlist "$EXPORT_OPTIONS_PLIST" \
-exportPath "$RUNNER_TEMP/export" \
| xcbeautify
- name: Prepare API key for altool
env:
APP_STORE_CONNECT_API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
run: |
# altool은 ~/.appstoreconnect/private_keys/ 또는 ./private_keys/에서 읽음
mkdir -p ~/.appstoreconnect/private_keys
echo -n "$APP_STORE_CONNECT_API_KEY_BASE64" | base64 --decode \
-o ~/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8
- name: Upload to TestFlight
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
run: |
xcrun altool --upload-app \
--type ios \
--file "$RUNNER_TEMP/export/YourApp.ipa" \
--apiKey "$APP_STORE_CONNECT_API_KEY_ID" \
--apiIssuer "$APP_STORE_CONNECT_API_ISSUER_ID"
- name: Clean up keychain and profiles
if: always()
run: |
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
rm -f ~/Library/MobileDevice/Provisioning\ Profiles/build_pp.mobileprovision
rm -rf ~/.appstoreconnect/private_keys
핵심 포인트 해설
빌드 번호에 github.run_number 사용
fastlane의 increment_build_number 없이도, GitHub Actions가 자동으로 부여하는 실행 번호를 활용합니다. 단조 증가가 보장되어 TestFlight의 "빌드 번호 중복 불가" 요건을 충족합니다. 날짜 기반($(date +%Y%m%d%H%M))도 사용 가능합니다.
xcbeautify로 로그 정리
xcodebuild의 원시 로그는 매우 읽기 어려워 xcbeautify로 파이핑합니다. macos-14 runner에는 기본 설치되어 있습니다. 없다면 brew install xcbeautify 단계를 추가하세요.
API 키 파일 배치 위치
xcrun altool은 다음 경로 중 하나에서 API 키를 자동으로 검색합니다. 경로를 명시하는 플래그가 없으므로 지정된 디렉터리에 배치해야 합니다.
./private_keys/AuthKey_<KEY_ID>.p8~/private_keys/AuthKey_<KEY_ID>.p8~/.private_keys/AuthKey_<KEY_ID>.p8~/.appstoreconnect/private_keys/AuthKey_<KEY_ID>.p8
임시 키체인을 사용하는 이유
CI 환경의 기본 키체인에 인증서를 넣으면 정리가 번거롭고 보안상도 좋지 않습니다. 잡(job)마다 생성하고 삭제하는 것이 베스트 프랙티스입니다.
if: always()로 후처리
빌드가 실패해도 반드시 임시 키체인과 키 파일을 삭제하도록 합니다. self-hosted runner를 사용할 경우 특히 중요합니다.
Step 6: 동작 확인
main 브랜치에 push하거나, GitHub Actions 탭에서 Run workflow로 수동 실행합니다.
5~10분 정도 후 완료되고, App Store Connect의 TestFlight 탭에 새 빌드가 표시됩니다. 처리 중(Processing) 상태에서 완료까지 추가로 10~30분 정도 걸릴 수 있습니다.
인증서 관리 선택지와 권장 구성
여기까지 .p12를 Base64로 인코딩해 GitHub Secrets에 등록하는 방식으로 구축했지만, GitHub Actions에는 다른 인증서 관리 선택지도 있습니다. 팀 규모와 요구사항에 따라 최적의 것을 선택하세요.
선택지 1: Base64로 Secrets에 저장 (본 글의 방식)
구조: .p12와 .mobileprovision을 Base64로 인코딩해 GitHub Secrets에 문자열로 저장.
장점
- 외부 의존성 없이 GitHub만으로 완결
- 초기 설정이 가장 간단
.p12는 보통 수 KB~수십 KB로 Secrets의 64KB 제한에 여유가 있음
단점
- 인증서 교체 시 Secrets 수동 업데이트 필요
- 여러 앱을 다룰 경우 Secrets 수가 늘어나 관리가 복잡해짐
- Azure DevOps의 Secure Files처럼 파일로 다룰 수 없음
적합한 경우: 개인 개발, 1~3명 소규모 팀, 단일 앱
선택지 2: 암호화한 파일을 리포지토리에 커밋
구조: .p12를 GPG나 OpenSSL로 암호화해 리포지토리에 커밋하고, 복호화 패스프레이즈만 Secrets에 저장.
# 로컬에서 암호화
gpg --symmetric --cipher-algo AES256 Certificates.p12
# → Certificates.p12.gpg를 리포지토리에 커밋
워크플로에서:
- name: Decrypt certificate
run: |
gpg --quiet --batch --yes --decrypt \
--passphrase="$CERT_PASSPHRASE" \
--output $RUNNER_TEMP/cert.p12 \
secrets/Certificates.p12.gpg
env:
CERT_PASSPHRASE: ${{ secrets.CERT_PASSPHRASE }}
장점
- 파일로 관리 가능, Git 이력으로 교체 이력 추적 가능
- Secrets 크기 제한에서 해방
- Azure DevOps의 Secure Files에 가장 가까운 감각
단점
- 리포지토리가 유출된 경우 패스프레이즈 강도가 마지막 방어선
.gpg파일이 리포지토리에 노출되는 것에 대한 호불호
적합한 경우: Azure DevOps에서 마이그레이션, 파일로 관리하고 싶은 경우, 프라이빗 리포지토리 운용
선택지 3: fastlane match
구조: 전용 프라이빗 리포지토리(또는 S3/GCS)에 암호화된 인증서를 저장하고, fastlane이 자동으로 취득·설치·복호화.
장점
- 여러 앱·여러 환경(dev/adhoc/appstore)의 인증서를 통합 관리
- 새 팀원이
fastlane match한 번으로 로컬 환경도 구성 완료 - 인증서 자동 재생성 지원
단점
- Ruby와 fastlane 도입 필요
- Xcode 업데이트 시 fastlane 호환성 문제 가능성
- 본 글의 탈 fastlane 취지와 상반됨
적합한 경우: 여러 앱 운용, 5명 이상의 iOS 팀, 인증서를 자주 교체하는 경우
선택지 4: AWS Parameter Store + OIDC 연계
구조: AWS Systems Manager Parameter Store에 .p12의 Base64 문자열과 비밀번호를 SecureString으로 저장하고, GitHub Actions에서 OIDC를 통해 취득. 장기 자격증명이 불필요한 것이 큰 포인트.
Parameter Store와 Secrets Manager 비교
AWS에는 유사한 서비스가 두 가지 있으므로 정리합니다.
| 항목 | Parameter Store | Secrets Manager |
|---|---|---|
| 요금 (표준) | 무료 | $0.40/시크릿/월 + API 요금 |
| 요금 (고급) | $0.05/10,000 API 호출 | 동일 |
| 값 크기 제한 | 표준 4KB / 고급 8KB | 64KB |
| 자동 교체 | 없음 | 있음 |
| KMS 암호화 | SecureString 타입으로 지원 | 기본 지원 |
Base64로 변환한 .p12는 보통 수십 KB~30KB 정도가 되는 경우가 많아, 여기가 서비스 선정의 분기점입니다. 8KB에 맞으면 Parameter Store가 훨씬 저렴하고, 초과하면 Secrets Manager나 아래의 S3 하이브리드 방식이 적합합니다.
파일이 8KB를 초과하는 경우
크기가 너무 큰 경우는, S3에 암호화된 .p12를 두고 Parameter Store에는 S3 키와 복호화 비밀번호만 저장하는 하이브리드 구성이 현실적으로 깔끔합니다. Parameter Store의 단순함과 S3의 크기 자유도를 양립할 수 있습니다.
AWS 측 준비
먼저 IAM에서 GitHub Actions용 OIDC ID 공급자를 생성합니다 (이미 생성했다면 스킵).
- Provider URL:
https://token.actions.githubusercontent.com - Audience:
sts.amazonaws.com
다음으로 Parameter Store에 파라미터를 저장합니다 (AWS CLI 예시).
# 인증서 본체
aws ssm put-parameter \
--name /ios/yourapp/dist-cert-base64 \
--type SecureString \
--value "$(base64 -i Certificates.p12)"
# .p12 비밀번호
aws ssm put-parameter \
--name /ios/yourapp/p12-password \
--type SecureString \
--value "your-p12-password"
# 프로비저닝 프로파일
aws ssm put-parameter \
--name /ios/yourapp/provisioning-profile-base64 \
--type SecureString \
--value "$(base64 -i YourApp_AppStore.mobileprovision)"
# App Store Connect API 키(.p8)
aws ssm put-parameter \
--name /ios/yourapp/asc-api-key-base64 \
--type SecureString \
--value "$(base64 -i AuthKey_XXXXXXXXXX.p8)"
GitHub Actions용 IAM 역할을 생성하고 Trust Policy에서 특정 리포지토리의 특정 브랜치만 Assume Role할 수 있도록 제한합니다.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"
}
}
}
]
}
권한 정책은 최소 권한으로, 대상 파라미터 경로의 GetParameter만 부여합니다.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["ssm:GetParameter", "ssm:GetParameters"],
"Resource": "arn:aws:ssm:ap-northeast-1:123456789012:parameter/ios/yourapp/*"
},
{
"Effect": "Allow",
"Action": "kms:Decrypt",
"Resource": "arn:aws:kms:ap-northeast-1:123456789012:key/your-key-id"
}
]
}
GitHub Actions 측 워크플로
permissions 블록에서 id-token: write를 설정하는 것을 잊지 마세요. 이것 없이는 OIDC 토큰을 취득할 수 없습니다.
permissions:
id-token: write # OIDC 토큰 취득에 필요
contents: read
jobs:
deploy:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-ios-deploy
aws-region: ap-northeast-1
- name: Fetch secrets from Parameter Store
env:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
# Parameter Store에서 취득 (SecureString은 --with-decryption 필요)
aws ssm get-parameter \
--name /ios/yourapp/dist-cert-base64 \
--with-decryption \
--query Parameter.Value --output text | base64 --decode > $CERTIFICATE_PATH
aws ssm get-parameter \
--name /ios/yourapp/provisioning-profile-base64 \
--with-decryption \
--query Parameter.Value --output text | base64 --decode > $PP_PATH
P12_PASSWORD=$(aws ssm get-parameter \
--name /ios/yourapp/p12-password \
--with-decryption \
--query Parameter.Value --output text)
# 키체인 생성 및 인증서 임포트
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" \
-A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: \
-k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles
# 이후의 archive / export / altool은 Step 5와 동일
장점
- 비용이 거의 없음: 표준 파라미터는 완전 무료, 고급도 매우 저렴
- OIDC 연계로 장기 자격증명 불필요: 액세스 키를 GitHub에 둘 필요 없음 (최대 장점)
- CloudTrail로 감사 로그: 누가 언제 인증서를 취득했는지 모두 기록됨
- IAM/KMS에 의한 세밀한 권한 제어: 개발 환경용과 프로덕션용 역할을 분리하는 등 유연한 운용 가능
- 여러 CI/CD에서 공유 가능: GitHub Actions 이외(CodeBuild, Jenkins, 로컬 개발)에서도 동일 인증서 참조 가능
- 버전 이력: 과거 버전으로 되돌릴 수 있음
단점
- AWS 계정 필수 (이미 사용하지 않는 팀에게는 초기 구축 비용이 큼)
.p12크기가 8KB를 초과하는 경우 대책 필요 (S3 병용 또는 Secrets Manager)- OIDC 설정 학습 비용이 다소 있음 (최초 한 번만)
적합한 경우: 이미 AWS를 사용 중인 경우, 감사 로그가 요건인 경우, 여러 CI/CD 도구에서 동일 인증서를 참조하고 싶은 경우, 저비용으로 엔터프라이즈 수준의 관리를 원하는 경우
선택지 5: AWS Secrets Manager + OIDC 연계
구조: AWS Secrets Manager에 .p12를 바이너리(SecretBinary)로 직접 저장하고, GitHub Actions에서 OIDC를 통해 취득합니다. Parameter Store와 달리, Base64 변환을 의식하지 않고 파일로 다룰 수 있는 것이 큰 특징입니다.
Parameter Store와의 차이
| 항목 | Parameter Store | Secrets Manager |
|---|---|---|
| 값의 저장 형식 | 문자열만 (바이너리는 Base64 필수) | 바이너리 직접 OK (SecretBinary 타입) |
| 크기 제한 | 4KB (표준) / 8KB (고급) | 64KB |
| 요금 | 무료 ~ 매우 저렴 | $0.40/월/시크릿 + API 요금 |
| 자동 교체 | 없음 | 있음 (Lambda 연계) |
| 용도 방향성 | 설정값, 작은 비밀 | 큰 비밀, 본격 운용 |
.p12가 8KB를 초과하는 경우나 파일로서의 다루기 쉬움을 중시한다면 Secrets Manager가 더 자연스럽습니다. 인증서 1~2개라면 월 1달러 미만으로 비용도 크게 신경 쓰이지 않습니다.
AWS 측 준비
OIDC ID 공급자 설정은 선택지 4와 공통이므로 생략합니다.
fileb:// 프리픽스를 사용해 바이너리로 등록합니다. 이를 통해 AWS CLI가 파일을 그대로 바이너리로 전송합니다.
# 인증서 본체 (바이너리 그대로 등록 가능)
aws secretsmanager create-secret \
--name ios/yourapp/dist-cert \
--secret-binary fileb://Certificates.p12
# 프로비저닝 프로파일도 바이너리로
aws secretsmanager create-secret \
--name ios/yourapp/provisioning-profile \
--secret-binary fileb://YourApp_AppStore.mobileprovision
# App Store Connect API 키(.p8)도 바이너리로
aws secretsmanager create-secret \
--name ios/yourapp/asc-api-key \
--secret-binary fileb://AuthKey_XXXXXXXXXX.p8
# 비밀번호 류는 문자열로
aws secretsmanager create-secret \
--name ios/yourapp/p12-password \
--secret-string "your-p12-password"
업데이트 시에는 update-secret을 사용합니다.
aws secretsmanager update-secret \
--secret-id ios/yourapp/dist-cert \
--secret-binary fileb://Certificates_new.p12
IAM 역할의 권한 정책은 Parameter Store 대신 Secrets Manager를 허용합니다.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue"],
"Resource": "arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:ios/yourapp/*"
},
{
"Effect": "Allow",
"Action": "kms:Decrypt",
"Resource": "arn:aws:kms:ap-northeast-1:123456789012:key/your-key-id"
}
]
}
GitHub Actions 측 워크플로
SecretBinary로 취득한 값은 API 응답에서 Base64로 인코딩되어 반환되므로, base64 --decode로 디코딩해 파일에 씁니다.
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-ios-deploy
aws-region: ap-northeast-1
- name: Fetch certificates from Secrets Manager
env:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
# SecretBinary는 Base64로 반환되므로 디코딩해서 파일로 저장
aws secretsmanager get-secret-value \
--secret-id ios/yourapp/dist-cert \
--query SecretBinary --output text | base64 --decode > $CERTIFICATE_PATH
aws secretsmanager get-secret-value \
--secret-id ios/yourapp/provisioning-profile \
--query SecretBinary --output text | base64 --decode > $PP_PATH
# SecretString은 그대로 취득 가능
P12_PASSWORD=$(aws secretsmanager get-secret-value \
--secret-id ios/yourapp/p12-password \
--query SecretString --output text)
# API 키(.p8)를 altool이 요구하는 경로에 배치
API_KEY_ID="${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}"
mkdir -p ~/.appstoreconnect/private_keys
aws secretsmanager get-secret-value \
--secret-id ios/yourapp/asc-api-key \
--query SecretBinary --output text | base64 --decode \
> ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8
# 키체인 생성 및 인증서 임포트
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" \
-A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: \
-k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles
# 이후의 archive / export / altool은 Step 5와 동일
장점
.p12를 파일로 그대로 등록·취득 가능 (Azure DevOps의 Secure Files에 가장 가까운 감각)- 64KB까지 지원: 크기 제한을 신경 쓰지 않고 이용 가능
- OIDC 연계로 장기 자격증명 불필요: 선택지 4와 동일한 안전성
- CloudTrail로 감사 로그 표준으로 취득 가능
- 자동 교체: Lambda 함수를 활용하면 인증서 자동 갱신도 가능 (단, Apple 측 API 연계도 필요해 구축 비용이 높음)
- IAM에 의한 세밀한 권한 제어
단점
- 비용 발생: $0.40/시크릿/월 + API 요금. 인증서 3~5개로 월 수 달러 정도
- AWS 계정 필수
- OIDC 설정 학습 비용 (선택지 4와 공통)
적합한 경우: 파일로서의 다루기 쉬움을 중시, .p12가 큰 경우, 여러 앱에서 인증서 파일을 다수 관리, 자동 교체를 장래적으로 고려, Azure DevOps의 Secure Files에 상당하는 운용감을 원하는 경우
선택지 6: 기타 클라우드 시크릿 관리 서비스
AWS 이외를 사용하는 팀을 위한 선택지로, 이하도 동일한 개념으로 이용 가능합니다. 모두 OIDC 연계 가능합니다.
| 서비스 | 요금 참고 | 크기 제한 | 바이너리 대응 |
|---|---|---|---|
| Azure Key Vault | $0.03/10,000 ops | 25KB | ○ (Certificate 타입) |
| Google Secret Manager | $0.06/월/시크릿 | 64KB | ○ |
| HashiCorp Vault (OSS) | 서버 비용만 | 제한 없음 | ○ |
특히 Azure Key Vault는 Certificate 타입이 준비되어 있어, .p12 형식의 직접 임포트/익스포트에 공식 대응합니다. Azure 계열을 사용하고 있다면 궁합이 좋습니다.
선택지 7: GitHub Environments에 의한 추가 보호
위 선택지 중 어느 것과 조합해서 사용할 수 있는 옵션입니다. GitHub Environments를 설정하여 특정 브랜치에서의 배포 시에만 인증서에 접근 가능하게 하거나, 수동 승인 단계를 추가할 수 있습니다.
jobs:
deploy:
runs-on: macos-14
environment: production # ← Environment에 연결된 Secrets를 사용 가능
Environment 단위로 Secrets를 분리할 수 있으므로, 프로덕션 배포용 인증서를 실수로 개발 빌드에 사용하는 사고를 방지할 수 있습니다.
규모별 권장 구성
| 규모 | 권장 구성 |
|---|---|
| 개인 개발·취미 앱 | 선택지 1 (Base64 + Secrets) 단순함이 정의 |
| 스타트업·소규모 팀 (1~3명) | 선택지 1 또는 선택지 2 (암호화 파일) |
| 중규모 팀 (여러 앱 또는 5명 이상) | 선택지 3 (fastlane match) 또는 선택지 5 (Secrets Manager) |
| AWS 업무 이용, 비용 중시 | 선택지 4 (Parameter Store + OIDC) |
| AWS 업무 이용, 운용 중시 | 선택지 5 (Secrets Manager + OIDC) 파일로 다룰 수 있음 |
| 엔터프라이즈·감사 요건 있음 | 선택지 4 또는 5 + 선택지 7 (Environments) 조합 |
| Azure DevOps에서 마이그레이션 | 선택지 2 (암호화 파일) 또는 선택지 5 (Secrets Manager) Secure Files에 가장 가까움 |
자주 발생하는 문제와 대처
No signing certificate "iOS Distribution" found
키체인으로의 인증서 임포트 실패 또는 set-key-partition-list 누락으로 발생하기 쉬운 에러입니다. 단계에 security find-identity -v -p codesigning $KEYCHAIN_PATH를 추가해 인증서가 보이는지 확인하면 문제를 특정하기 쉽습니다.
error: exportArchive: "YourApp.app" requires a provisioning profile
프로비저닝 프로파일의 배치 누락 또는 ExportOptions.plist의 provisioningProfiles에서 Bundle ID와 프로파일 이름이 일치하지 않는 경우입니다. 프로파일 이름은 "Apple Developer 사이트에 표시되는 이름"이며 파일명이 아닌 점에 주의하세요.
altool: Invalid API key
.p8 파일의 Base64 인코딩 시 줄 바꿈이나 불필요한 공백이 섞여 있는 경우가 많습니다. base64 -i file.p8 | pbcopy로 생성해 그대로 붙여넣는 것이 확실합니다. 또한 파일명이 AuthKey_<KEY_ID>.p8이라는 명명 규칙을 지키지 않으면 altool이 인식하지 못하므로, 환경 변수를 통해 정확하게 조합하세요.
xcodebuild: error: The operation couldn't be completed. No such file or directory
.xcworkspace를 사용하면서 -project를 지정하고 있는 (또는 그 반대) 경우입니다. CocoaPods나 Swift Package Manager의 워크스페이스를 사용하고 있다면 -workspace를 지정하세요.
Parameter Store: ParameterNotFound 또는 AccessDenied
AWS 연계 시 발생하는 에러. 다음 순서로 확인합니다.
- 파라미터 경로에 오타가 없는지 (
/ios/yourapp/...의 계층) - IAM 역할의 권한 정책에서 대상 경로에
ssm:GetParameter가 허용되어 있는지 - SecureString을 사용하고 있다면
--with-decryption플래그를 붙이고 있는지 - KMS 키의
kms:Decrypt권한이 IAM 역할에 부여되어 있는지 - Trust Policy의
sub조건이 워크플로 실행 브랜치와 일치하는지
더 개선하려면
이번에는 최소 구성으로 파이프라인을 구성했지만, 다음과 같은 확장을 고려할 수 있습니다.
- Slack 알림:
slackapi/slack-github-action을 도입해 업로드 완료를 자동 알림. - 릴리스 노트 자동 생성:
git log에서 변경점을 추출해 TestFlight의 "What to Test" 항목에 자동 입력 (altool 단독으로는 어려워 App Store Connect API를 직접 호출해야 함). - 태그 push 트리거화:
main으로의 push 대신v*태그 push로 발동시키면 보다 의도적인 릴리스 플로가 됩니다. - 유닛 테스트 선행 실행: archive 전에
xcodebuild test를 실행해 테스트 실패 시 배포를 중단.
마치며
fastlane을 사용하지 않는 구성은 워크플로가 직관적이고 이해하기 쉬우며, Ruby 환경이 불필요하고, Xcode 업데이트 시 fastlane 호환성 문제에도 영향받지 않는다는 장점이 있습니다. 대신 빌드 번호 관리 등은 직접 구성해야 하며, 여러 앱을 운용하는 규모가 되면 관리 비용이 증가하므로 그때는 fastlane match나 AWS Secrets Manager / Parameter Store로의 이전을 검토하는 것이 현실적입니다.
초기 설정의 관문은 인증서 관련 설정으로 압축되므로, 이 글과 "인증서 관리 선택지" 섹션이 그 벽을 넘는 데 도움이 되길 바랍니다.
