빌드 로그에 tee를 연결하면 터미널 출력은 온전히 남지만, CI가 명백히 실패한 xcodebuild를 간혹 성공으로 처리할 수 있습니다. 로그가 누락되는 것보다 더 위험한 문제는 파이프라인이 업로드, 알림 또는 배포 단계를 계속 실행한다는 점입니다. 원인은 대개 단순합니다. shell은 기본적으로 파이프라인의 마지막 명령이 반환한 종료 코드를 사용하므로, tee가 정상적으로 파일을 쓰면 앞선 xcodebuild의 실패 상태가 가려집니다.
오류가 어떻게 사라지는지 먼저 확인하기
다음과 같은 작성 방식은 흔히 볼 수 있습니다.
xcodebuild test \
-workspace App.xcworkspace \
-scheme App \
-destination "$CI_DESTINATION" \
2>&1 | tee build.log
이 파이프라인에는 두 개의 프로세스가 있습니다. 왼쪽에서는 테스트를 실행하고, 오른쪽에서는 출력을 파일과 표준 출력에 동시에 기록합니다. Bash에서 pipefail을 활성화하지 않으면 전체 명령은 기본적으로 tee의 상태를 따릅니다. 테스트가 0이 아닌 값을 반환하더라도 로그 기록에 성공하면 스크립트에는 여전히 0이 전달될 수 있습니다.
먼저 클라우드 Mac에서 최소 재현 검사를 실행합니다.
/bin/bash -c 'exit 65' | tee /tmp/pipeline-check.log
printf 'pipeline=%s\n' "$?"
출력이 pipeline=0이라면 현재 스크립트가 실패를 성공으로 잘못 보고할 수 있다는 뜻입니다. 로그에 error:가 있는지만 확인해서는 안 됩니다. 컴파일러, 테스트 프레임워크, 사용자 정의 스크립트의 출력 형식은 언제든 달라질 수 있습니다. 종료 코드가 CI에서 작업의 성공과 실패를 판단하는 안정적인 인터페이스입니다.
로그는 실패 원인을 설명하고, 종료 코드는 실패 사실을 알립니다. 두 정보는 각각 보존해야 하며, 프로세스 상태를 텍스트 검색으로 대체해서는 안 됩니다.
Shell을 고정한 다음 파이프라인 상태 처리하기
macOS의 기본 대화형 shell과 CI 스크립트를 실제로 해석하는 인터프리터는 서로 다를 수 있습니다. Bash는 PIPESTATUS를 사용하고 zsh는 pipestatus를 사용하며, 배열 인덱스 방식도 다릅니다. 가장 안전한 방법은 저장소의 스크립트에 인터프리터를 명시하고 파이프라인에서 해당 파일을 직접 실행하는 것입니다. 여러 줄의 명령을 어떤 shell이 처리할지 불확실한 상태로 넘기지 마십시오.
#!/bin/bash
set -uo pipefail
-u는 정의되지 않은 변수의 사용을 차단하고, pipefail은 파이프라인에 포함된 명령 중 하나라도 실패하면 전체 파이프라인이 0이 아닌 값을 반환하게 합니다. 그러나 pipefail만으로는 실패 지점을 정확히 구분할 수 없습니다. 디스크나 권한 문제로 tee가 실패했지만 xcodebuild 자체는 성공했다면, 최종 $?는 파이프라인이 실패했다는 사실만 알려 줄 뿐 어느 단계가 실패했는지는 보여 주지 않습니다.
따라서 파이프라인이 끝난 직후 PIPESTATUS를 복사해야 합니다. 변수 할당, 조건 검사, 로그 출력 명령만으로도 값이 갱신될 수 있으므로 한 단계라도 늦게 읽으면 다른 명령의 상태를 얻게 됩니다.
재사용 가능한 xcodebuild 래퍼 스크립트 작성하기
다음 스크립트는 원본 로그, 결과 번들, xcodebuild의 실제 종료 코드를 함께 보존합니다. 상태를 저장하기 전에 shell이 바로 종료되지 않도록 errexit을 잠시 비활성화합니다.
#!/bin/bash
set -uo pipefail
run_id="${CI_RUN_ID:-local-$(date +%s)}"
artifact_dir="${CI_ARTIFACT_DIR:-$PWD/artifacts/$run_id}"
result_path="$artifact_dir/TestResults.xcresult"
log_path="$artifact_dir/xcodebuild.log"
mkdir -p "$artifact_dir"
set +e
xcodebuild test \
-workspace App.xcworkspace \
-scheme App \
-configuration Debug \
-destination "$CI_DESTINATION" \
-resultBundlePath "$result_path" \
2>&1 | tee "$log_path"
pipeline_status=("${PIPESTATUS[@]}")
xcode_status="${pipeline_status[0]}"
tee_status="${pipeline_status[1]}"
set -e
printf 'xcodebuild_exit=%s\n' "$xcode_status"
printf 'tee_exit=%s\n' "$tee_status"
if (( tee_status != 0 )); then
printf 'Log capture failed\n' >&2
fi
exit "$xcode_status"
CI_DESTINATION은 작업 구성에서 명시적으로 전달해야 합니다. 예를 들어 사전에 검증한 시뮬레이터 대상으로 고정할 수 있습니다. 스크립트에서 사용 가능한 첫 번째 기기를 모호하게 검색하지 마십시오. 런타임이 업데이트되면 서로 다른 시스템 버전이 선택될 수 있습니다. 작업 디렉터리 경로에 공백이 있어도 인수가 분리되지 않도록 모든 경로 변수에는 따옴표를 사용합니다.
이 예에서는 xcodebuild 상태를 작업의 최종 상태로 사용합니다. 로그 저장 실패도 반드시 작업을 중단해야 한다면 종료 전에 조건을 추가하십시오. 두 상태 중 하나라도 0이 아니면 실패를 반환하고, 각 상태를 작업 요약에 별도로 기록하면 됩니다.
결과 번들을 진단 증거로 보존하기
콘솔 텍스트는 빠르게 읽기에 적합하고, xcresult는 테스트 계층 구조, 실패 첨부 파일, 활동 기록, 진단 정보를 보존합니다. 용도가 서로 다르므로 두 결과물을 모두 보관하는 것이 좋습니다.
| 결과물 | 주요 용도 | 실패 시 보존 여부 |
|---|---|---|
xcodebuild.log |
컴파일러 출력 및 스크립트 로그 검색 | 예 |
TestResults.xcresult |
테스트, 첨부 파일 및 활동 기록 확인 | 예 |
exit-status.txt |
각 파이프라인 단계의 상태 기록 | 예 |
| 임시 DerivedData | 현장 재현이 필요할 때 단기간 보존 | 작업 정책에 따름 |
머신이 읽을 수 있는 요약이 필요하다면 시스템에 포함된 xcresulttool을 사용할 수 있습니다. 결과 번들이 실제로 존재하는지 먼저 확인한 뒤 파싱해야 합니다. 그래야 파싱 실패가 원래 빌드 상태를 덮어쓰지 않습니다.
if [[ -d "$result_path" ]]; then
xcrun xcresulttool get test-results summary \
--path "$result_path" \
> "$artifact_dir/test-summary.json" || true
fi
printf 'xcodebuild=%s\ntee=%s\n' \
"$xcode_status" "$tee_status" \
> "$artifact_dir/exit-status.txt"
여기서 || true는 요약 생성이 실패해도 결과물 보관을 계속하도록 허용할 뿐이며, xcodebuild 주 명령 뒤에 붙여서는 안 됩니다. 보조 보고서의 실패가 이미 저장된 주 상태를 지워서는 안 되고, 주 빌드의 실패가 보조 명령 때문에 성공으로 바뀌어서도 안 됩니다.
정상 작업만 실행하지 말고 예외 상황으로 검증하기
래퍼 스크립트를 병합하기 전에 최소한 다음 네 가지 유형을 검증해야 합니다.
컴파일 실패
존재하지 않는 심벌을 임시로 참조합니다. 예상 결과는 xcodebuild가 0이 아닌 값을 반환하고 CI가 실패하며 로그가 남는 것입니다. 결과 번들의 생성 여부는 실패한 단계에 따라 달라집니다.
테스트 실패
반드시 실패하는 어설션을 추가합니다. 컴파일은 완료되지만 테스트 작업은 실패해야 하며, xcresult에서 실패한 테스트 케이스를 찾을 수 있어야 합니다. 파이프라인은 성공 분기를 계속 실행해서는 안 됩니다.
로그 기록 실패
결과물 디렉터리를 쓰기 권한이 없는 위치로 지정하고 tee_status가 0이 아닌지 확인합니다. 진단 결과물이 완전해야 배포할 수 있다는 팀 정책이 있다면 이 상황에서도 작업을 중단해야 합니다.
중단 및 시간 초과
CI에서 종료 신호를 보내 후속 릴리스 단계가 실행되지 않는지 확인하고, 자식 프로세스가 정리되는지도 검사합니다. 시간 초과를 일반적인 테스트 실패로 처리하지 마십시오. 두 상황은 재시도 정책과 장애 분석에서 의미가 다릅니다.
마지막으로 작업 정의 자체를 확인합니다. 진단 결과물 업로드 단계는 실패 후에도 실행되도록 설정할 수 있지만, 배포, 배포물 전달, 성공 알림은 반드시 래퍼 스크립트가 반환한 실제 상태에 의존해야 합니다. 이렇게 구성하면 출력 필터, 보관 도구, 요약 생성기가 변경되더라도 xcodebuild의 실패가 유일하고 검증 가능한 경로를 통해 CI로 전달됩니다.
자주 묻는 질문
pipefail을 켰는데도 PIPESTATUS가 필요한 이유는 무엇인가요?
pipefail은 파이프라인 실패 여부만 알려 주며 어느 명령이 실패했는지는 보장하지 않습니다. PIPESTATUS[0]을 즉시 읽어야 xcodebuild의 원래 종료 코드를 정확히 반환할 수 있습니다.
래퍼가 실패를 올바르게 전달하는지 어떻게 검사하나요?
컴파일 오류, 테스트 단언 실패, 쓰기 불가능한 로그 경로를 각각 만들어 실행합니다. 모든 작업이 실패로 끝나고 필요한 로그와 xcresult가 남는지 확인합니다.
다음 개발 작업을 클라우드 Mac에서 시작하세요
Berry M4 또는 Berry M4 Pro를 선택하고 5개 노드, 대여 기간 및 추가 옵션을 확인한 뒤 주문을 제출하세요.