형제 작업이 실패해도 성공한 Swift 계산을 유지하려면 각 계산의 오류를 자식 작업 내부에서 잡아 결과 값으로 반환하세요. 이 Mac 명령줄 예제는 모든 결과를 수집하고, 입력 순서를 유지하며, 성공한 값을 사용할 수 있도록 보존합니다.

작업은 작은 합성 배치 계산입니다. 음이 아닌 정수 목록의 제곱을 계산하고 성공한 입력을 보고합니다. 음수 입력과 정수 오버플로는 각각 실패를 발생시킵니다. 모든 계산 데이터는 메모리에 유지되며, 프로그램은 Terminal에 결과를 출력합니다.

오류 하나가 결과 수집을 중단할 수 있는 이유는 무엇인가요?

오류를 던지는 자식 작업이 형제 작업을 즉시 취소하지는 않습니다. 중요한 경계는 작업 그룹 본문입니다. 오류가 본문 밖으로 전파되면 Swift는 나머지 자식 작업을 취소합니다. Apple은 withThrowingTaskGroup(of:returning:isolation:body:) 참조 문서에서 이 차이를 설명합니다.

따라서 바깥쪽 catch가 있는 오류 발생 루프는 처음 관찰된 오류에서 수집을 중단할 수 있습니다. 앞서 저장된 값은 이미 있을 수 있지만, 해당 catch 뒤에서 루프가 재개되지는 않습니다. 오류가 그룹 본문 밖으로 전파되면 나머지 자식 작업은 취소 신호를 받습니다.

독립적인 계산에서는 실패 정책을 명시적으로 정하세요. 아래 예제는 자식 작업이 성공, 실패 또는 취소를 반환하는 비 throwing 작업 그룹을 사용합니다. 계산 자체는 여전히 오류를 던지지만, 래퍼가 그 오류를 그룹에 전달되기 전에 값으로 변환합니다.

이는 Swift에서 문서화한 경계와 일치합니다. 비 throwing 작업 그룹의 자식은 던진 오류를 전파할 수 없으므로, 내부에서 처리하거나 반환 값으로 결과를 표현해야 합니다. Apple의 TaskGroup 문서를 참조하세요.

각 입력이 반환해야 하는 값 정의하기

배치 계약은 입력 위치마다 하나의 결과를 반환하는 것입니다. 계산에 성공하면 정확한 정수 제곱을 반환합니다. 계산이 거부되면 실패 설명을 반환합니다. 취소가 확인되어 계산을 건너뛰면 취소 결과를 반환합니다.

상황 결과 배치 동작
제곱이 Int64 범위에 들어가는 음이 아닌 입력 제곱을 포함한 성공 값 유지
음수 입력 실패: negativeInput 다른 결과 계속 수집
제곱이 Int64 범위를 초과함 실패: overflow 다른 결과 계속 수집
계산 전에 취소가 확인됨 취소됨 완료된 결과는 유지하고 대기 중인 작업은 건너뜀

음수 입력 거부는 예시를 위한 애플리케이션 규칙입니다. 음수도 수학적으로 제곱할 수 있습니다. 이 규칙을 오버플로와 분리하면 예제에서 서로 다른 두 가지 실패 조건을 보여줄 수 있습니다.

곱셈에는 multipliedReportingOverflow(by:)을 사용하고 오버플로 플래그를 확인하세요. Apple 설명에 따르면 이 플래그가 false일 때만 반환된 부분 값이 완전한 곱이며, 그렇지 않으면 값이 잘립니다. 예제는 잘린 값을 버리고 오류를 던집니다. Int64.multipliedReportingOverflow(by:)를 참조하세요.

모든 결과에는 입력 인덱스도 포함됩니다. Swift는 자식 작업의 완료 순서를 보장하지 않으므로, 완료된 값을 단순히 추가하는 것만으로는 원래 순서를 유지할 수 없습니다. 언어 가이드의 동시성에서 이 스케줄링 동작을 설명합니다.

Mac에서 전체 예제 실행하기

Swift 6 이상 툴체인이 설치된 Mac을 사용하세요. Terminal에서 swift --version을 실행해 설치된 컴파일러를 확인하세요. Swift.org의 시작하기 가이드에서 권장하는 방법입니다. Swift를 사용할 수 없다면 계속하기 전에 해당 가이드의 설치 링크를 따르세요.

아래 전체 프로그램을 작업 폴더에 Calculations.swift로 저장하세요. 이 프로그램은 활성 프로세서 수를 바탕으로 초기 동시성 제한을 정하기 위해 Foundation을 가져옵니다.

