
Swift Concurrency - Task 알아보기 (1)
본 글에서는 Swift Concurrency를 이번에 한번 제대로 배우고, 실제 프로젝트에서 사용하는 것을 목표로 한다.
최대한 Swift Concurrency를 내가 이해하기 쉽게 정리해보았다.
이러한 개념을 사용해서, 추후 프로젝트에서 정확한 방법으로 다루기 위해 정리한다.
1. Task의 역할
“act as a bridge between our synchronous, main thread-bound UI code, and any background operations”
Task는 동기적인 UI 코드와 백그라운드 작업 사이의 다리 역할
2. 기존 방식과 차이점
“there are no self captures, no DispatchQueue.main.async calls, no tokens or cancellables that need to be retained”
전통적인 비동기 처리에서 필요했던 것들이 모두 불필요하다!
- DispatchQueue.main.async에서는 self 캡쳐를 하기 때문에, 순환 참조를 방지하기 위해 [weak self] 방식을 사용해야만 했다.
- 그러나 Task는 self 캡쳐도 필요없고, DispatchQueue.main.async 호출도 없고, 토큰이나 취소 가능한 객체도 보관이 불필요하다.
그렇다면 정확히 어떻게 명시적으로 DispatchQueue.main으로 보내는 작업 없이도 (백그라운드스레드에서 수행되는) 네트워크 호출을 수행하고 , 그 다음에 UI 업데이트 메서드를 직접 호출할 수 있을까?
3. MainActor
“MainActor attribute automatically ensures that UI-related APIs are correctly dispatched on the main thread”
여기서 MainActor를 사용한다. 이는 UI와 관련된 API(UIView나 UIViewController 같은)를 자동으로 메인스레드로 올바르게 디스패치하도록 보장한다.
그래서 우리가 비동기 코드를 MainActor와 같은 Swift의 새로운 동시성 시스템을 사용하여 작성하기만 하면, 더 이상 의도치않게 UI업데이트를 백그라운드 큐에서 수행할까 걱정하지 않아도 된다!
아래 예제를 통해 이해해보자.
기존 방식
override func viewWillAppear(_ animated: Bool) {
loader.loadUser(withID: userID) { [weak self] result in
// 기존 방식은 수동으로 메인스레드로 전환해야 했다.
// 그리고 클로저가 self 를 캡쳐했기에,
// ViewController 가 사라질 때, 아래 클로저에서도 참조를 해제하기 위해
// 보통 약한 참조로 사용하는 방식
guard let self = self else { return }
DispatchQueue.main.async {
switch result {
case .success(let user): self.userDidLoad(user)
case .failure(let error): self.handleError(error)
}
}
}
}새로운 방식 - Task 사용
override func viewWillAppear(_ animated: Bool) {
Task {
do {
let user = try await loader.loadUser(withID: userID)
// ↑ 백그라운드에서 실행되는 이유: loader.loadUser() 함수가
// 내부적으로 네트워크 작업(URLSession)을 수행하기 때문
// Task 컨텍스트나 await 때문이 아님!
userDidLoad(user)
// ↑ 자동으로 메인스레드에서 실행됨
// (UIViewController는 MainActor가 붙어있기 때문에,
// 그 안에 정의된 메서드를 호출 시, 자동으로 메인스레드에서 실행)
// 만약 혹시라도 userDidLoad를 백그라운드에서 실행시키려면
// detached Task를 사용한다. (아래에서 설명)
}
}
}그리고 여기서 주의할 점은 바로 아래 부분이다.
“asynchronous tasks are not automatically cancelled when their corresponding Task handle is deallocated”
Task는 참조가 해제되어도 자동으로 취소되지 않고 독립적으로 계속 실행
또 하나 흥미로운 점은, 우리는 명시적으로 로딩 작업이 완료되기를 기다릴 필요가 없다. 이것은 비동기 작업이 Task 핸들이 해제될 때 자동으로 취소되지 않기 때문이다. 즉, 여전히 Task는 독립적으로 실행중이다.
Task는 일단 시작되면 독립적으로 실행된다고 보면 된다. 그래서 만약 참조를 잃어도 (뷰컨트롤러가 사라져도) 계속 Task는 독립적으로 동작하게 된다.
따라서 취소하려면 명시적으로 참조를 보관해야 한다.
Task 참조를 유지하는 이유
자, 다시 정리하면 Task 참조를 유지하는 이유는 다음 두 가지다.
“we might want to cancel it when our view controller disappears, and we probably also want to prevent duplicate tasks”
-
화면이 사라지고 나서도 불필요한 작업을 하지 않게, ‘취소’를 하기 위해서 일단 보관을 한다.
-
이미 실행 중인 작업이 있으면 새로운 작업을 시작하지 않도록 해야한다. 예를 들어, 화면을 이동해서 사라졌다가 빠르게 다시 돌아오면, - Task 취소를 안했다는 가정하에 ‘이미 Task 가 실행중이었을 것이므로.. 이 중복된 작업을 미리 방지하기 위해서 사용하는 것이다.
아래 예제를 보자.
override func viewWillAppear(_ animated: Bool) {
Task { // 매번 새로운 Task 생성
let user = try await loader.loadUser(withID: userID)
userDidLoad(user)
}
}
// viewWillAppear가 여러 번 호출되면 동시에 여러 개 실행됨!위 코드처럼 Task 참조를 보관하지 않는다면 어떻게 될까?
만약 viewWillAppear가 여러 번 호출되면 동시에 Task도 백그라운드에서 여러 개 실행될 것이다.
이를 해결하는 방법이 바로 Task 참조를 보관 하는 것이다.
override func viewWillAppear(_ animated: Bool) {
guard loadingTask == nil else {
return // 이미 실행 중이면 새로 시작하지 않음
}
// 여기까지 오면, loadingTask는 nil 임 (실행중인 작업 없음을 확인.)
loadingTask = Task {
loadingTask = nil // 완료 후 참조를 nil로
}
}또한 viewWillAppear에서 Task 스코프가 실행되면, 아까 말했듯이 Task는 참조가 해제되어도 자동으로 취소되지 않고 백그라운드에서 계속 실행 되기 때문에, 반드시 아래처럼 화면이 사라지면 적절하게 취소를 할 필요가 있다.
override func viewDidDisappear(_ animated: Bool) {
loadingTask?.cancel() // 화면 사라지면 작업 취소
loadingTask = nil
}Task 의 제네릭 타입
위 사진을 보면, Task가 <Void, Never> 라는 제네릭 타입을 가지고 있다는 것을 볼 수 있다.
“Task has two generic types — the first indicates what type of output that it returns… and the second is for its error type”
이건 정리하면, 아래의 상황일 때 사용한다.
- Void: 반환값 없음 - 즉, UI 업데이트만 수행할 때
- Never: 에러를 Task 내부에서 모두 처리할 때
Task 취소는 전파된다.
“Calling the cancel method on a Task also marks all of its child-tasks as cancelled as well”
부모 Task 가 취소되면, 자식 Task들도 자동으로 취소 마킹된다.
그러나 여기서 주의할 점은, 각 Task가 스스로 실제로 취소 상태를 확인하고 처리해야 한다.
컨텍스트 상속?
이제 맨 처음 예제 (viewWillAppear의 userDidLoad 에서, 왜 Task로 묶었음에도 메인스레드에서 실행된다는 걸까?) 이 부분을 설명해보겠다.
바로 그 이유는 자식 Task는 부모의 실행 컨텍스트를 자동으로 상속받기 때문이다.
“child tasks automatically inherit the same execution context as their parent uses”
아하…
“it will still be executed on the main thread, since it’s being dispatched using the MainActor”
처음엔 잘 이해가 안됐지만, 실제 예제를 보니, 바로 이해가 되었다. (ProfileVC가 서버 통신이 아닌 로컬 DB에서 사용자 모델을 로드하고 DB API가 완전한 동기화가 되어 있다고 가정한 코드)
override func viewWillAppear(_ animated: Bool) {
loadingTask = Task {
let user = try database.loadModel(withID: userID)
userDidLoad(user)
}
}(위 에제에서는 loadModel이 동기함수라고 가정. 그래서 await도 없는것.) 위 예제에서 database.loadModel 는 Task 스코프 안에서 호출되었으니, 당연히 백그라운드 스레드에서 실행되겠지? —
라고 생각하면. 만만의 콩떡. 정말 큰 오해이다. 모르면 쉽게 착각할 수 있다.
하지만 원리는 아주 간단하다.
override func viewWillAppear(_ animated: Bool) { // @MainActor 메서드
loadingTask = Task {
// "Task니까 백그라운드에서 실행될 거야!" ❌
let user = try database.loadModel(withID: userID) // 실제로는 메인 스레드!
userDidLoad(user)
}
}위 코드는 사실 아래와 같다.
override func viewWillAppear(_ animated: Bool) {
DispatchQueue.main.async { // 메인 스레드에서 실행
let user = try database.loadModel(withID: userID) // 단 여기서 UI 블로킹이 일어날 수 있다
userDidLoad(user)
}
}왜 이런 일이 발생하나??
바로 위에서 설명한 이 문장. 다시 보자.
“child tasks automatically inherit the same execution context as their parent uses”
컨텍스트는 상속되기 때문이다. 정확히 말하면, 자식 Task는 부모의 실행 컨텍스트를 자동으로 상속받기 때문이다.
class ProfileViewController: UIViewController { // @MainActor 클래스
// viewWillAppear도 @MainActor 메서드
override func viewWillAppear(_ animated: Bool) {
// 1. 이 메서드는 MainActor 컨텍스트
super.viewWillAppear(animated)
loadingTask = Task {
// 2. 이 Task는 부모(viewWillAppear)의 MainActor 컨텍스트 상속
// 3. 따라서 메인 스레드에서 실행됨
let user = try database.loadModel(withID: userID)
}
}
}그럼.. try database.loadModel 을 메인스레드에서 실행하면.. UI가 멈출 것이다.
그렇다면 어떻게 하면 될까?
Task.detached 를 사용한다.
“detached task — which will be executed within its own, stand-alone context”
말 그대로. 독립적인 컨텍스트에서 실행되도록 해줄 수 있다.
loadingTask = Task.detached(priority: .userInitiated) { [weak self] in
guard let self = self else { return } // 아.. 여기서는 또 self를 캡쳐하기에 약한 참조를 사용했구나.
do {
// 백그라운드 스레드에서 실행됨
let user = try self.database.loadModel(withID: self.userID)
// 여기는 독립적인 Task
// 그렇기에 여기서 MainActor 메서드를 호출 시에는 await 필요 (다른 컨텍스트 이기 때문에.)
await self.userDidLoad(user)
} catch {
await self.handleError(error)
}
await self.loadingTaskDidFinish()
}자. 정리한번 더 하면
Task와 Task.detached 는 모두 비동기 컨텍스트이다!
차이점은, 컨텍스트를 상속 받냐 안받고 독립적이냐의 차이!
이제 위 코드를 왜 이렇게 짰고. 정확히 어떻게 실행되는지 한줄도 빠짐없이 이해했다.
database.loadModel 은 동기함수인데, 이를 메인스레드에서 실행시킨다면 UI가 멈출 수 있으므로, 백그라운드에서 실행시키고자 할 때! 그래서. Task.detached 를 사용한 것이다.
(이 때, 다른 컨텍스트(백그라운드 스레드)에서 실행되기 때문에, MainActor의 메서드를 호출하고자 한다면, await가 필요할 것이다.)
데이터베이스 로딩이 백그라운드 스레드에서 끝났다면 databaseTask가 이를 참조할 것이고, databaseTask.value 로 Task의 실행 결과(user)를 받고자 할 때, 비동기적으로 실행될 것이다. (나중에 더 자세히 보겠지만 await는 결과가 나올 때까지 “Task”스코프 내에서 기다린다는 것이다. — await를 쓴다고 백그라운드 스레드로 보낸다는 의미는 아니므로 혼동x, 그리고 기다린다고 해서 UI가 멈추는 것이 아님. Task 스코프에서 user결과 나올 때까지 잠시 멈춘다(기다린다)고 봐야함.. 동기함수가 아니니까. 비동기함수니까 Task 스코프 밖은 계속 실행됨)