[Swift] weak, unowned, Retain cycle 톺아보기
개요
코드를 작성하다보면 이젠 정말 무의식적으로 클로저안에 [weak self] , [unowned self] 를 사용하는데요.
처음 이 키워드들에 대해 이해했을 때 메모리 사이클에서 순환 참조가..메모리릭이...하면서 대충 이해하고 넘어갔던적이 있어 이 기회에 제대로 정리를 해보려 합니다.
마침 좋은 글이 영문글이 있어 그대로 번역하면서 이해하도록 하겠습니다!
출처: www.thomashanning.com/retain-cycles-weak-unowned-swift/
메모리 관리, retain cycle와 weak, unowned 키워드의 사용은 조금 복잡합니다.
하지만 retain cycle은 메모리 이슈의 매우 중요한 부분 중 하나이므로 이를 제대로 이해하는건 매우 중요한 일입니다.
하지만 걱정마세요~ 이 글을 읽으면 이해할 수 있습니다!
(믿음직)
1. Contents
스위프트의 메모리 관리의 기초부터 시작하겠습니다.
이를 기초로, retain cycle이 무엇인지, 그리고 어떻게 weak과 unowned 키워드를 사용해서 이를 피할 수 있는지 배울 것 입니다.
그 후에, retain cycle이 일어나는 2가지 시나리오를 살펴볼것 입니다.
우리는 이 기사를 retain cycle을 감지하는 두 가지 방법으로 결론지을 수 있을 것 입니다.
2. How does memory management work in Swift?
스위프트의 메모리 관리 기초에 대해 알아보겠습니다.
ARC (automatic refrence counting) 은 사용자를 위해 대부분의 메모리 관리를 해줍니다.
원리는 매우 간단합니다.
기본적으로 class의 instance를 가리키는 각 reference(참조)들은, strong reference(강한 참조)라고 할 수 있습니다.
인스턴스를 가리키는 strong reference가 하나라도 있는 이상, 이 인스턴스는 할당 해제(deallocate)가 되지 않습니다.
인스턴스를 가리키는 strong refrence가 더이상 없을 때만 그 인스턴스는 deallocated 됩니다.
예시를 볼까요?
class TestClass {
init() {
print("init")
}
deinit() {
print("deinit")
}
}
var testClass: TestClass? = TestClass()
testClass = nil
Test Class 인스턴스가 생성된 후에, 상황은 이렇습니다.
testClass는 TestClass 클래스의 인스턴스를 향한 strong reference를 갖습니다.
만일 이 reference를 nil로 세팅한다면, strong reference가 없어지고 남은 strong reference가 없기 때문에 TestClass의 인스턴스는 deallocate 됩니다.
또한 콘솔로그에서도 TestClass의 인스턴스가 deallocate 됐을 때 시스템에서 deinit 함수가 호출된것을 확인할 수 있습니다.
TestClass의 인스턴스가 deallocate 되지 않았다면, deinit은 절대 불리지 않았을 겁니다.
deinit 함수에 로그를 심어 오브젝트의 deallocation을 추적하는건 매우 좋은 방법 입니다.
3. What is a retain cycle(순환 참조)?
따라서 ARC(Automatic Reference Counting)의 원리는 매우 잘 작동하며, 대부분의 경우 사용자가 이에 대해 생각할 필요는 많이 없습니다.
그러나, ARC가 제대로 작동하지 않아 사용자가 도움을 줘야하는 상황들이 존재합니다!!
class TestClass {
var testClass: TestClass? = nil
init() {
print("init")
}
deinit() {
print("deinit")
}
}
var testClass1: TestClass? = TestClass()
var testClass2: TestClass? = TestClass()
testClass1?.testClass = testClass2
testClass2?.testClass = testClass1
TestClass의 2개의 인스턴스를 생성하고, 각 인스턴스들이 서로를 가리키게 해봅시다!
이 상황을 비쥬얼라이징하면...
이렇습니다.
그 다음에 이렇게 하면 어떻게 될까요?
testClass1 = nil
testClass2 = nil
하지만 두 인스턴스는 deallocate 되지 않습니다!
( 이것은 deinit의 콘솔로그로 확인할 수 있습니다. )
현재 상황은 이렇습니다.
각 클래스는 하나의 strong reference는 잃었지만, 여전히 하나가 남아있습니다!
이것은 이 인스턴스가 deallocate 되지 않을것이란 의미입니다.
더 난감하게도, 우리는 더이상 코드에서 이 클래스에 대한 reference를 찾을 수 없습니다! (ㅠㅠ)
이를 메모리 누수(memory leak)라고 합니다.
만일 우리의 App에 몇개의 memory leaks가 존재한다면, App의 메모리 사용량은 App을 사용할 때 마다 매우 상승할 것 입니다.
메모리 사용량이 너무 높으면, iOS는 App을 죽입니다(!!)
따라서 retain cycle을 신경써야 하는건 매우 중요한 문제인 것입니다.그럼 어떻게 memory leak을 막을 수 있을까요?
4. weak
weak reference (약한 참조) 를 사용하는 것은 retain cycle을 피할 수 있는 방법 중 하나 입니다.
만일 사용자가 reference를 weak으로 선언한다면, 이것은 strong reference가 아닙니다.
이것은 이 reference가 인스턴스의 deallocate를 방해하지 않는다는 의미입니다.
코드를 변경하고 봅시다!
class TestClass {
weak var testClass: TestClass? = nil // now this is a weak reference!
init() {
print("init!")
}
deinit() {
print("deinit!")
}
}
var testClass1: TestClass? = TestClass()
var testClass2: TestClass? = TestClass()
testClass1?.testClass = testClass2
testClass2?.testClass = testClass1
testClass1 = nil
testClass2 = nil
weak 만 붙였을 뿐인데, 우리는 이 콘솔로그를 확인할 수 있습니다.
init
init
deinit
deinit
이 상황을 비쥬얼라이징하면....
weak reference (약한 참조) 들만 남겨놓고 인스턴스들은 모두 deallocate 되었습니다!
우리가 weak 키워드에 대해 꼭 알아야할 중요한 것이 있습니다!
인스턴스를 deallocate 하고 나면, 해당 변수(variable)에 대한 응답은 nil이 될 것 입니다.
이것은 매우 좋은것입니다. 왜냐면 만일 우리가 아무 인스턴스도 남지 않은 곳을 가리키는 변수에 접근하려하면, runtile exception을 발생시키기 때문입니다.
옵셔널 변수만이 nil이 될 수 있기 때문에, 모든 weak이 붙은 변수들은 반드시 옵셔널이어야 합니다.
5. unowned
weak 뿐만 아니라, 변수에 적용할 수 있는 두번째 modifier가 존재합니다. 바로 unowned 입니다!
하나빼고 전부 weak과 같습니다.
unowned가 붙은 변수는 절대 nil이 될 수 없습니다. 따라서 unowned가 붙는 변수는 절대 옵셔널이여선 안됩니다.
저번 문단에서도 말했듯, App은 우리가 deallocate된 인스턴스에 접근하려하면 runtime에서 crash를 발생시킵니다.
이것은, 사용자는 unowned를 사용자가 인스턴스가 deallocate 된 후에 그곳에 접근하지 않는 변수라고 확신할 수 있는 변수에만 사용해야 한다는 것을 의미합니다.
일반적으로 항상 weak을 사용하는게 더 안전하다 말합니다.
하지만, 만일 사용자가 변수를 weak으로 만들고 싶지 않고 인스턴스가 deallocate 된 후에 접근하는 변수가 아님을 확신할 때는 unowned를 사용해도 됩니다.
이건 옵셔널 변수를 언래핑할 때 강제 해제 ! 를 사용하는 것과 비슷합니다. -> 근데 이건 별로 추천하는 방법이 아닙니다.
6. Common scenarios for retain cycles: delegates
그럼 retain cycle을 일으키는 주요 시나리오는 무엇일까요?
첫번째 매우 흔한 경우는 delegate의 사용입니다.
자식 VC를 가진 부모 VC를 상상해봅시다. 부모 VC는 자신을 자식 VC의 delegate로 설정했습니다.
class ParentViewController: UIViewController, ChildViewControllerProtocol {
let childViewController = ChildViewController()
func prepareChildViewController() {
childViewController.delegate = self
}
}
protocol ChildViewControllerProtocol: Class {
// important functions...
}
class ChildViewController: UIViewController {
var delegate: ChildViewControllerProtocol?
}
만일 이렇게 한다면, 부모VC를 popping 한다면 retain cycle(순환 참조)이 일어나기 때문에 memory leak이 발생합니다!
( 부모 VC가 nil이 된다면, delegate에 접근할 수 있는게 없어지니까요 ㅠㅠ)
하지만, 이 경우에 우리는 delegate 프로퍼티를 weak으로 선언할 수 있습니다!
weak var delegate: ChildViewControllerProtocol?
이러면 순환 참조가 풀리게되고, memory leak이 일어나지 않게되겠죠.
UITableView의 정의를 보면, delegate와 datasource 프로퍼티가 weak으로 선언되어있는것을 확인할 수 있습니다!
weak public var dataSource: UITableViewDataSource?
weak public var delegate: UITableViewDelegate?
기억하세요!
delegate를 선언하는 모든 경우에 순환 참조(retain cycle)을 방지하기 위해 weak 키워드를 써줘야 합니다!
7. Common scenarios for retain cycles: closures
클로저는 순환 참조가 매우 잘 발생하는 다른 시나리오 입니다!
class TestClass {
var aBlock: (() -> ())? = nil
let aConstant = 5
init() {
print("init")
aBlock = {
print(self.aConstant)
}
}
deinit {
print("deinit")
}
}
var testClass: TestClass? = TestClass()
testClass = nil
이 코드를 실행하면, 콘솔로그에 TestClass가 deallocate 되지 않는것을 확인할 수 있습니다.
문제의 원인은, TestClass가 클로저에 strong reference를 갖고 있고 클로저는 TestClass에 대해 strong reference를 갖고 있기 때문입니다.
사용자는 이 문제를 self 참조를 weak으로 capturing 함으로써 해결할 수 있습니다!
class TestClass {
var aBlock: (() -> ())? = nil
let aConstant = 5
init() {
print("init")
aBlock = { [weak self] in // self is captured as weak!!!!
print(self?.aConstant)
}
}
deinit {
print("deinit")
}
}
var testClass: TestClass? = TestClass()
testClass = nil
이제 순환 참조가 풀렸기 때문에 deallocate가 제대로 이루워져 deinit이 잘 찍히는걸 확인할 수 있습니다!
근데, 클로저를 사용할 때 항상 순환참조가 발생하는건 아닙니다!
예를들어, 만일 local 에서 클로저를 사용했다면, self를 굳이 weak 으로 캡쳐할 필요는 없습니다!
class TestClass {
let aConstant = 5
init() {
print("init")
// local using block
let aBlock = {
print("self.aConstant")
}
}
deinit {
print("deinit")
}
}
var testClass: TestClass? = TestClass()
testClass = nil
이유는, Block을 향한 strong reference가 없기 때문입니다. 그래서, 블락은 init 함수가 return 될 때 자동으로 deallocate 됩니다.
이것은 UIView.animationWithDuration에서도 똑같습니다.
class TestClass {
let aConstant = 5
init() {
print("init")
}
deinit {
print("deinit")
}
func doSomething() {
UIView.animate(withDuration: 5) {
let aConstnat = self.aConstant
// fancy animations....
}
}
}
var testClass: TestClass? = TestClass()
testClass?.doSomething()
testClass = nil
그래서 만약 블락에 대한 strong reference가 없다면, 사용자는 순환 참조에 대해 걱정할 필요가 없습니다!
사용자는 물론 weak 대신에 unowned를 사용할 수 있습니다.
그리고 더 안전하게 하기위해 위의 예제들에 weak, unowned 캡쳐를 해도 됩니다.
class TestClass {
var aBlock: (() -> ())? = nil
let aConstant = 5
init() {
print("init")
aBlock = { [unowned self] in
print(self.aConstant)
}
}
deinit {
print("deinit")
}
}
var testClass: TestClass? = TestClass()
testClass = nil
더 안전하게 할 수 있습니다. 왜냐면 TestClass가 deallocate되면 블락도 그럴테니까요.
블락이 deallocate되었을 때 TestClass에 접근할 일은 없습니다. 하지만, 필자의 의견으로는 모든 상황에서 weak을 쓰는 연습을 하는게 좋다고 생각합니다! weak을 쓰면 옵셔널 바인딩 때문에 귀찮긴 하지만, 항상 더 안전하니까요!