첫 번째 suspension 전에 actor 안에 계산 task를 저장한 다음, 동시 호출자들이 같은 task를 기다리게 합니다. 이 안내에서는 Mac 유틸리티를 위한 로컬 제곱합 예제를 만들고, 취소, 오류, 정리에 대한 명시적인 규칙을 설정합니다.
동시 호출자들은 무엇을 공유해야 할까요?
Mac 데스크톱 유틸리티의 두 부분에서 같은 계산 요약이 필요하다고 가정해 보겠습니다. 하나는 미리보기이고 다른 하나는 세부 정보 패널입니다. 각각 합성 배열 [1, 2, 3, 4]에 있는 값들의 제곱합을 요청합니다. 둘 다 30을 받아야 하며, 동일한 활성 실행에 합류하는 요청은 하나의 실행을 공유해야 합니다.
아래 구현에서는 각 계산기 인스턴스에 변경할 수 없는 입력 배열을 둡니다. task가 actor의 flight 슬롯을 차지하는 동안 현재 task를 공유하고, 호출자가 완료를 확인하면 슬롯을 비웁니다. 이후 요청은 새 계산을 시작할 수 있습니다. 이는 진행 중인 작업을 공유하는 방식입니다. 완료된 결과를 무기한 보관하려면 별도의 캐싱 정책이 필요합니다.
값에 대한 계약은 의도적으로 간단하게 정했습니다.
- 정수는 최대 100,000개까지 받고, 각 값은
0에서1,000사이여야 합니다. - 정수들의 제곱합을 반환하며, 빈 배열은
0을 반환합니다. - 실행 식별자를 붙여 호출자가 공유 실행과 단지 합계만 같은 실행을 구별할 수 있게 합니다.
- 해당 실행을 기다리는 호출자에게 계산 오류를 전달합니다.
- 개별 호출자는 공유 worker를 취소하지 않고 자신의 관심만 취소할 수 있습니다.
허용되는 최대 합계는 100,000 × 1,000² = 100,000,000,000이며, Mac의 64비트 Int에 들어갑니다. 이 제한은 이 예제의 계약에 속합니다. 제한을 바꾸려면 산술 오버플로와 리소스 사용을 다시 검토해야 합니다.
actor에도 저장된 task가 필요한 이유는 무엇일까요?
actor는 변경 가능한 상태를 보호하지만, 비동기 actor 메서드는 suspension될 수 있습니다. suspension 중에는 다른 호출이 actor에 진입할 수 있습니다. Swift 언어 가이드에서는 actor 격리와 suspension 지점을 설명하며, Apple의 actor 세션에서는 await 전에 세운 가정이 어떻게 오래된 정보가 될 수 있는지 보여 줍니다. Concurrency — The Swift Programming Language, Protect mutable state with Swift actors.
따라서 기존 결과를 확인한 뒤 새 계산을 기다리는 방식으로는 중복 작업을 막을 수 없습니다. 결과가 아직 나오기 전에 다른 호출자가 들어올 수 있습니다. 대신 task 핸들을 동기적으로 게시해야 합니다. flight 슬롯을 확인하고, 슬롯이 비어 있을 때만 worker를 만든 뒤 저장하고, 결과를 기다립니다.
Apple 문서에 따르면 task는 생성 직후 실행을 시작할 수 있습니다. 비동기 value에 접근하면 완료될 때까지 기다리고 발생한 오류를 전달합니다. 따라서 actor는 핸들 하나의 소유권을 조정하고, 호출자는 그 핸들을 통해 결과를 얻습니다. Task, value.
완전한 로컬 예제 만들기
macOS 13 이상에서 Swift 6 컴파일러를 사용하세요. 여기서 macOS 버전을 요구하는 이유는 예제의 Task.sleep(for:) 호출 때문입니다. 이 호출은 기반 스레드를 차단하지 않고 suspension되며, 짧은 대기 시간으로 겹치는 요청을 더 쉽게 관찰할 수 있습니다. 산술 연산은 수행하지 않으며, 나중에 제거해도 됩니다. sleep(for:tolerance:clock:).
아래 전체 프로그램을 SharedCalculation.swift로 저장하세요. 아래 명령줄 컴파일 예제에서 사용하는 일반적인 nonisolated 기본 설정을 따릅니다. Foundation은 UUID을 제공하며, 문서에 명시된 준수 항목에는 Sendable과 Equatable이 포함됩니다. 식별자는 각 flight에 라벨을 붙이고 조건부 정리를 지원합니다. UUID.
import Foundation
struct CalculationResult: Sendable {
let total: Int
let runID: UUID
}
enum CalculationError: Error {
case invalidInput
}
actor SharedCalculation {
private struct Flight {
let id: UUID
let task: Task<CalculationResult, Error>
}
private let samples: [Int]
private var flight: Flight?
init(samples: [Int]) {
self.samples = samples
}
func value() async throws -> CalculationResult {
try Task.checkCancellation()
let active: Flight
if let existing = flight {
active = existing
} else {
let id = UUID()
let snapshot = samples
let task = Task.detached(priority: .userInitiated) {
[snapshot, id] () async throws -> CalculationResult in
guard snapshot.count <= 100_000,
snapshot.allSatisfy({ (0...1_000).contains($0) })
else {
throw CalculationError.invalidInput
}
// Demonstration only: allow overlap to be observed.
try await Task.sleep(for: .milliseconds(100))
let total = snapshot.reduce(0) { sum, sample in
sum + sample * sample
}
return CalculationResult(total: total, runID: id)
}
active = Flight(id: id, task: task)
flight = active
}
defer {
// An older waiter must not clear a newer flight.
if flight?.id == active.id {
flight = nil
}
}
let result = try await active.task.value
try Task.checkCancellation()
return result
}
}
@main
struct Demo {
static func main() async throws {
let calculation = SharedCalculation(samples: [1, 2, 3, 4])
async let preview = calculation.value()
async let details = calculation.value()
async let inspector = calculation.value()
let (a, b, c) = try await (preview, details, inspector)
precondition(a.total == 30 && b.total == 30 && c.total == 30)
let observedRuns = Set([a.runID, b.runID, c.runID]).count
print("Total: \(a.total)")
print("Runs observed across three requests: \(observedRuns)")
let later = try await calculation.value()
precondition(later.total == 30)
print("Later request started another run: \(later.runID != c.runID)")
}
}
파일이 있는 디렉터리에서 컴파일하고 실행하세요.
swiftc -swift-version 6 -parse-as-library SharedCalculation.swift -o SharedCalculation
./SharedCalculation
산술 단언이 통과해야 합니다. 세 초기 호출이 flight가 비워지기 전에 모두 진입하면 관찰되는 실행 횟수는 1입니다. 이후 요청은 해당 호출들이 반환된 다음에 도착하므로 새 계산을 시작합니다. 대기 시간은 겹치는 실행을 유도하지만 스케줄링을 보장하지는 않습니다. 초기 요청이 충분히 오래 지연되면 정상적으로 이후 flight를 시작할 수 있습니다. 연구 환경에서 Swift를 사용할 수 없어 이 프로그램은 살펴보기만 했으며 여기서 컴파일하거나 실행하지 않았습니다.
이 계산은 요청한 호출자 한 명의 작업이 아니라 공유 계산기에 속하고, 산술 연산은 조정 actor와 별개로 실행되어야 하므로 worker에 detached task를 사용합니다. 이 task는 입력 스냅샷과 식별자만 캡처합니다. Apple은 필요한 수명을 표현할 수 있을 때 구조화된 자식 task를 권장합니다. detached task에는 명시적인 수명 및 취소 결정이 필요합니다. 이 예제는 우선순위도 명시합니다. detached(name:priority:operation:).
정리 과정에서 다음 계산을 지우지 않으려면 어떻게 해야 할까요?
확인과 저장 사이에는 await이 없으므로, 빈 슬롯을 확인한 뒤 새 flight를 게시하기 전에 두 번째 actor 호출이 끼어들 수 없습니다. 첫 호출자가 active.task.value에서 suspension되면 다른 호출자가 진입해 해당 flight를 찾고 같은 핸들을 기다릴 수 있습니다.
여러 호출자가 같은 완료 task에서 서로 다른 시점에 재개될 수 있으므로 정리에도 자체 식별 확인이 필요합니다. 다음 순서를 살펴보세요.
| 이벤트 | Flight 슬롯 |
|---|---|
| 호출자 A와 B가 실행 X에 합류 | X |
| X가 끝남. A가 재개되어 X를 지움 | 비어 있음 |
| 호출자 C가 진입해 실행 Y를 시작 | Y |
| B가 X에서 재개되어 정리를 수행 | Y가 유지되어야 함 |
B가 nil을 조건 없이 대입하면 Y의 핸들이 지워집니다. 그러면 네 번째 호출자가 Y가 실행 중인 동안 중복 작업을 시작할 수 있습니다. flight?.id를 active.id와 비교하면 각 호출자가 실제로 합류한 실행만 정리할 수 있습니다. 이 순서는 구현 방식과 문서화된 재진입 동작에 따른 결과입니다. Protect mutable state with Swift actors.
공유의 정확한 경계는 저장된 flight 슬롯입니다. 대기 중인 호출자들이 재개되어 슬롯을 비우기 전에 worker가 이미 끝났을 수 있습니다. 이 사이에 진입하는 요청도 완료된 task의 결과를 받을 수 있습니다. 슬롯이 비워진 뒤에는 새 요청이 새로운 실행을 시작합니다. 이 짧은 완료 구간도 계약의 일부입니다.
호출자가 취소하거나 계산이 실패하면 어떻게 될까요?
Swift에서 취소는 협력 방식입니다. 코드는 취소를 어디서 확인하고 어떻게 처리할지 결정해야 합니다. 이 예제는 공유 결과를 성공적으로 받은 뒤와 합류하기 전에 요청 task를 확인합니다. Task — Task Cancellation.
첫 확인에서 이미 취소된 호출자는 작업을 시작하거나 합류하지 않고 오류를 던집니다. 기다리는 도중 취소된 호출자는 공유 worker를 계속 기다립니다. worker 결과를 성공적으로 받은 뒤 두 번째 확인에서 CancellationError를 던집니다. 이 구현은 개별 호출자의 취소를 worker에 전달하지 않으므로 다른 호출자는 계속 결과를 받을 수 있습니다.
이 정책은 기다림을 즉시 취소하지 않습니다. 모든 호출자가 취소되어도 worker는 끝까지 실행됩니다. 범위가 제한된 이 로컬 계산에서는 의도적인 선택입니다. 개별 호출자가 즉시 빠져나가야 하는 설계에는 대기자에 대한 추가 추적과 공유 worker를 언제 중단할지에 관한 명확한 규칙이 필요합니다.
잘못된 입력이면 worker가 CalculationError.invalidInput을 던집니다. 이미 해당 task를 공유하는 호출자들은 value을 통해 오류를 받고, 지연 정리로 일치하는 슬롯을 비웁니다. 이후 요청은 같은 변경 불가 입력으로 다시 시도하고 또 실패합니다. 합계를 조용히 대신 반환하거나 잘못된 요소를 버리지 않습니다. value.
오류 순서에도 명시적인 세부 사항이 있습니다. worker가 실패할 때 호출자가 취소되었다면 두 번째 취소 확인보다 worker 오류가 먼저 전달됩니다. worker가 성공하면 호출자의 취소 확인에 따라 해당 호출자가 결과를 반환할지 결정됩니다.
Mac 유틸리티에서 이 패턴을 어떻게 검증하고 사용할까요?
합계가 같다는 것만으로 공유 실행이 입증되지는 않습니다. 이 입력을 세 번 각각 계산해도 30가 나옵니다. 겹치는 실행을 확인할 때는 실행 식별자를 비교하고, 숫자 결과는 별도로 검증하세요.
결정적인 동시성 테스트를 하려면 예제의 대기 시간을 제어 가능한 비동기 게이트로 바꾸고 flight에 진입하는 지점을 계측하세요. 의도한 모든 호출자가 합류할 때까지 worker를 대기시키고, 그 후 해제합니다. worker가 한 번 시작되는지, 식별자가 일치하는지, 합계가 올바른지, 오류가 전달되는지, 정리 후 새 실행이 시작되는지 확인하세요. 표에 나온 정리 순서도 테스트하세요. sleep 시간을 늘리는 것으로는 이런 조건을 신뢰성 있게 확인할 수 없습니다.
데스크톱 앱에서는 같은 입력을 사용하는 소비자들이 계산기 인스턴스 하나를 공유하도록 유지하세요. 패널마다 새 인스턴스를 만들면 각 패널에 독립된 flight 슬롯이 생깁니다. 합성 입력이 바뀌면 해당 스냅샷을 위한 새 계산기를 만드세요. 이 예제는 변경 가능한 입력이나 키 기반 공유를 정의하지 않습니다.
반환된 값을 UI 상태에 적용할 때는 main actor를 사용하세요. Swift 언어 가이드에서는 UI 작업에 @MainActor를 사용하는 방법을 설명합니다. 공유 계산기는 별도로 자체 상태를 계속 조정할 수 있습니다. Concurrency — The Swift Programming Language.
간단한 합계에는 task 생성이 불필요한 오버헤드입니다. 이 샘플은 더 실질적인 로컬 계산에 적용할 수 있도록 소유권 규칙을 분명히 보여 줍니다. 적용할 때 핵심 순서를 지키세요. suspension 전에 task 하나를 게시하고, 해당 핸들을 기다린 다음, 완료된 flight만 비웁니다.
출처
- Concurrency — The Swift Programming Language — Swift.org.
- Task — Apple Developer Documentation.
- value — Apple Developer Documentation.
- detached(name:priority:operation:) — Apple Developer Documentation.
- sleep(for:tolerance:clock:) — Apple Developer Documentation.
- UUID — Apple Developer Documentation.
- Protect mutable state with Swift actors — Apple Developer.