본문 바로가기

Swift/Concurrency

Discover Concurrency (3) - Async/await을 사용할 때 메모리 관리하기

비동기 코드 컨텍스트에서 앱의 메모리를 관리하는 것은 까다로운 작업이다. 비동기 호출이 수행되고 처리되기 위해 다양한 객체와 값이 시간이 지남에 따라 캡처되고 유지되어야 할 수 있기 때문이다.

 

비교적 최신 기술인 async/await 구문은 많은 종류의 비동기 작업을 더 쉽게 작성할 수 있게 해 주지만, 비동기 코드에 관련된 다양한 작업과 객체의 메모리를 관리할 때는 여전히 신중해야 한다.

 

Implicit captures

async/await 과 이를 동기 컨텍스트에서 호출하기 위하여 Task 로 래핑 하는 것의 흥미로운 점은 비동기 코드가 실행되었을 때, 어떻게 객체와 값들이 암시적으로 캡처되는 경우가 많다는 것이다.

 

예를 들어, DocumentViewController 에서 작업을 하고 있다고 해보자. 이것은 주어진 URL에서 Document 를 다운로드하고 표시한다.

 

만약 뷰 컨트롤러가 유저에게 보이고 난 뒤 다운로드의 실행을 시작하고 싶다면, 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) {
        ...
    }
}

 

위 코드를 살펴보면, self를 통해 캡처한 인스턴스가 없기 때문에 캡처되고 있지 않아 보일 수 있다.

 

따라서 DocumentViewController 가 시작되고, 다운로드가 완료되기 전에 다른 뷰 컨트롤러로 이동할 경우, 부모UINavigationController 와 같은 외부코드에서 강한 참조를 유지하지 않으면 성공적으로 할당 해제될 것이다.

 

그러나 실제로는 그렇지 않다.

이는 Task를 생성하거나 비동기 호출의 결과를 기다리기 위해 await을 사용할 때 발생하는 암묵적인 캡처 때문이다.

Task 내에서 사용되는 모든 객체는 해당 작업이 완료되거나 실패할 때까지 자동으로 유지되며, 위 코드에서 self와 같은 멤버를 참조할 때도 그렇다.

 

많은 경우 이런 동작이 실제로 문제가 되지 않으며, 이것이 메모리 누수로 이어지지도 않을 것이다.

왜냐하면 모든 캡처된 객체는 해당 Task가 완료되면 결국 해제될 것이기 때문이다.

하지만 만약 DocumentViewController 가 다운로드할 문서가 매우 크다면, 사용자가 다른 화면으로 빠르게 이동할 때 여러 개의 뷰컨트롤러와 해당 다운로드 작업들이 메모리에 유지되는 것은 바람직하지 않을 것이다.

 

이런 문제를 해결하기 위한 일반적인 방법은 약한 참조 (self capture)을 사용하는 것이다.

이는 일반적으로 캡처하는 클로저 내에 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)
            }
        }
    }
    
    ...

 

그러나 이 경우에는 그 방법이 통하지 않는다. 비동기 URLSession 호출이 일시 중단될 때까지 self.urlSession을 통해서 self의 참조가 유지된다.

그리고 이 참조는 클로저의 코드가 모두 실행될 때까지 유지된다.

 

따라서 self를 약한 참조로 캡처하려면 클로저 전체에 일관된 weak self 참조를 사용해야 한다.

 

urlSessiondocumentURL 프로퍼티들의 사용을 쉽게 하기 위해 이를 따로 캡처할 수 있으며, 이렇게 하더라도 뷰 컨트롤러 자체의 할당 해제를 방해하지 않는다.

 

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

 

결국 위 코드에서는 뷰 컨트롤러에서 다운로드가 완료되기 전에 사라진다면 뷰 컨트롤러는 성공적으로 해제될 것이다.

 

그러나 이것이 해당 작업이 자동으로 취소된다는 것을 의미하진 않는다.

