클라우드 Mac에서 iOS 작업의 동시 실행 상한 검증하기
첨부 파일 스무 개를 일괄 처리할 때 모든 작업을 태스크 그룹에 넣고 결과를 하나씩 읽는다고 해서 동시 실행 수가 제한되는 것은 아닙니다. 네트워크 연결, 디코딩 버퍼, 임시 파일은 여전히 동시에 늘어날 수 있습니다. 클라우드 Mac에서 이 로직을 검증할 때 중요한 것은 처리 속도가 아니라, 상한이 셋일 때 네 번째 작업이 반드시 빈 슬롯이 생길 때까지 기다린다는 사실을 입증하는 것입니다.
여기서는 스케줄러를 네트워크 구현에서 분리합니다. 먼저 제어 가능한 작업으로 슬롯 동작을 검증한 다음 iOS 비즈니스 코드에 연결합니다. 이렇게 하면 실패 원인이 스케줄링 오류인지 외부 서비스의 불안정성인지 구분할 수 있어, 결론을 얻으려고 테스트를 반복 실행할 필요가 없습니다.
먼저 검증 가능한 스케줄링 계약 정의하기
출력은 입력 순서를 따르되, 작업이 완료되는 순서는 달라도 됩니다. 입력이 비어 있으면 빈 배열을 반환하고, 동시 실행 상한은 반드시 0보다 커야 합니다. 하위 태스크 중 하나라도 오류를 던지면 전체 호출이 실패하며 부분 결과는 반환하지 않습니다. 부모 태스크가 취소된 뒤에는 대기 중인 작업을 새로 투입하지 않아야 합니다.
여기서 제한하는 것은 전달된 작업 중 활성 상태인 작업의 수입니다. 스레드 수나 요청 빈도를 제한하는 것이 아닙니다. 작업 하나가 내부에서 여러 요청을 시작하더라도 그 요청들은 외부 스케줄러의 집계 대상이 아닙니다. 초당 요청 할당량은 별도로 구현해야 하며, 동시 실행 슬롯으로 대신할 수 없습니다.
취소는 강제 종료가 아니라 협력적 신호입니다. 태스크 그룹은 스코프를 벗어나기 전에 하위 태스크가 끝날 때까지 기다립니다. 취소를 무시하는 작업이 있으면 전체 호출의 종료도 지연됩니다.
완료 이벤트가 도착하면 다음 작업 투입하기
다음 코드를 BoundedMap.swift로 저장합니다. Swift 툴체인이 설치된 클라우드 Mac에서 실행하되, 먼저 swift --version으로 버전을 기록합니다. 이 예제는 엄격한 동시성 검사에 필요한 Sendable 제약을 사용하며, GUI 세션이나 시뮬레이터에 의존하지 않습니다.
import Foundation
func boundedMap<Input: Sendable, Output: Sendable>(
_ inputs: [Input],
limit: Int,
operation: @escaping @Sendable (Input) async throws -> Output
) async throws -> [Output] {
precondition(limit > 0)
return try await withThrowingTaskGroup(
of: (Int, Output).self
) { group in
var nextIndex = 0
var completed: [(Int, Output)] = []
completed.reserveCapacity(inputs.count)
for index in 0..<min(limit, inputs.count) {
try Task.checkCancellation()
let input = inputs[index]
group.addTask {
try Task.checkCancellation()
return (index, try await operation(input))
}
nextIndex += 1
}
while let result = try await group.next() {
completed.append(result)
try Task.checkCancellation()
if nextIndex < inputs.count {
let index = nextIndex
let input = inputs[index]
group.addTask {
try Task.checkCancellation()
return (index, try await operation(input))
}
nextIndex += 1
}
}
try Task.checkCancellation()
return completed.sorted { $0.0 < $1.0 }.map { $0.1 }
}
}
처음에는 상한만큼만 태스크를 제출합니다. 이후에는 완료 결과를 하나 받을 때마다 새 태스크를 하나씩 제출합니다. 따라서 입력이 많아도 입력 개수만큼 대기 태스크를 만들지 않습니다. 결과와 함께 인덱스를 반환하고 마지막에 입력 순서로 정렬하므로, 먼저 완료된 결과를 앞에 놓는 실수를 피할 수 있습니다.
오류는 group.next()를 통해 전파됩니다. 클로저가 오류를 던지며 종료되면 태스크 그룹은 남은 하위 태스크를 취소하고 마무리가 끝날 때까지 기다립니다. 오류를 잡은 뒤에도 계속 작업을 투입하지 않으며, 부모 태스크의 수명과 분리된 독립 백그라운드 태스크도 사용하지 않습니다.
actor 프로브로 실제 활성 작업 수 측정하기
다음 코드를 같은 파일 끝에 추가한 뒤 swift BoundedMap.swift를 실행합니다. 프로브는 작업에 진입할 때 카운터를 하나 늘리고, 빠져나올 때 하나 줄입니다. 대기 중에는 actor가 다른 호출을 처리할 수 있으므로, 의도치 않게 모든 작업을 직렬화하지 않습니다.
actor Probe {
private var active = 0
private var peak = 0
func run(_ value: Int) async throws -> Int {
active += 1
peak = max(peak, active)
defer { active -= 1 }
let delay = UInt64(5 - value % 5) * 10_000_000
try await Task.sleep(nanoseconds: delay)
return value * 2
}
func snapshot() -> (active: Int, peak: Int) {
(active, peak)
}
}
let probe = Probe()
let inputs = Array(0..<20)
let output = try await boundedMap(inputs, limit: 3) {
try await probe.run($0)
}
let state = await probe.snapshot()
precondition(output == inputs.map { $0 * 2 })
precondition(state.active == 0)
precondition(state.peak <= 3)
서로 다른 지연 시간은 완료 순서를 뒤섞기 위한 것이지, 성능 벤치마크를 위한 것이 아닙니다. 특정 태스크가 몇 밀리초 안에 끝나야 한다고 단정하지 마세요. 머신이 바빠 최대 활성 작업 수가 셋에 못 미쳤다고 해서 오류로 판단해서도 안 됩니다. 반드시 충족해야 하는 조건은 최대 활성 작업 수가 상한을 넘지 않고, 결과가 빠짐없이 반환되며, 호출 종료 후 활성 작업 수가 0이 되는 것입니다.
빈 배열, 원소가 하나인 배열, 상한이 하나인 경우도 각각 실행해야 합니다. 호출자가 설정 파일에서 상한을 읽는다면 0과 음수를 먼저 거부해야 합니다. 예제의 사전 조건은 프로그래밍 오류를 드러내는 데 적합하며, 사용자 입력을 직접 처리하는 용도로는 적절하지 않습니다.
실패와 취소는 별도로 검증하기
성공 사례만으로는 취소가 올바르게 동작한다고 입증할 수 없습니다. 먼저 특정 입력에서 반드시 오류를 던지는 작업을 만들고, 호출이 실패하며 프로브의 활성 작업 수가 최종적으로 0이 되는지 확인합니다. 실패 전에 시작된 태스크 수가 정확히 몇 개여야 한다고 단정하지 마세요. 오류가 태스크 그룹에 전달되기 전에 다른 태스크가 이미 완료되어 빈 슬롯에 새 작업이 투입될 수 있습니다.
취소 테스트에는 제어 가능한 게이트를 사용해야 합니다. 첫 번째 작업들이 “진입 완료”를 알리게 하고, 테스트가 그 신호를 받은 뒤 부모 태스크를 취소한 다음 게이트를 엽니다. 각 작업은 게이트를 통과한 뒤 취소 여부를 확인해야 합니다. 테스트는 부모 태스크가 끝날 때까지 기다린 후, 진입했던 모든 작업이 빠져나왔는지 최종 확인합니다.
고정된 시간만큼 대기한 뒤 태스크가 시작되었을 것이라고 추측하지 마세요. 게이트 자체도 모든 대기자를 풀어 줄 수 있어야 합니다. 그렇지 않으면 테스트 픽스처의 교착 상태를 스케줄러 멈춤으로 오인할 수 있습니다. 검증 보고서에는 취소 요청 시점, 작업 종료 시점, 부모 태스크 반환 시점을 구분해 기록해야 합니다.
비즈니스 코드에 연결할 때도 슬롯 경계 유지하기
네트워크 다운로드에서는 다운로드, 필요한 디코딩, 임시 파일 정리를 모두 같은 작업 스코프에 포함해야 합니다. 응답을 받자마자 슬롯을 반환하고 큰 이미지의 디코딩을 백그라운드 태스크로 넘기면, 네트워크 동시 실행 수는 제어해도 최대 메모리 사용량은 제어할 수 없습니다.
이 구현은 모든 출력을 보관하므로 필요한 공간이 전체 결과 크기에 비례해 늘어납니다. 대용량 파일을 처리할 때는 파일 내용 전체를 배열에 담기보다 파일 위치와 작은 요약 정보를 반환하는 편이 좋습니다. 작업이 완료되는 대로 결과를 소비하는 파이프라인이 필요하다면 출력 백프레셔 계약을 별도로 정의해야 합니다. 이 구현의 메모리 사용량이 일정하다고 주장해서는 안 됩니다.
마지막으로 툴체인 버전, 테스트 입력, 상한, 최대 활성 작업 수, 종료한 작업 수를 기록해 둡니다. 스케줄러 테스트는 일반 단위 테스트 타깃에 넣고, 실제 작업에는 취소와 리소스 해제 검사를 추가합니다. 슬롯, 순서, 마무리라는 세 가지 경계를 안정적으로 입증할 수 있다면, 다른 클라우드 Mac으로 옮기더라도 다시 검증할 수 있는 동작 기준선을 확보한 셈입니다.
자주 묻는 질문
작업마다 지연을 넣으면 동시 실행 수가 제한되나요?
아닙니다. 시작 시각만 달라질 뿐 실행 중인 작업 수는 보장되지 않습니다. 상한만큼 제출하고 하나가 끝날 때 하나를 추가해야 합니다.
취소하면 작업 그룹이 즉시 반환하나요?
즉시 반환을 보장하지 않습니다. 그룹은 자식 작업의 종료를 기다리므로 각 작업이 취소에 협조하고 하위 작업도 끝내야 합니다.
이번 패키징에 사용할 전용 Mac이 필요하신가요?
필요한 기간에 맞춰 대여 기간을 선택하세요. Mac 한 대를 단독으로 사용하며, macOS 그래픽 인터페이스와 명령줄을 모두 이용할 수 있습니다. 기종, 노드 및 이용 가능 여부는 콘솔에 실시간으로 표시되는 정보를 기준으로 합니다.