순환참조(Retain Cycle)를 피하기 위해 Swift 클로저 내부에서 [weak self]를 사용하는 방법에 대해 이야기하고, [weak self]가 필요할 수도 있고 필요하지 않을 수도 있는 경우에 대한 글.
Swift의 메모리 관리는 ARC(Automatic Reference Counting)에 의해 처리됩니다. ARC는 더 이상 필요하지 않은 클래스 인스턴스에서 사용하는 메모리를 해제하기 위해 작동한다. ARC는 대부분 자체적으로 작동하지만 때로는 객체 간의 관계를 명확히 하기 위해 추가 정보를 제공해야 한다.
예를 들어, 속성(property)에 Parent Controller에 대한 참조(reference)를 저장하는 Child Controller가 있는 경우 순환 참조(Retain Cycle)를 방지하기 위해 해당 속성(property)을 weak 키워드로 표시해야 한다.
메모리 누수(Memory Leak)가 의심되는 경우 다음 작업을 수행 할 수 있다.
클로저는 정의된 컨텍스트에서 모든 상수 또는 변수를 강력하게 캡쳐할 수 있다. 예를 들어, 클로저 내에서 self를 사용하는 경우 클로저 범위(scope)의 수명 기간 동안 self에 대한 strong reference를 유지한다. self가 이 클로저에 대한 참조를 유지하는 경우(미래 어느 시점에 호출하기 위해) strong reference cycle을 갖게 된다.
unowned은 위와 같은 순환 참조를 피하기 위해 사용할 수 있는 방법 중 하나이다. unowned은 순환 참조를 피할 수 있게 하는 과정에서 self를 강제로 unwrapping하고 할당이 해제된 후에도 내용에 액세스하려고 시도한다. 때문에, unowned은 매우 안전하지 않다!
weak는 훨씬 안전한 방식으로 순환 참조를 피할 수 있게 한다.
weak는 순환 참조를 피할 수 있게 하는 과정에서 self를 Optional로 만든다. Optional Chaining(ex: self?.)을 사용할 수도 있지만 더 널리 사용되고 있는 방식은 guard let 구문을 사용해 클로저 시작 시 self에 대한 임시 강한 참조를 만드는 것이다.
// Example
guard let self = self else { return }
// Swift 4.2부터, guard let self = self 구문에 대한 공식 지원이 추가되어 이것이 가능해졌다.
Optional 처리를 피하기 위해 weak보다 unowned를 사용하고 싶다면 클로저 실행 중에 참조가 절대 nil이 되지 않을 것이라고 확신할 때만 unowned를 사용해야 한다. unowned는 optional을 강제로 unwrapping하는 것과 같으며, nil이 되면 충돌이 발생한다. [weak self]가 훨씬 더 안전한 대안이다.
[weak self]가 훨씬 안전하다는 사실을 확인했으니 모든 클로저에서 [weak self]를 사용해야 할까?
Non-escaping closure는 범위(scope) 내에서 실행된다. 즉, 코드를 즉시 실행하고 나중에 저장하거나 실행할 수 없다.
Non-escaping closure(예: compactMap과 같은 고차 함수)는 강력한 참조 주기를 도입할 위험이 없으므로 weak 또는 unowned를 사용할 필요가 없다.
Escaping closure는 저장될 수 있고 다른 클로저로 전달될 수 있으며 미래의 어느 시점에 실행될 수 있다.
Escaping closure는 다음 두 조건이 모두 충족되는 경우에 weak 또는 unowned를 사용한다.
Delayed Deallocation는 Escaping 및 Non-escaping 클로저에서 나타나는 부작용이다. 정확히 메모리 누수는 아니지만 원하지 않는 동작으로 이어질 수 있다.
(ex: Controller를 해체했지만 보류 중인 모든 클로저 작업이 완료될 때까지 메모리가 해제되지 않음)
기본적으로 클로저는 본문에서 참조하는 모든 객체를 강력하게 캡처하기 때문에 클로저 본문이나 범위가 살아있는 한 객체가 메모리에서 할당 해제되는 것을 방해한다.
이 경우 Escaping 및 Non-escaping 클로저 모두 [weak self]를 사용해 Delayed Deallocation를 방지할 수 있다.
([weak unowned]를 사용한다면 충돌을 일으키게 될 것이다.)
[weak self]를 사용할 때 Optional Chaining(ex: self?.)을 사용하여 self에 액세스하는 대신 guard let self = self를 사용하면 발생하는 잠재적인 부작용이 있다.
Delayed Deallocation가 나타날 수 있는 클로저에서 'guard let self = self' 구문을 사용한다면 Delayed Deallocation를 방지할 수 없다.
여기서 'guard let' 구문이 하는 일은 self가 nil과 같은지 여부를 확인하고, 그렇지 않은 경우 범위 기간 동안 self에 대한 일시적인 강력한 참조를 생성하는 것이다.
다시 말하면, 'guard let' 구문은 클로저의 수명 동안 self가 할당 해제되는 것을 방지해 self가 유지되도록 보장한다.
'guard let' 구문을 사용하지 않고 대신 Optional Chaining(ex: self?.)을 사용하는 경우 클로저가 시작할 때 강력한 참조를 만드는 대신 모든 메서드 호출에서 self에 대한 nil 검사를 진행한다.
즉, 클로저 실행 중 어느 시점에서 self가 nil이 되면 자동으로 해당 메서드 호출을 건너뛰고 다음 줄로 이동한다.
'guard let' 구문은 경우에 따라 Delayed Deallocation로 이어질 수 있다.
때문에 경우에 따라 'guard let' 및 'Optional Chaining' 중 어떤 구문을 사용해야 할지 생각할 필요가 있다.
- ViewController가 해제(dismiss)된 후 불필요한 작업을 피하려는 경우
- 객체가 할당 해제 되기 전 모든 작업이 완료되었는지 확인하려는 경우에는 이 경우(예: 데이터 손상 방지를 위해)
[weak self]가 필요할 수도 있고 필요하지 않을 수도 있는 일반적인 상황 몇 가지가 있다.
GCD 호출은 나중에 실행하기 위해 저장하지 않는 한 순환참조의 위험이 없다.
// 예를 들어, 이러한 호출은 [weak self] 없이도 즉시 실행되기 때문에 메모리 누수를 일으키지 않는다.
func nonLeakyDispatchQueue() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.view.backgroundColor = .red
}
DispatchQueue.main.async {
self.view.backgroundColor = .red
}
DispatchQueue.global(qos: .background).async {
print(self.navigationItem.description)
}
}
// 그러나 다음 DispatchWorkItem은 속성(property)에 저장하고 [weak self] 키워드 없이 클로저 내부에서 self를 참조하기 때문에 메모리 누수가 발생한다.
func leakyDispatchQueue() {
let workItem = DispatchWorkItem {
self.view.backgroundColor = .red
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0,
execute: workItem)
self.workItem = workItem // stored in a property
}
GCD와 마찬가지로 애니메이션 호출도 속성(property)에 UIViewPropertyAnimator를 저장하지 않는 한 순환참조의 위험이 없다.
// 예를 들어 다음 호출은 안전하다.
func animteToRed() {
UIView.animate(withDuration: 3.0) {
self.view.backgroundColor = .red
}
}
// 반면에 다음 방법은 [weak self]를 사용하지 않고 나중에 사용할 수 있도록 애니메이션을 저장하기 때문에 순환참조를 유발한다.
func setupAnimation() {
let anim = UIViewPropertyAnimator(duration: 2.0,
curve: .linear) {
self.view.backgroundColor = .red
}
anim.addCompletion { _ in
self.view.backgroundColor = .white
}
self.animationStorage = anim
}
한 객체의 클로저나 함수를 다른 객체에 전달하여 속성에 저장하는 상황은 눈에 띄지 않는 메모리 누수를 유발할 수 있다.
예를 들어, 속성(property)에 클로저를 저장하는 PresentedController가 있다.
class PresentedController: UIViewController {
var closure: (() -> Void)?
}
그리고 PresentedController를 가지고 있는 MainViewController가 있고, MainViewController의 printer() 메소드를 PresentedController의 클로저에 저장한다.
class MainViewController: UIViewController {
var presented = PresentedController()
func setupClosure() {
// 함수 자체를 할당하기 때문에 ()괄호를 포함하지 않음.
presented.closure = printer
}
func printer() {
print(self.view.description)
}
}
이제 PresentedController에서 클로저를 호출하면 MainViewController의 self.view.description를 프린트할 수 있다.
하지만 이 코드는 명시적으로 self를 사용하지 않았음에도 순환참조를 유발한다.
self는 printer에 암시되어 있다.(printer == self.priner) 따라서 클로저는 self.printer에 대한 강력한 참조를 유지하는 반면 self는 클로저를 소유하는 PresentedController를 소유한다.
때문에 순환참조를 제거하기 위해 [weak self]를 포함하도록 setupClosure를 수정해야 한다.
func setupClosure() {
presented.closure = { [weak self] in
// 범위(scope) 내에서 해당 함수를 호출하기를 원하기 때문에
// 이번에는 프린터 다음에 ()괄호를 포함.
self?.printer()
}
}
Timer는 속성(property)에 저장하지 않더라도 문제를 일으킬 수 있다.
이 두 조건이 충족되는 한 타이머는 참조된 컨트롤러 또는 객체의 할당 해제를 방지합니다. 따라서 기술적으로 메모리 누수보다는 Delayed Deallocation에 가깝다. Delayed Deallocation는 무한정 지속된다.
참조된 객체를 무기한 활성 상태로 유지하는 것을 피하기 위해 더 이상 필요하지 않을 때 타이머를 무효화하고 참조된 객체가 타이머에 대한 강한 참조를 유지하는 경우 [weak self]를 사용해야 한다.
cf.
https://docs.swift.org/swift-book/LanguageGuide/AutomaticReferenceCounting.html
https://medium.com/flawless-app-stories/you-dont-always-need-weak-self-a778bec505ef
글이 너무좋네요!