특정 상황에서는 문제가 되지 않겠지만, 예를 들어 데이터베이스를 업데이트한다거나 네트워크 호출과 연관된 작업일 경우 뷰 컨트롤러가 할당 해제된 후에도 코드가 실행될 수 있으며, 이것이 버그나 예기치 못한 동작을 초래할 수 있다.

 

Cancelling tasks

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

 

이제 예상대로 작동하며, 모든 뷰컨트롤러의 메모르와 비동기 state는 해제될 때 자동으로 정리될 것이다.

 

그러나, 코드가 조금 복잡해졌다.

모든 뷰 컨트롤러에 비동기 작업에 대한 메모리 관리 코드를 작성해줘야 하고, Combine, delegate 또는 클로저와 같은 기술들에 비해 async/await의 더 나은 부분이 퇴색되는 느낌일 것이다.

결국 다른 패턴을 구현해야 한다.

 

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

    ...
}

 

만약 긴 시간이 걸리는 async 메서드가 취소될 때, 에러를 발생시킨다면 뷰 컨트롤러가 해제되기 전에 loadingTask 를 취소하면 작업이 에러를 throw 하고 종료되며, self를 포함한 모든 캡처된 객체들이 해제된다.

 

결국 더 이상 self 를 약한 참조로 캡처할 필요가 없고 수동으로 메모리 관리 작업을 수행할 필요도 없게 된다.

약한 참조를 수동으로 설정/ 해제하는 메모리 관리 작업을 수행하지 않기 위해 해당 뷰컨트롤러가 해제될 때 비동기 task를 취소한다.

 

중요한 점은, task가 취소될 때(viewWillDisappear() 에서 loadingTask?.cancel() 가 호출될 때) 에도 메모리에 self가 남아있기 때문에 throw 한 에러를 실행하는 showErrorView(for: error) 부분이 호출될 것이다. 그러나 이 추가적인 메서드의 호출은 성능적으로 무시할 수 있을 정도이다.

 

Long-running observations

위 메모리 관리 기술은 async/await을 사용하여 비동기 시퀀스나 스트림을 오래 실행하는 관찰을 설정할 때 더욱 중요해질 것이다.

 

예를 들어, 다음과 같은 UserListViewController 에서 UserList 클래스를 옵저빙 하여 User모델의 배열이 변경될 때 테이블 뷰 데이터를 다시 로드하는 코드가 있다.

 

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에서 구현한 작업 취소 로직을 포함하지 않으므로 실제로 메모리 누수가 발생할 수 있다.

그 이유는 UserList 옵저빙 작업이 Publisher 기반의 비동기 시퀀스를 반복하며 오류를 던지거나 다른 방식으로 완료할 수 없기 때문에 계속 실행될 것이다.

 

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이 호출되지 않을 것이기 때문이다.

 

 

Conclusion

Task와 async/await는 비동기 및 메모리 관련 문제를 해결하기 위한 새로운 기술로 소개되었다. 이러한 기술은 코드를 더 간결하고 읽기 쉽게 만들어주며, 비동기 작업을 보다 직관적으로 다룰 수 있도록 도와주었다.

 

하지만 async/await를 사용하여 비동기 작업을 처리할 때에도 객체의 캡처와 보유에 주의해야 한다. 비동기 작업은 보통 더 오래 걸리는 작업이기 때문에, 작업이 진행되는 동안 해당 작업에 필요한 객체들이 메모리에 계속해서 보관되기 때문이다. 이로 인해 예상치 못한 메모리 누수나 객체 참조 문제가 발생할 수 있다.

 

따라서 우리는 async/await를 사용할 때에도 객체의 적절한 관리에 주의해야 할 것이다. 적절한 타이밍에 객체를 해제하거나 작업을 취소하는 등의 조치를 취해야 한다.

 

 

 

출처

Memory management when using async/await in Swift | Swift by Sundell

 

Memory management when using async/await in Swift | Swift by Sundell

Managing an app’s memory is something that tends to be especially tricky to do within the context of asynchronous code, as various objects and values often need to be captured and retained over time in order for our asynchronous calls to be performed and

www.swiftbysundell.com