클라우드 Mac의 Swift 타임아웃 검증: 취소와 종료 경계

클라우드 Mac의 Swift 타임아웃 검증: 취소와 종료 경계

클라우드 Mac의 Swift 타임아웃 검증: 취소와 종료 경계

여러 시간대의 팀이 클라우드 Mac에서 비동기 코드 작업을 인계할 때 특히 재현하기 어려운 문제가 있습니다. 화면에는 이미 타임아웃이 표시됐는데 테스트는 좀처럼 끝나지 않는 상황입니다. 대개 타이머가 고장 난 것이 아니라 하위 작업이 취소에 반응하지 않는 것이 원인입니다. 작업과 타이머를 작업 그룹에 넣으면 둘 사이의 경합은 만들 수 있지만, 임의의 코드를 강제로 중단할 수는 없습니다.

아래에서는 Swift의 구조적 동시성으로 실행 가능한 기준 구현을 만든 뒤 결과, 취소, 실제 종료를 각각 검증합니다. 예제를 사용하려면 Duration과 Task.sleep(for:)를 지원하는 툴체인이 필요하며, 명령줄 실행 환경은 macOS 13 이상이어야 합니다. 팀 간 인계 시에는 실제로 사용한 컴파일러 버전을 기록해야 합니다.

타이머를 만들기 전에 타임아웃 계약부터 정의하기

계약은 세 가지로 정리합니다. 작업이 먼저 완료되면 결과를 반환합니다. 타이머 분기의 완료가 먼저 관찰되면 타임아웃 오류를 던집니다. 부모 작업이 취소되면 취소를 그대로 전파하며, 이를 비즈니스 로직의 타임아웃으로 바꾸지 않습니다. 두 분기가 거의 동시에 완료될 때는 어느 쪽이 이길지 보장하지 않습니다.

상대적인 대기 시간과 엄격한 마감 시각도 구분해야 합니다. 아래 구현의 시간 측정은 타이머 분기가 대기를 시작할 때부터 적용됩니다. 호출 진입 시점부터 계산한 절대적인 응답 시간 상한이 아닙니다. 대기 시간을 아주 짧게 설정한다고 스케줄링 지연이 사라지는 것도 아닙니다.

타임아웃 발생, 취소 요청 전송, 하위 작업 종료는 서로 다른 세 가지 사건입니다. 마지막 사건까지 일어나야 작업 그룹이 종료될 수 있습니다.

요구사항이 정해진 시각에 즉시 반환하는 것이라면, 모든 작업이 여전히 이 작업 그룹의 범위 안에 있다고 동시에 주장할 수는 없습니다. 하위 작업의 취소 인터페이스를 다시 설계하거나, 중단할 수 없는 작업을 별도 프로세스로 옮겨 수명 주기를 관리해야 합니다.

대기 로직을 주입할 수 있는 경합 함수 구현하기

다음 내용을 Timeout.swift로 저장합니다. 대기 클로저에는 실제 시계를 연결할 수도 있고, 테스트에서 제어 가능한 게이트를 대신 넣을 수도 있습니다. 작업 결과는 작업 간 전달에 필요한 조건을 충족해야 합니다.

import Foundation

enum TimeoutFailure: Error {
    case expired
}

func raceTimeout<Value: Sendable>(
    wait: @escaping @Sendable () async throws -> Void,
    operation: @escaping @Sendable () async throws -> Value
) async throws -> Value {
    try Task.checkCancellation()

    return try await withThrowingTaskGroup(of: Value.self) { group in
        defer { group.cancelAll() }

        group.addTask {
            try Task.checkCancellation()
            return try await operation()
        }

        group.addTask {
            try Task.checkCancellation()
            try await wait()
            try Task.checkCancellation()
            throw TimeoutFailure.expired
        }

        guard let value = try await group.next() else {
            throw CancellationError()
        }

        try Task.checkCancellation()
        return value
    }
}

먼저 컴파일 명령으로 타입과 동시성 제약을 확인한 뒤 프로젝트 테스트에 연결합니다.

xcrun swiftc -swift-version 6 -strict-concurrency=complete \
  -parse-as-library -emit-module \
  -module-name Timeout Timeout.swift \
  -emit-module-path /tmp/Timeout.swiftmodule

실제 타이머를 사용하는 호출 예시는 다음과 같습니다. 호출하는 코드는 비동기 컨텍스트에서 실행되어야 합니다.

let result = try await raceTimeout(
    wait: { try await Task.sleep(for: .seconds(2)) },
    operation: {
        try await Task.sleep(for: .milliseconds(20))
        return "ready"
    }
)

defer는 성공 경로와 오류 경로 모두에서 남은 분기에 취소를 요청하도록 보장합니다. 타이머는 취소되면 오류를 던집니다. 이 오류를 try?로 무시하면 이후 코드가 계속 실행되어 타임아웃으로 잘못 보고될 수 있습니다.

sleep에 기대지 말고 게이트로 실행 순서 고정하기

