클로저에서 self
키워드를 사용하면 클로저가 호출되는 시점에 self
가 메모리에 남아 있다는 것을 보장할 수 없기 때문에 클로저는 self
를 강한 참조한다. 예를 들어 네트워크 요청과 같이 비교적 시간이 오래 걸리는 작업에서 클로저가 self
를 강한 참조하면 네트워크 요청이 종료될 때까지 self
의 레퍼런스 카운트가 감소하지 않아 메모리에서 해제되지 않게 된다. 이는 잠재적인 메모리 누수로 이어질 가능성이 있기 때문에 클로저 매개변수 앞에 캡쳐 목록을 통해 weak self
를 통해 self
를 약한 참조 하게 만들 수 있다. 가령 아래와 같은 상황에서 말이다.
class ViewModel: ObservableObject {
@Published var content: String = ""
...
func fetch() {
repository.fetch()
.receive(on: DispatchQueue.main)
.sink { [weak self] content in //self 약한 참조
self?.content = content
}
.store(in: &cancellables)
}
}
Swift Concurrency에서 비동기 작업은 Task 단위로 실행된다. 이때, Task
를 생성하면서 수행할 비동기 작업을 클로저로 전달하게 되는데 이때도 마찬가지로 외부의 참조가 필요한 경우 강한 참조를 하게 된다. 하지만 일반적인 경우 Task
에 전달되는 클로저에 self
를 약한 참조하지 않아도 되는데, 그 이유는 Task
가 생성되면 그 즉시 클로저는 실행되고 클로저의 실행이 끝나면 self
또한 즉시 메모리에서 해제된다.
기본적으로
Task
로 전달되는 클로저에서self
키워드를 표기해도 되고, 안 해도 되는데, Task.swift 깃허브 레포지토리의 생성자 선언를 보면@_implicitSelfCapture
어트리뷰트를 통해 탈출 클로저에서self
키워드를 생략해도 암시적으로self
를 캡쳐할 수 있도록 되어 있다.
하지만 네트워크 통신 같이 시간이 걸리는 작업을 Task
로 전달하는 클로저에서 실행하는 경우, 만약 해당 작업이 시간이 오래 걸리거나, 영영 끝나지 않는 경우 강한 참조된 인스턴스는 참조 카운트가 유지되어 메모리에서 해제되지 못 하고 메모리 누수를 일으키게 된다.
이런 비슷한 상황을 구현한 아래의 코드를 통해 다시 살펴보자.
class Foo {
var bat: String?
deinit {
print("Foo deinit")
}
func foobar() {
Task {
do {
try await Task.sleep(for: .seconds(3))
bat = "Hello Swift!"
print(bat)
} catch {
print(error)
}
}
}
}
var foo: Foo? = .init()
foo?.foobar()
foo = nil
print(foo)
// nil
// Optional("Hello Swift!")
// Foo deinit
위의 상황처럼 Task
의 클로저 내부에서 self
가 캡쳐되어 있고, Task
는 3초의 시간이 걸리기 때문에 foo
에 nil
을 할당하더라도 foo
의 deinit
가 바로 호출되지 않고 Task
작업이 온전히 종료된 후에 deinit
가 호출 되는 것을 확인할 수 있었다.
이러 상황을 해결하기 위해서 기존 클로저에서 사용했던 것처럼 Task
에 전달하는 클로저의 캡쳐 목록에 self
에 weak
키워드를 통해 약한 참조를 만들면 메모리 누수를 해결할 수 있다.
class Foo {
var bat: String?
deinit {
print("Foo deinit")
}
func foobar() {
Task { [weak self] in
do {
try await Task.sleep(for: .seconds(3))
guard let self else {
print("self is deallocated")
return
}
bat = "Hello Swift!"
print(bat)
} catch {
print(error)
}
}
}
}
var foo: Foo? = .init()
foo?.foobar()
foo = nil
print(foo)
// Foo deinit
// nil
// self is deallocated
결과적으로 메모리 누수의 상황은 피하긴 했으나, 위의 상황도 썩 달갑지만은 않다. Foo
클래스의 인스턴스는 약한 참조를 통해 메모리에서 해제 되었지만 아직 Task
는 끝나지 전까지 메모리에 남아 있다가 self is deallocated
를 출력하는 것을 확인할 수 있었다.
따라서 self
가 메모리에서 해제될 때 관련된 Task
또한 함께 취소하는 것이 좋다.
Task
가 생성되면 그 즉시 클로저는 실행되고 클로저의 실행이 끝나면self
또한 즉시 메모리에서 해제된다.
위에서 언급한 Task
의 동작 방식과 Task
의 생명 주기를 관리할 수 있는 .cancel()
메서드를 잘 조합하면 self
를 약한 참조하지 않아도 메모리를 안전하게 관리할 수 있다고 생각한다.
먼저, .cancel()
메서드를 사용하기 위해서는 아래의 코드와 같이 생성된 Task
를 저장할 변수가 필요하다.
class Foo {
var bat: String?
var task: Task<Void, Never>?
deinit {
print("Foo deinit")
}
func foobar() {
task = Task {
try? await Task.sleep(for: .seconds(3))
bat = "Hello Swift!"
print(bat)
}
}
}
var foo: Foo? = .init()
foo?.foobar()
foo?.task?.cancel()
foo = nil
print(foo)
// nil
// Optional("Hello Swift!")
// Foo deinit
그리고 self
에 nil
을 할당하기 전에 .cancel()
메서드를 호출하면 현재 진행 중인 Task
의 작업과 앞으로 생성될 작업까지 모두 취소되고 self
메모리에서 안전하게 해제되며 메모리 누수를 방지할 수 있다.
이러한 취소 작업은 View 생명주기와 함께 사용하면 효과적으로 사용할 수 있다고 생각하는데, 예를 들어 아래와 같이 View가 사라지면 더 이상 진행 중인 비동기 작업은 필요가 없게 되므로 viewDidDisappear(_:)
에서 .cancel()
을 호출하여 메모리 누수를 안전하게 방지할 수 있고 생각한다.
import UIKit
class ViewModel {
var task: Task<Void, Never>?
func fetch() {
task = Task {
// do something
}
}
}
class SomeViewController: UIViewController {
...
override func viewDidDisappear(_ animated: Bool) {
viewModel.task?.cancel()
}
}