Skip to main content
mobile2026년 3월 1일8분 소요

iOS 배포 자동화: App Store Connect 및 TestFlight

Fastlane 설정부터 TestFlight 배포 및 App Store 제출까지, iOS 앱 배포 자동화에 대한 완벽 가이드입니다.

iosfastlanecicd
iOS 배포 자동화: App Store Connect 및 TestFlight

모든 iOS 개발자는 이 의식을 겪어봤을 것입니다. Xcode를 열고, 빌드 번호를 올리고, 아카이브 스키마를 선택하고, 15분 동안 기다리고, Organizer를 클릭하고, App Store Connect에 업로드하고, 또 기다린 다음, TestFlight에서 테스터를 수동으로 추가하는 과정 말이죠. 여러 앱에 걸쳐 일주일에 두 번 이 작업을 반복하면 코드를 작성하는 데 써야 할 시간을 낭비하게 됩니다. 가장 나쁜 점은 시간 낭비가 아니라 일관성 부족입니다. 한 단계를 잊거나 잘못된 프로비저닝 프로파일을 사용하면 빌드가 조용히 실패하거나 몇 시간 후에 Apple의 자동화된 검사에 의해 거부될 수 있습니다.

저는 Flutter 기반 iOS 앱의 전체 파이프라인을 자동화했으며, 그 이후로 다른 프로젝트에도 동일한 패턴을 적용했습니다. 이 투자는 세 번째 또는 네 번째 릴리스 주기 후에 그 가치를 증명합니다.

수동 배포가 실패하는 이유

수동 프로세스는 명백한 시간 낭비 외에도 세 가지 근본적인 문제를 가지고 있습니다.

첫째, 지식 집중. 팀에서 한 사람만이 배포 방법을 알면 그 사람이 병목 현상이 됩니다. 휴가, 병가 또는 다른 시간대에 있다는 것은 릴리스가 지연된다는 것을 의미합니다. 문서화가 도움이 되지만, 15단계 배포 가이드 자체는 누군가 빌드 설정을 변경하는 순간 구식이 되어버리는 부담이 됩니다.

둘째, 최악의 시기에 발생하는 인적 오류. 릴리스는 압박감 속에서 이루어지는 경향이 있습니다. 치명적인 버그 수정, 클라이언트의 마감 기한, 경쟁사보다 먼저 출시해야 하는 기능 등. 이러한 상황에서 누군가 빌드 번호를 올리는 것을 잊거나, 잘못된 스키마를 선택하거나, 릴리스 빌드 대신 디버그 빌드를 업로드하는 실수를 저지르기 쉽습니다.

셋째, 감사 가능성 부족. 릴리스에 문제가 발생하면 어떤 커밋에서 어떤 구성으로 무엇이 빌드되었는지 정확히 알고 싶을 것입니다. 수동 프로세스는 신뢰할 수 있는 기록을 남기지 않습니다.

Fastlane: 기반

Fastlane은 iOS 배포 자동화의 사실상 표준이며, 그럴 만한 이유가 있습니다. 배포 체크리스트처럼 읽히는 Ruby 기반 DSL을 통해 코드 서명, 빌드, 업로드 및 메타데이터 관리를 처리합니다.

초기 설정

기존 프로젝트에 Fastlane을 설정하는 것은 간단합니다.

# Install Fastlane
brew install fastlane

# Initialize in your project directory (for Flutter, run in ios/)
cd ios
fastlane init

초기화하는 동안 Fastlane은 무엇을 자동화하고 싶은지 묻습니다. 수동 설정을 선택하고 모든 것을 명시적으로 구성하는 것을 권장합니다. 자동화된 설정은 프로젝트 구조와 항상 일치하지 않는 가정을 하기 때문이며, 특히 Flutter 앱의 경우 더욱 그렇습니다.

그 결과 AppfileFastfile을 포함하는 fastlane/ 디렉토리가 생성됩니다.

