# [iOS] Xcode 프로젝트 Multi Scheme TestFlight 자동 업로드 스크립트 만들기 안녕하세요 __물먹고하자__ 입니다 :) 요즘에 AI(CLI)들로 재밌게 놀면서 귀찮았던 작업들을 정리하고 있습니다. **더블클릭 한 번으로 TestFlight까지 자동 배포되는 스크립트**를 만든 내용을 공유합니다. --- ## 배경 > 일단 저희팀이 맡고있는 __프로젝트가 3개의 Scheme로 앱(A10, OmniEsol, CSAP) 여러개__로 나뉘어있는데, 메인급은 주기적은 TestFlight로 fastline을 통한 자동업로드가 설정되어있고, 최신 업데이트를 위한 2개의 앱을 한번에 업로드 하는 방향을 준비하고자 합니다. __Xcode는 한 번에 1개 Scheme만 아카이브할 수 있다보니, 매번 반복 작업이 생겨 자동화를 고민하게 되었습니다.__ ## 1. 스크립트 구성 ``` 프로젝트 루트/ ㄴ 프로젝트이름.xcworkspace ㄴ deploy.sh // 실제 배포 로직 ㄴ Deploy_OmniEsol_CSAP.command // 더블클릭으로 실행하는 파일 ``` ### Deploy_OmniEsol_CSAP.command 팀원이 더블클릭하면 터미널이 열리며 자동으로 배포가 시작되는 파일입니다. `deploy.sh` 와 같은 폴더에 위치해야 합니다. ### 실행 권한 부여 (최초 1회) ```bash chmod +x deploy.sh Deploy_OmniEsol_CSAP.command ``` Git에 올릴 때는 실행 권한도 같이 커밋하면 팀원이 따로 할 필요 없습니다. ```bash git update-index --chmod=+x Deploy_OmniEsol_CSAP.command git update-index --chmod=+x deploy.sh ``` ### App Store Connect API Key 발급 `API_KEY_ID`, `API_ISSUER_ID`, `.p8` 파일은 **App Store Connect → 사용자 및 액세스 → 통합 → App Store Connect API** 에서 발급받을 수 있습니다. 발급 후 `.p8` 파일은 아래 경로에 복사해야 `altool` 이 인식합니다. ```bash mkdir -p ~/.appstoreconnect/private_keys cp ./PushKey/AuthKey_XXXXXXXXXX.p8 ~/.appstoreconnect/private_keys/ ``` ### Deploy_OmniEsol_CSAP.command 파일 ``` #!/bin/bash # ================================================================ # Deploy앱배포.command # 이 파일을 더블클릭하면 터미널이 열리며 배포가 시작됩니다. # 📌 deploy.sh 와 같은 폴더에 위치해야 합니다. # ================================================================ # 이 파일이 있는 디렉토리 = 프로젝트 루트로 이동 SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" cd "$SCRIPT_DIR" # deploy.sh 존재 확인 if [ ! -f "./deploy.sh" ]; then echo "❌ deploy.sh 파일을 찾을 수 없습니다." echo " Deploy앱배포.command 와 deploy.sh 를 같은 폴더에 두세요." echo "" read -p "종료하려면 Enter..." exit 1 fi # 실행 권한 자동 부여 chmod +x ./deploy.sh # 배포 실행 ./deploy.sh EXIT_CODE=$? # 완료 후 터미널 유지 (결과 확인용) echo "" if [ $EXIT_CODE -eq 0 ]; then echo "✅ 배포가 완료되었습니다. 창을 닫아도 됩니다." else echo "⚠️ 배포 중 오류가 발생했습니다. 위 로그를 확인해주세요." fi echo "" read -p "종료하려면 Enter..." ``` ### deploy.sh (실제 아카이브 > TestFlight 업로드) __결과물 먼저__ 이렇게 실행해놓고 웹툰보면 됨! 올라가는 시간 로딩 효과도 넣어두고 생각보다 간단하네요. Claude cowork 사용하면 __"앱 배포해줘"__ 라고 했을때 실행되게도 설정가능하다고 하네요. (아직못해봄) --- ## 2. 삽질 기록 (겪었던 이슈들) ### 이슈 1. 멀티 스킴에서 엉뚱한 앱이 빌드되는 문제 `xcodebuild -scheme ERP` 로 아카이브해도 `Amaranth10.app` (기본 스킴 앱) 이 나오는 현상이 있었습니다. 원인은 `ERP.xcscheme` 파일 내부를 보니 `BuildableName` 이 `Amaranth10.app` 으로 되어있었던 것. ```bash cat KLAGO.xcodeproj/xcshareddata/xcschemes/ERP.xcscheme | grep "BuildableReference" -A5 # 결과 BuildableName = "Amaranth10.app" BlueprintName = "KLAGO" ``` 알고보니 ERP 스킴은 **타겟은 KLAGO(Amaranth10)를 바라보되, Configuration만 ERP** 로 설정된 구조였습니다. 즉 `Amaranth10.app` 이 나오는 게 정상이었고, 내부 bundle ID 가 `com.XXXX.XXXXX.XXXX.erp` 이면 되는 구조였습니다. 따라서 ExportOptions.plist 에 bundle ID 를 명시해서 정확히 추출되도록 수정했습니다. ```xml provisioningProfiles ``` ### 이슈 2. Pod이 ERP/CSAP Configuration을 못 찾는 문제 `-configuration ERP` 로 빌드하면 아래 에러가 발생했습니다. ``` PhaseScriptExecution [CP] Embed Pods Frameworks ... FAILED ``` 원인은 Podfile 에 `ERP`, `CSAP` Configuration 매핑이 없어서 Pods가 해당 Configuration을 찾지 못하는 것이었습니다. 처음엔 `PODS_CONFIGURATION_BUILD_DIR` 을 커스텀 경로로 지정해 해결하려 했지만, 오히려 더 꼬이는 문제가 발생했고, 결국 **해당 옵션을 제거하는 것이 해결책** 이었습니다. xcodebuild 가 이미 ERP Configuration 을 `ERP-iphoneos` 경로로 잘 찾고 있었던 것이었고, 커스텀 경로 지정이 오히려 방해가 되고 있었습니다. ```bash # 제거한 옵션 "PODS_CONFIGURATION_BUILD_DIR=$(pwd)/build/pods" \ "PODS_BUILD_DIR=$(pwd)/build/pods" \ ``` 만약 Pods가 진짜로 Configuration을 못 찾는다면 Podfile에 아래처럼 매핑을 추가하면 됩니다. ```ruby project 'KLAGO', { 'Debug' => :debug, 'Release' => :release, 'ERP' => :release, 'CSAP' => :release } ``` ### 이슈 3. 빌드 번호가 프로젝트 설정과 다르게 빌드되는 문제 Xcode에서 빌드 번호를 `1621` 로 올렸는데, 실제 아카이브된 앱은 `1614` 로 빌드되는 현상이 있었습니다. 원인을 추적해보니 두 가지였습니다. **원인 1.** `project.pbxproj` 에 Configuration별로 `CURRENT_PROJECT_VERSION` 이 따로 저장되어 있었고, ERP/CSAP Configuration은 여전히 `1614` 로 남아있었습니다. ```bash grep "CURRENT_PROJECT_VERSION" KLAGO.xcodeproj/project.pbxproj | sort -u # CURRENT_PROJECT_VERSION = 1614; // ERP/CSAP Configuration # CURRENT_PROJECT_VERSION = 1621; // 메인 설정 ``` Xcode UI 에서 빌드 번호를 변경할 때 `All` 탭이 아닌 특정 Configuration에서만 변경하면 이런 현상이 발생합니다. 전체 일괄 수정 방법: ```bash sed -i '' 's/CURRENT_PROJECT_VERSION = 1614/CURRENT_PROJECT_VERSION = 1621/g' KLAGO.xcodeproj/project.pbxproj ``` **원인 2.** fastlane의 `increment_build_number` 액션이 `$(CURRENT_PROJECT_VERSION)` 변수 참조를 실제 숫자값으로 하드코딩해버리는 동작 때문이었습니다. `Info_ERP.plist`, `Info_CSAP.plist` 의 `CFBundleVersion` 이 변수 참조 대신 숫자로 고정된 경우 아래처럼 복구합니다. ```xml com.XXXX.XXXXX.XXXX.erp com.XXXX.XXXXX.XXXX.erp CFBundleVersion 1614 CFBundleVersion $(CURRENT_PROJECT_VERSION) ``` --- ### deploy.sh 파일부분 ``` #!/bin/bash # ================================================================ # A10 Project - OmniEsol & CSAP TestFlight 배포 스크립트 # 사용법: 직접 실행하거나 Deploy앱배포.command 를 더블클릭 # ================================================================ # 스크립트가 있는 폴더로 자동 이동 (어디서 실행해도 경로 문제 없음) cd "$(dirname "$0")" # ---------------------------------------------------------------- # ⚙️ 설정값 (프로젝트에 맞게 수정하세요) # ---------------------------------------------------------------- WORKSPACE="프로젝트이름.xcworkspace" SCHEMES=("ERP" "CSAP") BUNDLE_IDS=("1번항목_bundle_id_넣기" "2번항목_bundle_id_넣기") CONFIGURATIONS=("ERP" "CSAP") # 스킴명과 동일한 Configuration BUILD_DIR="$HOME/Library/Developer/Xcode/Archives/$(date +%Y-%m-%d)" API_KEY_ID="XXXXXXXXX" # App Store Connect API Key ID API_ISSUER_ID="XXXXXXXXX" # App Store Connect Issuer ID API_KEY_PATH="./PushKey/XXXXXXXXX.p8" # .p8 키 파일 경로 # ---------------------------------------------------------------- RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' TOTAL=${#SCHEMES[@]} TOTAL_STEPS=3 declare -A STEP_TIMES declare -A APP_VERSIONS print_header() { clear echo "" echo -e "${BOLD}${BLUE}╔════════════════════════════════════════╗${RESET}" echo -e "${BOLD}${BLUE}║ A10 TestFlight 배포 도구 ║${RESET}" echo -e "${BOLD}${BLUE}║ OmniEsol · CSAP ║${RESET}" echo -e "${BOLD}${BLUE}╚════════════════════════════════════════╝${RESET}" echo "" } format_time() { local secs=$1 local m=$((secs / 60)) local s=$((secs % 60)) if [ $m -gt 0 ]; then echo "${m}분 ${s}초" else echo "${s}초" fi } print_step() { local app_idx=$1 local step_idx=$2 local scheme=$3 local msg=$4 echo -e "${CYAN}[${scheme}]${RESET} ${DIM}(앱 ${app_idx}/${TOTAL} · 단계 ${step_idx}/${TOTAL_STEPS})${RESET} ${msg}" } print_success() { echo -e "${GREEN} ✅ $1${RESET}" } print_error() { echo -e "${RED} ❌ 오류: $1${RESET}" } print_divider() { echo -e "${DIM}────────────────────────────────────────${RESET}" } spinner() { local pid=$1 local msg=$2 local start=$3 local frames=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏") local i=0 while kill -0 "$pid" 2>/dev/null; do local now=$(date +%s) local elapsed=$((now - start)) local m=$((elapsed / 60)) local s=$((elapsed % 60)) local timer=$(printf "%02d:%02d" $m $s) printf "\r ${YELLOW}${frames[$i]}${RESET} ${DIM}%s...${RESET} ${DIM}[%s]${RESET}" "$msg" "$timer" i=$(( (i+1) % ${#frames[@]} )) sleep 0.1 done printf "\r\033[K" } get_app_version() { local archive_path="$1" local info_plist="${archive_path}/Info.plist" if [ -f "$info_plist" ]; then local version=$(/usr/libexec/PlistBuddy -c "Print :ApplicationProperties:CFBundleShortVersionString" "$info_plist" 2>/dev/null) local build=$(/usr/libexec/PlistBuddy -c "Print :ApplicationProperties:CFBundleVersion" "$info_plist" 2>/dev/null) echo "${version} (${build})" else echo "버전 정보 없음" fi } # ---------------------------------------------------------------- # 사전 체크 # ---------------------------------------------------------------- preflight_check() { echo -e "${BOLD}🔍 사전 체크 중...${RESET}" echo "" if ! command -v xcodebuild &>/dev/null; then print_error "Xcode가 설치되어 있지 않습니다." exit 1 fi print_success "Xcode: $(xcodebuild -version | head -1)" if [ ! -d "$WORKSPACE" ]; then print_error "$WORKSPACE 파일을 찾을 수 없습니다. 프로젝트 루트에서 실행해주세요." exit 1 fi print_success "Workspace: $WORKSPACE" if [ ! -f "$API_KEY_PATH" ]; then print_error "API Key 파일을 찾을 수 없습니다: $API_KEY_PATH" exit 1 fi print_success "API Key: $API_KEY_PATH" echo "" print_divider } # ---------------------------------------------------------------- # 배포 함수 # ---------------------------------------------------------------- deploy_scheme() { local scheme=$1 local app_idx=$2 local bundle_id=$3 local configuration=$4 local archive_name="$scheme $(LC_TIME=en_US date +'%-m-%-d-%y, %I.%M %p')" local archive_path="$BUILD_DIR/${archive_name}.xcarchive" local export_path="/tmp/deploy_export/$scheme" local export_plist="/tmp/${scheme}_ExportOptions.plist" local log_file="/tmp/${scheme}_build.log" local scheme_start=$(date +%s) echo "" echo -e "${BOLD}🚀 [$scheme] 배포 시작${RESET}" # 아카이브 전 프로젝트 설정에서 버전 읽기 local pre_version=$(xcodebuild -workspace "$WORKSPACE" -scheme "$scheme" -configuration "$configuration" -showBuildSettings 2>/dev/null | grep -E "^\s+MARKETING_VERSION" | head -1 | awk '{print $3}') local pre_build=$(xcodebuild -workspace "$WORKSPACE" -scheme "$scheme" -configuration "$configuration" -showBuildSettings 2>/dev/null | grep -E "^\s+CURRENT_PROJECT_VERSION" | head -1 | awk '{print $3}') echo -e "${DIM} 앱 버전: ${pre_version} (${pre_build})${RESET}" print_divider # ── Step 1: 아카이브 ────────────────────────────────────────── print_step $app_idx 1 $scheme "아카이브 중..." local step1_start=$(date +%s) xcodebuild archive \ -workspace "$WORKSPACE" \ -scheme "$scheme" \ -configuration "$configuration" \ -destination "generic/platform=iOS" \ -archivePath "$archive_path" \ -allowProvisioningUpdates \ > "$log_file" 2>&1 & spinner $! "아카이브 빌드 중" $step1_start wait $! local step1_result=$? local step1_elapsed=$(($(date +%s) - step1_start)) STEP_TIMES["${scheme}_1"]=$step1_elapsed if [ $step1_result -ne 0 ]; then print_error "아카이브 실패. 로그: $log_file" echo "" echo -e "${DIM}--- 마지막 20줄 ---${RESET}" tail -20 "$log_file" return 1 fi print_success "아카이브 완료 → $(format_time $step1_elapsed)" # 아카이브 완료 후 실제 생성된 xcarchive 에서 버전 읽기 local actual_archive=$(find "$BUILD_DIR" -maxdepth 1 -name "${scheme}*.xcarchive" -type d | sort | tail -1) APP_VERSIONS["$scheme"]="${pre_version} (${pre_build})" echo -e " ${DIM}📦 $scheme ${pre_version} (${pre_build})${RESET}" # ── Step 2: IPA Export ──────────────────────────────────────── print_step $app_idx 2 $scheme "IPA 추출 중..." local step2_start=$(date +%s) mkdir -p "$export_path" cat > "$export_plist" << EOFEOF xcodebuild -exportArchive \ -archivePath "$archive_path" \ -exportPath "$export_path" \ -exportOptionsPlist "$export_plist" \ -allowProvisioningUpdates \ > "$log_file" 2>&1 & spinner $! "IPA 추출 중" $step2_start wait $! local step2_result=$? local step2_elapsed=$(($(date +%s) - step2_start)) STEP_TIMES["${scheme}_2"]=$step2_elapsed if [ $step2_result -ne 0 ]; then print_error "IPA Export 실패. 로그: $log_file" tail -20 "$log_file" return 1 fi print_success "IPA 추출 완료 → $(format_time $step2_elapsed)" # ── Step 3: TestFlight 업로드 ───────────────────────────────── print_step $app_idx 3 $scheme "TestFlight 업로드 중..." local step3_start=$(date +%s) local IPA_FILE="$(find "$export_path" -name "*.ipa" | head -1)" local UPLOAD_SUCCESS=false for attempt in 1 2 3; do xcrun altool --upload-app \ --type ios \ --file "$IPA_FILE" \ --apiKey "$API_KEY_ID" \ --apiIssuer "$API_ISSUER_ID" \ > "$log_file" 2>&1 & spinner $! "TestFlight 업로드 중" $step3_start wait $! if [ $? -eq 0 ]; then UPLOAD_SUCCESS=true break fi if [ $attempt -lt 3 ]; then echo -e " ${YELLOW}⚠️ 업로드 재시도 중... ($attempt/3)${RESET}" sleep 5 fi done local step3_elapsed=$(($(date +%s) - step3_start)) STEP_TIMES["${scheme}_3"]=$step3_elapsed STEP_TIMES["${scheme}_total"]=$(($(date +%s) - scheme_start)) if [ "$UPLOAD_SUCCESS" = false ]; then print_error "TestFlight 업로드 실패 (3회 시도). 로그: $log_file" tail -20 "$log_file" return 1 fi print_success "TestFlight 업로드 완료 → $(format_time $step3_elapsed) 🎉" return 0 } # ---------------------------------------------------------------- # 메인 실행 # ---------------------------------------------------------------- main() { print_header preflight_check mkdir -p "$BUILD_DIR" local TOTAL_START=$(date +%s) FAILED=() echo -e "${BOLD}📦 배포 대상: ${SCHEMES[*]}${RESET}" echo "" for i in "${!SCHEMES[@]}"; do deploy_scheme "${SCHEMES[$i]}" "$((i+1))" "${BUNDLE_IDS[$i]}" "${CONFIGURATIONS[$i]}" if [ $? -ne 0 ]; then FAILED+=("${SCHEMES[$i]}") fi print_divider done # ── 최종 결과 ───────────────────────────────────────────────── local TOTAL_ELAPSED=$(($(date +%s) - TOTAL_START)) echo "" echo -e "${BOLD}📊 배포 결과 요약${RESET}" echo "" for scheme in "${SCHEMES[@]}"; do if [[ " ${FAILED[*]} " == *" $scheme "* ]]; then echo -e " ${RED}✗ $scheme — 실패${RESET}" echo "" else local ver="${APP_VERSIONS["$scheme"]:-버전 정보 없음}" local t1=$(format_time "${STEP_TIMES["${scheme}_1"]:-0}") local t2=$(format_time "${STEP_TIMES["${scheme}_2"]:-0}") local t3=$(format_time "${STEP_TIMES["${scheme}_3"]:-0}") local tt=$(format_time "${STEP_TIMES["${scheme}_total"]:-0}") echo -e " ${GREEN}✓ $scheme $ver${RESET}" echo -e " ${DIM}아카이브 $t1 · IPA추출 $t2 · 업로드 $t3 → 총 $tt${RESET}" echo "" fi done echo -e "${DIM}────────────────────────────────────────${RESET}" echo -e " ${BOLD}총 소요시간: $(format_time $TOTAL_ELAPSED)${RESET}" echo "" if [ ${#FAILED[@]} -eq 0 ]; then echo -e "${BOLD}${GREEN}🎉 모든 앱 배포 완료!${RESET}" else echo -e "${BOLD}${RED}⚠️ 일부 앱 배포 실패: ${FAILED[*]}${RESET}" exit 1 fi echo "" } main ``` --- ## 마무리 팀내에서 자동화 부분에 대해 관심이 많아지고 있고, 귀찮은 일은 하나씩 줄이려고 노력하고 있습니다. 스크립트쪽은 저도 정확히 몰라서 AI 도움을 많이 받았고, 팀내에서는 FastLine으로만 사용할지, 스크립트부분을 사용할지 정하진 못해서 백업 / 블로그 글 우선 써둡니다. 오늘은 이만 즐거운 코딩되세요~ 끝. method app-store signingStyle automatic provisioningProfiles ${bundle_id} ${bundle_id} uploadBitcode uploadSymbols

댓글
댓글 쓰기