클라우드 Mac에서 iOS Mach-O 아키텍처와 버전 검사하기

클라우드 Mac에서 iOS Mach-O 아키텍처와 버전 검사하기

로컬 시뮬레이터에서 정상적으로 실행되던 iOS 프로젝트도 HopVM 클라우드 Mac에서 Archive를 완료한 뒤 설치 단계에서 문제가 드러날 수 있습니다. 사전 컴파일된 Framework에 x86_64 슬라이스가 포함되어 있거나, 확장 프로그램이 잘못된 플랫폼용으로 링크되었거나, 중첩된 동적 라이브러리가 최소 시스템 버전을 조용히 높였을 수 있습니다. 배포 후에 원인을 추적하기보다 내보낸 .app을 직접 검사해 바이너리의 실제 상태를 파이프라인 게이트로 전환하는 편이 낫습니다.

최종 내보내기 패키지를 검사해야 하는 이유

Xcode 프로젝트 설정은 “어떻게 빌드할 예정인지”만 보여 줍니다. 실제로 무엇이 배포되는지는 최종 패키지에서 확인해야 합니다. 메인 실행 파일, App Extension, Framework, .dylib는 각각 독립된 Mach-O 헤더를 가질 수 있으며, 의존성 관리자가 내려받은 바이너리 패키지도 메인 프로젝트의 설정을 반드시 상속하는 것은 아닙니다.

게이트에서는 최소한 다음 세 가지를 확인해야 합니다.

  1. 실제 iOS 기기용 바이너리에 허용된 아키텍처만 포함되어 있는가?
  2. 모든 Mach-O의 플랫폼이 시뮬레이터나 macOS가 아닌 iOS인가?
  3. 중첩된 바이너리의 최소 시스템 버전이 메인 App의 선언보다 높지 않은가?

“Archive 성공”을 배포 승인으로 간주해서는 안 됩니다. 링커는 현재 타깃을 결과물로 구성할 수 있는지만 검증할 뿐, 중첩된 모든 파일이 팀의 릴리스 기준을 충족하는지까지 판단하지 않습니다.

DerivedData만 검사하지 말고 xcodebuild -exportArchive로 생성한 최종 패키지를 검사해야 합니다. 내보내기 과정에서는 Framework, 확장 프로그램, 서명 콘텐츠가 재구성되므로 이 패키지가 실제 배포 상태에 가장 가까운 입력입니다.

입력과 승인 기준 고정하기

먼저 아카이브, 내보내기, 보고서 디렉터리를 고정해 스크립트가 이전 작업의 잔여 파일을 검사하지 않도록 합니다. 여러 파이프라인이 하나의 클라우드 Mac을 순차적으로 사용하는 환경에서는 작업 디렉터리 격리가 특히 중요합니다.

set -euo pipefail

ARCHIVE_PATH="$PWD/output/App.xcarchive"
EXPORT_PATH="$PWD/output/export"
REPORT_PATH="$PWD/output/macho-report.tsv"

rm -rf "$EXPORT_PATH"
mkdir -p "$EXPORT_PATH"

xcodebuild -exportArchive \
  -archivePath "$ARCHIVE_PATH" \
  -exportPath "$EXPORT_PATH" \
  -exportOptionsPlist "$PWD/ci/ExportOptions.plist"

APP_PATH="$(find "$EXPORT_PATH" -maxdepth 2 -type d -name '*.app' -print -quit)"
test -n "$APP_PATH"

제품 기준은 CI 설정 화면 곳곳에 흩어 놓지 말고 저장소에서 관리해야 합니다. 예를 들어 허용 아키텍처와 최소 버전을 ci/macho-policy.env에 저장할 수 있습니다.

EXPECTED_ARCHS="arm64"
EXPECTED_PLATFORM="IOS"
DECLARED_MIN_IOS="17.0"

DECLARED_MIN_IOS는 제품이 실제로 지원하는 범위와 일치해야 합니다. 여기의 버전은 스크립트 예시일 뿐이므로 그대로 복사해 제품 정책으로 사용해서는 안 됩니다.

모든 Mach-O 파일을 재귀적으로 열거하기

Framework의 메인 바이너리에는 일반적으로 확장자가 없으므로 확장자만으로 파일을 검색해서는 안 됩니다. 모든 파일을 순회하면서 file로 Mach-O 파일인지 판별하는 방식이 더 안정적입니다.

: > "$REPORT_PATH"
failure=0

while IFS= read -r -d '' candidate; do
  if ! file -b "$candidate" | grep -q 'Mach-O'; then
    continue
  fi

  archs="$(lipo -archs "$candidate" 2>/dev/null || true)"
  build="$(vtool -show-build "$candidate" 2>/dev/null || true)"
  platform="$(awk '/platform/{print $2; exit}' <<<"$build")"
  minos="$(awk '/minos/{print $2; exit}' <<<"$build")"

  printf '%s	%s	%s	%s
' \
    "${candidate#"$APP_PATH"/}" "$archs" "$platform" "$minos" \
    >> "$REPORT_PATH"

  if [[ "$archs" != "$EXPECTED_ARCHS" ]]; then
    printf 'architecture mismatch: %s (%s)
' "$candidate" "$archs" >&2
    failure=1
  fi

  if [[ "$platform" != "$EXPECTED_PLATFORM" ]]; then
    printf 'platform mismatch: %s (%s)
' "$candidate" "$platform" >&2
    failure=1
  fi
done < <(find "$APP_PATH" -type f -print0)

exit "$failure"

