Swift 계산을 취소할 수 있게 하려면 작업 핸들을 보관하고, 요청이 있을 때 cancel()을 호출한 다음 루프 안에서 취소 여부를 확인하세요. 이 macOS 예제는 인터페이스의 응답성을 유지하고, 취소된 실행이 늦게 결과를 표시하지 않도록 합니다.

Swift 작업을 취소하면 실제로 어떤 일이 일어날까요?

Swift는 협력적 취소를 사용합니다. 취소 요청은 작업을 취소된 상태로 표시하고, 실행 중인 코드는 언제 중단할지 결정합니다. 취소 여부를 확인하지 않는 CPU 루프는 끝까지 계속 실행될 수 있습니다. 취소가 감지되면 Task.checkCancellation()을 호출할 때 CancellationError가 발생합니다. Apple의 취소 문서, checkCancellation().

여기서 다루는 작업은 구체적입니다. 작은 Mac 앱에 작동하는 Start 및 Cancel 버튼을 추가해 합성 합계를 계산합니다. 모든 입력은 로컬에서 생성됩니다. 이 계산은 연속된 양의 정수의 제곱근을 더하며, 요청된 항을 모두 처리한 경우에만 Double 하나를 생성합니다.

취소를 구현하기 전에 결과 계약을 정하세요. 이 예제는 완료되지 않은 합계를 버리고 취소를 별도로 알립니다. 부분 합계를 반환하려면 포함된 항의 개수를 식별하는 별도의 계약이 필요합니다. 그렇지 않으면 호출자가 완료되지 않은 작업을 요청한 답으로 오해할 수 있습니다. Swift 언어 가이드에는 오류 던지기, 빈 결과 반환, 부분 결과 반환이 가능한 취소 응답으로 설명되어 있습니다. Concurrency.

구현할 책임은 두 가지입니다. 불필요한 계산을 중단하고, 완료된 값이 여전히 인터페이스에 표시되어야 하는지 판단해야 합니다. 이 예제는 작업자의 마지막 확인 이후 결과가 표시되기 전에 취소가 요청되는 경우까지 두 가지 모두 처리합니다.

계산 내부에서 취소 확인하기

Xcode에서 SwiftUI와 Swift를 사용하는 macOS 앱 프로젝트를 만드세요. 이 예제에서는 Swift 6 언어 모드와 macOS 13 이상 배포 대상을 사용합니다. 다음 전체 파일을 SyntheticCalculation.swift라는 이름으로 추가하세요. 이 파일은 뷰에 의존하지 않습니다.

// SyntheticCalculation.swift
import Swift

enum SyntheticCalculation {
    nonisolated static func sumOfSquareRoots(
        terms: Int,
        checkEvery: Int = 4_096
    ) throws -> Double {
        precondition(terms >= 0, "terms must be nonnegative")
        precondition(checkEvery > 0, "checkEvery must be positive")

        try Task.checkCancellation()

        var completed = 0
        var total = 0.0

        while completed < terms {
            try Task.checkCancellation()

            let count = min(checkEvery, terms - completed)
            let end = completed + count

            while completed < end {
                completed += 1
                total += Double(completed).squareRoot()
            }
        }

        try Task.checkCancellation()
        return total
    }
}

이 함수는 시작 전, 각 청크 경계에서, 그리고 반환 전에 취소 여부를 확인합니다. 이는 의도적인 중단 지점입니다. 청크 계산 중 취소가 요청되면 다음 확인 시 감지됩니다. Task.checkCancellation()은 동기 함수이므로 계산은 일반적인 오류 던지기 함수로 둘 수 있습니다. 취소를 확인하기 위해 await가 필요하지 않습니다. checkCancellation().

청크 계산은 입력 경계도 명확하게 처리합니다. 취소되지 않은 작업에서 항이 0개면 결과는 0입니다. 음수 항 개수와 0 이하의 청크 크기는 전제 조건으로 강제되는 프로그래머 오류입니다. 남은 개수로 각 청크를 계산하므로 끝점이 terms을 넘어가지 않습니다.

누산기는 작업자 내부에만 유지됩니다. 취소 오류가 발생하면 함수는 그 누산기를 반환하지 않고 종료되어, 선택한 완전한 결과 계약을 구현합니다. 부동소수점 덧셈이므로 완료된 값은 수학적 합의 근삿값입니다.

Cancel 버튼의 응답성 유지하기

취소 확인은 버튼 동작이 실행될 기회가 없으면 도움이 되지 않습니다. Apple은 SwiftUI 뷰에서 만든 일반 Task 안에 동기식 고부하 작업을 넣으면 해당 작업이 주변 액터 컨텍스트를 상속하므로 여전히 메인 액터를 점유할 수 있다고 설명합니다. Apple은 이 상속된 컨텍스트 바깥에서 동기 작업을 실행하는 지원 방법으로 분리된 작업을 문서화하고 있습니다. 앱 응답성 개선하기.