import Foundation

enum CalculationError: Error {
    case negativeInput
    case overflow
}

enum Outcome: Sendable {
    case success(Int64)
    case failure(String)
    case cancelled
}

struct Row: Sendable {
    let index: Int
    let outcome: Outcome
}

func square(_ input: Int64) throws -> Int64 {
    guard input >= 0 else {
        throw CalculationError.negativeInput
    }
    let product = input.multipliedReportingOverflow(by: input)
    guard !product.overflow else {
        throw CalculationError.overflow
    }
    return product.partialValue
}

func evaluate(index: Int, input: Int64) -> Row {
    do {
        try Task.checkCancellation()
        let value = try square(input)
        return Row(index: index, outcome: .success(value))
    } catch is CancellationError {
        return Row(index: index, outcome: .cancelled)
    } catch {
        return Row(
            index: index,
            outcome: .failure(String(describing: error))
        )
    }
}

func collect(
    _ inputs: [Int64],
    maxConcurrent: Int
) async -> [Row] {
    guard !inputs.isEmpty else { return [] }
    let width = min(inputs.count, max(1, maxConcurrent))

    return await withTaskGroup(of: Row.self) { group in
        var rows: [Row] = []
        rows.reserveCapacity(inputs.count)

        for index in 0..<width {
            let input = inputs[index]
            group.addTask {
                evaluate(index: index, input: input)
            }
        }
        var nextIndex = width

        while let row = await group.next() {
            rows.append(row)

            if Task.isCancelled {
                while nextIndex < inputs.count {
                    rows.append(Row(
                        index: nextIndex,
                        outcome: .cancelled
                    ))
                    nextIndex += 1
                }
            } else if nextIndex < inputs.count {
                let index = nextIndex
                let input = inputs[index]
                nextIndex += 1
                group.addTask {
                    evaluate(index: index, input: input)
                }
            }
        }

        return rows.sorted { $0.index < $1.index }
    }
}

@main
struct CalculationDemo {
    static func main() async {
        let inputs: [Int64] = [3, -2, 4, Int64.max, 0]
        let width = min(
            4,
            max(1, ProcessInfo.processInfo.activeProcessorCount)
        )
        let rows = await collect(inputs, maxConcurrent: width)
        var successes: [Int64] = []

        for row in rows {
            let input = inputs[row.index]
            switch row.outcome {
            case .success(let value):
                successes.append(value)
                print("[\(row.index)] \(input): success \(value)")
            case .failure(let message):
                print("[\(row.index)] \(input): failure \(message)")
            case .cancelled:
                print("[\(row.index)] \(input): cancelled")
            }
        }
        print("Successful values: \(successes)")
    }
}

해당 폴더에서 프로그램을 컴파일하고 실행하세요.

swiftc -swift-version 6 -parse-as-library Calculations.swift -o Calculations
./Calculations

-parse-as-library 옵션을 사용하면 이 단일 소스 파일에서 명시적인 @main 진입점을 사용할 수 있습니다. 실행 파일은 애플리케이션 데이터를 읽거나 쓰지 않고 계산을 수행합니다. 조사 환경에서는 Swift를 사용할 수 없어 프로그램을 살펴보기만 했으며, 컴파일하거나 실행하지는 못했습니다.

취소 없이 제공된 입력을 사용하면 예상 출력은 다음과 같습니다.

[0] 3: success 9
[1] -2: failure negativeInput
[2] 4: success 16
[3] 9223372036854775807: failure overflow
[4] 0: success 0
Successful values: [9, 16, 0]

수집 후 입력 인덱스로 정렬하므로 출력 순서는 입력 순서를 따릅니다. 이 표시 순서를 유지하면서 실제 실행 순서는 달라질 수 있습니다. 성공한 값 배열은 나중에 구성되므로 전체 결과 보고서를 계속 확인할 수 있습니다.

수집기는 어떻게 성공한 결과를 모두 보존하나요?

부모 작업이 결과 배열을 소유합니다. 각 자식 작업은 인덱스 하나와 변경할 수 없는 입력 하나를 캡처해 결과를 계산하고 행 하나를 반환합니다. 자식 작업은 부모의 배열에 직접 추가하지 않습니다. 이는 작업 그룹 본문 안에서 자식 결과를 누적하는 언어 가이드의 방식을 따릅니다. 동시성을 참조하세요.

