지난 글에서 메모리 영역(코드, 데이터, 힙, 스택)과 ARC에 대해서 공부했는데, 이번에는 ARC가 자동으로 메모리 관리를 해주긴 하지만, 순환 참조가 발생하는 경우에는 ARC가 이러지도 저러지도 못하고 메모리 누수가 발생한다!! 라는 것에 대해서 순환 참조가 발생하는 이유와 순환 참조를 예방하는 방법을 알아보도록 하겠습니다!
시작하기에 앞서 이전에 배웠던 내용을 빠르게 복습하는 시간을 가져보도록 하겠습니다.
class Animal {
var name: String
var age: Int
init(name: String, age: Int) {
self.name = name
self.age = age
}
}
let syonge = Animal(name: "숑이", age: 8)
let clone = syonge
Animal이라는 클래스는 name, age 프로퍼티를 가지고 있고, Animal 클래스 인스턴스를 생성해서 지역 변수에 할당해주고 있습니다. Animal 인스턴스는 참조 타입이기 때문에 자동으로 힙 영역에 할당되게 됩니다. 지역변수인 syonge와 clone에는 인스턴스의 주소가 저장될거구요!
이렇게요! :) 이 때, 힙 영역에 할당된 인스턴스는 RC(Reference Count)를 가지고 있습니다!
ARC는 RC값을 보고 메모리를 해제할지 안할지 결정한다고 했었죠?!
RC가 0이되면, ARC는 자동으로 해당 인스턴스를 메모리 해제시켜서 메모리를 관리해줍니다.
ARC를 잘 활용하면 메모리 낭비가 되는 일은 없겠죠?!!! 하지만, 현실은 ARC가 메모리 관리를 알아서 해주는데도 불구하고
순환 참조
가 발생하면 RC값이 0에 도달하지 못해 memory leak이 발생할 수 있습니다!
순환 참조를 알기 이전에 strong(강한 참조)에 대해서 알아보도록 하겠습니다!
사실 우리는 우리도 모르게 strong을 사용하고 있었습니다!
let syonge = Animal(name: "숑이", age: 8)
let clone = syonge
이렇게 Animal 인스턴스를 생성해서 syonge와 clone 이라는 지역변수는 인스턴스를 참조하고, Animal의 RC값은 1씩 증가해서 RC값은 총 2가 됐었죠?
이렇게 인스턴스의 주소값이 변수에 할당 될 때, RC가 증가하면 strong(강한 참조)인 것입니다!
strong으로 따로 선언해주지는 않았지만, default가 strong입니다.
"강한 참조를 하면 RC를 증가시킨다" 라는 특징 때문에 순환 참조가 발생할 수 있습니다.
순환 참조는 서로 다른 객체가 서로를 강하게 참조할 때(strong) 발생하고, memory leak을 유발한다.
순환 참조가 발생하는 예를 보겠습니다.
class Company {
var worker: Worker?
deinit {
print("Company 인스턴스 메모리 해제")
}
}
class Worker {
var company: Company?
deinit {
print("Worker 인스턴스 메모리 해제")
}
}
var company: Company? = Company()
var worker: Worker? = Worker()
company?.worker = worker
worker?.company = company
Company 클래스는 Worker 클래스 인스턴스 프로퍼티를 가지고,
Worker 클래스는 Company 클래스 인스턴스 프로퍼티를 가집니다.
서로 다른 객체가 서로를 강하게 참조(strong)하고 있습니다.
이런 경우에 순환 참조가 발생하고, RC가 0에 도달하지 못해 ARC는 메모리 해제를 하지 못합니다. 결국 memory leak 이 발생하게 됩니다.
정말로 순환 참조가 memory leak 을 유발하는지 확인해보겠습니다.
위 코드를 실행시켰을 때, 메모리에는 다음과 같이 할당됩니다.
company와 worker 지역변수가 각각 Company, Worker 인스턴스를 강하게 참조해서 RC값이 1씩 증가합니다.
또한, Company 인스턴스는 Worker를 강하게 참조해서 Worker의 RC값이 2가됩니다.
Company의 RC값도 마찬가지로 2가 되겠죠.
그런데 만약에 company와 worker가 메모리 해제되었다고 가정해봅시다.
worker = nil
company = nil
worker와 company에 각각 nil이 할당되면, 스택 영역에 있던 메모리는 해제가 됩니다.
그러면 RC값도 1만큼 감소하겠죠?!
근데 자기들 끼리 서로를 가리키고 있느라 RC가 여전히 1입니다.
이런 형태를 순환 참조(Retain Cycle)라고하고 순환 참조에 의해서 메모리 해제를 하지 못하니깐 memory leak이 발생하는 것입니다.
실제로 코드를 돌려보시면 아실겁니다! worker와 company에 nil을 할당했을 때 deinit이 호출되지 않거든요ㅎㅎ..
순환 참조가 발생하면, 앱이 종료될 때까지 메모리에서 해제되지 않습니다! 쓸데없는 데이터로 메모리를 낭비하는셈이니 개발자는 순환 참조가 발생하지 않도록 하는 것이 중요합니다.
순환 참조를 해결하는 방법은 간단합니다. strong(강한 참조)대신 weak(약한 참조)를 사용하는 것!
strong은 참조할 때, 인스턴스의 RC값을 증가시킨다고 했었죠?!
weak는 참조할 때 인스턴스의 RC값을 증가시키지 않습니다! 그리고, 참조하는 인스턴스가 메모리에서 해제되면, 자동으로 nil이 할당되어 메모리에서 해제됩니다!
위 내용이 weak의 핵심이고, nil이 할당될수도 있으니 당연히 optional 타입입니다.
그럼 이제 weak가 무엇인지 대충 알았으니 weak를 사용해서 순환 참조를 해결해보겠습니다.
순환 참조는 서로가 서로를 강하게 가리키는 것이 문제였으니 한놈을 weak(약한 참조)로 선언하면 해결 될 것 입니다.
class Company {
weak var worker: Worker?
deinit {
print("Company 인스턴스 메모리 해제")
}
}
class Worker {
var company: Company?
deinit {
print("Worker 인스턴스 메모리 해제")
}
}
Company가 가지고 있는 worker를 weak로 선언했습니다.
그러면, Company 인스턴스가 Worker 인스턴스를 참조 해도 약한 참조이기 때문에 Worker 인스턴스는 RC값이 증가하지 않겠죠?!
var company: Company? = Company()
var worker: Worker? = Worker()
company?.worker = worker
worker?.company = company
클래스 인스턴스를 생성해서 메모리에 할당해보면 다음과 같습니다.
지역변수 company와 worker는 각각 Company 인스턴스와 Worker 인스턴스를 강하게 참조합니다. 즉, RC 값을 증가시키는 것이죠.
Worker 인스턴스는 Company 인스턴스를 강하게 참조합니다.
반면에 Company 인스턴스는 Worker 인스턴스를 약하게 참조해서 RC값을 증가시키지 않습니다.
worker = nil
이 때 지역변수 worker에 nil을 할당해서 메모리 해제가 되면 어떻게 될까요?
Worker 인스턴스는 RC값이 1 감소해서 RC가 0이 됐습니다. ARC는 인스턴스의 RC가 0이 되면 메모리 해제를 한다고 했죠?
ARC에 의해서 Worker 인스턴스의 메모리가 해제되고, 이 인스턴스를 약하게 참조하고 있던 Company 인스턴스의 worker에는 nil이 자동으로 할당되게 됩니다.
그리고, Worker 인스턴스는 Company 인스턴스를 강하게 참조하고 있었는데 메모리 해제가 되면서 Company 인스턴스의 RC가 1 감소합니다.
company = nil
이제 company도 메모리에서 해제가 되면, RC값이 0이 되고 Company 인스턴스는 메모리에서 해제됩니다.
이렇게 weak를 선언해서 순환 참조를 해결할 수 있습니다!
실제로 코드를 실행해보면, 서로를 강하게 참조해서 순환 참조가 발생했을 때와는 다르게 deinit이 호출되는 것을 확인해볼 수 있습니다.
// 순환 참조가 발생하지 않아 ARC가 정상적으로 메모리 해제
Worker 인스턴스 메모리 해제
Company 인스턴스 메모리 해제
weak를 선언할 때 선언하는 기준은 다음과 같습니다.
weak는 수명이 더 짧은 인스턴스를 참조하는 프로퍼티에 약한 참조로 선언합니다.
위 예시에서도 worker가 company 보다 먼저 메모리에서 해제되는 것을 가정했습니다.
즉, worker의 수명이 더 짧기 때문에 Company 인스턴스에서 참조하는 worker를 weak로 선언했죠.
unowned는 깊게는 다뤄보지 않겠습니다. 글이 너무 길어지기도 했고, 사실 unowned를 굳이 사용할까...? 라는 생각때문에 개념만 알고 넘어가겠습니다.
unowned는 weak와 공통점을 가지고 있습니다.
차이점은
unowned는 인스턴스를 참조하는 도중에 해당 인스턴스가 메모리에서 사라질 일이 없다고 확신할 때 사용합니다.
따라서 참조하던 인스턴스가 만약 메모리에서 해제된 경우, nil을 할당받지 못하고 해제된 메모리 주소값을 계속 들고 있습니다...
매우 위험하겠죠? 인스턴스가 메모리에서 해제됐는데 접근하려는 경우 에러가 발생합니다.
정리하자면 강한 순환 참조를 해결할 수 있고, RC값을 증가시키지 않는다는 점에서 weak와 동일하지만, 참조하는 인스턴스가 메모리에서 해제되는 경우 nil을 할당하지 않는 것이 unowned입니다.
따라서 확실한 경우에는 unowned를 사용해도 되지만, 웬만하면 weak로 선언하는 것이 안전합니다.