여기서 분리된 작업자는 변경할 수 없는 정수 하나만 캡처하고, 명시적으로 nonisolated로 선언된 동기 함수를 호출합니다. 모든 인터페이스 상태는 메인 액터에 유지됩니다. 별도의 메인 액터 작업이 작업자의 값을 기다린 다음 화면을 업데이트합니다.

생성된 앱 진입 파일을 다음 전체 파일로 바꾸고, 생성된 ContentView.swift가 남아 있다면 삭제하세요. SwiftUI 앱 생명주기에 따라 대상에는 @main 선언이 정확히 하나만 있어야 합니다. 이 두 파일이 함께 예제 앱을 구성합니다. App.

// CancellationDemoApp.swift
import Foundation
import SwiftUI

@main
struct CancellationDemoApp: App {
    var body: some Scene {
        WindowGroup {
            CalculationView()
        }
    }
}

@MainActor
struct CalculationView: View {
    @State private var worker: Task<Double, Error>?
    @State private var runID: UUID?
    @State private var status = "Ready"

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Synthetic square-root sum")
                .font(.headline)
            Text(status)
                .textSelection(.enabled)

            HStack {
                Button("Start") {
                    start()
                }
                .disabled(worker != nil)

                Button("Cancel") {
                    guard let worker else { return }
                    worker.cancel()
                    status = "Cancellation requested"
                }
                .disabled(worker == nil)
            }
        }
        .padding(24)
        .frame(minWidth: 420, minHeight: 160)
        .onDisappear {
            worker?.cancel()
            runID = nil
            worker = nil
            status = "Ready"
        }
    }

    private func start() {
        guard worker == nil else { return }

        let id = UUID()
        let terms = 500_000_000
        runID = id
        status = "Calculating"

        let run = Task.detached(priority: .userInitiated) {
            try SyntheticCalculation.sumOfSquareRoots(terms: terms)
        }
        worker = run

        Task { @MainActor in
            do {
                let value = try await run.value
                guard runID == id else { return }

                if run.isCancelled {
                    status = "Cancelled"
                } else {
                    status = "Result: \(value)"
                }
            } catch is CancellationError {
                guard runID == id else { return }
                status = "Cancelled"
            } catch {
                guard runID == id else { return }
                status = "Failed: \(error)"
            }

            guard runID == id else { return }
            worker = nil
            runID = nil
        }
    }
}

worker을 보관하는 것이 중요합니다. 작업 핸들을 버려도 작업이 취소되지는 않습니다. 그 핸들을 통해 작업을 취소할 수 없게 됩니다. 오류를 던질 수 있는 작업자의 value을 기다리면 오류도 확인할 수 있습니다. 분리된 작업은 명시적인 수명 관리가 필요하므로 버튼과 뷰가 사라질 때 실행되는 핸들러에서 작업자를 직접 취소합니다. 결과를 기다리는 작업만 취소해도 분리된 작업자로 취소가 전파된다고 보장할 수 없습니다. 분리된 작업 문서.

Foundation의 UUID은 각 실행의 식별자를 제공합니다. 식별자 비교는 애플리케이션 수준의 규칙입니다. 뷰가 사라져 실행이 무효화되면, 나중에 완료되더라도 현재 뷰의 상태를 업데이트할 수 없습니다. 식별자는 계산을 중단하지 않습니다. worker.cancel()와 루프의 확인이 그 역할을 합니다. UUID.

작업자가 완료되거나 오류를 던질 때까지 Start 버튼은 비활성화됩니다. Cancel을 누르면 먼저 Cancellation requested가 표시되고, 결과 관찰자가 결과를 처리할 때 Cancelled로 바뀝니다. 이렇게 하면 취소 요청과 작업이 종료되었다는 확인을 구분할 수 있습니다.

늦게 도착한 결과는 어떻게 처리해야 할까요?

작업자가 마지막 확인을 통과하고 완료된 뒤, 메인 액터가 값을 받아들이기 전에 사용자가 Cancel을 누르는 상황을 생각해 보세요. Apple은 마지막으로 확인할 수 있는 지점 이후에 취소하면 작업자에게 아무런 가시적 영향이 없을 수 있다고 명시합니다. 마지막 확인은 그 시간 간격을 줄이지만 없애지는 못합니다. cancel().

