클라우드 Mac에서 멈춘 XCTest를 감시하고 실행 현장 보존하기

클라우드 Mac에서 멈춘 XCTest를 감시하고 실행 현장 보존하기

평소라면 십여 분 안에 끝나는 XCTest 작업이 클라우드 Mac에서 40분이 지나도록 종료되지 않는다. 로그의 마지막 줄에는 특정 테스트 케이스가 시작됐다는 내용만 남아 있고, CPU 사용률은 높지 않으며 결과 번들도 완성되지 않았다. 이 상태에서 작업을 곧바로 중단하면 결과는 단지 ‘시간 초과’일 뿐이다. 메인 스레드 교착 상태인지, 비동기 콜백이 반환되지 않은 것인지, 시뮬레이터 서비스와의 연결이 끊긴 것인지, 아니면 테스트 프로세스가 영원히 충족되지 않을 조건을 계속 기다리는 것인지 판단할 수 없다.

이런 문제를 처리할 때 중요한 것은 재시도 횟수를 늘리는 일이 아니라 종료 전에 실행 현장을 보존하는 것이다. 프로세스 관계, 연속으로 수집한 스레드 샘플, 시뮬레이터 상태, 원본 로그와 미완성 xcresult를 모두 동일한 실행 기록에 포함해야 한다.

느린 테스트와 실제 멈춤을 먼저 구분하기

전체 제한 시간 하나만 설정하면 잘못 판단하기 쉽다. 최초 빌드, 시뮬레이터 시작, 테스트 실행은 각각 지연 원인이 다르므로 먼저 분리해야 한다.

  1. build-for-testing으로 빌드를 완료한다.
  2. test-without-building으로 테스트를 실행한다.
  3. 빌드 시간, 테스트 시간, 마지막 로그 변경 시각을 각각 기록한다.
  4. 테스트가 과거의 상한을 넘으면 즉시 강제 종료하지 말고 실행 현장을 수집한다.

전체 상한은 작업이 무한정 실행되는 것을 막고, 무응답 상한은 프로세스가 살아 있지만 진행되지 않는 상태를 감지한다. 다만 표준 출력이 없다는 이유만으로 무응답이라고 판단해서는 안 된다. 일부 장시간 테스트는 원래 출력이 거의 없다. 로그 파일 수정 시각, 테스트 프로세스의 생존 여부, xcresult 디렉터리의 변경 여부를 함께 관찰하는 편이 더 신뢰할 수 있다.

시간 초과는 보호 장치이지 근본 원인이 아니다. 시간 초과가 발생한 순간에 충분한 증거를 남겨야 진단에 의미가 있다.

재현 가능한 진입점 고정하기

먼저 scheme, 테스트 계획, 대상 기기와 DerivedData 경로를 고정한다. 진단 작업 중에 Xcode, 시뮬레이터 런타임 또는 종속성 버전까지 함께 바꾸면 두 실행의 현장을 비교할 수 없다.

set -euo pipefail

WORKSPACE="Demo.xcworkspace"
SCHEME="Demo"
PLAN="Regression"
DERIVED="$PWD/.build/DerivedData"
RESULT="$PWD/.artifacts/TestRun.xcresult"

rm -rf "$RESULT"
mkdir -p "$PWD/.artifacts"

xcodebuild build-for-testing \
  -workspace "$WORKSPACE" \
  -scheme "$SCHEME" \
  -testPlan "$PLAN" \
  -destination "platform=iOS Simulator,name=iPhone 16" \
  -derivedDataPath "$DERIVED"

xcodebuild test-without-building \
  -workspace "$WORKSPACE" \
  -scheme "$SCHEME" \
  -testPlan "$PLAN" \
  -destination "platform=iOS Simulator,name=iPhone 16" \
  -derivedDataPath "$DERIVED" \
  -resultBundlePath "$RESULT"

문제가 특정 테스트 클래스에서만 발생한다면 먼저 -only-testing:TargetName/TestClass로 범위를 좁혀야 한다. 처음부터 전체 테스트 계획을 병렬로 다시 실행하면 프로세스와 시뮬레이터의 잡음만 늘어난다.