보고서는 탭으로 필드를 구분하므로 빌드 첨부 파일로 보관할 수 있고 나중에 표로 변환하기도 쉽습니다. 실패 메시지에는 반드시 파일 경로와 실제 값이 포함되어야 합니다. “아키텍처 검사 실패”만 출력하면 문제를 조사하는 사람이 전체 작업을 다시 실행해야 합니다.

게이트에서 패키지를 직접 수정하지 않기

x86_64를 발견한 직후 lipo -remove를 실행하면 간단해 보이지만, 이미 서명된 콘텐츠를 변경하고 의존성 생성 과정의 문제를 감추게 됩니다. 해당 파일이 소스 빌드, 바이너리 의존성, 복사 스크립트 중 어디에서 왔는지 파악한 뒤 상위 단계를 수정하고 다시 Archive해야 합니다.

최소 시스템 버전 비교하기

먼저 메인 App의 제품 선언을 읽습니다.

PLIST="$APP_PATH/Info.plist"
APP_MIN_IOS="$(/usr/libexec/PlistBuddy \
  -c 'Print :MinimumOSVersion' "$PLIST")"

printf 'declared minimum iOS: %s
' "$APP_MIN_IOS"

그다음 각 Mach-O의 LC_BUILD_VERSION에서 minos를 읽습니다. 일반 문자열 비교가 아니라 의미 기반 버전 비교를 사용해야 합니다. 그렇지 않으면 17.1017.9보다 앞선 버전으로 잘못 정렬될 수 있습니다.

검사 대상 읽는 위치 실패 조건
메인 App 선언 Info.plistMinimumOSVersion 저장소 기준과 일치하지 않음
메인 실행 파일 LC_BUILD_VERSIONminos 제품 선언보다 높음
Framework와 동적 라이브러리 각각의 LC_BUILD_VERSION 제품 선언보다 높음
App Extension 확장 프로그램 Info.plist와 Mach-O 선언값 또는 실제 값이 기준을 초과함

시스템에 기본 제공되는 버전 정렬 기능으로 비교할 수 있습니다.

version_gt() {
  [[ "$1" != "$2" ]] &&
    [[ "$(printf '%s
%s
' "$1" "$2" | sort -V | tail -n 1)" == "$1" ]]
}

if version_gt "$minos" "$APP_MIN_IOS"; then
  printf 'minimum iOS mismatch: %s requires %s
' \
    "$candidate" "$minos" >&2
  failure=1
fi

vtool이 플랫폼이나 버전을 반환하지 않더라도 해당 파일을 조용히 건너뛰어서는 안 됩니다. 먼저 otool -l로 전체 로드 명령을 저장한 뒤 이전 형식의 바이너리인지, 비정상 파일인지, 스크립트가 잘못 판별한 것인지 확인해야 합니다.

CI에 통합하고 일반적인 오탐 처리하기

검사는 Archive와 내보내기 이후, 업로드 이전에 실행하고 macho-report.tsv를 항상 업로드해야 합니다. 작업이 실패하더라도 CI의 실패 후 첨부 파일 기능을 사용해 보고서를 보관해야 합니다.

일반적인 오탐은 크게 세 가지입니다.

따라서 제품 유형별로 정책을 따로 관리해야 하며, 하나의 규칙으로 iOS, 시뮬레이터 테스트 패키지, macOS 도구를 모두 검사해서는 안 됩니다. 파이프라인에서 여러 종류의 결과물을 생성한다면 각 내보내기 디렉터리마다 검사를 따로 실행하고 보고서 첫 번째 열에 결과물 유형을 기록해야 합니다.

마지막으로 서명 구조를 다시 검증합니다.

codesign --verify --deep --strict --verbose=2 "$APP_PATH"

이 검증은 Mach-O 검사를 대체할 수 없지만, 스크립트가 바이너리를 잘못 수정했거나 중첩 서명이 무효화된 것과 같은 후속 문제를 찾아낼 수 있습니다. 전체 순서는 내보내기, 검사, 버전 비교, 서명 검증, 보고서 보관이어야 하며, 이 과정이 끝난 뒤에만 배포를 허용해야 합니다. 이렇게 하면 설치 실패 후 원인을 추측하는 대신, 실패할 때마다 정확한 파일과 필드, 수정 지점을 바로 확인할 수 있습니다.

자주 묻는 질문

메인 앱 바이너리만 검사하면 왜 부족한가요?

Framework, 앱 확장, 동적 라이브러리는 각각 별도의 Mach-O 파일을 포함합니다. 중첩 파일 하나의 플랫폼이나 버전이 잘못돼도 설치 또는 실행이 실패할 수 있습니다.

iOS 결과물에서 x86_64가 발견되면 어떻게 해야 하나요?

해당 파일을 만든 의존성 빌드나 아카이브 단계를 찾아 수정해야 합니다. 배포 직전에 lipo로 슬라이스만 제거하는 방식은 피하는 것이 좋습니다.

최소 iOS 버전은 어디에서 확인해야 하나요?

Info.plist의 MinimumOSVersion과 Mach-O LC_BUILD_VERSION의 minos를 함께 비교합니다. 중첩 바이너리의 minos가 앱 선언보다 높아서는 안 됩니다.

전용 물리 노드

프로젝트에 따라 켜고 끄는 클라우드 Mac으로 워크플로 재현하기

세 가지 Apple Silicon 구성 중 적합한 리소스를 선택하고 일·주·월·분기 단위로 대여하세요. 가상 머신이 아닌 전용 물리 장비이며, 실제 사용 가능 상태는 콘솔에서 실시간으로 확인할 수 있습니다.

구성 선택 후 주문하기