[Swift] Concurrency - Task(1)
안녕하세요, 루피입니다.
이번 포스팅부터 Swift Concurrency에 대해 학습해보려 하는데요, 이전에 한번 정리한 적이 있지만, 좀 더 체계적인 방법으로 정리해보고자 합니다.
Swift 5.5부터 도입된 Swift의 내장된 Concurrency 시스템은 가볍지만 효율적인 동시성 코드를 작성할 수 있도록 도와줍니다.
기존 GCD와 OperationQueue의 복잡성을 해결하고, 더 안전하고 직관적인 비동기 프로그래밍을 가능하게 합니다.
기존 방식의 한계점
기존 GCD 방식에서는 다음과 같은 복잡성이 있었습니다.
// 기존 GCD 방식의 문제점
DispatchQueue.global().async {
let data = self.fetchData()
DispatchQueue.main.async { [weak self] in
self?.updateUI(data) // weak self 캡처, 수동 스레드 관리
}
}
이런 방식은 메모리 관리, 스레드 전환, 에러 처리 등에서 많은 추가 작업을 필요로 했습니다.
Task란 무엇인가?
Task는 쉽게 설명하자면, 동기적 코드 안에서 비동기 작업을 할 수 있게 도와주는 비동기 작업의 단위라고 생각하시면 됩니다.
// Swift Concurrency로 개선된 방식
Task {
let data = await fetchData()
updateUI(data) // 간결하고 안전함
}
Concurrency에서 Task의 역할
Task를 생성하면 새로운 비동기 컨텍스트에 접근할 수 있게 되는데요, 이 안에서 async로 표시된 API를 자유롭게 호출하여 백그라운드에서 작업을 수행할 수 있습니다.
이렇게 비동기 코드를 캡슐화할 수 있게 해주는 것 외에도, 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) {
// 에러 뷰 표시
}
private func userDidLoad(_ user: User) {
// 사용자 프로필 렌더링
}
}
Swift Concurrency의 혁신적 특징
위 코드에서 정말 흥미로운 점은 다음과 같습니다:
self캡처가 불필요: 메모리 관리 자동화DispatchQueue.main.async호출 불필요: MainActor가 자동 처리- 토큰이나
cancellable관리 불필요: 구조적 동시성으로 자동 관리 - 복잡한 클로저나 Combine 체이닝 불필요: 직관적인 순차 코드
MainActor의 등장
그러면 여기서 한 가지 의문을 가지실 겁니다. "UI 업데이트 메서드는 어떻게 처리해야 하는 거지? 우리는 UI 작업을 메인 스레드에서 해야 한다고 알고 있는데?"
그래서 Swift에 MainActor가 등장했습니다. 이는 UI 관련 API가 메인 스레드에서 올바르게 디스패치되도록 자동으로 보장합니다.
따라서 우리는 더 이상 백그라운드 큐에서 실수로 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의 제네릭 타입 구조
Task에는 두 개의 제네릭 타입이 있습니다.
Task<Void, Never>
- 첫 번째 타입 (Void): Task가 반환하는 값의 타입
- 두 번째 타입 (Never): "에러를 외부로 던지지 않는" 작업 (내부 처리)
협력적 취소 메커니즘
Task에서 cancel 메서드를 호출하면 모든 자식 작업도 취소된 것으로 표시됩니다. 따라서 뷰 컨트롤러 내에서 최상위 loadingTask를 취소함으로써 동시에 기본 네트워크 작업도 암시적으로 취소하게 됩니다.
예를 들어 loadingTask?.cancel()을 호출하면 내부의 loader.loadUser() 네트워크 작업도 취소 신호를 받습니다.
컨텍스트 상속
주어진 Task와 그 부모 간의 관계는 특히 뷰와 뷰 컨트롤러와 같은 @MainActor로 표시된 클래스 내에서 매우 중요합니다. UIViewController와 SwiftUI는 @MainActor가 자동으로 내재되어 있습니다.
자식 작업은 취소 측면에서 부모와 연결되어 있을 뿐만 아니라 부모가 사용하는 것과 동일한 실행 컨텍스트를 자동으로 상속받기 때문입니다.
Task {
// MainActor 컨텍스트에서 실행
let result = await someAsyncOperation() // 네트워크 작업은 백그라운드 스레드 이용
updateUI(result) // 메인 스레드에서 안전하게 실행
}
Task vs Task.detached
일반적으로 새로운 최상위 작업을 생성하여 자체 실행 컨텍스트를 사용하려는 경우에만 detached 작업을 사용하는 것이 권장됩니다. 다른 상황에서는 단순히 Task {}를 사용하여 비동기 코드를 캡슐화하는 것이 권장되는 방법입니다.
// 일반 Task - 컨텍스트 상속
Task {
let result = await someAsyncOperation()
updateUI(result) // 자동으로 MainActor에서 실행
}
// Detached Task - 독립적인 컨텍스트
Task.detached(priority: .background) {
let result = await heavyComputation()
await MainActor.run {
updateUI(result) // 수동으로 메인 스레드 전환
}
}
Task의 결과 대기
마지막으로 주어진 Task 인스턴스의 결과를 await하는 방법을 살펴보겠습니다.
예를 들어, Database 기반 뷰 컨트롤러 구현을 네트워크를 통해 현재 사용자의 이미지를 로드하는 지원으로 확장하고 싶다고 가정해봅시다.
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
}
혼합된 동시성의 장점
최상위 Task가 이전과 마찬가지로 MainActor에 바인딩되어 있기 때문에 뷰 컨트롤러의 메서드(위 코드에서 userDidLoad)를 다시 직접 호출할 수 있다는 점에 주목하세요.
이는 잘못된 스레드에서 실수로 UI 업데이트를 수행하는 것에 대해 걱정할 필요 없이 메인 큐와 메인 큐가 아닌 곳에서 수행되는 작업을 얼마나 원활하게 혼합할 수 있는지를 보여줍니다.
병렬 처리 패턴
// 순차 실행
let user = await fetchUser()
let posts = await fetchPosts()
// 병렬 실행 (async let)
async let user = fetchUser()
async let posts = fetchPosts()
let results = try await (user, posts)
// TaskGroup 활용
await withTaskGroup(of: String.self) { group in
for id in userIDs {
group.addTask {
await fetchUser(id: id)
}
}
}
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
How Swift’s new Task type works, and how it enables us to encapsulate, observe, and control the way that our asynchronous code is executed.
www.swiftbysundell.com
오늘도 화이팅입니다!!