ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Swift Concurrency - Task (1)
    Concurrency 2023. 2. 24. 08:16

    안녕하세요. 그린입니다🍏

    이번 포스팅부터는 Swift Concurrency에 대해 체계적으로 학습해보려해요🙋🏻

    그래서 제목도 이번이 처음 (1)을 붙였습니다!

     

    앞으로 해볼 학습들은 다 아래 레퍼런스 토대로 제 나름의 번역? 같은 해석을 기반으로 학습함을 말씀드립니다🙌

     

    https://www.swiftbysundell.com/discover/concurrency/

     

    Discover Concurrency on Swift by Sundell

    Introduced in Swift 5.5, Swift’s built-in concurrency system provides a lightweight, yet highly efficient set of tools for writing concurrent code. That all starts with async/await, a pattern that’s become increasingly popular among modern programming

    www.swiftbysundell.com

     

    자 그럼 첫 주제인 Task부터 시작해보시죠🕺🏻

     

    Task는 Swift의 동시성 시스템 내에서 어떤 역할을 할까요?

    비동기 코드를 작성할 때 Task를 생성하면 비동기 API를 자유롭게 호출하고 백그라운드에서 작업을 수행할 수 있는 새로운 비동기 컨텍스트에 접근할 수 있습니다.

    Task를 사용하면 비동기 코드를 캡슐화할 수 있을 뿐만 아니라 이러한 코드의 실행, 관리 그리고 잠재적으로는 취소되는 방식까지도 제어할 수 있습니다.

     

    동기식 코드와 비동기식 코드 사이의 격차 해소

    UI 기반의 앱 내에서 Task를 사용하는 가장 일반적인 방법은 동기화된 main thread binding UI 코드와 UI에서 렌더링하는 데이터를 가져오거나 처리하는데 사용되는 모든 백그라운드 작업 사이의 브릿지 역할을 하는 것입니다.

    예시로 아래 코드를 보시죠!

    class ProfileViewController: UIViewController {
        private let userID: User.ID
        private let loader: UserLoader
        private var user: User?
        ...
    
        override func viewWillAppear(_ animated: Bool) {
            super.viewWillAppear(animated)
    
            Task {
                do {
                    let user = try await loader.loadUser(withID: userID)
                    userDidLoad(user)
                } catch {
                    handleError(error)
                }
            }
        }
        
        ...
    
        private func handleError(_ error: Error) {
            // Show an error view
            ...
        }
    
        private func userDidLoad(_ user: User) {
            // Render the user's profile
            ...
        }
    }

     

    보시면 UIKit 기반 ProfileVC 내의 Task를 사용하여 비동기 API를 사용해 VC가 렌더링해야하는 사용자 모델을 로드할 수 있습니다.

    userDidLoad에 user를 넘겨줌으로써 말이죠.

    즉 그전에 try await로 API를 호출하여 user를 추출하고 이를 넘겨주죠.

     

    여기서는 기존과 달리 self capture, DispatchQueue.main.async의 호출 혹은 클로저나 combine과 같은 비동기 작업을 처리할 때 수행하는 코스트가 더이상 없다는 것입니다.

     

    그러면 도대체 어떻게 DispatchQueue.main을 쓰지 않고 네트워크 호출 후 userDidLoad나 에러 처리를 해주는것과 같이 UI 업데이트를 시켜줄 수 있는걸까요?

     

    여기서 이제 Swift의 MainActor라는 개념이 등장합니다.

    MainActor는 자동으로 UI관련 API(UIView, UIViewController 내 정의된 기본 API)가 메인 스레드에서 적절하게 디스패치 되도록 제공해주는 속성입니다.

    즉, Swift의 Concurrency를 사용하고 MainActor가 표시된 컨텍스트 내에서 비동기 코드를 작성하면 실수로 백그라운드 큐에서 UI 업데이트를 해주는 오류에 대해 더이상 걱정할 필요가 없습니다🙏🏻

     

    또 로드 작업을 완료하기 위해 수동으로 유지하고 있을 필요가 없습니다.

    Task handle이 할당 해제 되어도 비동기 작업이 자동으로 취소되지 않고 백그라운드에서 계속 실행됩니다.

     

    Task 참조 및 취소

    간혹 특정한 경우에는 로드 작업에 대해 참조를 유지해야할 수 도 있습니다.

    만약 VC가 viewDisappear된다면 해당 작업이 취소될 수도 있기 때문이죠.

    혹은 작업이 이미 진행중인 상태에서 viewWillAppear가 호출되면 중복 작업을 수행하게 되니 이것도 방지해야 할 수 있죠.

    class ProfileViewController: UIViewController {
        private let userID: User.ID
        private let loader: UserLoader
        private var user: User?
        private var loadingTask: Task<Void, Never>?
        ...
    
        override func viewWillAppear(_ animated: Bool) {
            super.viewWillAppear(animated)
    
            guard loadingTask == nil else {
                return
            }
    
            loadingTask = Task {
                do {
                    let user = try await loader.loadUser(withID: userID)
                    userDidLoad(user)
                } catch {
                    handleError(error)
                }
    
                loadingTask = nil
            }
        }
    
        override func viewDidDisappear(_ animated: Bool) {
            super.viewDidDisappear(animated)
            loadingTask?.cancel()
            loadingTask = nil
        }
    
        ...
    }

    참조는 이와 같이 loadingTask라는 Task 변수를 만들어줍니다.

    Task에는 두가지 유형이 있어요.

    제네릭하게 먼저 들어오는것은 반환하는 출력의 유형을 나타내고 두번째로 들어오는것은 흔히 아는 오류 유형입니다.

    즉 Result와 비슷한 형태이죠.

    위 코드에서 loadingTask가 nil 즉 없으면 종료하게 해둔 이유는 바로 밑에 Task를 넣어주고 nil로 설정해주기 때문이죠.

    즉 중복되지 않도록 하기 위함입니다.

    그리고 viewDidDisappear가 불릴때 loadingTask를 cancel하고 해당 값도 nil로 변경해주죠.

    즉 작업이 취소된걸 알려주는겁니다.

    또한 내부에서도 로드 작업이 취소됩니다.

     

    컨텍스트 상속

    최소 뷰 및 뷰컨과 같은 @MainActor가 붙는 클래스 내에서는 요청한 작업과 상위 작업 간의 관계가 중요합니다.

    하위 작업은 취소 측면에서 상위 작업과 연결되면서 상위 작업이 사용하는것과 동일한 실행 컨텍스트를 자동으로 상속합니다.

    일단 코드를 보면서 얘기해보시죠!

    class ProfileViewController: UIViewController {
        private let userID: User.ID
        private let database: Database
        private var user: User?
        private var loadingTask: Task<Void, Never>?
        ...
    
        override func viewWillAppear(_ animated: Bool) {
            super.viewWillAppear(animated)
    
            guard loadingTask == nil else {
                return
            }
    
            loadingTask = Task {
                do {
                    let user = try database.loadModel(withID: userID)
                    userDidLoad(user)
                } catch {
                    handleError(error)
                }
    
                loadingTask = nil
            }
        }
    
        ...
    }

    여기서 database가 변수가 나왔습니다.

    ProfileVC가 서버 통신이 아닌 로컬 DB에서 사용자 모델을 로드하고 DB API가 완전한 동기화가 되어 있다고 가정한 코드입니다.

    try database.loadModel(withID: userID)해주는 비동기 작업이 우리는 백그라운드 스레드에서 실행될거라 예상하기에 문제가 없어 보입니다.

     

    그런데 실제론 해당 작업은 비동기적 수행은 맞지만 MainActor를 사용해 디스패치 되기에 메인 스레드에서 실행됩니다.

    즉, 기본적으로 DispatchQueue.main.async 클로저 내에서 DB 호출을 수행하는것과 같은것이죠.

     

    그럼 아래 코드는 어떨까요?

     

    class ProfileViewController: UIViewController {
        ...
    
        override func viewWillAppear(_ animated: Bool) {
            super.viewWillAppear(animated)
    
            guard loadingTask == nil else {
                return
            }
    
            loadingTask = Task.detached(priority: .userInitiated) { [weak self] in
                guard let self = self else { return }
    
                do {
                    let user = try self.database.loadModel(withID: self.userID)
                    await self.userDidLoad(user)
                } catch {
                    await self.handleError(error)
                }
    
                await self.loadingTaskDidFinish()
            }
        }
    
        ...
    
        private func loadingTaskDidFinish() {
            loadingTask = nil
        }
    }

     

    만약 DB 호출을 메인 스레드에서 벗어나 detached Task 즉 분리된 Task로 사용할 수 있습니다.

    이 Task는 독립적인 컨텍스트 내에서 실행됩니다.

    이렇게 한다면 VC의 메서드를 다시 호출할 때도 await를 사용해야 합니다.

    일반적으로 독립적인 실행 컨텍스트를 사용하는 최상위 작업을 명시적으로 생성하려는 경우 detached Task만 사용하는것이 좋습니다.

    다른 경우에는 Task { }를 이용해 비동기 코드를 캡슐화하는것이 좋습니다.

     

    Await - Task Result

    이제 Task 인스턴스의 result를 await하는 방법을 보겠습니다.

    예시로, 서버 통신을 통해 사용자의 이미지를 로드하는 지원으로 위의 예시로 다뤘던 DB 기반 VC 구현을 확장해볼께요!

    class ProfileViewController: UIViewController {
      private let userID: User.ID
      private let database: Database
      private let imageLoader: ImageLoader
      private var user: User?
      private var loadingTask: Task<Void, Never>?
      ...
      
      override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        guard loadingTask == nil else {
          return
        }
        
        loadingTask = Task {
          let databaseTask = Task.detached(
            priority: .userInitiated,
            operation: { [database, userID] in
              try database.loadModel(withID: userID)
            }
          )
          
          do {
            let user = try await databaseTask.value
            let image = try await imageLoader.loadImage(from: user.imageURL)
            userDidLoad(user, image: image)
          } catch {
            handleError(error)
          }
          
          loadingTask = nil
        }
      }
      
      ...
      
      private func userDidLoad(_ user: User, image: UIImage) {
        // Render the user's profile
        ...
      }
    }

    이를 위해 detached Task를 다른 Task 인스턴스 내에 정리하고 await 키워드를 이용해 DB 로드 작업이 완료될때 까지 기다린 후 이미지 다운로드를 진행합니다.

     

    보시면 이전과 마찬가지로 MainActor에 바인딩되었기에 VC의 메서드를 최상위 작업 내에서 다시 한번 호출할 수 있는 방법에만 유의하면 됩니다.

    위 흐름을 보면 이제는 실수로 다른 스레드에서 UI 업데이트를 수행할 걱정도 없고 메인 큐와 외부에서 수행한 작업이 꽤나 원활하게 혼합되었습니다🙌

     

    결론

    Task를 사용하면 비동기 작업 단위를 캡슐화도 할 수 있고 관찰/제어도 할 수 있습니다.

    이를 통해 비동기 API를 호출하고 완전히 동기화된 코드 내에서도 백그라운드 작업을 수행할 수 있죠.

    즉, 우리는 새로운 기능을 염두에 두고 설계되지 않은 앱 내에서도 비동기 기능과 새로운 Swift Concurrency의 기능들을 점진적으로 도입할 수 있습니다!

     

    마무리

    아마도 많은 분들이나 회사에서도 Swift Concurrency에 관심이 많을거라 예상합니다.

    다만 현재 구조에서 어떻게 변경해야할지 막막할텐데요.

    현재 구조를 다 갈아엎지 않으면서 점진적으로 충분히 도입할 수 있다고 생각합니다😲

     

    [참고 자료]

    https://www.swiftbysundell.com/articles/the-role-tasks-play-in-swift-concurrency/

     

    What role do Tasks play within Swift’s concurrency system? | Swift by Sundell

    When writing asynchronous code using Swift’s new built-in concurrency system, creating a Task gives us access to a new asynchronous context, in which we’re free to call async-marked APIs, and to perform work in the background. But besides enabling us t

    www.swiftbysundell.com

    'Concurrency' 카테고리의 다른 글

    Swift Concurrency - Parallel  (4) 2023.03.06
    Swift Concurrency - Task (4)  (8) 2023.03.02
    Swift Concurrency - Task (3)  (4) 2023.03.02
    Swift Concurrency - Task (2)  (2) 2023.02.27
    Swift 5.5 - async & await  (0) 2021.09.25
Designed by Tistory.