테스트 프로세스에 증거를 남기는 감시기 추가하기

macOS에는 모든 환경에서 일관되게 사용할 수 있는 GNU timeout 명령이 기본 제공되지 않는다. CI 스크립트에서는 백그라운드 감시 프로세스로 같은 기능을 구현할 수 있다. 올바른 순서는 테스트 시작, 제한 시간 대기, 현장 수집, TERM 전송, 정리 시간 확보, 마지막으로 KILL 전송이다.

set -euo pipefail

OUT="$PWD/.artifacts/hang-$(date +%Y%m%d-%H%M%S)"
LIMIT="${TEST_TIMEOUT_SECONDS:-1200}"
mkdir -p "$OUT"

xcodebuild test-without-building \
  -workspace Demo.xcworkspace \
  -scheme Demo \
  -testPlan Regression \
  -destination "platform=iOS Simulator,name=iPhone 16" \
  -derivedDataPath "$PWD/.build/DerivedData" \
  -resultBundlePath "$OUT/TestRun.xcresult" \
  >"$OUT/xcodebuild.log" 2>&1 &

test_pid=$!

(
  sleep "$LIMIT"
  if kill -0 "$test_pid" 2>/dev/null; then
    date -u >"$OUT/timeout-at.txt"
    ps -axo pid,ppid,state,%cpu,%mem,etime,command >"$OUT/host-processes.txt"
    xcrun simctl list devices >"$OUT/simulators.txt"
    xcrun simctl spawn booted ps -axo pid,ppid,state,etime,command \
      >"$OUT/simulator-processes.txt" 2>&1 || true

    for round in 1 2 3; do
      xcrun simctl spawn booted sample DemoTests-Runner 5 1 \
        >"$OUT/test-runner-sample-$round.txt" 2>&1 || true
      sleep 3
    done

    kill -TERM "$test_pid" 2>/dev/null || true
    sleep 10
    kill -KILL "$test_pid" 2>/dev/null || true
  fi
) &

watchdog_pid=$!

set +e
wait "$test_pid"
status=$?
set -e

kill "$watchdog_pid" 2>/dev/null || true
wait "$watchdog_pid" 2>/dev/null || true
exit "$status"

테스트 Runner 이름은 프로젝트 산출물에 맞게 수정해야 한다. 이름이 일정하지 않다면 시뮬레이터 프로세스 목록에서 테스트 번들 경로를 기준으로 PID를 찾은 뒤 sample에 전달할 수 있다. 한 번의 긴 샘플링보다 세 번의 짧은 샘플링이 호출 스택의 진행이 실제로 멈췄는지 판단하기 쉽다.

스레드 스택으로 대기 유형 판단하기

먼저 세 개의 샘플에서 메인 스레드와 테스트 스레드가 같은 위치에 멈춰 있는지 확인한다. 대표적인 패턴은 다음과 같다.

메인 스레드가 테스트 스레드를 기다리는 경우

메인 스레드가 세마포어, 조건 변수 또는 동기 디스패치에서 멈춰 있고 테스트 스레드는 메인 스레드에서 실행돼야 하는 콜백을 기다린다면, 대개 비동기 API를 동기 방식으로 기다리면서 발생한 교착 상태다. XCTest expectation을 사용하도록 수정하고, 대기 동작이 콜백을 처리할 큐를 점유하지 않도록 해야 한다.

테스트 스레드가 외부 조건을 기다리는 경우

호출 스택이 계속 socket, 파일 조정, 데이터베이스 잠금 또는 프로세스 간 통신에 머문다면 종속 서비스, 임시 디렉터리 권한, 이전 테스트가 남긴 잠금을 추가로 확인해야 한다. 모든 대기를 네트워크 문제로 단정하지 말고, 먼저 프로세스와 파일 상태를 통해 무엇을 기다리는지 입증해야 한다.

호출 스택이 계속 변하는 경우

세 번의 샘플에서 핫스팟이 계속 바뀐다면 작업이 멈춘 것이 아니라 단순히 매우 느린 것일 수 있다. 이때는 개별 테스트 케이스의 소요 시간을 기록하고 데이터 규모를 줄이거나, 테스트 자체의 expectation에 합리적인 제한 시간을 설정해야 한다. 전체 제한 시간을 무한정 늘리는 방식으로 대응해서는 안 된다.

