본문 바로가기
Swift

[Swift] Task, async let, Task Group 동시 작업의 모든것

by Jimmy_iOS 2024. 3. 7.

비동기 작업을 수행할 때, 여러 개의 작업을 동시에 진행해야 하는 경우가 있습니다.

이를테면, 두 개의 이미지를 다운 받고, 그 두개의 이미지를 return하는 작업이 있을 수 있습니다.

다음은 코드 예시입니다.

func fetchTwoURLs() async throws -> (Data, Data) {
    let url1 = URL(string:"https://www.apeth.com/pep/manny.jpg")!
    let url2 = URL(string:"https://www.apeth.com/pep/moe.jpg")!
    let data1 = try await self.download(url: url1)
    let data2 = try await self.download(url: url2)
    return (data1, data2)
}

async 키워드를 사용한 메서드의 스코프에서는

코드가 비동기적으로 동작합니다. 그리고 순차적으로 동작합니다.

즉 data1에서 await self.download(url: url1) 코드가 실행되는 동안 해당 스코프가 실행되고 있는 스레드는 일시정지상태입니다.

data1의 작업이 완료되면 그 다음 이어서 data2의 작업이 순차적으로 진행됩니다.

이 코드는 정상적으로 작동하지만, 우리가 원하는 결과는 아닙니다. 왜냐하면 연속적인 await는 우리가 호출한 비동기 메소드가 완료될 때까지 일시 중지를 의미하기 때문입니다. 이는 불필요하게 시간을 소비하는것 처럼 보입니다.

Task 는 동시성(concurrent)의 단위(Unit)입니다.

“단일 Task 는 어떠한 동시성도 포함하지 않는다.” 가 Task의 핵심입니다.

따라서 여러 비동기 작업을 수행하려면 여러 비동기 Task를 생성해야 합니다.

정말 다행이도 Task 안에서 여러 비동기 하위 Task 생성할 수 있습니다.

async let 이해하기

async let 표현식을 사용하면, async 함수를 호출하고 그 반환 값을 기다리지 않고 다음 작업을 수행할 수 있습니다.

async let 은 현재 Task 의 하위 Task 를 생성합니다. 그리고 하위 Task 는 독립적인 비동기 단위이기 때문에 바로 코드가 실행됩니다.

아래는 예시코드 입니다.

func fetchTwoURLs() async throws -> (Data, Data) {
    let url1 = URL(string:"https://www.apeth.com/pep/manny.jpg")!
    let url2 = URL(string:"https://www.apeth.com/pep/moe.jpg")!
    async let data1 = self.download(url: url1)
    async let data2 = self.download(url: url2)
    return (try await data1, try await data2)
}

위에서 봤던 코드와 다르게 data1 앞에 async let 표현식이 붙어있습니다

let data1 = try await self.download(url: url1) 의 경우

await 때문에 작업이 완료될 때 까지 스레드가 일시정지 하지만

async let 의 경우 하나의 Task 로 이루러져 있기 때문에 두 개의 하위 Task를 독림적으로 동시에 실행합니다. 그 후, 이 두 하위 작업의 결과가 모두 완료될 때까지 기다립니다.

async let의 흥미로운 특징

async let과 해당 try await의 분리로 인해 발생하는 흥미로운 논리적 결과 중 하나는, async let 할당을 호출하는 async 함수가 에러를 발생시켜도 우리의 코드가 중단되지 않는다는 것입니다.

async let data1 = self.download(url: url1)
async let data2 = self.download(url: url2)

return (try await data1, try await data2)

첫 번째 download(url:) 호출이 에러를 즉시 발생시켜도, 두 번째 호출은 여전히 실행되고 기다려집니다. 하위 작업에서 발생한 에러는 우리가 try를 사용할 때까지 코드를 중단하지 않습니다.

이 외에도, async let 할당문은 더욱 표현적인 코드를 작성할 수 있도록 다음과 같은 구문으로 축약됩니다.

async let d1 = { () async throws -> Data in
    let result = try await self.download(url: url1)
    return result
}()
async let d2 = { () async throws -> Data in
    let result = try await self.download(url: url2)
    return result
}()

Task Group 이해하기

Task Group은 async let과 마찬가지로 하위 작업들을 동시에 실행하는것이 가능합니다.

그렇가면 이미 async let 이 있는데 왜 또 Task Group이라는 것이 필요할까요?

그것은 바로 async let 같이 이미 정해져있는 작업의 경우 문제 없지만

우리가 수행할 작업의 개수를 사전에 알 수 없는 경우는 어떻게 대처해야 할까요?

예를 들어, 배열 안에 있는 모든 URL에서 동시에 다운로드를 받아야 하는 상황을 생각해봅시다.

이러한 경우에는 async let을 사용할 수 없습니다. 왜냐하면 우리는 몇 개의 async let 선언이 필요한지 사전에 알 수 없기 때문입니다.

이런 상황에서는 배열을 순회하며 각 항목에 대한 다운로드를 시작하고, 배열의 모든 항목에 대한 값을 받을 때까지 기다려야 합니다. 이를 위해 구조적 동시성에서 task group을 사용합니다.

아래는 코드 예시입니다.

func fetchManyURLs() async throws -> [Data] {
    let urls: [URL] = // ...
    var result = [Data]()
    try await withThrowingTaskGroup(of: Data.self) { group in
        for url in urls {
            group.addTask { // group에 테스크를 추가하고 data를 반환
                return try await self.download(url: url)
            }
        }
        for try await data in group { // 반환했던 data를 가져옴
            result.append(data)
        }
    }
    return result
}

