엔지니어링 가이드

클라우드 Mac CI에서 Thread Sanitizer로 Swift 데이터 경쟁 찾기

클라우드 Mac CI에서 Thread Sanitizer로 Swift 데이터 경쟁 찾기

동일한 XCTest 세트가 로컬에서는 계속 통과하지만 병렬 파이프라인에 넣으면 간헐적으로 딕셔너리 쓰기, 배열 인덱스 접근 또는 상태 검증에서 실패한다면, 대개 원인은 “불안정한 머신”이 아니라 두 실행 경로가 공유 가변 상태에 동시에 접근하는 데 있습니다. 데이터 경쟁은 구체적인 스케줄링 순서에 따라 발생하므로 한 번만 재실행해도 문제가 사라지기 쉽습니다. 더 효과적인 방법은 클라우드 Mac에 별도의 Thread Sanitizer 테스트 작업을 구성해 경쟁 발생 가능성을 높이고 결과 번들을 보관한 다음, 첫 번째 충돌 접근 쌍에서 상태 소유권을 역추적하는 것입니다.

탐지 작업을 일반 테스트와 먼저 분리하기

Thread Sanitizer는 메모리 접근을 기록하고 스레드 간 관계를 추적하므로 실행 시간과 메모리 사용량이 모두 증가합니다. 전체 파이프라인에 바로 활성화하지 마세요. 먼저 ConcurrencySanitizer Scheme 또는 Test Plan을 만들고, 캐시, 다운로드 큐, 데이터베이스 래퍼, 콜백 브리지, 병렬 파싱처럼 여러 스레드에서 상태를 변경할 가능성이 있는 테스트만 포함합니다.

실행 계층은 다음 세 단계로 나누는 것이 좋습니다.

계층 테스트 범위 실행 시점
빠른 검사 순수 함수 및 일반 단위 테스트 커밋할 때마다
경쟁 검사 동시성 위험이 큰 테스트 병합 요청마다
확장 검사 전체 단위 및 통합 테스트 별도의 정기 작업

명령줄 환경에서 Scheme을 찾을 수 있도록 반드시 저장소에 공유해야 합니다. 커밋하기 전에 xcodebuild -list -workspace App.xcworkspace로 이름을 확인할 수 있습니다. 또한 테스트 대상에서는 병렬 테스트 실행으로 인한 암묵적인 무작위성을 제거하고, 각 테스트 케이스 내부에서 의도적으로 동시 실행을 만들어야 합니다. 이렇게 하면 부하의 원천이 제어할 수 없는 테스트 스케줄러가 아니라 읽고 이해할 수 있는 테스트 코드가 됩니다.

명령줄에서 재현 가능한 실행 고정하기

먼저 클라우드 Mac에 설치된 시뮬레이터 이름을 확인한 뒤, 예제의 기기를 실제 값으로 바꿉니다. 두 실행기가 같은 인덱스와 빌드 중간 산출물을 덮어쓰지 않도록 각 작업에 별도의 DerivedData를 할당합니다.

set -euo pipefail

RUN_ID="${CI_RUN_ID:-local}"
RESULT_DIR="$PWD/Artifacts/tsan"
DERIVED_DATA="$PWD/.derived-data/tsan-$RUN_ID"

mkdir -p "$RESULT_DIR"

xcodebuild test \
  -workspace App.xcworkspace \
  -scheme ConcurrencySanitizer \
  -configuration Debug \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -derivedDataPath "$DERIVED_DATA" \
  -enableThreadSanitizer YES \
  -resultBundlePath "$RESULT_DIR/result.xcresult"

Address Sanitizer와 동시에 활성화하지 마세요. 두 가지 계측을 함께 적용하면 리소스 비용과 로그 노이즈가 모두 증가하며, 실패 원인을 구분하기도 어려워집니다. Release 구성에는 일반적으로 최적화와 다른 검증 정책도 포함되므로 첫 번째 진단에는 Debug를 사용해야 합니다. 수정 사항을 확인한 뒤 프로젝트 요구에 따라 다른 구성을 추가로 검증하세요.

결과가 한 번 성공했다는 것은 해당 스케줄링에서 경쟁이 발생하지 않았다는 뜻일 뿐, 공유 상태가 안전하다는 의미는 아닙니다.

작업 교차 실행 가능성을 의도적으로 높이기

가장 유용한 부하 테스트는 App 전체를 무작정 반복하는 것이 아니라, 하나의 공유 객체를 중심으로 읽기, 쓰기, 취소, 재설정을 동시에 수행하는 테스트입니다. 다음 테스트는 여러 작업이 카운터 업데이트를 두고 경쟁하게 하므로 탐지 경로가 실제로 작동하는지 검증하는 데 적합합니다.

final class UnsafeCounter: @unchecked Sendable {
    private(set) var value = 0

    func increment() {
        value += 1
    }
}

func testConcurrentIncrement() async {
    let counter = UnsafeCounter()

    await withTaskGroup(of: Void.self) { group in
        for _ in 0..<100 {
            group.addTask {
                counter.increment()
            }
        }
    }

    XCTAssertEqual(counter.value, 100)
}

@unchecked Sendable은 수정 방법이 아니라 책임을 개발자에게 넘기는 선언일 뿐입니다. 실제 코드가 이 선언에 의존한다면 모든 사용 지점을 검토 대상에 포함해야 합니다. 탐지 확률을 높이기 위해 위험도가 높은 테스트를 20~50회 반복할 수 있지만, 파이프라인 전체 제한 시간을 설정해야 합니다. 무작위로 긴 sleep을 추가하지 마세요. 짧은 Task.yield()가 작업을 제어 가능한 상태로 유지하면서 교차 실행을 늘리는 데 더 적합합니다.

