기존 SDK의 완료 콜백은 로컬 테스트에서 줄곧 정상적으로 동작했지만, 클라우드 Mac으로 옮겨 병렬 실행하자 간헐적으로 continuation이 중복 재개되거나 작업을 취소한 뒤에도 결과를 기다리는 문제가 나타났습니다. 원인은 대개 머신 성능이 아니라, 어댑터가 “콜백은 한 번만 호출되며 반드시 비동기로 도착한다”는 가정을 사실로 받아들인 데 있습니다. 아래에서는 이 두 가정을 제거하고, 실제 네트워크나 임의의 대기 시간에 의존하지 않는 독립 실행형 Swift 프로그램으로 경계 조건을 검증합니다.
최종 결과를 누가 결정하는지 먼저 정의하기
이 글에서 다루는 대상은 일회성 작업입니다. 성공, 실패, 취소 가운데 상태 머신에 먼저 제출된 결과가 확정됩니다. 이후의 알림은 결과를 바꿀 수 없으며, 대기 중인 쪽을 다시 재개해서도 안 됩니다. 스트리밍이나 여러 차례 업데이트를 전달하는 인터페이스에는 이 계약을 적용할 수 없습니다.
저장해야 할 상태는 두 가지입니다. 아직 설치되지 않았을 수 있는 continuation과 이미 확정된 최종 결과입니다. continuation이 설치되기 전에 취소가 발생할 수 있으므로, 값이 없을 수 있는 변수 하나만으로 작업 종료 여부를 판단해서는 안 됩니다.
checked continuation은 잘못된 사용을 검사하지만, 중복 알림을 걸러 주거나 작업 취소를 하위 작업의 취소로 자동 전환해 주지는 않습니다.
여기서 적용하는 원칙은 “먼저 제출된 결과가 유효하다”이지, “취소가 항상 우선한다”가 아닙니다. 성공이 이미 제출되었다면 재개 동작이 아직 스케줄링되어 실행되지 않았더라도 뒤늦게 도착한 취소가 성공을 덮어쓸 수 없습니다. 비즈니스 요구 사항상 취소에 더 엄격한 우선순위가 필요하다면, 반환값 주변에 임의로 검사를 덧붙이는 대신 결과 확정 시점을 별도로 정의해야 합니다.
짧은 임계 구역으로 완료 상태 보호하기
다음 코드를 BridgeCheck.swift로 저장합니다. 이 예제는 Swift 6과 NSLock.withLock을 제공하는 macOS SDK를 사용합니다. 잠금은 상태를 읽고 쓰는 동안에만 유지하며, 외부 콜백 호출, 요청 시작, continuation 재개는 잠금 범위에 넣지 않습니다.
import Foundation
final class CompletionGate<Value: Sendable>: @unchecked Sendable {
private let lock = NSLock()
private var outcome: Result<Value, any Error>?
private var waiter: CheckedContinuation<Value, any Error>?
func install(_ continuation: CheckedContinuation<Value, any Error>) {
let ready: Result<Value, any Error>? = lock.withLock {
if let outcome {
return outcome
}
precondition(waiter == nil)
waiter = continuation
return nil
}
if let ready {
continuation.resume(with: ready)
}
}
func finish(_ result: Result<Value, any Error>) {
let continuation: CheckedContinuation<Value, any Error>? =
lock.withLock {
guard outcome == nil else {
return nil
}
outcome = result
let current = waiter
waiter = nil
return current
}
continuation?.resume(with: result)
}
}
func bridge(
start: @Sendable (
@escaping @Sendable (Result<Int, any Error>) -> Void
) -> Void
) async throws -> Int {
let gate = CompletionGate<Int>()
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Int, any Error>) in
gate.install(continuation)
start { result in
gate.finish(result)
}
}
} onCancel: {
gate.finish(.failure(CancellationError()))
}
}
@unchecked Sendable은 검사를 끈 뒤 더는 신경 쓰지 않아도 된다는 뜻이 아닙니다. 공유되는 모든 가변 필드를 같은 잠금으로 보호하고, 결과 값이 Sendable을 충족한다는 유지보수자의 약속입니다. 나중에 카운터나 핸들을 추가할 때도 동기화 방식을 검토해야 합니다.
설치와 완료의 순서는 두 가지뿐입니다. 설치가 먼저라면 대기 중인 continuation을 꺼내 재개합니다. 완료가 먼저라면 결과를 저장했다가 설치 시점에 재개합니다. 대기 중인 continuation을 비우면 참조를 신속하게 해제할 수 있지만, 종료 여부의 판단 기준은 언제나 결과 상태입니다. 대기 항목이 비워졌다고 해서 새 결과를 다시 받을 수 있는 상태로 오해해서는 안 됩니다.
결정적 예제로 핵심 실행 순서 검증하기
다음 진입점을 같은 파일에 추가합니다. 첫 번째 사례는 의도적으로 콜백을 동기적으로 두 번 호출합니다. 두 번째 사례는 콜백을 전혀 호출하지 않는 작업을 시작한 뒤 대기 중인 태스크를 취소합니다. 전체 과정에서 스케줄링 시점을 추측하기 위한 sleep은 사용하지 않습니다.
@main
struct BridgeCheck {
static func main() async throws {
let value = try await bridge { complete in
complete(.success(7))
complete(.success(9))
}
precondition(value == 7)
let pending = Task {
try await bridge { _ in }
}
pending.cancel()
do {
_ = try await pending.value
preconditionFailure("Expected cancellation")
} catch is CancellationError {
}
}
}
xcrun swiftc -parse-as-library -swift-version 6 \
BridgeCheck.swift -o BridgeCheck
./BridgeCheck
정상적으로 실행되면 종료 코드 0으로 끝나야 합니다. 동기 완료 사례는 “먼저 시작하고 나중에 continuation을 저장하는” 잘못된 구현을 찾아냅니다. 중복 알림 사례는 보호 장치 없이 두 번째 재개가 발생하는 문제를 찾아내고, 취소 사례는 콜백이 오지 않아도 대기를 끝낼 수 있는지 확인합니다.
프로젝트 테스트에 통합한 뒤에는 상태 머신의 “완료 후 설치” 순서도 직접 테스트해야 합니다. 성공 후 실패, 실패 후 성공 순서도 각각 한 번씩 실행해야 합니다. 동시성 부하 테스트로 검증 범위를 보완할 수는 있지만, 실행 순서를 명시한 이러한 사례를 대신할 수는 없습니다.
대기 취소와 하위 작업 중단은 다르다
이 어댑터는 대기가 끝나도록 보장하지만, 하위 작업까지 중단하지는 않습니다. 취소가 먼저 도착해도 start는 실행될 수 있으며, 이후의 알림은 무시됩니다. 이는 의도적으로 남겨 둔 책임 범위이므로, 이 예제를 완전한 네트워크 클라이언트로 간주해서는 안 됩니다.
취소 핸들이 있을 때 확장하는 방법
실제 인터페이스가 취소 핸들을 반환한다면, 그 핸들도 같은 보호 상태 안에 등록해야 합니다. 취소가 먼저 도착하면 플래그를 기록하고, 나중에 핸들이 반환되는 즉시 꺼내 취소합니다. 핸들이 먼저 도착했다면 취소 처리 경로에서 꺼냅니다. 실제 취소 동작은 여전히 잠금 밖에서 실행해야 합니다. 취소 호출이 완료 콜백을 동기적으로 호출할 수 있기 때문입니다.
시작 전에 취소 상태를 한 번 확인하는 것만으로는 부족합니다. 검사를 통과한 시점과 핸들이 반환되는 시점 사이에도 경쟁 상태가 남아 있습니다. 또한 잠금을 잡은 상태에서 취소 인터페이스를 호출해서는 안 됩니다. 동기 콜백이 상태 머신에 다시 진입하면서 교착 상태가 발생할 수 있습니다.
하위 작업이 끝내 알림을 보내지 않고 호출 측도 취소하지 않으면 대기는 계속됩니다. 비즈니스 로직의 타임아웃도 같은 완료 진입점을 거쳐야 하며, 하위 리소스 정리까지 함께 처리해야 합니다. continuation 자체에 타임아웃 기능이 있다고 가정해서는 안 됩니다.
클라우드 실행 진입점에 검증 통합하기
클라우드 Mac의 빌드 사전 단계에서 이 검증 프로그램을 컴파일하고 실행한 뒤, 컴파일러 버전, 커밋 식별자, 종료 코드를 기록합니다. 이 예제에는 그래픽 세션이 필요하지 않으므로 명령줄에서 반복 실행하기에 적합합니다.
병합 전에는 네 가지를 확인합니다. 호출마다 독립된 상태 머신을 사용하는지, 동기 콜백이 유실되지 않는지, 중복 알림이 중복 재개로 이어지지 않는지, 취소 후 대기가 끝나지 않은 채 남아 있지 않은지입니다. 핸들을 사용하는 실제 인터페이스에는 “핸들 반환보다 취소가 먼저 발생하는 경우”를 위한 별도 테스트도 추가합니다.
마지막으로 클로저의 참조 관계를 점검합니다. 대기가 이미 끝났더라도 하위 작업이 완료 클로저를 계속 보유할 수 있습니다. 상태 머신에서 중복 알림을 걸러 내는 것만으로 모든 생명주기 문제가 해결되지는 않습니다. 완료 계약, 중단 계약, 리소스 해제를 각각 검증해야 비동기 래핑이 제대로 마무리됩니다.
자주 묻는 질문
checked continuation이 두 번째 콜백을 무시하나요?
아닙니다. resume을 두 번 호출하면 계약 위반입니다. 잠금으로 보호한 상태 머신에서 결과 하나를 선택하고 잠금을 해제한 뒤 continuation을 재개해야 합니다.
작업을 취소하면 실제 요청도 중단되나요?
자동으로 중단되지 않습니다. 이 어댑터는 대기 중인 작업을 취소 오류로 끝냅니다. 실제 요청에는 별도의 취소 API와 핸들 반환 전 취소에 대한 처리가 필요합니다.
다음 빌드는 단독 클라우드 Mac에서
MacFTP는 기본 macOS를 실행하는 Apple Silicon 전용 물리 서버를 제공합니다. 일·주·월·분기 단위로 이용 기간을 선택하세요. 먼저 구성을 확인한 뒤 기존 도구 체인을 노드에 연결하면 됩니다. 실제 사용 가능 여부는 콘솔에 표시되는 실시간 상태를 기준으로 확인하세요.