# fastlane/Appfile
app_identifier("com.yourcompany.yourapp")
apple_id("your@email.com")
itc_team_id("123456789")
team_id("ABCD1234EF")

Appfile은 Apple Developer 계정 정보를 저장합니다. Apple ID가 여러 팀에 속해 있는 경우 팀 ID가 중요합니다. 팀 ID가 없으면 Fastlane이 대화형으로 프롬프트를 표시하여 CI를 중단시킬 수 있습니다.

Match를 사용한 코드 서명

코드 서명은 대부분의 iOS 자동화 노력이 좌절되는 지점입니다. 프로비저닝 프로파일, 인증서, 권한 — 전체 시스템이 자동화를 방해하도록 설계된 것처럼 느껴집니다. Fastlane의 match 도구는 인증서와 프로파일을 비공개 Git 저장소 또는 클라우드 스토리지에 저장하여 빌드가 필요한 모든 머신에서 액세스할 수 있도록 함으로써 이 문제를 해결합니다.

Match 설정

fastlane match init

이렇게 하면 스토리지 백엔드를 구성하는 Matchfile이 생성됩니다.

# fastlane/Matchfile
git_url("https://github.com/your-org/ios-certificates.git")
storage_mode("git")
type("appstore")
app_identifier(["com.yourcompany.yourapp"])

그런 다음 인증서를 생성하고 저장합니다.

# Generate development certificates and profiles
fastlane match development

# Generate App Store distribution certificates and profiles
fastlane match appstore

Match는 저장소에 푸시하기 전에 모든 것을 암호화합니다. 설정 중에 암호 문구를 설정하며, CI를 포함한 모든 머신에서 이 암호 문구가 필요합니다.

수동 서명 대신 Match를 사용하는 이유

대안인 Apple Developer 포털을 통해 인증서를 수동으로 관리하고 .p12 파일을 배포하는 방식은 작동하다가도 작동하지 않을 수 있습니다. 일반적인 실패 모드는 다음과 같습니다.

  • 개발자 인증서가 만료되어 릴리스 빌드가 실패할 때까지 아무도 알아차리지 못합니다.
  • 누군가 서명 문제를 디버깅하는 동안 인증서를 취소하여 해당 인증서에 의존하는 모든 프로파일을 무효화합니다.
  • 새로운 CI 머신에 프로비저닝이 필요하지만, 원래 머신을 설정한 사람이 팀을 떠났습니다.

Match는 단일 진실 공급원(single source of truth)이 됨으로써 이 모든 문제를 제거합니다. 인증서가 만료되면 fastlane match nuke를 실행하고 다시 생성합니다. 모든 머신은 동일한 저장소에서 가져옵니다.

CI 코드 서명

CI에서는 macOS 키체인을 잠금 해제할 GUI가 없으므로 키체인을 처리해야 합니다. Fastlane은 이를 위한 헬퍼를 제공합니다.

lane :ci_setup do
  create_keychain(
    name: "ci_keychain",
    password: ENV["KEYCHAIN_PASSWORD"],
    default_keychain: true,
    unlock: true,
    timeout: 3600,
    lock_when_sleeps: false
  )

  match(
    type: "appstore",
    keychain_name: "ci_keychain",
    keychain_password: ENV["KEYCHAIN_PASSWORD"],
    readonly: true
  )
end

readonly: true 플래그는 CI에 매우 중요합니다. 이 플래그가 없으면 match는 기존 인증서를 찾을 수 없을 경우 새 인증서를 생성하려고 시도할 수 있으며, 이는 대화형 인증 없이 실패하고 기존 인증서를 취소할 수 있습니다.

Gym으로 빌드하기

Gym은 Fastlane의 빌드 도구입니다. xcodebuild를 합리적인 기본값과 더 나은 오류 출력으로 래핑합니다.

