[Swift] Concurrency - Task(3)
안녕하세요, 루피입니다.
이번 포스팅은 Swift Concurrency 3번째 시간입니다. 오늘은 Task를 이용한 메모리관리에 대한 정리를 해보겠습니다.
바로 시작합니다.
async/await 사용 시 메모리 관리
앱의 메모리를 관리하는 것은 비동기 코드의 맥락에서 특히 까다로운 일입니다. 다양한 객체와 값들이 비동기 호출을 수행하고 처리하기 위해 시간에 걸쳐 캡처되고 유지되어야 하기 때문입니다.
Swift의 비교적 새로운 async/await 구문은 많은 종류의 비동기 작업을 더 쉽게 작성할 수 있게 해주지만, 이러한 비동기 코드에 관련된 다양한 작업과 객체들의 메모리를 관리할 때는 여전히 상당히 주의해야 합니다.
암시적 캡처
async/await의 흥미로운 측면 중 하나는 비동기 코드가 실행되는 동안 객체와 값들이 종종 암시적으로 캡처되는 방식입니다.
예를 들어, 주어진 URL에서 다운로드한 Document를 다운로드하고 표시하는 DocumentViewController를 작업한다고 가정해봅시다. 뷰 컨트롤러가 사용자에게 표시되려고 할 때 다운로드가 지연 실행되도록 하기 위해, 뷰 컨트롤러의 viewWillAppear 메서드 내에서 해당 작업을 시작하고, 사용 가능해지면 다운로드된 문서를 렌더링하거나 발생한 오류를 표시합니다.
class DocumentViewController: UIViewController {
private let documentURL: URL
private let urlSession: URLSession
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task {
do {
let (data, _) = try await urlSession.data(from: documentURL)
let decoder = JSONDecoder()
let document = try decoder.decode(Document.self, from: data)
renderDocument(document)
} catch {
showErrorView(for: error)
}
}
}
private func renderDocument(_ document: Document) {
...
}
private func showErrorView(for error: Error) {
...
}
}
위 코드를 빠르게 살펴보면, 전혀 객체 캡처가 일어나지 않는 것처럼 보일 수 있습니다.
결국, 비동기 캡처는 전통적으로 escaping 클로저 내에서만 발생했고, 이는 차례로 이러한 클로저 내에서 로컬 프로퍼티나 메서드에 접근할 때마다 항상 명시적으로 self를 참조하도록 요구했습니다. 따라서 DocumentViewController를 표시하기 시작했지만 다운로드가 완료되기 전에 이를 벗어나면, 외부 코드(예: 부모 UINavigationController)가 강한 참조를 유지하지 않는 한 성공적으로 할당 해제될 것이라고 예상할 수 있습니다. 하지만 실제로는 그렇지 않습니다.
이는 Task를 생성하거나 await를 사용하여 비동기 호출의 결과를 기다릴 때마다 발생하는 앞서 언급한 암시적 캡처 때문입니다. Task 내에서 사용되는 모든 객체는 해당 작업이 완료(또는 실패)될 때까지 자동으로 유지되며, 여기에는 위에서 하고 있는 것처럼 멤버를 참조할 때마다의 self도 포함됩니다.
많은 경우에 이 동작은 실제로 문제가 되지 않을 수 있으며, 캡처된 모든 객체가 캡처하는 작업이 완료되면 결국 해제되므로 실제 메모리 누수로 이어지지 않을 것입니다. 하지만 DocumentViewController에서 다운로드하는 문서가 잠재적으로 상당히 클 수 있고, 사용자가 다른 화면 간을 빠르게 탐색하는 경우 여러 뷰 컨트롤러(와 그들의 다운로드 작업)가 메모리에 남아있는 것을 원하지 않는다고 가정해봅시다.
이런 종류의 문제를 해결하는 전통적인 방법은 weak self 캡처를 수행하는 것이며, 이는 종종 캡처하는 클로저 자체 내에서 guard let self 표현식과 함께 사용됩니다. 이는 약한 참조를 클로저의 코드 내에서 사용할 수 있는 강한 참조로 변환하기 위해서입니다.
class DocumentViewController: UIViewController {
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task { [weak self] in
guard let self = self else { return }
do {
let (data, _) = try await self.urlSession.data(
from: self.documentURL
)
let decoder = JSONDecoder()
let document = try decoder.decode(Document.self, from: data)
self.renderDocument(document)
} catch {
self.showErrorView(for: error)
}
}
}
...
}
불행히도, 이는 이 경우에 작동하지 않습니다. 로컬 self 참조가 비동기 URLSession 호출이 일시 중단되는 동안 여전히 유지되고, 클로저의 모든 코드가 실행을 완료할 때까지 유지되기 때문입니다(함수 내의 로컬 변수가 해당 스코프가 종료될 때까지 유지되는 것과 같은 방식으로).
따라서 정말로 self를 약하게 캡처하고 싶다면, 클로저 전체에서 그 약한 self 참조를 일관되게 사용해야 합니다. urlSession과 documentURL 프로퍼티를 더 간단하게 사용하기 위해, 이들을 별도로 캡처할 수 있습니다. 그렇게 하면 뷰 컨트롤러 자체가 할당 해제되는 것을 방해하지 않습니다.
class DocumentViewController: UIViewController {
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task { [weak self, urlSession, documentURL] in
do {
let (data, _) = try await urlSession.data(from: documentURL)
let decoder = JSONDecoder()
let document = try decoder.decode(Document.self, from: data)
self?.renderDocument(document)
} catch {
self?.showErrorView(for: error)
}
}
}
...
}
좋은 소식은 위의 코드가 적용되면, 다운로드가 완료되기 전에 해제되는 경우 뷰 컨트롤러가 이제 성공적으로 할당 해제된다는 것입니다.
하지만 이것이 작업이 자동으로 취소된다는 의미는 아닙니다. 이 특정한 경우에는 문제가 되지 않을 수 있지만, 네트워크 호출이 어떤 종류의 부작용(데이터베이스 업데이트 같은)을 초래한다면, 뷰 컨트롤러가 할당 해제된 후에도 해당 코드가 여전히 실행되어 버그나 예상치 못한 동작을 초래할 수 있습니다.
작업 취소
DocumentViewController가 메모리에서 벗어날 때 진행 중인 다운로드 작업이 실제로 취소되도록 보장하는 한 가지 방법은 해당 작업에 대한 참조를 저장하고, 뷰 컨트롤러가 할당 해제될 때 그것의 cancel 메서드를 호출하는 것입니다.
class DocumentViewController: UIViewController {
private let documentURL: URL
private let urlSession: URLSession
private var loadingTask: Task<Void, Never>?
...
deinit {
loadingTask?.cancel()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
loadingTask = Task { [weak self, urlSession, documentURL] in
...
}
}
...
}
이제 모든 것이 예상대로 작동하고, 뷰 컨트롤러의 모든 메모리와 비동기 상태가 해제되면 자동으로 정리됩니다. 하지만 우리의 코드도 그 과정에서 상당히 복잡해졌습니다. 비동기 작업을 수행하는 모든 뷰 컨트롤러에 대해 이 모든 메모리 관리 코드를 작성해야 한다면 상당히 지루할 것이고, async/await가 Combine, 델리게이트, 또는 클로저와 같은 기술에 비해 실제로 어떤 이점을 제공하는지 의문을 갖게 할 수도 있습니다.
다행히, 그렇게 많은 코드와 복잡성을 포함하지 않는 위 패턴을 구현하는 다른 방법이 있습니다. 장기 실행되는 async 메서드가 취소되면 오류를 던지는 것이 관례이므로(비동기 작업 지연에 대한 더 많은 정보는 이 기사를 참조), 뷰 컨트롤러가 해제되려고 할 때 loadingTask를 취소하기만 하면 됩니다. 그러면 작업이 오류를 던지고, 종료하고, 캡처된 모든 객체(self 포함)를 해제할 것입니다. 이렇게 하면 더 이상 self를 약하게 캡처하거나 다른 종류의 수동 메모리 관리 작업을 할 필요가 없어집니다.
class DocumentViewController: UIViewController {
private let documentURL: URL
private let urlSession: URLSession
private var loadingTask: Task<Void, Never>?
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
loadingTask = Task {
do {
let (data, _) = try await urlSession.data(from: documentURL)
let decoder = JSONDecoder()
let document = try decoder.decode(Document.self, from: data)
renderDocument(document)
} catch {
showErrorView(for: error)
}
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
loadingTask?.cancel()
}
...
}
작업이 취소될 때 showErrorView 메서드가 여전히 호출될 것입니다(오류가 던져지고, 그 시점에서 self가 메모리에 남아있기 때문에). 하지만 그 추가 메서드 호출은 성능 측면에서 완전히 무시할 수 있을 것입니다[^1].
장기 실행 관찰
위의 메모리 관리 기법들은 어떤 종류의 비동기 시퀀스나 스트림에 대한 장기 실행 관찰을 설정하기 위해 async/await를 사용하기 시작하면 더욱 중요해집니다. 예를 들어, 여기서는 UserListViewController가 User 모델의 배열이 변경되면 테이블 뷰 데이터를 다시 로드하기 위해 UserList 클래스를 관찰하도록 만들고 있습니다.
class UserList: ObservableObject {
@Published private(set) var users: [User]
...
}
class UserListViewController: UIViewController {
private let list: UserList
private lazy var tableView = UITableView()
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task {
for await users in list.$users.values {
updateTableView(withUsers: users)
}
}
}
private func updateTableView(withUsers users: [User]) {
...
}
}
위 구현에는 현재 이전에 DocumentViewController 내에서 구현했던 작업 취소 로직이 포함되어 있지 않으며, 이 경우에는 실제로 메모리 누수로 이어질 것입니다. 그 이유는 (이전의 Document 로딩 작업과 달리) UserList 관찰 작업이 무한정 계속 실행되기 때문입니다. 오류를 던지거나 다른 방식으로 완료될 수 없는 Publisher 기반 비동기 시퀀스를 반복하고 있기 때문입니다.
좋은 소식은 이전에 DocumentViewController가 메모리에 유지되는 것을 방지하기 위해 사용했던 것과 정확히 같은 기법을 사용하여 위의 메모리 누수를 쉽게 수정할 수 있다는 것입니다. 즉, 뷰 컨트롤러가 사라지려고 할 때 관찰 작업을 취소하는 것입니다.
class UserListViewController: UIViewController {
private let list: UserList
private lazy var tableView = UITableView()
private var observationTask: Task<Void, Never>?
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
observationTask = Task {
for await users in list.$users.values {
updateTableView(withUsers: users)
}
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
observationTask?.cancel()
}
...
}
이 경우 deinit 내에서 위 취소를 수행하는 것은 작동하지 않을 것입니다. 실제 메모리 누수를 다루고 있기 때문에 관찰 작업의 끝없는 루프를 깨뜨리지 않는 한 deinit이 절대 호출되지 않을 것입니다.
https://www.swiftbysundell.com/articles/memory-management-when-using-async-await/
Memory management when using async/await in Swift | Swift by Sundell
Managing an app’s memory is something that tends to be especially tricky when it comes to asynchronous code, so let’s take a look at how to do just that when using async/await.
www.swiftbysundell.com
오늘도 화이팅입니다!