Swift 작업 그룹을 사용해 완료 순서대로 경쟁 계산의 결과를 모으고, nil은 건너뛰며, 값이 도착하면 나머지 자식 작업을 취소합니다. 이 Mac 명령줄 예제는 합성 타일에 사용할 수 있는 직사각형 레이아웃을 찾고, 결과가 실제로 언제 반환될 수 있는지 설명합니다.

첫 번째로 유용한 결과의 기준 정의하기

작은 데스크톱 레이아웃 도구가 합성 타일 221개를 직사각형으로 배치해야 한다고 가정해 봅시다. 여러 로컬 계산이 서로 다른 행 수를 탐색합니다. 1이 아닌 약수라면 무엇이든 괜찮습니다. 13행 17열도 되고, 17행 13열도 됩니다. 호출자는 유효한 계산 중 무엇이든 가장 먼저 완료되는 것을 받으면 됩니다.

이는 완료 순서에 관한 계약입니다. 가장 작은 약수나 가장 균형 잡힌 직사각형, 또는 목록에서 가장 먼저 나온 계산을 선택하지 않습니다. Apple 문서에 따르면 작업 그룹의 연속된 결과는 삽입 순서와 관계없이 완료 순서대로 도착합니다. TaskGroup.next(isolation:)

도우미 함수를 작성하기 전에 동작을 정의합니다.

상황 필요한 동작
계산이 nil을 반환하며 완료됨 다른 계산을 계속 기다립니다.
계산이 값과 함께 완료됨 해당 값을 선택하고 나머지 작업의 취소를 요청합니다.
모든 계산이 nil을 반환함 nil을 반환합니다.
계산 목록이 비어 있음 nil을 반환합니다.
상위 작업의 취소가 감지됨 결과 선택을 중단하고 nil를 반환합니다.

마지막 행은 이 예제에서 의도적으로 선택한 API 동작입니다. 여기서 nil는 호출에 사용할 수 있는 값이 없다는 뜻이며, 검색 실패와 취소를 구분하지 않습니다. 호출자가 이 구분을 필요로 한다면 명시적인 결과 타입이나 오류를 던지는 계약을 사용하세요.

Mac에서 완전한 로컬 예제 만들기

Swift 6 이상 툴체인을 사용하세요. 터미널에서 swift --version으로 확인할 수 있습니다. Swift를 사용할 수 없다면 공식 Install Swift — macOS 안내를 따르세요. 이 프로그램은 외부 패키지 의존성 없이 Foundation과 Swift 동시성 API를 사용합니다.

다음 명령으로 새 실행 파일 패키지를 만드세요. 이 방법은 Swift.org에서 문서화한 패키지 생성 및 실행 절차를 따릅니다. Build a Command-line Tool

mkdir FirstNonNilDemo
cd FirstNonNilDemo
swift package init --name FirstNonNilDemo --type executable

생성된 실행 파일의 소스를 아래의 완전한 프로그램으로 바꾸세요. 생성된 디렉터리 구조에 따라 파일 이름은 Sources/FirstNonNilDemo/FirstNonNilDemo.swift 또는 Sources/main.swift일 수 있습니다. FirstNonNilDemo.swift라는 진입점 파일 하나만 사용하세요. 템플릿에서 main.swift을 만들었다면 @main 프로그램을 추가하기 전에 파일 이름을 바꾸세요. Swift 가이드에 따르면 프로그램은 main.swift 또는 @main 진입점 중 하나를 사용합니다. Build a Command-line Tool

import Foundation

func firstNonNil<Value: Sendable>(
    _ calculations: [@Sendable () async -> Value?]
) async -> Value? {
    guard !Task.isCancelled else { return nil }

    return await withTaskGroup(
        of: Value?.self,
        returning: Value?.self
    ) { group in
        defer { group.cancelAll() }

        for calculation in calculations {
            let added = group.addTaskUnlessCancelled {
                guard !Task.isCancelled else { return nil }
                return await calculation()
            }
            guard added else { break }
        }

        for await candidate in group {
            guard !Task.isCancelled else { return nil }
            if let value = candidate {
                return value
            }
        }

        return nil
    }
}

func findFactor(total: Int, range: ClosedRange<Int>) -> Int? {
    guard total > 1 else { return nil }

    for rows in range {
        guard !Task.isCancelled else { return nil }
        guard rows > 1, rows < total else { continue }

        if total % rows == 0 {
            return rows
        }
    }

    return nil
}