lane :build do
  gym(
    scheme: "Runner",
    workspace: "Runner.xcworkspace",
    export_method: "app-store",
    output_directory: "./build",
    output_name: "YourApp.ipa",
    clean: true,
    include_bitcode: false,
    export_options: {
      provisioningProfiles: {
        "com.yourcompany.yourapp" => "match AppStore com.yourcompany.yourapp"
      }
    }
  )
end

Flutter 프로젝트의 경우 두 가지 옵션이 있습니다. flutter build ipa를 실행하고 출력을 처리하거나, Flutter가 생성하는 Xcode 워크스페이스에서 gym을 직접 사용하는 것입니다. 저는 서명 및 내보내기 옵션에 대한 더 많은 제어를 제공하기 때문에 gym을 선호하지만, 둘 다 작동합니다.

# Flutter-specific build lane
lane :build_flutter do
  Dir.chdir("..") do
    sh("flutter build ios --release --no-codesign")
  end

  gym(
    scheme: "Runner",
    workspace: "Runner.xcworkspace",
    export_method: "app-store",
    clean: false,  # Flutter already built, no need to clean
    include_bitcode: false
  )
end

Flutter 빌드 단계의 --no-codesign 플래그는 의도적인 것입니다. Xcode의 자동 서명에 의존하는 대신 gym이 match로 관리되는 프로파일을 사용하여 서명을 처리하도록 합니다.

Pilot으로 TestFlight에 업로드하기

Pilot은 TestFlight 업로드 및 베타 테스터 관리를 처리합니다.

lane :beta do
  ci_setup
  build_flutter

  pilot(
    skip_waiting_for_build_processing: true,
    apple_id: "1234567890",
    distribute_external: false,
    notify_external_testers: false
  )
end

skip_waiting_for_build_processing 플래그는 CI에 매우 중요합니다. 이 플래그가 없으면 Fastlane은 Apple이 빌드 처리를 마칠 때까지 App Store Connect를 계속 폴링합니다. 이 과정은 15분에서 45분까지 걸릴 수 있습니다. 이 시간 동안 CI 러너는 유휴 상태로 컴퓨팅 크레딧을 소모하게 됩니다. 업로드하고 처리가 비동기적으로 이루어지도록 하는 것이 좋습니다.

TestFlight 테스터 관리

테스터 그룹 관리도 자동화할 수 있습니다.

lane :distribute_to_testers do
  pilot(
    distribute_external: true,
    groups: ["External Beta Testers"],
    changelog: "Bug fixes and performance improvements",
    notify_external_testers: true
  )
end

외부 테스터의 경우, Apple은 외부 그룹에 처음 전송되는 빌드와 새로운 외부 테스트 그룹을 추가할 때마다 베타 앱 심사를 요구한다는 점을 기억하십시오. 이는 자동화할 수 없는 수동 게이트이므로, 첫 외부 배포 시 24-48시간의 지연을 계획해야 합니다.

Deliver로 App Store 제출

앱이 프로덕션 준비가 되면 deliver가 App Store 제출을 처리합니다.

lane :release do
  ci_setup
  build_flutter

  deliver(
    submit_for_review: true,
    automatic_release: false,
    force: true,  # Skip HTML preview verification
    submission_information: {
      add_id_info_uses_idfa: false
    },
    precheck_include_in_app_purchases: false
  )
end

Deliver는 또한 App Store 메타데이터(스크린샷, 설명, 키워드, 릴리스 노트)를 관리할 수 있으며, 이 모든 것은 저장소에 파일로 저장됩니다.

fastlane/metadata/en-US/
├── description.txt
├── keywords.txt
├── name.txt
├── release_notes.txt
├── subtitle.txt
└── privacy_url.txt

fastlane/screenshots/en-US/
├── iPhone 6.5" Display/
│   ├── 01_home.png
│   ├── 02_detail.png
│   └── 03_settings.png
└── iPad Pro 12.9" Display/
    ├── 01_home.png
    └── 02_detail.png