수집기는 처음에 제한된 수의 자식 작업을 제출합니다. 행 하나가 완료될 때마다 다른 입력을 처리할 자리가 생깁니다. 따라서 계산 실패도 성공과 똑같이 배치를 진행합니다. 둘 다 일반 행 하나를 반환하며 수집은 계속됩니다.

Foundation의 activeProcessorCount은 컴퓨터에서 사용할 수 있는 활성 처리 코어 수를 보고합니다. 이 예제는 설명을 위해 그 수를 4로 제한합니다. 이 제한은 측정된 최적값도 아니고 계산 4개가 동시에 실행된다는 보장도 아닙니다. Apple의 activeProcessorCount 문서를 참조하세요.

동시성 제한은 대기 중인 자식 작업 수를 제한하지만, 입력 수가 늘면 결과 배열은 계속 커집니다. 이는 데스크톱에서 소규모 배치를 처리하는 데 적합합니다. 아주 작은 정수의 제곱을 계산하는 이 예제는 결과 처리 방식을 보여줄 뿐, 순차 루프보다 성능이 좋다고 주장하지 않습니다.

입력이 비어 있으면 빈 배열을 반환합니다. 동시성 제한이 0 이하이면 1로 조정됩니다. 중복 입력도 각각 별도로 유지됩니다. 항목의 정체성은 위치로 결정되므로, 값이 같은 입력 두 개도 행 두 개를 반환합니다.

부모 작업이 취소되면 어떻게 되나요?

Swift의 취소는 협력 방식으로 작동합니다. 부모 작업을 취소하면 구조화된 자식 작업에도 취소가 전파되지만, 자식 작업은 신호를 확인하고 응답해야 합니다. Apple의 TaskGroup 문서는 전파와 협력 방식의 처리 필요성을 모두 설명합니다.

여기서 evaluate은 계산 전에 Task.checkCancellation()를 호출합니다. Apple 문서에 따르면 이 메서드는 취소가 확인되면 CancellationError를 던집니다. 래퍼는 이를 별도로 잡아 취소된 행을 반환합니다. checkCancellation()을 참조하세요.

수집기가 취소를 확인하면 아직 제출하지 않은 입력을 취소됨으로 표시합니다. 이미 제출된 자식 작업은 계속 완료될 때까지 수집합니다. 성공한 결과는 보고서에 남고, 취소를 확인한 자식 작업은 취소된 행을 반환합니다.

자식 작업이 확인을 마친 뒤 취소가 도착할 수 있습니다. 이 경우 자식 작업은 성공적으로 완료될 수 있으며, 이 계약은 이를 허용합니다. 계산이 긴 루프가 된다면 계산 중 적절한 간격으로 취소를 확인하세요. TaskGroup 문서에 설명된 대로 그룹은 반환하기 전에 제출된 모든 자식 작업이 끝날 때까지 기다립니다.

대신 throwing 작업 그룹을 사용해야 하는 경우는 언제인가요?

기존 자식 작업이 이미 오류를 던지고 그 인터페이스를 유지하고 싶다면, throwing 작업 그룹으로도 개별 결과를 수집할 수 있습니다. nextResult()를 반복해서 호출하고, 반환된 성공 또는 실패를 전환 처리하여 옵셔널 결과가 비어 있을 때까지 진행하세요. next()와 달리 이 메서드는 자식의 오류를 호출자에게 던지는 대신 Result 값을 반환합니다. Apple은 nextResult() 문서에서 완료 순서 동작을 설명합니다.

이 설계에서는 성공한 값과 귀속 정보가 필요한 모든 오류에 입력 식별 정보를 포함하세요. 단순한 산술 오류만으로는 여러 입력 중 어느 입력에서 발생했는지 알 수 없습니다. 또한 취소를 일반적인 계산 실패처럼 취급하지 말고 명시적인 취소 정책을 선택하세요.

입력별 전체 보고서가 주요 요구사항이라면 결과 반환 방식을 사용하세요. 오류를 그룹 밖으로 전파하는 것이 원하는 배치 동작이거나 기존 throwing 연산을 nextResult()를 통해 처리하는 편이 더 편리하다면 throwing 그룹을 사용하세요.

이 로컬 계산 작업의 핵심 계약은 간단합니다. 성공한 값은 유지하고, 실패한 입력은 기록하며, 수집을 계속하세요. 모든 결과에 입력 인덱스를 포함하면 계산 완료 순서를 예측할 수 없어도 최종 보고서를 유용하게 만들 수 있습니다.

출처