@main
struct FirstNonNilDemo {
    static func main() async {
        let checks: [@Sendable () async -> Int?] = [
            { nil },
            { 42 }
        ]
        let checkResult = await firstNonNil(checks)
        precondition(checkResult == 42)

        let empty: [@Sendable () async -> Int?] = []
        let emptyResult = await firstNonNil(empty)
        precondition(emptyResult == nil)

        for rawText in [" 221\n", " 223 ", "tiles"] {
            let text = rawText.trimmingCharacters(
                in: .whitespacesAndNewlines
            )

            guard let total = Int(text), total > 1 else {
                print("Input must be a whole number greater than 1.")
                continue
            }

            let calculations: [@Sendable () async -> Int?] = [
                { findFactor(total: total, range: 2...5) },
                { findFactor(total: total, range: 6...16) },
                { findFactor(total: total, range: 17...30) }
            ]

            if let rows = await firstNonNil(calculations) {
                print("\(total) tiles: \(rows) rows x \(total / rows) columns")
            } else {
                print("\(total) tiles: no factor in the supplied ranges.")
            }
        }
    }
}

패키지 디렉터리에서 실행하세요.

swift run FirstNonNilDemo

221의 경우 221 tiles: 13 rows x 17 columns 또는 221 tiles: 17 rows x 13 columns가 나오면 됩니다. 223의 경우 프로그램은 지정된 범위에서 약수를 찾지 못했다고 보고합니다. tiles 텍스트는 입력 메시지를 출력합니다. 이는 예상되는 결과이지, 실제 실행 기록은 아닙니다. 산술은 별도로 확인했으며, 조사 환경에는 Swift 컴파일러가 없어 Swift 프로그램을 검토했지만 실행하지는 못했습니다.

Foundation은 앞뒤 공백을 정리하는 용도로만 사용됩니다. trimmingCharacters(in:) 메서드는 양 끝에서 일치하는 문자를 제거하며, 문자열 중간의 문자는 제거하지 않습니다. 따라서 221은 221이 되지만, 2 21은 이 프로그램에서 여전히 유효하지 않습니다. trimmingCharacters(in:)

나중에 나온 결과를 잃지 않고 nil 건너뛰기

작업 그룹의 자식 결과 타입은 Value?입니다. 따라서 각 자식은 사용 가능한 값이나 일반적인 빈 결과 중 하나를 제공합니다. for await 루프는 완료된 자식의 결과를 받으며, 내부 옵셔널 바인딩은 nil이 아닌 후보만 선택합니다.

group.next()를 직접 사용하면 빈 상태에는 두 가지가 있습니다. 문서화된 반환 타입은 ChildTaskResult?입니다. 자식 결과가 옵셔널이면 이것은 Value??가 됩니다. 바깥 옵셔널은 다른 자식 결과가 있는지를 나타내고, 안쪽 옵셔널은 그 자식이 값을 찾았는지를 나타냅니다. TaskGroup.next(isolation:)

이 차이를 이해하면 흔한 실수를 피할 수 있습니다. 결과 하나만 받고 즉시 반환하는 것입니다. 가장 먼저 완료된 계산이 nil를 반환하더라도 다른 자식은 여전히 유용한 답을 찾을 수 있습니다. 완전한 예제의 루프는 값을 찾거나 그룹이 소진될 때까지 결과를 계속 받습니다.

도우미 함수는 addTaskUnlessCancelled을 사용하고 Boolean 반환값을 확인합니다. Apple 문서에 따르면 그룹이 이미 취소된 경우 이 메서드는 자식 작업을 생성하지 않습니다. 제출 루프를 중단하면 해당 조건을 감지한 뒤에도 작업을 계속 제출하는 일을 피할 수 있습니다. addTaskUnlessCancelled(priority:operation:)

계산은 변경되지 않는 타일 수를 캡처하고 그룹을 통해 답을 반환합니다. 공유된 승자 변수는 업데이트하지 않습니다. 제네릭 결과는 Sendable을 준수해야 하며, 이는 문서화된 작업 그룹 제약 조건과 일치합니다. withTaskGroup(of:returning:isolation:body:)

승자를 선택해도 즉시 반환되는 것은 아님

그룹 클로저에서 값을 반환하면 답이 선택되지만, 바깥의 withTaskGroup는 여전히 모든 자식 작업이 끝날 때까지 기다립니다. Apple 문서에는 취소된 자식 작업도 함수가 반환되기 전에 완료되어야 한다고 명시되어 있습니다. withTaskGroup(of:returning:isolation:body:)

defer 블록은 클로저가 종료될 때마다 group.cancelAll()을 호출합니다. 이는 남은 계산의 취소를 요청합니다. 취소가 임의의 계산을 중단시키지는 않습니다. 자식 작업이 협조적으로 동작하고 스스로 일찍 끝나야 합니다. TaskGroup