핵심 단위 테스트를 “조금 기다린 뒤 어느 분기가 먼저 실행됐을지 추측하는” 방식으로 작성하면 안 됩니다. 테스트 게이트 두 개를 만들어 작업 완료와 타이머 완료를 각각 제어합니다. 시작 신호도 따로 두어 두 분기가 모두 대기에 들어갔음을 확인한 다음 원하는 게이트를 엽니다.

게이트는 취소 상태를 기록하고 취소 핸들러를 통해 대기 중인 작업을 풀어 주어야 합니다. 내부에서 continuation을 사용한다면 반드시 한 번만 재개되도록 보장해야 합니다. 취소가 먼저 도착하고 대기자가 나중에 등록되는 순서도 검증해야 합니다. 그렇지 않으면 테스트 도구 자체가 멈춤 현상을 만들어 냅니다.

최소한 다음 네 가지 검증 묶음을 유지합니다.

각 검증에서는 호출 결과만 확인하지 말고 두 분기의 종료 표시까지 기다려야 합니다. 결과가 맞더라도 자식 작업이 종료되지 않았다면 검증을 통과한 것으로 볼 수 없습니다.

취소에 반응하지 않으면 종료가 막힌다는 사실을 별도로 입증하기

의도적으로 취소를 무시하되 테스트에서 열 수 있는 작업 게이트를 추가합니다. 먼저 타이머 분기가 이기게 한 다음, 작업이 취소 요청을 받았는데도 바깥 호출은 아직 완료되지 않았음을 확인합니다. 마지막으로 작업 게이트를 열고, 그제야 바깥 호출이 타임아웃 오류를 던지는지 확인합니다.

이 테스트는 구조적 동시성의 대기 동작을 입증하기 위한 것이지 취소를 무시하라고 권장하는 것이 아닙니다. 테스트에는 반드시 게이트를 열 수 있는 안전장치가 있어야 합니다. 무한 루프로 멈춤을 흉내 내거나, 검증 실패 후 영원히 재개되지 않는 continuation을 남겨서는 안 됩니다.

프로덕션 코드에서는 호출 경로 전체를 점검해야 합니다. 긴 루프가 주기적으로 Task.checkCancellation()을 실행하는지, 콜백 어댑터가 하위 작업의 취소 핸들을 호출하는지, 블로킹 호출이 반환될 수 있는지 확인합니다. 작업 진입 시점에 한 번만 확인해서는 이후의 장시간 작업까지 다룰 수 없습니다.

종료 증거까지 다음 근무조에 인계하기

클라우드 Mac에서 실행할 때는 커밋 식별자, 컴파일러 버전, 테스트 케이스 이름, 승리한 분기, 취소 관찰 기록, 두 개의 종료 표시를 테스트 첨부 자료에 남깁니다. 소요 시간은 단조 시계로 계산해야 합니다. 서로 다른 팀원의 데스크톱에 표시된 시스템 시각을 빼서 계산하면 안 됩니다.

실제 시계를 사용하는 테스트는 프로덕션 대기 클로저가 올바르게 연결됐는지만 검증합니다. 스케줄링을 위한 여유를 두고 특정 밀리초에 정확히 맞아야 한다는 조건은 걸지 않습니다. 테스트 프로세스에는 별도의 실행 시간 상한도 필요합니다. 종료되지 않는 작업 그룹은 자신의 타이머 자식 작업만으로 스스로 빠져나올 수 없기 때문입니다.

마지막으로 오류 처리가 모든 CancellationError를 타임아웃으로 바꾸지는 않는지, 대기 클로저가 오류를 무시하지는 않는지, 실패 경로에서 종료 증거가 빠지지는 않는지 확인합니다. 인계 완료의 기준은 “타이머가 울렸다”가 아닙니다. 결과를 설명할 수 있고, 취소를 관찰할 수 있으며, 남은 작업이 종료됐어야 합니다.

자주 묻는 질문

타이머 작업이 오류를 던지면 함수가 바로 반환되나요?

항상 그렇지는 않습니다. 작업 그룹을 벗어나려면 모든 자식 작업이 끝나야 합니다. cancelAll은 취소 요청이며 응답하지 않는 작업을 강제로 종료하지 않습니다.

모든 테스트에서 실제로 몇 초씩 기다려야 하나요?

핵심 계약 테스트에서는 제어 가능한 대기 함수를 주입하고 게이트로 완료 순서를 정하면 됩니다. 실제 시간을 쓰는 통합 테스트는 소수만 유지하고 별도 실행 상한을 둡니다.

다음 작업에 맞는 클라우드 Mac 선택

팀이 주로 위치한 지역을 기준으로 리전을 선택하고, Mac mini 전체를 단독으로 사용하세요. 브라우저 원격 데스크톱을 이용하려면 최신 브라우저와 안정적인 네트워크가 필요합니다. 주문 전에 구성, 결제 주기, 실제 이용 가능 여부를 확인하세요.

다음 연결은 가까운 곳에서

먼저 구성과 이용 기간을 확인한 다음, 팀이 주로 사용하는 지역과 가까운 리전을 선택하세요.