따라서 예제는 성공한 값을 기다린 뒤 run.isCancelled을 확인합니다. 이는 작업자의 핸들을 확인하는 것입니다. 여기서 정적 Task.isCancelled를 사용하면 결과를 관찰하는 작업을 확인하게 됩니다. Swift 언어 가이드는 타입 속성으로 현재 작업을 확인하는 방법과 인스턴스 속성으로 다른 작업을 확인하는 방법을 구분합니다. Concurrency.

이로써 구체적인 수락 규칙이 정해집니다. 결과를 수락하기 전에 처리된 취소 요청이 우선합니다. 수락이 이미 완료되어 작업자 핸들이 정리된 뒤에는 비활성화된 Cancel 버튼으로 완료된 결과를 되돌릴 수 없습니다. 이 규칙은 취소가 완료된 작업을 되돌린다고 약속하지 않고 메인 액터 동작의 순서를 따릅니다.

유용한 취소 확인 간격 선택하기

기본 청크 크기인 4,096개 항은 설명을 위한 출발점입니다. 청크가 작으면 취소를 더 자주 확인할 수 있고 확인 작업도 더 많이 실행됩니다. 청크가 크면 확인 빈도는 줄어들지만 중단 지점 사이에 더 많은 계산을 수행합니다.

대략적인 계획을 위해 청크에 4,096개 항이 있고 각 항을 계산하는 데 예시로 2마이크로초가 걸린다고 가정해 보겠습니다. 해당 청크의 계산 시간은 약 8.2밀리초입니다. 이는 계산 방법을 보여 주기 위해 만든 입력값이지, Swift나 특정 Mac을 측정한 결과가 아닙니다. 스케줄링과 인터페이스 업데이트로 지연이 더해질 수 있습니다.

관찰한 현상 확인하거나 변경할 항목
계산 중 인터페이스가 반응하지 않음 루프가 분리된 작업자에서 실행되고 메인 액터 격리 밖에 있는지 확인하세요.
인터페이스는 반응하지만 취소에 너무 오래 걸림 checkEvery을 줄이고 가장 긴 청크 시간을 측정하세요.
청크에 나중에 비용이 큰 연산이 추가됨 구현이 지원한다면 해당 연산 내부에도 중단 지점을 추가하세요.
취소된 실행이 결과를 표시함 수락 시점에 작업자 핸들을 확인하고 실행 식별자 검사를 유지하세요.

이 루프에서 다른 플래그를 설정하기 위해 취소 핸들러를 추가하지 마세요. 작업은 이미 취소 상태를 제공하며, Swift 언어 가이드 설명에 따르면 핸들러를 설치해도 중단 방식은 여전히 협력적입니다. 이 로컬 계산에는 명시적인 확인만으로 필요한 중단 메커니즘을 제공할 수 있습니다. Concurrency.

완료, 취소, 반복 실행 확인하기

연구 환경에서는 Swift 컴파일러와 macOS 런타임을 사용할 수 없었으므로 이 예제는 검토만 했으며 컴파일하거나 실행하지 않았습니다. 두 파일을 Xcode에서 빌드한 뒤 다음 동작을 각각 확인하세요.

  1. 작은 완료 결과: terms을 4로 임시 변경하세요. 부동소수점 반올림을 고려하면 표시 결과는 대략 6.146264369941973여야 합니다.
  2. 빈 입력: terms을 0로 변경하세요. 취소하지 않은 실행은 0을 표시해야 합니다.
  3. 작업 중 취소: 500_000_000으로 복원하고 Start를 누른 다음 실행 중에 Cancel을 누르세요. 부분 결과 없이 Cancellation requested가 표시된 뒤 Cancelled가 표시되어야 합니다.
  4. 다시 실행: 취소가 완료된 뒤 Start를 다시 누르세요. 새 실행이 정상적으로 시작되어야 합니다.
  5. 뷰가 사라지는 경우: 계산 중인 창을 닫으세요. 뷰가 사라질 때 실행되는 핸들러가 취소를 요청하고 해당 실행의 인터페이스 업데이트를 무효화해야 합니다.

큰 항 개수는 상호작용할 시간을 확보하기 위한 값이지만, 실행 시간은 Mac과 빌드 구성에 따라 다릅니다. 취소하기 전에 계산이 완료되면 지원되는 정수 범위 안에서 개수를 늘리세요. 큰 예제가 끝날 때까지 기다리는 대신 작은 입력으로 완료된 계산을 검증하세요.

완성된 설계에는 명확한 중단 경로와 수락 규칙이 있습니다. 버튼이 보관된 작업자를 취소하고, 청크 경계에서 계산이 오류를 던질 수 있으며, 메인 액터는 현재 실행에서 나온 취소되지 않은 결과만 수락합니다. 이 세 가지 결정으로 일반적인 Mac 계산에서 취소를 유용하게 사용할 수 있습니다.

출처