이 예제에서 findFactor은 후보 제수마다 검사하기 전에 Task.isCancelled를 확인합니다. 각 작업 단위가 작으므로 자주 멈출 기회가 있습니다. 더 큰 계산이라면 의미 있는 크기로 제한된 작업 구간 사이에 확인을 넣으세요. 길고 나눌 수 없는 작업을 시작하기 전에만 확인하면 해당 작업이 끝날 때까지 계속 실행됩니다. Swift 언어 가이드는 취소가 협력적으로 이루어진다고 설명하며, 이에 대응하는 한 가지 방법으로 nil을 반환하는 것을 제시합니다. Concurrency

경쟁 계산 중 하나가 끝나지 않고 취소에도 응답하지 않는다면, 이 구조화된 도우미는 신속한 반환을 보장할 수 없습니다. 각 작업자가 제한된 시간 안에 진행하거나 취소를 인식하도록 만드는 것이 지원되는 방법입니다. 경쟁하는 시간 제한 결과를 추가해도 그룹이 자식 작업을 기다려야 하는 의무는 사라지지 않습니다.

상위 작업의 취소는 작업 제출 전, 자식 작업 시작 시, 산술 루프 안, 그리고 각 결과를 수락하기 전에 확인됩니다. 이런 검사는 특정 시점의 취소 상태를 관찰할 뿐, 취소가 항상 값을 우선하는 원자적 규칙을 만들지는 않습니다. 마지막 확인 뒤에 취소가 도착하면 성공적인 반환과 동시에 일어날 수 있습니다.

적절한 선택 규칙을 정하고 경계 조건 확인하기

합성 예제는 유용한 판단 기준을 제공합니다. 명시된 계약에서는 13과 17이 모두 조건을 충족합니다. 도구에서 더 작은 행 수가 필요하다면 둘 중 아무 결과나 수락하는 것은 잘못입니다. 완료 순서 방식은 허용되는 모든 답이 호출자의 작업에 서로 대체 가능할 때만 유용합니다.

호출자가 필요한 것 적절한 접근 방식
계산이 끝나는 대로 허용 가능한 값 하나 위에 나온 작업 그룹 루프를 사용합니다.
고정된 선호 순서 목록에서 가장 먼저 성공한 계산 선호 순서대로 평가하거나, 인덱스를 보존하고 더 앞선 후보를 확인한 뒤 선택합니다.
가장 작거나 점수가 가장 높은 결과 관련 결과를 비교한 뒤 선택합니다.
함께 필요한 여러 결과 async let을 고려합니다.

Swift 언어 가이드는 결과를 나중에 사용할 알려진 자식 작업에는 async let을, 동적으로 추가하는 자식 작업을 명시적으로 제어할 때는 작업 그룹을 제시합니다. 지정된 순서로 이름 있는 결과를 기다리는 방식은 완료 순서에 따른 선택 루프를 구현하지 않습니다. Concurrency

프로그램을 수정할 때는 다음을 확인하세요.

  • 빈 목록 검사를 유지하세요. nil을 반환해야 합니다.
  • nil과 42가 섞인 검사를 유지하세요. 완료 순서와 관계없이 42을 반환해야 합니다.
  • 모든 계산이 nil을 반환하는 경우를 테스트하세요.
  • 유효한 답이 여러 개라면 특정 승자가 아니라 유효한 값 집합에 속하는지 확인하세요.
  • 모든 작업자가 제한된 시간 안에 진행하고 취소를 확인하는지 살펴보세요.
  • 검색 실패는 지정된 범위에 한정해 보고하세요. 다른 범위에도 답이 없다고 추론하지 마세요.

세 개의 작은 약수 검색은 동작을 보여 줄 뿐, 속도 향상을 입증하지 않습니다. 이 예시 숫자에는 순차 검색으로 충분합니다. 서로 대체 가능한 결과 중 하나를 수락하는 것이 실제 작업에 도움이 될 때 경쟁 계산 패턴을 사용하고, 어느 정도의 작업을 동시에 실행할지는 해당 작업량을 측정한 뒤 정하세요.

수락 기준을 명확히 하고, 빈 자식 결과는 건너뛰며, 남은 작업이 멈출 수 있도록 만드세요. 그러면 Mac 도구는 예측 가능한 값 계약을 따릅니다. 그룹이 전달하는 첫 번째 사용 가능한 결과를 반환한 뒤, 자식 작업이 협력적으로 종료될 때까지 기다립니다.

출처