🔥 태스크와 태스크 그룹

938자
13분

Swift에서 태스크(Task)는 프로그램의 일부로 비동기적으로 실행될 수 있는 작업 단위입니다. 모든 비동기 코드는 어떤 태스크의 일부로 실행됩니다. 태스크 자체는 한 번에 하나의 작업만 수행하지만, 여러 개의 태스크를 생성하면 Swift는 이들을 동시에 실행하도록 예약할 수 있습니다.

이전 섹션에서 설명한 async-let 구문은 암시적으로 자식 태스크를 생성합니다. 이 구문은 프로그램이 실행해야 하는 태스크를 이미 알고 있을 때 잘 작동합니다. 또한 태스크 그룹(TaskGroup의 인스턴스)을 생성하고 해당 그룹에 명시적으로 자식 태스크를 추가할 수도 있는데, 이렇게 하면 우선순위와 취소에 대한 더 많은 제어권을 갖게 되고, 필요에 따라 유동적으로 태스크의 수를 조절할 수 있습니다.

태스크는 계층 구조로 배열됩니다. 주어진 태스크 그룹의 각 태스크는 동일한 부모 태스크를 가지며, 각 태스크는 자식 태스크를 가질 수 있습니다. 태스크와 태스크 그룹 사이의 명시적인 관계 때문에 이 접근 방식을 구조화된 동시성(structured concurrency)이라고 합니다. 태스크 사이의 명시적인 부모-자식 관계에는 여러 가지 장점이 있습니다:

  • 부모 태스크에서는 자식 태스크가 완료될 때까지 기다리는 것을 잊을 수 없습니다.
  • 자식 태스크에 높은 우선순위를 설정하면 부모 태스크의 우선순위도 자동으로 높아집니다.
  • 부모 태스크가 취소되면 각 자식 태스크도 자동으로 취소됩니다.
  • 태스크-로컬 값이 자식 태스크로 효율적이고 자동으로 전파됩니다.

다음은 사진을 다운로드하는 코드의 또 다른 버전으로, 임의의 수의 사진을 처리합니다:

await withTaskGroup(of: Data.self) { group in
    let photoNames = await listPhotos(inGallery: "Summer Vacation")
    for name in photoNames {
        group.addTask {
            return await downloadPhoto(named: name)
        }
    }
 
    for await photo in group {
        show(photo)
    }
}
swift

위의 코드는 새로운 태스크 그룹을 생성한 다음, 갤러리의 각 사진을 다운로드하기 위한 자식 태스크를 생성합니다. Swift는 조건이 허용하는 한 이러한 태스크를 동시에 실행합니다. 자식 태스크가 사진 다운로드를 완료하는 즉시 해당 사진이 표시됩니다. 자식 태스크가 완료되는 순서에 대한 보장은 없으므로 이 갤러리의 사진은 어떤 순서로든 표시될 수 있습니다.

위의 코드 목록에서는 각 사진이 다운로드된 후 바로 표시되므로 태스크 그룹은 결과를 반환하지 않습니다. 결과를 반환하는 태스크 그룹의 경우 withTaskGroup(of:returning:body:)에 전달하는 클로저 내에서 결과를 누적하는 코드를 추가합니다.

let photos = await withTaskGroup(of: Data.self) { group in
    let photoNames = await listPhotos(inGallery: "Summer Vacation")
    for name in photoNames {
        group.addTask {
            return await downloadPhoto(named: name)
        }
    }
 
    var results: [Data] = []
    for await photo in group {
        results.append(photo)
    }
 
    return results
}
swift

이전 예제와 마찬가지로 이 예제에서는 각 사진을 다운로드하기 위해 자식 태스크를 생성합니다. 이전 예제와 달리 for-await-in 루프는 다음 자식 태스크가 완료될 때까지 기다린 다음, 해당 태스크의 결과를 결과 배열에 추가하고 모든 자식 태스크가 완료될 때까지 계속 기다립니다. 마지막으로 태스크 그룹은 다운로드한 사진의 배열을 전체 결과로 반환합니다.

태스크 취소

Swift 동시성은 협력적 취소 모델을 사용합니다. 각 태스크는 실행의 적절한 시점에 취소되었는지 확인하고 취소에 적절히 대응합니다. 태스크가 수행하는 작업에 따라 취소에 대한 대응은 일반적으로 다음 중 하나를 의미합니다:

  • CancellationError와 같은 오류 발생
  • nil 또는 빈 컬렉션 반환
  • 부분적으로 완료된 작업 반환

사진이 크거나 네트워크가 느린 경우 사진 다운로드에 오랜 시간이 걸릴 수 있습니다. 모든 태스크가 완료될 때까지 기다리지 않고 사용자가 이 작업을 중단할 수 있게 하려면 태스크가 취소를 확인하고 취소된 경우 실행을 중단해야 합니다. 태스크가 이를 수행하는 방법에는 두 가지가 있습니다:

Task.checkCancellation() 메서드를 호출하거나 Task.isCancelled 속성을 읽는 것입니다. checkCancellation()을 호출하면 태스크가 취소된 경우 오류가 발생합니다. 오류를 발생시키는 태스크는 오류를 태스크 밖으로 전파하여 태스크의 모든 작업을 중단할 수 있습니다. 이는 구현과 이해가 간단하다는 장점이 있습니다. 더 많은 유연성을 위해서는 isCancelled 속성을 사용할 수 있습니다. 이를 통해 네트워크 연결 닫기 및 임시 파일 삭제와 같이 태스크 중지의 일부로 정리 작업을 수행할 수 있습니다.

let photos = await withTaskGroup(of: Optional<Data>.self) { group in
    let photoNames = await listPhotos(inGallery: "Summer Vacation")
    for name in photoNames {
        group.addTaskUnlessCancelled {
            guard isCancelled == false else { return nil }
            return await downloadPhoto(named: name)
        }
    }
 
    var results: [Data] = []
    for await photo in group {
        if let photo { results.append(photo) }
    }
    return results
}
swift

위의 코드는 이전 버전에서 여러 가지 변경 사항을 만듭니다:

  • 각 태스크는 취소 후 새로운 작업을 시작하지 않도록 TaskGroup.addTaskUnlessCancelled(priority:operation:) 메서드를 사용하여 추가됩니다.
  • 각 태스크는 사진 다운로드를 시작하기 전에 취소를 확인합니다. 취소된 경우 태스크는 nil을 반환합니다.
  • 마지막에 태스크 그룹은 결과를 수집할 때 nil 값을 건너뜁니다. nil을 반환하여 취소를 처리하면 태스크 그룹이 해당 완료된 작업을 버리는 대신 취소 시점에 이미 다운로드된 사진인 부분 결과를 반환할 수 있습니다.

취소에 대한 즉각적인 알림이 필요한 작업의 경우 Task.withTaskCancellationHandler(operation:onCancel:) 메서드를 사용하세요. 예를 들면 다음과 같습니다:

let task = await Task.withTaskCancellationHandler {
    // ...
} onCancel: {
    print("Canceled!")
}
 
// ... some time later...
task.cancel()  // Prints "Canceled!"
swift

취소 핸들러를 사용할 때도 태스크 취소는 협력적입니다: 태스크는 완료까지 실행되거나 취소를 확인하고 조기에 중단됩니다. 취소 핸들러가 시작될 때 태스크가 여전히 실행 중이므로 태스크와 취소 핸들러 간에 상태를 공유하지 않도록 하세요. 이는 경쟁 상태를 유발할 수 있습니다.

비구조화된 동시성

이전 섹션에서 설명한 구조화된 동시성 접근 방식 외에도 Swift는 비구조화된 동시성도 지원합니다. 태스크 그룹의 일부인 태스크와 달리 비구조화된 태스크에는 부모 태스크가 없습니다. 프로그램에서 필요로 하는 방식으로 비구조화된 태스크를 완전히 유연하게 관리할 수 있지만, 그 정확성에 대해서도 전적으로 책임을 집니다. 현재 액터에서 실행되는 비구조화된 태스크를 생성하려면 Task.init(priority:operation:) 이니셜라이저를 호출하세요. 현재 액터의 일부가 아닌 비구조화된 태스크를 생성하려면 분리된 태스크라고 하는 Task.detached(priority:operation:) 클래스 메서드를 호출하세요. 이 두 작업은 모두 상호 작용할 수 있는 태스크를 반환합니다. 예를 들어 결과를 기다리거나 태스크를 취소할 수 있습니다.

let newPhoto = // ... some photo data ...
let handle = Task {
    return await add(newPhoto, toGalleryNamed: "Spring Adventures")
}
let result = await handle.value
swift

분리된 태스크 관리에 대한 자세한 내용은 Task를 참조하세요.

lecture image

위의 다이어그램은 태스크 그룹과 취소 가능한 자식 태스크의 흐름을 보여줍니다. 각 자식 태스크는 시작할 때 취소 여부를 확인하고, 취소된 경우 nil을 반환하거나 취소되지 않은 경우 사진을 다운로드합니다. 다운로드된 사진은 결과에 추가되고, 최종적으로 태스크 그룹은 모든 결과를 반환합니다.

Swift의 태스크와 태스크 그룹은 동시성을 처리하기 위한 강력하고 유연한 도구입니다. 구조화된 동시성을 통해 코드의 정확성을 보장하면서도 성능을 최적화할 수 있습니다. 또한 비구조화된 태스크를 사용하여 특별한 요구 사항이 있는 상황을 처리할 수도 있습니다. Swift 동시성 모델을 이해하고 활용함으로써 응답성이 뛰어나고 효율적인 비동기 프로그램을 작성할 수 있습니다.