이것은 가장 저평가된 Fastlane 기능 중 하나입니다. App Store 목록을 버전 제어 시스템에 두면 풀 리퀘스트에서 메타데이터 변경 사항을 검토하고, 설명이 언제 업데이트되었는지 추적하며, 카피라이터가 실수를 했을 경우 롤백할 수 있습니다.

버전 범핑 전략

버전 관리는 놀랍도록 논쟁의 여지가 많습니다. 제가 정착한 접근 방식은 마케팅 버전에 대한 시맨틱 버전 관리와 자동 증가 빌드 번호를 조합하여 사용합니다.

lane :bump_patch do
  increment_version_number(bump_type: "patch")
  increment_build_number
  commit_version_bump(message: "Bump version to #{get_version_number} (#{get_build_number})")
end

lane :bump_minor do
  increment_version_number(bump_type: "minor")
  increment_build_number(build_number: 1)
  commit_version_bump(message: "Bump version to #{get_version_number} (#{get_build_number})")
end

특히 빌드 번호의 경우, 단순히 증가하는 정수 대신 CI 빌드 번호 또는 타임스탬프를 사용하는 것을 권장합니다. 이렇게 하면 여러 브랜치가 동시에 빌드될 때 충돌을 피할 수 있습니다.

lane :set_ci_build_number do
  build_number = ENV["GITHUB_RUN_NUMBER"] || Time.now.strftime("%Y%m%d%H%M")
  increment_build_number(build_number: build_number)
end

Flutter 프로젝트의 경우 pubspec.yaml을 Xcode 프로젝트와 동기화해야 합니다.

lane :sync_flutter_version do
  version = get_version_number
  build = get_build_number
  Dir.chdir("..") do
    sh("sed -i '' 's/^version: .*/version: #{version}+#{build}/' pubspec.yaml")
  end
end

완전한 Fastfile

다음은 모든 것을 결합한 프로덕션 Fastfile입니다.

default_platform(:ios)

platform :ios do
  before_all do
    setup_ci if ENV["CI"]
  end

  desc "Setup CI environment"
  lane :ci_setup do
    create_keychain(
      name: "ci_keychain",
      password: ENV["KEYCHAIN_PASSWORD"],
      default_keychain: true,
      unlock: true,
      timeout: 3600,
      lock_when_sleeps: false
    )

    match(
      type: "appstore",
      keychain_name: "ci_keychain",
      keychain_password: ENV["KEYCHAIN_PASSWORD"],
      readonly: true
    )
  end

  desc "Build the Flutter iOS app"
  lane :build_flutter do
    Dir.chdir("..") do
      sh("flutter build ios --release --no-codesign")
    end

    gym(
      scheme: "Runner",
      workspace: "Runner.xcworkspace",
      export_method: "app-store",
      output_directory: "./build",
      clean: false,
      include_bitcode: false
    )
  end

  desc "Deploy to TestFlight"
  lane :beta do
    ci_setup if ENV["CI"]
    set_ci_build_number
    build_flutter

    pilot(
      skip_waiting_for_build_processing: true,
      distribute_external: false
    )

    slack(
      message: "New TestFlight build uploaded!",
      slack_url: ENV["SLACK_WEBHOOK"]
    ) if ENV["SLACK_WEBHOOK"]
  end

  desc "Deploy to App Store"
  lane :release do
    ci_setup if ENV["CI"]
    set_ci_build_number
    build_flutter

    deliver(
      submit_for_review: true,
      automatic_release: false,
      force: true,
      precheck_include_in_app_purchases: false
    )
  end

  desc "Set build number from CI"
  private_lane :set_ci_build_number do
    build_number = ENV["GITHUB_RUN_NUMBER"] || Time.now.strftime("%Y%m%d%H%M")
    increment_build_number(build_number: build_number)
  end
end

