이 포스트는 다음 버전을 기준으로 작성되었습니다.

  • iOS 15.6
  • Swift 5.6.1
  • Xcode 13.4.1

Intro

TaskGroup 은 동시성 프레임워크의 일부로, Swift 5.5 버전 업데이트에서 공개되었습니다. 그리고 그 이름에서부터 Task 의 모음이라는 것을 유추해 볼 수 있습니다. 그렇다면 먼저 Task 가 무엇인지 알아야 겠네요.

하지만 이 포스트는 TaskGroup 이 주제이므로 길게 설명하지는 않겠습니다. Task 란 비동기 작업의 단위 정도라고만 알아두세요.


공식문서 살펴보기

그럼 공식문서를 보며 TaskGroup 에 대해 더 자세히 알아봅시다. 공식문서는 Task Group 을 다음과 같이 설명합니다.

Task Group 공식문서

TaskGroup 이란 다이나믹하게 생성된 Child Task 를 가진 Group 이라고 하네요. 음… 이해하기가 조금 어려운 것 같은데요. 위에서 얘기했던 것 처럼 Task 의 모음이라고 생각하면 됩니다.

그리고 TaskGroup 을 생성하려면 withTaskGroup(of:returning:body:) 메서드를 사용하면 된다고 합니다. 그렇다면 이 메서드는 무엇인지 공식문서에서 찾아보겠습니다.

withTaskGroup 공식문서

withTaskGroup(of:returning:body:) 메서드를 사용해 여러 개의 Child Task 가 포함된 스코프를 생성할 수 있다고 합니다. 한마디로 TaskGroup 을 만들 수 있는거에요. 단 이 때 만들어지는 TaskGroup 은 에러가 발생하지 않는 TaskGroup 입니다.

만약 TaskGroup 이나 Child Task 에서 에러가 throws 된다면 withThrowingTaskGroup(of:returning:body:) 메서드를 사용해야 합니다. 이 메서드에 대한 공식문서도 살펴보겠습니다.

withThrowingTaskGroup 공식문서

throwing child task 라는 단어를 제외하고는 다른 점이 없네요. 공식문서에 써져 있는대로 TaskGroup 을 생성할 때 에러가 throws 될 수 있다면 이 메서드를 사용하면 됩니다.


TaskGroup 의 특성

예제 코드를 작성하기 전에 잠깐 TaskGroup 은 어떤 특성을 가지고 있는지 알아볼게요. TaskGroup 은 크게 다음과 같은 5가지의 특성을 가지고 있습니다.

  1. 각 Task 는 독립적입니다. 즉 하나의 Task 가 다른 Task 에 영향을 줄 수 없습니다.
  2. TaskGroup 에 추가된 Task 는 추가된 시점에서 자동적으로 실행됩니다.
  3. Task 는 시작한 순서대로 완료되지 않습니다. 각 Task 가 완료되는 시점을 알 수 없으며, 처리하고자 하는 비동기 작업이 순서대로 처리되어야 한다면 사용하면 안됩니다.
  4. TaskGroup 은 추가된 Task 가 모두 완료되었을 때 값을 리턴합니다.
  5. TaskGroup 은 리턴값은 Value, Void, 혹은 에러가 될 수 있습니다.

Usage

공식문서를 통해 TaskGroup 을 이론적으로 파악하고 어떤 특성을 가지고 있는지 살펴보았어요. 이제 실제로 코드를 구현하며 익숙해져 봅시다.

실습 준비

실습에 필요한 다음 코드를 먼저 입력해 주세요.

let url = URL(string: "https://api.quotable.io/random")

enum NetworkError: Error {
    case badUrl
    case badResponse
    case communicationError
    case decodeFailed
    case noData
}

struct Quote: Decodable {
    let content: String
}

func getQuote(from: URL?) async throws -> Quote {
    guard let url = url else {
        throw NetworkError.badUrl
    }
    let (data, response) = try await URLSession.shared.data(from: url)
    if let response = response as? HTTPURLResponse,
       !(200..<300).contains(response.statusCode) {
        throw NetworkError.badResponse
    }
    guard let quote = try? JSONDecoder().decode(Quote.self, from: data) else {
        throw NetworkError.decodeFailed
    }
    return quote
}

Quote 를 서버에 요청해 async 방식으로 가져오는 코드입니다.

TaskGroup 사용법

이제 본격적으로 TaskGroup 을 생성해 보겠습니다. TaskGroup 내에서 Task 생성에 사용할 getQuote(from:) 함수는 에러를 throws 할 수 있으므로 withThrowingTaskGroup(of:returning:body:) 메서드를 사용하겠습니다.

메서드의 형태를 보면 이렇습니다.

withThrowingTaskGroup(of: <Sendable.Protocol>,
                      returning: <GroupResult.Type>,
                      body: <(inout ThrowingTaskGroup<Sendable, Error>) async throws -> GroupResult>)