상태 소유권 수정하기

비즈니스 상태는 하나의 Actor만 쓰기를 담당하도록 하는 방식을 우선 적용합니다.

actor Counter {
    private var value = 0

    func increment() {
        value += 1
    }

    func currentValue() -> Int {
        value
    }
}

호출자는 반드시 await를 통해 접근해야 하므로 소유권 경계가 타입 시스템에 반영됩니다. 기존 인터페이스가 동기 방식이어야 한다면 명확한 단일 직렬 큐나 범위가 매우 작은 잠금을 사용할 수 있습니다. 단, 일부 경로에만 잠금을 적용하고 다른 경로에서는 직접 읽어서는 안 됩니다. 잠금이 보호하는 것은 한 줄의 대입문이 아니라 불변 조건입니다.

첫 번째 충돌 접근 쌍부터 보고서 읽기

Thread Sanitizer 보고서는 대개 매우 깁니다. 이후에 연쇄적으로 발생한 오류는 우선 무시하고, 첫 번째 ReadWrite 조합 또는 두 번의 Write만 확인합니다. 각 접근의 스레드, 큐, 소스 코드 행, 객체 생성 위치를 기록한 뒤 다음 세 가지 질문에 답합니다.

  1. 두 경로가 동일한 인스턴스에 접근하는가?
  2. 해당 상태의 소유자는 누구여야 하는가?
  3. 동기화 경계가 읽기-수정-쓰기 전체 과정을 포괄하는가?

예를 들어 cache[key] = value는 한 줄처럼 보이지만 내부적으로 조회, 용량 확장, 쓰기가 포함될 수 있습니다. 호출 전후에 각각 표시만 추가해도 상호 배제가 보장되지는 않습니다. 마찬가지로 “배열이 비어 있지 않은지 확인한 뒤 첫 번째 요소를 가져오는” 작업은 동일한 격리 영역 안에서 이루어져야 합니다. 그렇지 않으면 확인과 읽기 사이에 다른 작업이 배열을 비울 수 있습니다.

터미널의 마지막 수십 줄만 캡처하지 말고 .xcresult를 보관하세요. 먼저 구조화된 요약을 내보낼 수 있습니다.

xcrun xcresulttool get test-results summary \
  --path Artifacts/tsan/result.xcresult \
  --format json > Artifacts/tsan/summary.json

명령의 지원 여부는 로컬 Xcode 버전에 따라 달라지므로 스크립트에서 먼저 xcrun xcresulttool help를 실행해 하위 명령을 확인해야 합니다. 결과 번들, 소스 코드 커밋 번호, Scheme 이름, 시뮬레이터 런타임 버전을 함께 보관하지 않으면 나중에 동일한 상황을 재현하기 어렵습니다.

수정 검증을 안정적인 게이트로 전환하기

수정 후에는 먼저 기존 부하 테스트를 실행해 Sanitizer가 더 이상 경쟁을 보고하지 않는지 확인합니다. 그런 다음 계측하지 않은 일반 테스트 스위트를 실행해 격리 변경으로 인해 교착 상태, 실행 순서 변화 또는 시간 초과가 발생하지 않았는지 검증합니다. 코드 리뷰에서는 다음 항목도 확인해야 합니다.

  • 근거 없는 @unchecked Sendable을 제거했는가?
  • 가변 컬렉션의 쓰기 진입점이 하나뿐인가?
  • 콜백을 async로 변환할 때 continuation이 두 번 이상 재개될 가능성이 있는가?
  • Task.detached가 기존 Actor를 우회하는가?
  • 테스트 더블과 전역 싱글턴이 테스트 케이스 사이에서 상태를 공유하는가?
  • 실패한 작업이 .xcresult 전체를 업로드하는가?

Thread Sanitizer는 런타임에 실제로 발생한 경쟁을 찾는 데 적합하고, Swift의 엄격한 동시성 검사는 컴파일 시점에 격리를 제한하는 역할을 합니다. 둘은 서로를 대체할 수 없습니다. 컴파일 경고, 동시성 부하 테스트, Sanitizer 결과를 서로 다른 단계에 배치하면 실패 원인을 더 명확히 파악할 수 있습니다. 최종 목표는 보고서를 사라지게 하는 것이 아니라, 모든 가변 상태에 설명 가능하고 검토 가능한 단 하나의 소유자가 있도록 만드는 것입니다.

자주 묻는 질문

Thread Sanitizer를 모든 커밋에서 실행해야 하나요?

커밋마다 동시성 위험이 큰 소규모 테스트만 실행하고 전체 계측 테스트는 별도 작업으로 분리하는 편이 효율적입니다.

테스트가 통과하면 데이터 경쟁이 없다는 뜻인가요?

아닙니다. 해당 실행에서 충돌 접근이 관찰되지 않았다는 뜻입니다. 반복 실행과 의도적인 작업 교차로 탐지 가능성을 높여야 합니다.

Actor와 잠금 중 무엇을 우선해야 하나요?

서로 관련된 가변 상태에는 Actor를 우선합니다. 잠금은 범위가 매우 짧고 소유권 규칙이 명확한 동기식 임계 구역에 제한하는 것이 좋습니다.

전용 물리 노드

OpsVM에서 클라우드 Mac 워크플로 배포

세 가지 Apple Silicon 구성과 판매 중인 6개 노드 중에서 선택할 수 있으며, 실제 사용 가능 여부는 콘솔에서 실시간으로 확인되는 상태를 따릅니다.

구성 선택 및 주문