비동기 작업을 수행할 때, 여러 개의 작업을 동시에 진행해야 하는 경우가 있습니다.
이를테면, 두 개의 이미지를 다운 받고, 그 두개의 이미지를 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
}
위의 코드는 다음과 같은 패턴을 보여줍니다.
- 결과를 추적하기 위한 빈 컬렉션을 선언합니다.
- Task Group 블록을 선언하고, 각 하위 작업에서 반환될 값의 타입을 선언합니다. Task Group 블록 자체가 비동기적이므로 이를 기다려야 하고, 에러를 던질 수 있는 경우에는 try를 붙여야 합니다.
- Task Group 블록 내에서, Task Group을 매개변수로 받아서 addTask 메소드를 반복적으로 호출하여 각 하위 작업을 시작합니다.
- 각 하위 작업은 비동기 함수를 호출합니다. 이 예제에서는 해당 메소드가 2단계에서 선언한 타입의 값을 반환하고, 이 결과를 Task Group에 반환합니다.
- for await을 사용하여 Task Group을 비동기 시퀀스로 순회하고, 각 반환 값이 도착할 때마다 이를 수집합니다.
- 모든 반환 값이 도착하면 비동기 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의 구조적 동시성을 활용하면 배열의 모든 요소에 대해 동시에 작업을 수행하고 그 결과를 수집하는 코드를 간결하고 효율적으로 작성할 수 있습니다. 이 방법은 어떤 길이의 배열이든 처리할 수 있으므로, 동시에 처리해야 할 작업의 수를 미리 알 수 없는 경우에 매우 유용합니다.