이렇게 보니 조금 복잡해 보이는데요. 막상 구현해보면 어려울 것이 없습니다. 각 파라미터에 어떤 값을 전달해야 하는지 알아볼게요.

  • of: 파라미터에는 TaskGroup 내에서 각 Task 가 리턴할 값의 타입을 명시합니다.
  • returning:에는 TaskGroup 이 리턴할 값의 타입을 명시합니다.
  • body:에는 group 인스턴스를 가진 클로저를 전달합니다. group 인스턴스를 사용해 Task 를 추가하고 TaskGroup 의 최종 결과를 리턴할 수 있습니다.

우리가 사용할 getQuote(from:) 함수로 생각해보면, 이 함수는 String 타입의 값을 리턴하므로 각 Task 는 String 을 리턴합니다. 즉 of:String.self를 명시합니다.

retruning:에는 TaskGroup 이 리턴할 값의 타입을 명시하면 된다고 했었는데요. 각 Task 에서 받은 String 타입의 값을 배열에 담아 리턴하겠습니다. 즉 [String].self가 됩니다.

Task {
    let quotes = try await withThrowingTaskGroup(of: String.self,
                                                 returning: [String].self,
                                                 body: { group in
}

코드를 직접 작성해 보니 그렇게 복잡하진 않네요. 다음으로 body: 파라미터에 전달할 클로저의 group 인스턴스에 Task 를 추가하겠습니다.

Task {
    let quotes = try await withThrowingTaskGroup(of: String.self,
                                                 returning: [String].self,
                                                 body: { group in
        for _ in 1...5 {
            group.addTask {
                return try await getQuote(from: url).content
            }
        }
}

반복문을 통해 총 5개의 Quote 를 요청했어요. group 인스턴스에서 제공하는 .addTask(priority:operation:) 메서드로 getQuote(from:)에서 받은 String 값을 리턴하였습니다. 이 값이 of: 에서 명시한 타입입니다.

이제 각 Task 에서 받은 값을 배열에 추가해 리턴하겠습니다.

Task {
    let quotes = try await withThrowingTaskGroup(of: String.self,
                                                 returning: [String].self,
                                                 body: { group in
        for _ in 1...5 {
            group.addTask {
                return try await getQuote(from: url).content
            }
        }
        
        var result = [String]()
        for try await value in group {
            result.append(value)
        }
        return result
    })

    print(quotes)
}

group 인스턴스에는 각 Task 가 리턴한 값들이 저장되어 있습니다. 그리고 반복문을 사용하면 이 값들을 꺼낼 수가 있어요. 그리고 순회 시 값을 꺼내는 과정에서 getQuote(from:)의 비동기 작업이 정상적으로 종료되는 것을 기다리므로 await 을 명시해야 합니다. 최종적으로 String 배열을 리턴함으로써 retruning: 에 명시된 타입의 값을 전달하였습니다.

반복문이 연속으로 나오는 것이 코드 가독성 측면에서 지저분하게 느껴진다면 reduce(into:) 를 사용해 코드를 보다 간결하게 표현할 수 있습니다.

Task {
    let quotes = try await withThrowingTaskGroup(of: String.self,
                                                 returning: [String].self,
                                                 body: { group in
        for _ in 1...5 {
            group.addTask {
                return try await getQuote(from: url).content
            }
        }
        
        let result = try await group.reduce(into: [String]()) { $0.append($1) }
        return result
    })
    print(quotes)
}

잘 생각해보면 TaskGroup 은 async let 과 역할이 비슷하다는 것을 알 수 있어요. 그렇다면 언제 무엇을 사용하는 것이 좋을까요?

대부분의 상황에서 기본적으로 async let 을 사용하면 됩니다. 사용방법이 간단하고 코드의 가독성이 더 좋기 때문이에요. 하지만 TaskGroupasync let 이 할 수 없는 반복문을 통한 Task 추가가 가능하다는 장점이 있기 때문에 비슷한 작업을 여러번 추가하는 경우에는 TaskGroup 을 사용하는 것이 유리합니다.


Wrap Up

여기까지 TaskGroup 의 사용법을 알아보았습니다. async/await 시리즈를 진행하며 다룬 내용 중에서는 사용법이 조금 복잡한 편이었어요. 이제 익숙해질 수 있도록 반복해서 사용해 봅시다!


Entire Code

import UIKit

let url = URL(string: "https://api.quotable.io/random")

enum NetworkError: Error {
    case badUrl
    case badResponse
    case communicationError
    case decodeFailed
    case noData
}

struct Quote: Decodable {
    let content: String
}

func getQuote(from: URL?) async throws -> Quote {
    guard let url = url else {
        throw NetworkError.badUrl
    }
    let (data, response) = try await URLSession.shared.data(from: url)
    if let response = response as? HTTPURLResponse,
       !(200..<300).contains(response.statusCode) {
        throw NetworkError.badResponse
    }
    guard let quote = try? JSONDecoder().decode(Quote.self, from: data) else {
        throw NetworkError.decodeFailed
    }
    return quote
}


Task {
    let quotes = try await withThrowingTaskGroup(of: String.self,
                                                 returning: [String].self,
                                                 body: { group in
        for _ in 1...5 {
            group.addTask {
                return try await getQuote(from: url).content
            }
        }
        
        let result = try await group.reduce(into: [String]()) { $0.append($1) }
        return result
    })
    print(quotes)
}

References

How to create a task group and add tasks to it Understanding Swift Task Groups With Example