클라우드 Mac에서 두 내보내기 요청이 같은 actor의 검사를 거쳤는데도 둘 다 마지막 남은 자리를 차지하는 일이 생길 수 있습니다. 반드시 데이터 경쟁 때문인 것은 아닙니다. 첫 번째 요청이 용량을 확인한 뒤 비동기 준비 작업을 기다리는 사이에 두 번째 요청이 진입할 수 있기 때문입니다. 검증해야 할 것은 actor가 변수 읽기와 쓰기를 직렬화하는지가 아니라, await 전후에도 비즈니스 제약이 유지되는지입니다.
먼저 용량 제약을 명확히 정의하기
내보내기 서비스에서 임시 작업 공간을 사용할 수 있는 작업이 최대 하나라고 가정하겠습니다. 사용 중인 용량에는 ‘실행 중’인 작업뿐 아니라 ‘준비 중’인 작업도 포함해야 합니다. 준비가 끝난 작업만 집계하면 준비 속도가 느린 작업이 제한을 우회하는 통로가 됩니다.
위험한 순서는 ‘빈자리 확인 → 준비 완료 대기 → 사용량 증가’입니다. 기다리는 동안 actor는 다른 호출을 처리할 수 있으므로, 앞서 확인한 결과가 실행을 재개할 때의 상태까지 보장하지는 않습니다. 올바른 순서는 일시 중단 지점이 없는 코드 구간에서 확인과 예약을 먼저 마친 다음 비동기 작업을 수행하는 것입니다.
actor 격리는 상태 접근을 보호할 뿐, 비동기 메서드 전체를 중간에 끼어들 수 없는 트랜잭션으로 만들지는 않습니다.
await를 만날 때마다 확인해야 합니다. 실행을 재개했을 때도 내가 의존하는 조건이 여전히 성립할까요?
이 예제에서는 용량이 가득 차면 대기열에 넣지 않고 즉시 거부합니다. 대기열, 공정성, 타임아웃은 별도의 계약으로 정의해야 합니다. 호출자가 무한히 재시도하도록 한다고 해결되는 문제가 아닙니다.
토큰으로 예약의 소유권 표현하기
클라우드 Mac에서 ActorQuota라는 이름의 macOS 프로젝트를 만들고 XCTest 단위 테스트 타깃을 추가합니다. 테스트 파일의 기본 예제 코드를 아래 코드로 교체하세요. 여기서 검증하는 것은 공통 Swift 동시성 로직이므로 iOS 시뮬레이터를 실행할 필요는 없습니다.
import Foundation
import XCTest
enum QuotaError: Error {
case full
}
actor ReservationPool {
private let capacity: Int
private var active: Set<UUID> = []
init(capacity: Int) {
precondition(capacity > 0)
self.capacity = capacity
}
var count: Int { active.count }
func run(
_ operation: @Sendable () async throws -> Void
) async throws {
try Task.checkCancellation()
guard active.count < capacity else {
throw QuotaError.full
}
let token = UUID()
active.insert(token)
defer { active.remove(token) }
try await operation()
try Task.checkCancellation()
}
}
확인과 삽입 사이에 await가 없으므로 다른 호출이 그 사이에 끼어들 수 없습니다. 각 호출은 자기 토큰을 보유하고, 종료할 때 자기 예약만 해제합니다. 따라서 실패 처리 과정에서 다른 호출의 사용량을 잘못 줄이는 일을 방지할 수 있습니다.
defer는 정상 반환과 오류 발생을 모두 처리하지만, 모든 자원 관리 문제를 대신 해결해 주지는 않습니다. 전달한 작업은 실제 작업이 끝날 때까지 기다린 뒤 반환해야 합니다. 현재 호출의 수명과 무관하게 실행되는 태스크를 시작하고 즉시 반환하면 예약한 자리 역시 너무 일찍 해제됩니다. 호출 계약과 구현을 함께 검토해야 합니다.
sleep에 기대지 말고 재진입 순서를 고정하기
첫 번째 작업에 제어 가능한 일시 정지 지점을 둡니다. 두 번째 호출은 예약이 완료된 뒤, 해제되기 전에 발생해야 합니다. 고정된 시간만큼 재우는 방식은 실행 진행 상황을 추측할 뿐이라, 머신 부하가 달라지면 테스트가 간헐적으로 통과할 수 있습니다.
이어서 아래 일회용 테스트 픽스처와 테스트 클래스를 같은 파일에 추가합니다. 이 픽스처는 대기를 시작하기 전에 먼저 열어 두어도 동작합니다. 따라서 신호가 먼저 도착해 영구히 멈추는 일을 피할 수 있습니다.
actor PauseGate {
private var opened = false
private var waiter: CheckedContinuation<Void, Never>?
func pause() async {
if opened { return }
await withCheckedContinuation { continuation in
waiter = continuation
}
}
func open() {
opened = true
waiter?.resume()
waiter = nil
}
}
final class ReservationTests: XCTestCase, @unchecked Sendable {
func testRejectsOverlapAndReleases() async throws {
let pool = ReservationPool(capacity: 1)
let gate = PauseGate()
let entered = expectation(description: "Operation entered")
let first = Task {
try await pool.run {
entered.fulfill()
await gate.pause()
}
}
await fulfillment(of: [entered], timeout: 2.0)
let during = await pool.count
XCTAssertEqual(during, 1)
do {
try await pool.run {}
XCTFail("Overlapping operation was accepted")
} catch QuotaError.full {
}
await gate.open()
try await first.value
let after = await pool.count
XCTAssertEqual(after, 0)
try await pool.run {}
}
}
기댓값이 충족되었다면 첫 번째 호출이 이미 작업 본문에 진입했다는 뜻이므로 예약도 반드시 존재합니다. 2초는 픽스처가 해당 지점에 도달하기를 기다리는 최대 시간이지, 성능 기준이 아닙니다. 이 픽스처는 대기자 하나만 지원하므로 프로덕션용 브로드캐스트 구성 요소로 그대로 사용해서는 안 됩니다.
실패와 취소를 각각 검증하기
오류를 던지는 테스트 케이스를 추가합니다. 작업이 테스트 전용 오류를 즉시 던지게 하고, 이를 잡아 오류 타입을 확인한 다음 count를 읽습니다. 결과는 0이어야 하며 다음 호출도 성공해야 합니다. 오류가 발생했다는 사실만 검증하면 ‘작업은 실패했지만 예약한 자리가 반환되지 않는’ 문제를 놓치게 됩니다.
취소 테스트에서도 같은 일시 정지 지점을 사용합니다. 첫 번째 작업이 진입할 때까지 기다린 다음 first.cancel()을 호출합니다. 이때 사용량을 읽으면 여전히 1이어야 합니다. 이후 픽스처를 열고 태스크 결과를 기다리면 CancellationError가 발생해야 하며, 최종 사용량은 0이 되어야 합니다.
이는 의도적으로 보수적인 동작 의미를 택한 것입니다. 취소는 협력적 신호를 보낼 뿐이며, 이 예제의 일시 정지 픽스처는 취소되었다고 자동으로 재개되지 않습니다. 하위 작업이 아직 종료되지 않았는데 용량을 반환하면 새 작업이 기존 작업과 겹쳐 실행될 수 있습니다.
실제 내보내기 API가 취소를 지원한다면 취소 신호를 전달하고, 작업 종료가 확인될 때까지 기다려야 합니다. 작업이 이미 되돌릴 수 없는 부수 효과를 일으켰다면 마지막 취소 검사로도 그 결과를 되돌릴 수는 없습니다. 커밋 시점과 결과 조회 방식을 별도로 정의해야 하며, ‘취소 오류를 던졌다’를 ‘아무 일도 일어나지 않았다’로 해석해서는 안 됩니다.
빌드에 통합하고 반례도 남기기
프로젝트 루트 디렉터리에서 다음 명령을 실행합니다. 프로젝트 이름과 공유 Scheme은 앞서 프로젝트를 만들 때 정한 이름을 그대로 사용하고, 테스트 클래스는 ActorQuotaTests 타깃에 넣습니다.
set -euo pipefail
xcodebuild \
-project ActorQuota.xcodeproj \
-scheme ActorQuota \
-destination 'platform=macOS' \
-only-testing:ActorQuotaTests/ReservationTests \
test
먼저 올바른 구현으로 테스트가 통과한 결과를 남깁니다. 그런 다음 임시 브랜치에서 의도적으로 예약 코드를 비동기 작업 뒤로 옮깁니다. 작업 중첩을 검사하는 테스트가 실패해야 합니다. 여전히 통과한다면 테스트가 용량 확인과 실제 점유 사이의 빈틈을 제대로 검증하지 못한다는 뜻입니다.
마지막으로 네 가지를 확인합니다. 대기 전에 이미 예약이 존재하는지, 거부된 호출은 사용량을 바꾸지 않는지, 오류가 발생해도 자기 토큰만 해제하는지, 취소 후 실제 작업이 종료되어야 용량을 반환하는지입니다. 이어서 용량을 2로 바꾸고 작업 두 개를 일시 정지한 상태에서 세 번째 작업이 거부되는지 확인합니다. 이렇게 하면 제한을 단순한 불리언 스위치로 고정해 구현하지 않았는지도 검증할 수 있습니다.
이 테스트들을 자원 풀 구현과 함께 커밋하세요. 이후 비동기 준비 흐름을 리팩터링할 때, ‘여기서는 actor를 썼으니 안전하다’는 말보다 실제 용량 경계를 훨씬 확실하게 지켜 줍니다.
자주 묻는 질문
actor를 사용해도 할당량이 초과되는 이유는 무엇인가요?
await에서 실행을 양보하면 다른 호출이 들어올 수 있습니다. 검사 직후 예약 상태를 기록하지 않으면 여러 호출이 같은 여유 공간을 사용할 수 있습니다.
취소 신호를 받으면 예약을 바로 반환해야 하나요?
실제 작업이 자원을 계속 사용한다면 반환하면 안 됩니다. 작업이 중단되거나 반환된 뒤에 예약을 해제해야 중복 사용을 막을 수 있습니다.
나만의 Mac으로 워크플로를 확장하세요
기본 macOS 환경에서 빌드, 테스트, 실험을 진행하세요. 사양과 이용 기간을 비교한 뒤 적합한 물리 노드를 선택할 수 있습니다.