클라우드 Mac CI에서 macOS와 Xcode 출력을 안정적으로 파싱하기

CI/CD ·약 10분 읽기

클라우드 Mac CI에서 macOS와 Xcode 출력을 안정적으로 파싱하기

동일한 CI 스크립트가 대화형 터미널에서는 정상적으로 실행되지만 무인 클라우드 Mac에서는 잘못된 Scheme을 선택하는 경우가 있다. 흔히 문제의 원인은 Xcode 자체가 아니라, 스크립트가 사람이 읽도록 만들어진 텍스트를 인터페이스처럼 사용한다는 데 있다. 시스템 언어, 도구 버전, 경고가 삽입되는 위치는 물론 공백 하나만 추가되어도 grep, cut, 고정 행 번호에 의존하는 로직이 깨질 수 있다. 더 안정적인 방법은 JSON이나 plist를 우선 사용하고, 종료 코드와 원본 출력, 파싱 결과를 각각 별도로 보관하는 것이다.

불안정한 텍스트 파싱부터 찾기

먼저 파이프라인에서 위험 가능성이 높은 명령 조합을 검색한다.

grep -RInE 'xcodebuild|simctl|system_profiler|sw_vers' ci scripts \
  | grep -E 'grep|awk|sed|cut|tail|head'

모든 파이프가 잘못된 것은 아니다. 위험한 경우는 스크립트가 자연어에 의존할 때다. 예를 들어 “Build Settings”라는 문구를 찾거나, 콜론 뒤의 두 번째 열을 잘라 내거나, 대상 기기가 항상 세 번째 줄에 있다고 가정하는 방식이다. LC_ALL=C로 언어 차이를 줄일 수는 있지만 필드 순서, 경고 내용, 향후 버전의 출력 형식까지 고정할 수는 없다.

명령 출력이 주로 사람이 읽도록 제공되는 것이라면, 이를 안정적인 머신 인터페이스라고 가정해서는 안 된다.

감사할 때는 각 읽기 작업마다 세 가지를 명확히 해야 한다. 필요한 필드가 무엇인지, 명령이 구조화 형식을 제공하는지, 필드가 없을 때 실패해야 하는지 아니면 명시적인 기본값을 사용해야 하는지다. 빈 문자열이 검증 없이 후속 아카이브 단계로 넘어가게 두지 않는다.

빌드 설정을 JSON 계약으로 변환하기

xcodebuild -showBuildSettings -json을 사용하면 대상의 빌드 설정을 직접 받을 수 있다. 먼저 명령의 종료 코드를 확인한 뒤 파일을 파싱해야 하며, 표준 오류를 JSON에 섞어서는 안 된다.

set -euo pipefail

mkdir -p artifacts/environment

if ! xcodebuild \
  -workspace App.xcworkspace \
  -scheme App \
  -configuration Release \
  -showBuildSettings \
  -json \
  > artifacts/environment/build-settings.json \
  2> artifacts/environment/build-settings.stderr; then
  cat artifacts/environment/build-settings.stderr >&2
  exit 1
fi

python3 - <<'PY'
import json
from pathlib import Path

path = Path("artifacts/environment/build-settings.json")
if not path.exists() or path.stat().st_size == 0:
    raise SystemExit("build settings output is empty")

items = json.loads(path.read_text(encoding="utf-8"))
if len(items) != 1:
    raise SystemExit(f"expected one target, got {len(items)}")

settings = items[0].get("buildSettings", {})
required = ["PRODUCT_BUNDLE_IDENTIFIER", "SDKROOT", "CONFIGURATION_BUILD_DIR"]
missing = [key for key in required if not settings.get(key)]
if missing:
    raise SystemExit("missing build settings: " + ", ".join(missing))

print(settings["PRODUCT_BUNDLE_IDENTIFIER"])
PY

워크스페이스에 대상이 여러 개 있다면 배열 길이가 1이어야 한다고 강제해서는 안 된다. target 필드를 기준으로 대상을 선택하고, 일치 항목이 없거나 여러 개면 실행을 중단해야 한다. 이렇게 하면 확장 대상이 새로 추가되더라도 스크립트가 잘못된 설정을 조용히 읽는 일을 방지할 수 있다.

simctl JSON으로 기기 선택 고정하기

시뮬레이터 목록은 특히 행 단위 파싱에 적합하지 않다. 기기 이름에는 공백이 포함될 수 있고, 같은 이름의 기기가 서로 다른 런타임에 존재할 수도 있다. 먼저 전체 결과를 저장한 다음 런타임, 이름, 사용 가능 상태를 기준으로 필터링한다.

xcrun simctl list -j devices available \
  > artifacts/environment/simulators.json

python3 - <<'PY'
import json
from pathlib import Path

data = json.loads(
    Path("artifacts/environment/simulators.json").read_text(encoding="utf-8")
)
matches = []
for runtime, devices in data.get("devices", {}).items():
    if not runtime.startswith("com.apple.CoreSimulator.SimRuntime.iOS-"):
        continue
    for device in devices:
        if device.get("isAvailable") and device.get("name") == "iPhone 16":
            matches.append((runtime, device["udid"]))

if len(matches) != 1:
    raise SystemExit(f"expected one matching simulator, got {len(matches)}")

print(matches[0][1])
PY

