본문 바로가기

Swift/Concurrency

Discover Concurrency (1) - Task는 Swift Concurrency system내에서 어떤 역할을 할까?

개인적으로 WWDC 2022 - Eliminate data races using Swift Concurrency 에서 기본적인 Concurrency의 개념을 학습하고 나서  이 글을 읽으니 훨씬 이해하기 편했습니다. 아직 안 보셨다면 먼저 보시는 걸 추천드립니다! 

https://kswift.tistory.com/25

 

WWDC 2022 - Eliminate data races using Swift Concurrency

이번 세션의 주제는 data race 문제를 발생시키지 않고 효율적인 동시성 프로그램을 구성하는 전체론적인 Swift Concurrency의 관점을 확인할 것이다. 시작에 앞서 진행자는 concurrency를 바다에 비유한

kswift.tistory.com

 

 

스위프트의 concurrency 시스템에서 Task 컨텍스트 안에서는 async 로 마킹된 API들을 호출할 수 있고 백그라운드로 실행된다.

Task를 통해 비동기 코드 조각을 캡슐화하는 것뿐만 아니라, Task는 코드를 실행되는 방식을 제어하고 관리하거나, 필요에 따라 취소할 수 있는 기능을 제공한다.

 

동기 코드와 비동기 코드간 간극 좁히기

Task를 사용하여 메인 스레드 범주의 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
        ...
    }
}

 

흥미로운 점은 이 코드엔 self 를 캡처하거나, DispatchQueue.main.async 호출 또는 비동기 작업에서 일반적으로 사용되는cancellables 객체의 참조를 관리하는 작업이 없다.

그렇다면 어떻게 DispatchQueue.main 을 호출하지 않고 네트워킹 호출 뒤 직접 userDidLoadhandleError 메서드를 호출함으로써 UI를 업데이트했을까?

 

이는 스위프트의 새로운 속성인 MainActor을 통해서 UI와 관련된 (UIView 나 UIViewController등에 정의된) API들은 알맞게 메인 스레드에서 실행되기 때문이다.

따라서, concurrency 시스템에서 MainActor 내부 컨텍스트에서 코드를 작성하면, UI를 백그라운드 큐에서 업데이트하는 것을 더 이상 걱정하지 않아도 된다!

 

위 구현에서 흥미로운 점은 작업을 수행하기 위해 유저를 불러오는 작업을 수동으로 보관할 필요가 없다는 것이다. 이는 비동기 작업에 해당하는 Task가 해제되어도 자동으로 취소되지 않기 때문에 백그라운드에서는 계속 실행될 것이다.

 

작업 참조 및 취소하기

그러나 이 상황에서는 뷰컨트롤러가 사라질 때, 작업을 취소하거나 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
    }

    ...
}

 

💡 Task 는 두 제네릭 타입이 있다. 첫 번째는 return 될 값 (여기선 User모델을 뷰 컨트롤러에 불러오는 Void타입), 그리고 두 번째는 에러타입(여기선 task의 에러를 핸들링할 Never타입)이다.

 

Taskcancel메서드를 호출하면, 해당 작업의 모든 child-task들도 취소된다. 그러므로 뷰 컨트롤러 내에서 가장 높은 레벨의 loadingTask를 취소하면 해당 작업의 기반이 되는 네트워킹 실행도 취소된다.

하지만 Task.cancel()을 호출한다고 해서 작업이 취소되는 것이 아니라, 단지 isCancelled 프로퍼티가 변경되기 때문에 실제의 취소 구현을 직접 해줘야 한다

// 취소 구현의 예시
let task = Task {
    // 작업 내용...
    
    // 작업 중간에 취소 체크
    if Task.isCancelled {
        // 취소 처리 로직...
        return
    }
    
    // 작업 계속 진행...
}

// 작업 실행
task.run()

// 작업 취소
task.cancel()

 

컨텍스트 상속

뷰와 뷰컨트롤러와 같은 @MainActor 마킹된 클래스에서 Task와 해당 부모 Task 간 관계는 중요할 수 있다. 이는 자식 task들이 취소 시에 단순히 부모 task의 취소와 연결되어 있는 것이 아니고 부모의 실행 컨텍스트 또한 자동으로 상속받기 때문이다.

이 문제를 보여주기 위해서, ProfileViewController는 네트워킹 대신에 로컬 데이터베이스의 User 모델을 불러오며, Database 의 API는 동기로 실행된다고 가정해 보자.

 

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
        }
    }

    ...
}

 

처음 봤을 땐 괜찮아 보일 수 있다. 왜냐하면 Task 내에서 await 기반 호출을 수행하지 않더라도 비동기 작업이 여전히 백그라운드 스레드에서 실행될 것으로 기대할 수 있기 때문이다.