GitHub Actions 통합

CI 워크플로우는 모든 것을 하나로 묶습니다. 다음은 TestFlight 배포를 위한 완전한 GitHub Actions 워크플로우입니다.

name: Deploy to TestFlight

on:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  deploy:
    runs-on: macos-14
    timeout-minutes: 45

    steps:
      - uses: actions/checkout@v4

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.27.x'
          channel: stable
          cache: true

      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2'
          bundler-cache: true
          working-directory: ios

      - name: Flutter dependencies
        run: flutter pub get

      - name: Deploy to TestFlight
        working-directory: ios
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.ASC_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY: ${{ secrets.ASC_API_KEY }}
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_AUTH }}
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
        run: bundle exec fastlane beta

App Store Connect API 키

Apple ID 자격 증명 대신 API 키를 사용하고 있다는 점에 주목하십시오. 이는 CI에 중요합니다. Apple ID 인증은 비대화형 환경에서는 작동하지 않는 2단계 인증을 요구합니다. API 키는 App Store Connect의 '사용자 및 접근 권한'에서 생성되며, 취소하지 않는 한 만료되지 않습니다.

# In your Appfile or Fastfile
app_store_connect_api_key(
  key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"],
  issuer_id: ENV["APP_STORE_CONNECT_API_ISSUER_ID"],
  key_content: ENV["APP_STORE_CONNECT_API_KEY"],
  is_key_content_base64: true,
  in_house: false
)

.p8 키 파일 내용을 GitHub Actions에 base64로 인코딩된 비밀로 저장하십시오. 이렇게 하면 파일 경로 문제를 피하고 키를 저장소 외부에 유지할 수 있습니다.

일반적인 문제점 및 해결 방법

프로비저닝 프로파일 불일치

가장 흔한 빌드 실패는 프로비저닝 프로파일과 앱의 권한(entitlements) 간의 불일치입니다. 증상으로는 "일치하는 프로비저닝 프로파일 없음" 또는 "코드 서명 권한이 유효하지 않음"과 같은 오류가 있습니다.

해결책은 거의 항상 match 프로파일을 다시 생성하는 것입니다.

fastlane match nuke appstore
fastlane match appstore

그런 다음 Xcode 프로젝트의 번들 식별자가 MatchfileAppfile에 있는 내용과 정확히 일치하는지 확인하십시오.

권한(Entitlements) 문제

앱이 푸시 알림, 연관 도메인, Apple로 로그인 또는 기타 기능을 사용하는 경우, 이들은 Apple Developer 포털과 Runner.entitlements 파일 모두에서 구성되어야 합니다. 둘 사이의 불일치는 오해의 소지가 있는 오류 메시지를 생성하는 서명 실패를 유발합니다.

<!-- ios/Runner/Runner.entitlements -->
<?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>aps-environment</key>
    <string>production</string>
    <key>com.apple.developer.associated-domains</key>
    <array>
        <string>applinks:yourdomain.com</string>
    </array>
</dict>
</plist>

빌드 번호 거부

App Store Connect는 동일한 버전에 대해 이전 업로드보다 빌드 번호가 엄격하게 크지 않은 업로드를 거부합니다. CI 빌드 번호를 사용하는 경우 일반적으로 문제가 되지 않습니다. 그러나 CI를 재설정하거나 CI 제공업체를 변경하면 이 문제에 부딪힐 수 있습니다. 해결책은 app_store_build_number를 사용하여 최신 빌드 번호를 가져와 거기서부터 증가시키는 것입니다.

lane :smart_build_number do
  current = app_store_build_number(live: false)
  increment_build_number(build_number: current + 1)
end

Xcode 버전 불일치

Apple은 오래된 Xcode 버전으로 만든 빌드에 대한 지원을 정기적으로 중단합니다

DU

Danil Ulmashev

Full Stack Developer

함께 일하는 데 관심이 있으신가요?