하나의 iOS 프로젝트에 수천 개의 단위 테스트와 UI 테스트가 있다면 xcodebuild test를 한 번 실행하는 것만으로도 컴파일, 시뮬레이터 부팅, 앱 설치, 전체 테스트 케이스 실행이 모두 이어집니다. 마지막 몇 분에 실패하더라도 재실행은 다시 컴파일부터 시작합니다. 클라우드 Mac에서는 이 과정을 두 단계로 나누는 편이 더 적합합니다. 먼저 테스트 가능한 빌드를 한 번 생성한 다음, 경계가 명확한 여러 테스트 샤드에서 이를 재사용합니다.
빌드와 실행부터 분리하기
Xcode의 build-for-testing은 앱, 테스트 호스트, 테스트 번들을 빌드하고 실행 환경을 기술하는 .xctestrun 파일을 생성합니다. 이후 test-without-building을 사용하면 샤드마다 의존성을 다시 해석하고 소스 코드를 컴파일하는 작업을 피할 수 있습니다.
먼저 워크스페이스, Scheme, 대상 기기, DerivedData 경로를 고정합니다.
set -euo pipefail
DERIVED_DATA="$PWD/.ci/DerivedData"
RESULTS="$PWD/.ci/Results"
DESTINATION="platform=iOS Simulator,name=iPhone 16,OS=latest"
rm -rf "$DERIVED_DATA" "$RESULTS"
mkdir -p "$RESULTS"
xcodebuild build-for-testing \
-workspace App.xcworkspace \
-scheme App \
-configuration Debug \
-destination "$DESTINATION" \
-derivedDataPath "$DERIVED_DATA" \
CODE_SIGNING_ALLOWED=NO
find "$DERIVED_DATA/Build/Products" -name "*.xctestrun" -print
CODE_SIGNING_ALLOWED=NO는 실제 기기용 서명에 의존하지 않는 시뮬레이터 테스트에만 적용할 수 있습니다. 테스트 Target에 반드시 서명해야 하는 구성 요소가 포함되어 있다면 이 인수를 제거하고 프로젝트 자체의 서명 설정이 적용되도록 해야 합니다. 빌드가 성공한 뒤에는 DerivedData를 이동하지 마십시오. .xctestrun이 그 안의 절대 경로를 참조할 수 있습니다.
샤딩은 테스트 스케줄링 문제를 해결할 뿐, 테스트 간에 공유되는 상태를 바로잡지는 않습니다. 어떤 테스트가 다른 테스트의 선행 실행에 의존한다면 단일 머신에서 순차 실행할 때는 안정적으로 보여도 샤딩 후에는 실제 결함이 드러납니다.
테스트 목록으로 안정적인 샤드 정의하기
“그룹당 메서드 약 100개”와 같은 방식으로 수동 분할하지 마십시오. 메서드 이름은 자주 바뀌므로 유지보수 비용이 큽니다. 테스트 Target, 테스트 클래스 또는 비즈니스 도메인을 기준으로 나누고 그 목록을 저장소에 포함하는 편이 더 안정적입니다.
| 샤드 | 권장 범위 | 포함하기 적합한 테스트 |
|---|---|---|
| unit-core | 순수 로직 계층 | 데이터 변환, 검증, 상태 머신 |
| unit-storage | 영속성 계층 | 데이터베이스, 캐시, 마이그레이션 |
| ui-account | 계정 흐름 | 로그인, 설정, 권한 화면 |
| ui-checkout | 거래 흐름 | 상품 선택, 확인, 예외 경로 |
목록은 일반 텍스트로 관리할 수 있습니다.
AppTests/ParserTests
AppTests/SessionReducerTests
AppTests/ValidationTests
실행할 때는 각 줄을 하나의 -only-testing: 인수로 변환합니다. 테스트 식별자는 일반적으로 Target/Class 또는 Target/Class/testMethod 형식을 사용합니다. 먼저 xcodebuild -list를 실행해 Scheme을 확인한 다음, 작은 범위의 명령으로 식별자를 검증하십시오. 오타 때문에 샤드가 실제로는 테스트를 하나도 실행하지 않는 상황을 방지할 수 있습니다.
샤드 하나만 실행하기
XCTESTRUN="$(find "$DERIVED_DATA/Build/Products" -name '*.xctestrun' -print -quit)"
xcodebuild test-without-building \
-xctestrun "$XCTESTRUN" \
-destination "$DESTINATION" \
-parallel-testing-enabled NO \
-only-testing:AppTests/ParserTests \
-only-testing:AppTests/SessionReducerTests \
-resultBundlePath "$RESULTS/unit-core.xcresult"
각 샤드는 서로 다른 resultBundlePath를 사용해야 합니다. 같은 경로를 재사용하면 증거가 덮어써지고, 병렬 프로세스가 동일한 디렉터리를 두고 경합할 수도 있습니다.
동시 실행 작업마다 독립된 시뮬레이터 할당하기
두 xcodebuild 프로세스가 동시에 같은 시뮬레이터를 사용하면 설치, 실행, 권한 상태, 클립보드 데이터가 서로 영향을 줄 수 있습니다. 각 동시 실행 슬롯에 전용 기기를 준비하고 UDID로 대상을 지정해야 합니다.
먼저 사용 가능한 런타임을 확인합니다.
xcrun simctl list runtimes
xcrun simctl list devicetypes
현재 환경의 런타임 식별자를 확인한 뒤 전용 기기를 생성합니다.
xcrun simctl create \
ci-shard-1 \
com.apple.CoreSimulator.SimDeviceType.iPhone-16 \
"$RUNTIME_ID"
반환된 UDID는 관리되는 실행 설정에 저장합니다. 매 실행 전에 시뮬레이터를 종료하고 상태를 초기화합니다.
xcrun simctl shutdown "$SIMULATOR_UDID" 2>/dev/null || true
xcrun simctl erase "$SIMULATOR_UDID"
xcrun simctl boot "$SIMULATOR_UDID"
xcrun simctl bootstatus "$SIMULATOR_UDID" -b
그런 다음 destination을 platform=iOS Simulator,id=$SIMULATOR_UDID로 변경합니다. 언어, 권한 또는 테스트 데이터를 미리 설정해야 하는 작업은 erase 이후에 일관된 방식으로 주입해야 하며, 이전 실행에서 남은 상태에 의존해서는 안 됩니다.
리소스 부하에 따라 동시 실행 수 결정하기
두 개의 샤드를 명시적으로 실행하면서 각 샤드에서 Xcode 내장 병렬 실행까지 활성화하면 중첩된 동시 실행이 발생합니다. 시뮬레이터, 테스트 프로세스, 컴파일 보조 프로세스가 함께 늘어나 결국 순차 실행보다 느려질 수 있습니다. 첫 구성에서는 -parallel-testing-enabled NO를 설정하고 샤드 슬롯을 한 번에 하나씩만 늘려야 합니다.
모니터링할 때는 최소한 다음 세 가지 신호를 기록합니다.
memory_pressure가 계속 높은 압력 상태로 진입하는지vm_stat에서 메모리 압축과 페이지 교체가 빠르게 증가하는지- 각 샤드의 테스트 케이스 수, 실행 시간, 실패 지점이 안정적인지
샤드는 단순히 테스트 수를 동일하게 맞추는 방식으로 구성해서는 안 됩니다. 앱 재실행이 많은 UI 테스트 클래스 하나가 순수 함수 테스트 수백 개보다 오래 걸릴 수 있습니다. 최근 여러 파이프라인의 실행 시간을 기준으로 목록을 조정할 수 있지만, 런타임에 무작위로 배정해서는 안 됩니다. 그렇게 하면 실패 재현과 추세 비교가 어려워집니다.
실패한 샤드를 개별 재실행하고 증거 보존하기
각 샤드는 종료 코드, 콘솔 로그, 독립된 .xcresult를 저장해야 합니다. 파이프라인의 집계 단계는 실패할 수 있지만, 첫 번째 샤드가 실패했다는 이유로 다른 샤드의 증거를 삭제해서는 안 됩니다. 실패한 그룹을 다시 실행할 때는 원본 .xctestrun을 계속 사용하되 결과 경로는 변경합니다.
xcodebuild test-without-building \
-xctestrun "$XCTESTRUN" \
-destination "platform=iOS Simulator,id=$SIMULATOR_UDID" \
-parallel-testing-enabled NO \
-only-testing:AppUITests/CheckoutTests \
-resultBundlePath "$RESULTS/ui-checkout-retry-1.xcresult"
소스 코드, 컴파일 인수, Xcode 버전 또는 시뮬레이터 런타임이 변경되었다면 build-for-testing을 다시 실행해야 합니다. 이전 산출물을 새 커밋의 증거로 사용해서는 안 됩니다. 테스트 목록, 빌드 로그, 샤드별 결과, 환경 버전을 최종적으로 보존해야 실패 원인이 코드인지, 테스트 상태인지, 실행 환경인지 판단할 수 있습니다.
이 구조의 핵심은 테스트 명령을 여러 벌 복사하는 것이 아니라 세 가지 소유권을 명확히 하는 데 있습니다. 빌드 산출물은 한 번만 생성하고, 각 시뮬레이터는 하나의 샤드가 독점하며, 결과 번들은 절대 서로 덮어쓰지 않아야 합니다. 이 세 가지를 지켜야 재현 가능성을 희생하지 않고 샤드 수를 조정하거나 특정 샤드만 다시 실행할 수 있습니다.
자주 묻는 질문
Xcode 병렬 테스트와 명시적 샤딩을 함께 사용해도 되나요?
가능하지만 두 단계의 병렬화를 동시에 크게 설정하면 메모리와 시뮬레이터가 과도하게 사용됩니다. 먼저 샤드 수를 제한하고 각 샤드의 내부 병렬화는 끄는 편이 안전합니다.
샤드 하나만 실패하면 전체 빌드를 다시 해야 하나요?
빌드 산출물과 xctestrun이 보존되어 있고 환경이 바뀌지 않았다면 실패한 샤드만 test-without-building으로 다시 실행할 수 있습니다.
단일 주문으로 독점 사용하는 물리 Mac mini가 필요하신가요?
M4와 M4 Pro 두 가지 구성, 네 가지 대여 기간, 다섯 개의 선택 가능한 노드를 확인한 후 워크플로에 맞는 장비를 선택하세요.