프로덕션 스크립트에서는 기대하는 런타임 버전도 저장소 설정에 명시해야 하며, 자동으로 “최신” 버전을 선택해서는 안 된다. 최신 버전을 자동 선택하면 시스템 구성 요소 업데이트만으로 테스트 기준 환경이 바뀔 수 있다. 최종 테스트 실행 시에는 기기 이름만 지정하는 것보다 UDID를 전달하는 편이 모호함을 줄일 수 있다.

빈 기기 집합을 무시하지 않기

“일치하는 기기 없음”은 명시적인 실패로 처리해야 한다. 임시 시뮬레이터를 만들기 전에 필요한 런타임이 실제로 존재하는지 먼저 확인한다. 작업이 끝난 뒤에는 해당 작업이 생성한 기기만 삭제하고 공유 기기 집합은 정리하지 않는다. 병렬 작업에서는 각각 별도의 -derivedDataPath와 독립적인 CoreSimulator 기기 집합을 설정하는 것이 좋다.

plist에서 호스트 정보 읽기

macOS 버전 정보는 시스템 plist에서 직접 읽을 수 있으므로 sw_vers의 레이블 텍스트를 파싱할 필요가 없다.

SYSTEM_PLIST=/System/Library/CoreServices/SystemVersion.plist

product_version=$(
  /usr/libexec/PlistBuddy -c 'Print :ProductVersion' "$SYSTEM_PLIST"
)
build_version=$(
  /usr/libexec/PlistBuddy -c 'Print :ProductBuildVersion' "$SYSTEM_PLIST"
)

printf '%s\n' "$product_version" > artifacts/environment/macos-version.txt
printf '%s\n' "$build_version" > artifacts/environment/macos-build.txt
xcodebuild -version > artifacts/environment/xcode-version.txt

프로젝트 plist를 읽을 때는 plutil -extract KEY raw -o - FILE을 우선 사용한다. 필드가 없을 수 있다면 먼저 plutil -type KEY FILE로 확인한 뒤, 누락을 허용할지 스크립트에서 결정한다. 정규식으로 XML을 처리해서는 안 된다. plist가 바이너리 형식일 수도 있고 문자열이 이스케이프되어 있을 수도 있기 때문이다.

진단 가능한 데이터 수집 경계 만들기

구조화 형식이라고 해서 명령이 반드시 성공하는 것은 아니다. 신뢰할 수 있는 수집 단계라면 최소한 네 가지 증거를 보관해야 한다. 명령 종료 코드, 표준 오류, 수정하지 않은 원본 파일, 그리고 파싱한 요약 결과다. 원본 파일은 도구의 동작을 사후 분석하는 데 사용하고, 요약 결과는 후속 작업이 소비하도록 제공한다.

각 수집기는 하나의 객체만 담당하게 하고 출력을 임시 파일에 기록하는 것이 좋다. 구문과 필수 필드를 모두 검증한 다음 mv로 정식 파일을 원자적으로 교체한다. 이렇게 하면 명령이 중단되더라도 불완전한 JSON 파일이 남지 않는다.

커밋 전 점검 항목

파이프라인 변경 사항을 병합하기 전에 최소한 다음 항목을 확인한다.

  • 스크립트가 고정 행 번호나 자연어 레이블을 기준으로 필드를 추출하지 않는다.
  • 표준 출력과 표준 오류를 서로 다른 파일에 기록한다.
  • JSON과 plist는 읽기 전에 구문을 검사한다.
  • 배열 필터링 시 일치 항목이 없는 경우와 여러 개인 경우를 모두 명시적으로 처리한다.
  • Xcode 버전, macOS 빌드 번호, 선택된 기기의 UDID를 작업 결과와 함께 아카이브할 수 있다.
  • 로그에는 환경 정보만 기록하며 토큰, 접근 정보, 전체 환경 변수를 출력하지 않는다.
  • 도구 버전을 업데이트한 뒤에는 먼저 수집 검증을 실행하고, 이후에 프로덕션 빌드 작업을 허용한다.

최종 목표는 모든 명령을 JSON으로 바꾸는 것이 아니라 명확한 경계를 세우는 것이다. 머신은 구조화 데이터를 소비하고, 사람은 진단 로그를 읽어야 한다. 구조화 인터페이스가 실제로 없다면 명령 버전을 고정하고, 언어 환경을 격리하고, 열 개수를 검사해야 한다. 또한 파싱 실패를 그럴듯한 빈 값으로 계속 처리하지 말고 명확한 실패로 간주해야 한다.

자주 묻는 질문

LC_ALL=C만 설정하면 충분한가요?

충분하지 않습니다. 언어 차이는 줄지만 필드 순서, 공백, 경고 문구와 버전별 형식은 바뀔 수 있으므로 자동화에는 JSON이나 plist가 더 안전합니다.

구조화 명령이 실패하면 무엇을 보관해야 하나요?

종료 코드, 표준 오류, 원본 JSON 또는 plist, Xcode 버전과 macOS 빌드 번호를 보관하고 파싱 전에 파일 존재 여부와 문법을 검사해야 합니다.

전용 물리 Mac

다음 개발 작업을 클라우드 Mac에서 시작하세요

Berry M4 또는 Berry M4 Pro를 선택하고 5개 노드, 대여 기간 및 추가 옵션을 확인한 뒤 주문을 제출하세요.

구성 선택 및 주문