xcresult와 시스템 상태를 함께 보관하기

결과 번들이 정상적으로 마무리되지 않았더라도 원본 그대로 보관해야 하며, 실패 분기에서 먼저 삭제해서는 안 된다. 완성된 결과는 다음 명령으로 검색하기 쉬운 JSON 형식으로 내보낼 수 있다.

xcrun xcresulttool get \
  --legacy \
  --path ".artifacts/TestRun.xcresult" \
  --format json \
  >".artifacts/xcresult.json"

각 시간 초과 디렉터리에는 최소한 다음 항목이 포함돼야 한다.

  • 전체 xcodebuild 출력과 종료 코드
  • scheme, 테스트 계획, destination과 Xcode 버전
  • 호스트 및 시뮬레이터 프로세스 목록
  • 일정 간격으로 수집한 스레드 샘플 세 개
  • 시뮬레이터 기기 상태
  • 원본 xcresult와 내보낸 구조화 결과
  • 현재 커밋 식별자와 테스트 진입점 매개변수

PingMini의 클라우드 Mac에서 실행할 때도 하나의 작업이 명확하게 지정된 시뮬레이터 기기 세트 하나만 사용하도록 해야 한다. 다른 동시 실행 작업이 기기 상태를 변경하지 못하게 하기 위해서다. 구성과 노드 선택은 콘솔에서 현재 선택 가능한 항목을 확인하고, 동일한 장애 재현 과정에 시스템 차이가 섞이지 않도록 한다.

수정 사항을 회귀 검사로 전환하기

근본 원인을 찾았다고 해서 감시기를 삭제해서는 안 된다. 시간 초과 시 현장을 수집하는 기능은 유지하되, 임계값을 과거 백분위수를 기준으로 한 시간 예산으로 변경한다. 동시에 수정된 상황을 검증하는 최소 회귀 테스트도 추가한다. 예를 들어 교착 상태 문제라면 콜백이 제한 시간 안에 완료되는지 검증하고, 리소스 잠금 문제라면 테스트가 끝난 뒤 잠금 파일과 자식 프로세스가 모두 정리되는지 확인해야 한다.

마지막으로 세 가지를 점검한다. 정상 작업에서는 샘플링이 실행되지 않아야 하고, 비정상 작업은 제한된 시간 안에 종료돼야 하며, 보관 자료만으로도 ‘어떤 프로세스가 무엇을 기다리는가’에 답할 수 있어야 한다. 여기까지 갖추면 간헐적인 XCTest 멈춤은 더 이상 수동 재실행만 가능한 빨간색 기록이 아니라, 지속적으로 범위를 좁혀 해결할 수 있는 엔지니어링 문제가 된다.

자주 묻는 질문

XCTest 출력이 얼마나 없으면 멈춤으로 판단해야 하나요?

평소 실행 시간을 기준으로 한 전체 제한과 더 짧은 무출력 제한을 함께 사용합니다. 프로세스가 살아 있으면서 로그와 테스트 상태가 모두 진행되지 않을 때만 진단을 시작합니다.

시간 초과 직후 강제 종료하면 안 되는 이유는 무엇인가요?

즉시 강제 종료하면 스레드 상태와 결과 번들 정리 정보가 사라집니다. 먼저 프로세스와 스택을 수집하고 TERM을 보낸 뒤, 유예 시간에도 종료되지 않을 때만 KILL을 사용합니다.

프로세스 샘플에서 무엇을 먼저 확인해야 하나요?

메인 스레드와 테스트 스레드, 데이터베이스 큐, 사용자 정의 직렬 큐를 확인합니다. 여러 샘플에서 같은 잠금 대기나 IPC 호출이 반복되면 멈춤 원인을 좁히기 쉽습니다.

다음 빌드를 위한 전용 물리 Mac

개발, 지속적 통합 또는 로컬 모델 테스트에 적합한 세 가지 고정 구성 중 하나를 선택하고, 작업 기간에 맞춰 클라우드 Mac을 구성하세요.

클라우드 Mac 구성하기