-
Swift Concurrency - Task (2)Concurrency 2023. 2. 27. 08:41
안녕하세요. 그린입니다🍏
이번 포스팅에서는 저번 Task의 역할에 대해 알아봤었다면 Task에서 async/await를 사용할때 실제 Swift에서 메모리 관리가 어떻게 되는지 학습해보겠습니다🙌
이번 학습 자료도 아래 링크를 기반으로 개인적인 이해를 바탕으로한 번역을 토대로 이뤄졌습니다🎉
https://www.swiftbysundell.com/discover/concurrency/
Swift에서 async/await를 사용할 때 메모리 관리
앱의 메모리 관리는 비동기 처리를 위해 시간이 지나도 객체와 값을 캡처하고 유지하는 경우가 많습니다.
이에 비동기 코드의 컨텍스트 내에서 수행하기가 까다롭습니다.
Swift의 새로운 비동기 처리 방식인 async/await 코드는 많은 종류의 비동기 작업을 더 쉽게 작성하고 구현할 수 있습니다.
다만 비동기 코드와 관련하여 다양한 작업 및 객체에 대한 메모리를 관리할 때 마찬가지로 조심해야 합니다.
그럼 우선 async/await를 사용할 때 어떻게 캡처가 일어나는지 확인해볼까요?
암시적 캡처
async/await (혹은 동기화된 컨텍스트 내 코드를 호출할 때 해당 코드를 래핑하는데 사용하는 Task)들은 비동기 코드가 실행되는 동안 객체와 값이 암시적 캡처가 되는 경우가 많습니다.
예시로 아래 코드를 보면 URL에서 다운로드한 문서를 표시하는 코드가 있다고 생각해볼께요.
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) { ... } }
해당 VC가 사용자에게 표시될 때 다운로드를 느리게 실행되도록 하려면 VC의 ViewWillAppear 메서드 내에서 해당 코드를 배치하여 실행하고 다운로드한 문서를 사용 가능한대로 렌더링한다면 문제가 발생합니다.
위의 코드에서 객체 캡처가 전혀 진행되지 않고 있는것처럼 보일 수 있습니다.
비동기 캡처는 일반적으로 escaping closure 내에서만 발생했고 우리가 그러한 클로저 내에서 로컬 속성이나 메서드에 접근할 때 항상 명시적으로 self를 참조해야 한다는것을 요구합니다.
그럼으로 VC를 나타내기 시작했지만 다운로드가 완료되기 전에 VC를 벗어나면 외부 상위뷰 혹은 코드 (예를들어 UINavigationController)가 해당 코드에 대한 강력 참조를 유지하지 않으면 할당이 정상적으로 해제될 거라 예상할 수 있습니다.
그러나 실상은 그렇지 않습니다.
Task를 만들거나 사용하여 비동기 호출의 결과를 기다릴 때마다 발생하는 앞서 언급했던 암시적 캡처가 이유입니다.
위와 같이 자체 구성요소를 참조할 때마다 자체 자신을 포함하여 작업이 완료되거나 실패할 때 까지 Task 내에서 사용되는 객체는 자동으로 유지됩니다.
대부분의 경우 캡처된 모든 객체는 캡처 작업이 완료되면 해제 됨으로 실제 메모리 누수로 이어지지 않을 수 있습니다.
그런데 만약 위처럼 다운로드한 문서의 크기가 굉장히 클 때 다운 중 화면을 빠르게 이동한다면 여러 VC나 다운로드한 작업이 메모리에 남아있지 않도록 해야합니다.
그래서 이러한 위의 문제를 해결하는 전통적인 방법으로 weak 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) } } } ... }
그렇지만 해당 코드는 여전히 비동기 URL 세션 호출이 일시 중단되는 동안에도 로컬 자체 참조가 유지되고 모든 클로저 코드가 실행될 때까지 유지되지 않습니다.
즉 정상적이지 않죠.
우리가 원하는 조건을 수행하기 위해서는 클로저 내 약한 참조를 지속적으로 사용해야합니다.
그럼으로 urlSession이나 documentURL과 같은 속성을 별도로 캡처하여 사용할 수 있습니다.
이렇다면 VC 자체 할당이 해제되는것을 막을 수 없습니다.
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) } } } ... }
이렇게 된다면 문서 다운로드가 완료되기 전 VC가 이동된다면 성공적으로 할당 해제가 됩니다.
그렇지만 해당 Task가 자동으로 취소되는것은 아닙니다.
해당 경우에는 네트워크 호출이 사이드 이펙트를 초래하면서 예기치 못한 동작을 발생시킬 여지도 있습니다.
예를들어 DB 업데이트와 같은!
그렇다면 이어서 Task 취소를 어떻게 하는지 알아보겠습니다.
Task 취소
메모리가 부족해지면 실제 다운중인 작업이 취소되도록 cancel 메서드를 이용해 VC가 deinit될 때 호출해줄 수 있습니다.
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 ... } } ... }
이렇게 된다면 VC의 메모리와 비동기가 제거되면 자동으로 정리됩니다.
그러나 여기서도 코드가 조금 복잡해졌죠!?
매번 비동기를 수행하는 VC에서 해당 메모리 관리 코드를 일일히 구현하는건 아주 귀찮은 일이죠😭
그리고 이렇게 되면 async/await가 combine이나 클로저와 같은 비동기 방식보다 더 실질적으로 좋은점이 있는지도 의문이 생기죠.
그렇기에 복잡하지 않은 또 다른 방법이 있습니다.
viewWillDisappear에서 처리를 해준다면 더 이상 자신을 약한 캡쳐를 하거나 다른 종류의 수동 메모리 관리 작업을 수행할 필요가 없게됩니다.
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() } ... }
즉 Task가 취소되더라도 showErrorView와 같은 에러 캐치 코드는 실행됩니다.
주의할 점은 이러한 추가 메서드 호출은 성능면에서 무시될 정도로 라이트해야 합니다.
지속적인 실행 관찰
async/await를 사용해 비동기 시퀀스 혹은 스트림의 지속적인 실행 관찰을 시작하면 위의 메모리 관리 기술이 훨씬 더 중요해집니다.
예시로 아래 코드에서 VC가 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]) { ... } }
해당 코드에서는 Task 취소 로직이 없어 실제 메모리 누수가 일어날 수 있습니다.
userList를 계속 관찰하며 갱신해주는 Task가 오류가 날 수 없는 published 기반 비동기 시퀀스를 통해 반복하여 들어오고 실행되기 때문에 끊이지 않고 있는것이죠.
그렇기에 우리는 위에서 알아봤던것처럼 Task 타입으로 만들어 메모리에 유지되는것을 방지할 수 있어 누수를 막을 수 있습니다🙌
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에서 Task cancel을 해도 실제 메모리 누수를 처리하지 않기에 해당 경우에는 작동하지 않습니다.
즉, 관찰 작업의 끝이없는 이 루프를 깨지 않는다면 deinit은 호출되지 않겠죠?
결론
async/await를 사용해도 여전히 비동기를 수행할 때 객체가 캡처되고 유지되는 방법에 대해서는 주의해아 합니다.
실제 메모리 누수 및 유지 주기는 combine이나 closure를 사용할 때 처럼 쉽게 발생하지 않지만 여전히 관리를 해줘야하죠.
마무리
확실히 combine보다 메모리 관리에 대해 덜 신경쓸 수 있긴 하지만 크게 엄청 좋다! 라는건 아직 체감이 잘 안되네요😇
그래도 확실히 async/await가 Task와 더불어 해당 VC의 메모리 관리를 엮어가는 과정을 알 수 있었습니다!
[참고 자료]
https://www.swiftbysundell.com/articles/memory-management-when-using-async-await/
'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 (1) (6) 2023.02.24 Swift 5.5 - async & await (0) 2021.09.25