iOSGitHub ActionsTestFlightXcodeCI/CDCode Signing

GitHub Actions로 iOS 앱 빌드·서명 후 TestFlight 자동 업로드하기 (fastlane 없이)

Sloth255
Sloth255
·3 min read·520 words

시작하며

iOS 앱 릴리스 작업, 매번 로컬 Xcode에서 Archive → Distribute... 를 수동으로 진행하고 있지 않으신가요?
이 작업은 시간이 많이 걸릴 뿐만 아니라 빌드 환경이 특정 개인에게 종속되기 쉬워, "내 Mac에서는 빌드되는데 다른 Mac에서는 실패한다"는 문제의 원인이 됩니다.

이 글에서는 GitHub Actions를 사용해 iOS 앱을 빌드·서명하고 TestFlight에 자동 업로드하는 전체 파이프라인 구축 방법을 설명합니다.

많은 튜토리얼에서 fastlane을 사용한 구성을 소개하지만, 이 글에서는 fastlane 없이 Apple 순정 xcodebuildxcrun altool만으로 완결되는 구성을 채택합니다. Ruby 환경을 도입할 필요가 없어 워크플로가 단순해지고, Xcode 업데이트 시 fastlane 호환성 문제에서도 해방됩니다.

인증 방식은 Apple ID + 비밀번호 대신, 더 안전하고 2FA의 영향을 받지 않는 App Store Connect API 키를 사용합니다.

사전 조건

  • Apple Developer Program 등록 완료 (유료)
  • App Store Connect에 앱 레코드 생성 완료
  • GitHub 리포지토리에 iOS 프로젝트 push 완료
  • macOS / Xcode로 로컬 빌드가 통과됨을 확인

전체 흐름

구축할 파이프라인의 전체 개요는 다음과 같습니다.

  1. main 브랜치 push 또는 수동 트리거로 워크플로 시작
  2. GitHub Actions의 macOS runner에서 Xcode 준비
  3. 인증서와 프로비저닝 프로파일을 임시로 키체인에 임포트
  4. 빌드 번호 업데이트
  5. xcodebuild archive.xcarchive 생성
  6. xcodebuild -exportArchive.ipa로 내보내기
  7. xcrun altool로 TestFlight에 업로드
  8. 임시 키체인 삭제 및 정리
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 키를 사용합니다.

  1. App Store Connect에 로그인
  2. "사용자 및 접근 권한" → "Integrations" → "App Store Connect API" 선택
  3. "+" 버튼으로 키 생성 (역할은 App Manager 이상)
  4. 다운로드한 AuthKey_XXXXXXXXXX.p8 파일 저장 (재다운로드 불가이므로 주의)
  5. 발급 시 표시되는 Key IDIssuer ID 메모

.p8 파일도 동일하게 Base64로 변환합니다.

base64 -i AuthKey_XXXXXXXXXX.p8 | pbcopy

Step 3: GitHub Secrets에 등록하기

리포지토리의 SettingsSecrets and variablesActions에서 다음 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로 커밋해 둡니다 (민감한 정보는 포함되지 않습니다).

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을 만듭니다.

.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할 수 있도록 제한합니다.

trust-policy.json
{
  "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만 부여합니다.

permission-policy.json
{
  "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 토큰을 취득할 수 없습니다.

.github/workflows/ios-testflight.yml
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를 허용합니다.

permission-policy.json
{
  "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로 디코딩해 파일에 씁니다.

.github/workflows/ios-testflight.yml
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 연계 시 발생하는 에러. 다음 순서로 확인합니다.

  1. 파라미터 경로에 오타가 없는지 (/ios/yourapp/...의 계층)
  2. IAM 역할의 권한 정책에서 대상 경로에 ssm:GetParameter가 허용되어 있는지
  3. SecureString을 사용하고 있다면 --with-decryption 플래그를 붙이고 있는지
  4. KMS 키의 kms:Decrypt 권한이 IAM 역할에 부여되어 있는지
  5. 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로의 이전을 검토하는 것이 현실적입니다.

초기 설정의 관문은 인증서 관련 설정으로 압축되므로, 이 글과 "인증서 관리 선택지" 섹션이 그 벽을 넘는 데 도움이 되길 바랍니다.