하지만 Task는 실제로 비동기로 수행되지만, MainActor에서 실행되기 때문에 이는 메인 스레드에서 실행될 것이다. 따라서 위 Task는 사실상 DispatchQueue.main.async 클로저 내에서 database를 호출하는 것과 거의 동일하다.

우리는 UI의 반응성을 간섭받지 않기 위해서 database의 호출을 메인스레드로부터 분리하고 싶을 것이다.

 

이때 detached task를 사용함으로써 독립된 컨텍스트 내에서 실행하는 task를 만들 수 있다. 그러고 나서 뷰컨트롤러 내의 메서드에 다시 호출할 때 이것은 MainActor 과 격리되어 있기 때문에 (MainActor의 실행 컨텍스트가 아닌 독립된 컨텍스트이기 때문에) await 을 사용해야 한다. 또한 detached task 내부에서 loadingTask 프로퍼티를 nil로 직접 설정할 수 없다.

 

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
    }
}

 

일반적으로 detached task 명시적으로 자신의 실행 컨텍스트를 사용하는 새로운 최상위 작업을 생성하고 싶을 때에만 사용하는 것이 좋다. 다른 상황에서는 단순히 Task { } 를 통해서 비동기 코드를 캡슐화하는 것이 권장된다.

작업 결과 기다리기

마지막으로, 주어진 Task 인스턴스의 결과를 await 하는지 살펴보자. 예를 들어, 위에서 설명한 데이터베이스 기반 뷰 컨트롤러를 확장하여 현재 사용자의 이미지를 네트워킹을 통해서 불러오는 기능을 추가하려고 한다.

이를 위해, detached task 를 또 다른 Task 인스턴스로 래핑 하였고, await 키워드를 통해서 이미지 다운로드를 진행하기 전에 데이터베이스의 불러오는 기능이 완료될 때까지 기다릴 것이다.

 

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
        ...
    }
}

 

위 코드에서 다시 최상위 Task ( loadintTask = Task { … } 부분 ) 내에서 뷰 컨트롤러의 메서드를 직접 호출할 수 있다. 이는 이전과 마찬가지로 MainActor에 바인딩되었기 때문이다.

이는 실수로 잘못된 스레드에서 UI를 업데이트하는 것에 대해서 걱정할 필요 없이 메인 큐 안팎에서 수행되는 작업을 얼마나 원활하게 혼합할 수 있는지 확인할 수 있다.

 

결론

스위프트의 새로운 Task 타입은 비동기 작업을 캡슐화하고 관찰하며 제어할 수 있도록 해준다. 이를 통해 async 로 표시된 API를 호출하고, 동기적인 코드 내에서도 백그라운드 작업을 수행할 수 있다.

이를 통해 새로운 기능을 염두에 두고 설계되지 않은 애플리케이션 내에서도 concurrency 시스템을 점진 적으로 도입할 수 있을 것이다.

 

 

정리해 보기

  • UIViewController의 속성인 MainActor는 유저 인터페이스와 관련된 actor이기 때문에 항상 메인스레드에서 실행된다. 따라서 MainActor 내부에서 Task 컨텍스트를 통해서 UI를 업데이트할 때 백그라운드 큐에서 업데이트가 되는 걱정을 하지 않아도 된다.

  • Task의 제네릭 타입을 인스턴스화하면, 이를 참조하여 이미 작업이 진행 중인 경우 중복 작업을 방지하거나 작업을 취소할 수 있다. 그렇지만 Task.cancel()을 호출한다고 해서 작업이 취소되는 것이 아니고 isCancelled 프로퍼티가 변경 되는 것이기 때문에 실제 취소 구현을 직접 해줘야 한다.

  • MainActor 내부에 Task.init() 컨텍스트는 메인 스레드에서 실행되기 때문에 DispatchQueue.main.async 와 같은 성격이다. 백그라운드 스레드에서 실행을 기대한다면 Task.detached() 를 사용하여 MainActor에서 독립된 컨텍스트를 생성해야 된다.

  • MainActor와 바인딩되지 않은(main-actor-nonisolated) 컨텍스트에서는 일시 중지 지점에 await을 붙여 해당 컨텍스트가 완료될 때까지 기다리게 한다. MainActor와 바인딩된 (main-actor-isolated) 컨텍스트에서는 내부 메서드를 직접 호출할 수 있다. 이것을 통해 메인 큐 안팎에서 수행되는 작업을 원활하게 혼합할 수 있다.

 

 

 

출처

Using the MainActor attribute to automatically dispatch UI updates on the main queue | Swift by Sundell

 

Using the MainActor attribute to automatically dispatch UI updates on the main queue | Swift by Sundell

One challenge when it comes to concurrency on Apple’s platforms is that an app’s UI can, for the most part, only be updated on the main thread. So, whenever we’re performing any kind of work on a background thread (either directly or indirectly), the

www.swiftbysundell.com