위의 코드는 다음과 같은 패턴을 보여줍니다.

  1. 결과를 추적하기 위한 빈 컬렉션을 선언합니다.
  2. Task Group 블록을 선언하고, 각 하위 작업에서 반환될 값의 타입을 선언합니다. Task Group 블록 자체가 비동기적이므로 이를 기다려야 하고, 에러를 던질 수 있는 경우에는 try를 붙여야 합니다.
  3. Task Group 블록 내에서, Task Group을 매개변수로 받아서 addTask 메소드를 반복적으로 호출하여 각 하위 작업을 시작합니다.
  4. 각 하위 작업은 비동기 함수를 호출합니다. 이 예제에서는 해당 메소드가 2단계에서 선언한 타입의 값을 반환하고, 이 결과를 Task Group에 반환합니다.
  5. for await을 사용하여 Task Group을 비동기 시퀀스로 순회하고, 각 반환 값이 도착할 때마다 이를 수집합니다.
  6. 모든 반환 값이 도착하면 비동기 for 루프가 종료되고, Task Group의 태스크가 모두 소진된 상태가 됩니다. 이것이 2단계에서 기다리고 있는 상황입니다! 컬렉션이 채워졌으므로 이제 Task Group 블록 다음의 라인으로 이동할 수 있습니다. 이제 이 컬렉션을 사용할 수 있습니다.

하. 지. 만

위와 같은 방법에는 한가지 단점이 있습니다.

let urls: [URL] = // ...
var result = [Data]()
///...
for try await data in group { // 반환했던 data를 가져옴
		result.append(data)
}

위 코드를 실행하면 urls에서 작업을 수행하고 result에 데이터를 추가합니다. 여기서 주의할 점은 result 컬렉션에 할당되는 데이터의 순서가 url과 일치하지 않는다는 것입니다.

이 문제를 해결하기 위해, Data 배열을 반환하는 대신 URL 키와 Data 값을 연결한 딕셔너리를 반환할 수 있습니다.

Dictionary를 활용한 데이터 관리

func fetchManyURLs() async throws -> [URL:Data] {
    let urls: [URL] = // ...
    var result = [URL:Data]()
    try await withThrowingTaskGroup(of: [URL:Data].self) { group in
        for url in urls {
            group.addTask {
                return [url: try await self.download(url: url)]
            }
        }
        for try await data in group {
            result.merge(data) {current,_ in current}
        }
    }
    return result
}

merge는 Swift의 Dictionary에서 제공하는 메소드입니다.

이 메소드는 두 사전을 합치는데 사용됩니다. 이미 존재하는 키에 대해서는 선택적으로 지정된 클로저를 이용해 어떤 값을 사용할지 결정할 수 있습니다.

예를 들어, 다음과 같은 두 사전이 있다고 가정해봅시다:

var dict1 = ["a": 1, "b": 2]
let dict2 = ["b": 3, "c": 4]

이 두 사전을 merge 메소드를 통해 합치면, "b"라는 키가 두 사전에 모두 있으므로, 이 키에 대한 값을 어떻게 처리할지 결정해야 합니다. 이때 클로저를 사용하여 첫 번째 사전의 값을 사용하도록 지정할 수 있습니다:

dict1.merge(dict2) { (current, _) in current }

이렇게 하면 최종 dict1은 ["a": 1, "b": 2, "c": 4]가 됩니다. "b"의 값은 첫 번째 사전의 값인 2를 유지하고, "c"의 값은 새로운 사전에서 가져온 4가 됩니다.

merge 메소드에서 클로저를 사용하여 두 번째 사전의 값을 사용하도록 지정할 수도 있습니다:

dict1.merge(dict2) { (_, new) in new }

이렇게 하면 최종 dict1은 ["a": 1, "b": 3, "c": 4]가 됩니다. "b"의 값은 두 번째 사전의 값인 3으로 교체되고, "c"의 값은 새로운 사전에서 가져온 4가 됩니다.

Task Group 코드를 더 깔끔하게 작성하기

Task Group의 블록 내에서 결과를 추적하는 result 변수를 선언하는 것이 좋지 않을 수도 있습니다. 이 변수가 Task Group 블록에 로컬로 선언되면 더 좋을 것입니다. 그렇게 하려면 Task Group 블록을 returning: 매개변수로 선언하여 블록에서 반환될 전체 결과의 타입을 선언할 수 있습니다. 이 옵션을 사용하면, result를 Task Group 블록 내부에서 누적하고 반환할 수 있습니다. 그리고 이를 fetchManyURLs에서 바로 반환할 수 있습니다.

func fetchManyURLs() async throws -> [URL:Data] {
    let urls: [URL] = // ...
    return try await withThrowingTaskGroup(
        of: [URL:Data].self,
        returning: [URL:Data].self) { group in
            var result = [URL:Data]()
            for url in urls {
                group.addTask {
                    return [url: try await self.download(url: url)]
                }
            }
            for try await d in group {
                result.merge(d) {current,_ in current}
            }
            return result
        }
}

또한, returning: 매개변수를 제공하지 않으면, 타입은 of: 매개변수의 타입으로 기본 설정됩니다. 우리의 경우에는 둘 다 같은 타입이므로, Task Group 블록 내에서 결과 누적기를 유지하면서 returning: 매개변수를 생략할 수 있습니다.

 

이렇게 Swift의 구조적 동시성을 활용하면 배열의 모든 요소에 대해 동시에 작업을 수행하고 그 결과를 수집하는 코드를 간결하고 효율적으로 작성할 수 있습니다. 이 방법은 어떤 길이의 배열이든 처리할 수 있으므로, 동시에 처리해야 할 작업의 수를 미리 알 수 없는 경우에 매우 유용합니다.