안녕하세요. 개발하는 정주입니다.
오늘은 "ARC"에 대해 알아보겠습니다.
ARC의 개념은 공식 문서가 최고라고 생각하기 때문에 공식 문서 번역으로 진행했습니다.
이번 포스팅에서는 간단한 개념 정리 후 실습을 해보겠습니다.
틀린 내용이 있을 때 댓글로 알려주시면 정말 감사하겠습니다.
ARC 공식 문서 번역 보러 가기
ARC란?
ARC란 Automatic Reference Counting의 줄임말로 앱의 메모리 사용량을 추적하고 관리합니다.
ARC는 자동으로 클래스 인스턴스를 추적하여 더 이상 필요하지 않을 경우 deallocated 시키는데요.
자동으로 관리를 해주는데 왜 ARC에 대해 공부해야 할까요?
바로 순환 참조가 생기면 메모리 해제가 안 되어 memory leak이 발생하기 때문입니다.
이 순환 참조를 만들지 않기 위해 적절한 참조 코드를 작성해야 하고
그러기 위해서는 ARC에 대한 이해가 필요합니다.
참고로 Reference Counting을 확인할 수 있는 메서드가 있습니다.
CFGetRetainCount( )로 인스턴스의 Reference Count를 출력해줍니다.
하지만 이 결과의 신뢰도가 100%가 아니고 특히 playground에서는 정확도가 더 떨어지기 때문에
코드에 대한 이해가 필수적입니다.
위에서 말했듯이 이번 포스팅에서는 간단히만 정리하고 자세한 내용은 공식 문서를 확인해 보세요.
정말 자세히 가이드되어 있답니다.
순환 참조란?
최소한 순환 참조가 무엇인지는 알아야겠죠?
위 공식 문서에서는 "Strong Reference Cycles Between Class Instances"을 참고하면 됩니다.
ARC는 클래스 인스턴스에 대한 참조 수를 추적하고 더 이상 필요하지 않을 때 인스턴스를 해제시킬 수 있습니다.
그러나 클래스 인스턴스가 강한 참조가 0인 지점에 도달하지 못하는 코드가 있습니다.
두 클래스 인스턴스가 서로 강한 참조를 유지하여 각 인스턴스가 다른 인스턴스를 active 상태로 유지하는 경우에 발생합니다.
이럴 경우 인스턴스에 nil을 할당하더라도 강한 순환 참조에 의해 완벽하게 메모리 해제가 되지 않는 것입니다.
이제 이 순환 참조를 방지하는 방법도 간단히 알아볼까요?
weak와 unowned
강한 순환 참조를 방지하기 위해서는 weak와 unowned 참조를 사용해야 합니다.
한국어로 번역하면 약한 참조와 미소유 참조로 딱 봐도 "강한"과는 반대되는 개념 같죠?
둘의 공통점은 Reference Counting이 증가하지 않는다는 것입니다.
강한 참조는 Reference Counting가 증가하여 ARC에 의해 메모리 해제되지 않습니다.
하지만 Reference Counting가 증가하지 않는다면 ARC에 의해 해제될 수 있습니다.
weak를 사용하는 변수는 변수를 정의한 클래스보다 수명이 짧을 때 사용하기 적절합니다. (아래 예시에서 이해 가능)
또한 nil이 들어가서 메모리 해제되기 때문에 옵셔널 변수여야 합니다.
unowned를 사용하는 변수는 변수를 정의한 클래스보다 수명이 같거나 길 때 사용하기 적절합니다.
또한 값이 있다는 것이 확실할 때 사용하는 것이 좋습니다.
직접 해보자 - 준비
class Student {
let name: String
var school: School?
init(name: String) {
print("Student(\(name)) init")
self.name = name
}
deinit {
print("Student(\(name)) deinit")
}
}
class School {
let name: String
var topStudent: Student?
init(name: String) {
print("School(\(name)) init")
self.name = name
}
deinit {
print("School(\(name)) deinit")
}
}
사용할 클래스들입니다.
Student와 School 클래스를 정의해주었고 각각 서로를 옵셔널 변수로 선언되어 있습니다.
init와 deinit에는 print를 찍어 메모리 할당과 해제를 알 수 있도록 했어요.
직접 해보자 - 강한 참조
가장 기본이 되는 강한 참조부터 살펴봅시다.
var student1: Student? = Student(name: "팀쿡") //Student(팀쿡) init
var student2: Student? = student1
var school: School? = School(name: "애플") //School(애플) init
student1 = nil //student2에 의해 강한 참조가 남아있기 때문에 deinit 호출 안 됨
school = nil //School(애플) deinit
student2 = nil //Student(팀쿡) deinit
student1을 생성하고 student2에 student1을 대입했습니다.
school 인스턴스도 하나 생성했습니다.
인스턴스를 생성할 때 init이 호출되는 것을 볼 수 있습니다.
student2에 student1을 대입할 때는 인스턴스를 새로 생성하는 것은 아니기 때문에 init이 호출되지 않습니다.
대신 student1의 rc가 2로 증가합니다.
student1에 nil을 할당하면 rc가 1로 감소합니다.
아직 0이 아니기 때문에 deinit이 호출되지 않습니다.
school에 nil을 할당하면 rc가 0이 되기 때문에 deinit이 호출됩니다.
student2도 nil로 할당했을 때 rc가 0이 되면서 Student(팀쿡)의 deinit이 호출됩니다.
직접 해보자 - 강한 순환 참조
강한 순환 참조가 생기는 상황을 만들어 봅시다.
var student1: Student? = Student(name: "팀쿡") //Student(팀쿡) init
var school: School? = School(name: "애플") //School(애플) init
student1?.school = school
school?.topStudent = student1
student1 = nil
school = nil
student1과 school 생성은 똑같습니다.
student1의 school에 school을 할당하고 school의 topStudent에 student1을 할당합니다.
이러면 서로 강한 참조를 하게 되죠.
그림으로 보면 이런 모양입니다.
서로를 참조하고 있기 때문에 rc가 1씩 증가하여 2가 됩니다.
student1과 school에 nil을 하면 두 개 모두 rc가 1이기 때문에 deinit이 호출되지 않습니다.
직접 해보자 - 약한 참조
weak를 사용해서 문제를 해결해볼까요?
weak는 클래스의 수명이 weak 변수보다 길 때 사용하면 좋습니다.
여기에서는 School 클래스에 topStudent를 weak로 하면 좋겠네요. (학교가 학생보다 수명이 더 기니까요.)
사실 이 예제처럼 수명에 대해 생각하기도 민망한 코드에서는 아무 곳에나 weak를 적어도 되긴 합니다.
하지만 weak를 어디에 써야 할지는 알고 있어야 실전에서 적절히 응용할 수 있겠죠?
class School {
let name: String
weak var topStudent: Student?
...
}
위처럼 topStudent만 weak로 선언해주고 똑같은 코드를 실행해봅시다.
var student1: Student? = Student(name: "팀쿡") //Student(팀쿡) init
var school: School? = School(name: "애플") //School(애플) init
student1?.school = school
school?.topStudent = student1
student1 = nil //Student(팀쿡) deinit
school = nil //School(애플) deinit
nil을 할당할 때 정상적으로 deinit이 호출되는 모습을 볼 수 있습니다.
weak에 의해 rc가 증가하지 않아 ARC에 의해 정상적으로 메모리 해제될 수 있었습니다.
직접 해보자 - 미소유 참조
unowned 이용해 강한 순환 참조를 끊어봅시다.
unowned는 클래스의 수명이 변수와 같거나 짧을 때 사용하기 좋습니다.
여기에서는 Student의 school을 unowned로 하기 적절하겠네요.
class Student {
let name: String
unowned var school: School?
...
}
school에 unowned 키워드를 붙여 미소유 참조를 나타내었습니다.
결과가 어떤지 볼까요?
var student1: Student? = Student(name: "팀쿡") //Student(팀쿡) init
var school: School? = School(name: "애플") //School(애플) init
student1?.school = school
school?.topStudent = student1
student1 = nil
school = nil //School(애플) deinit
//Student(팀쿡) deinit
둘 다 deinit이 정상적으로 호출되었습니다.
하지만 주석의 순서가 달라졌습니다.
Reference Count를 잘 살펴봅시다.
//student1 rc : 2
student1 = nil //student1 rc : 1
school = nil //School(애플) deinit -> student1 rc : 0
//Student(팀쿡) deinit
student1에 nil을 할당하기 전 student의 rc는 2입니다.
student1에 nil을 할당하면서 1 감소하여 student의 rc가 1이 되었고 school의 rc도 1이 되었습니다.
school = nil을 하면서 school의 rc가 1 감소하여 0이 되면서 school의 deinit이 호출됩니다.
동시에 student의 rc도 1 감소되면서 Student의 deinit이 호출됩니다.
흐름이 이해가 가시나요?
혹시 어려우시다면 이해가 안 되는 부분을 댓글로 남겨주세요.
직접 해보자 - 클로저에 의한 강한 순환 참조
클로저로 인해 강한 순환 참조가 발생할 수 있습니다.
공식 문서의 "Strong Reference Cycles for Closures" 문단 내용입니다.
예시를 봅시다.
class Student {
let name: String
lazy var greeting: () -> String = {
return "Hi, [\(self.name)]"
}
init(name: String) {
print("Student(\(name)) init")
self.name = name
}
deinit {
print("Student(\(name)) deinit")
}
}
greeting이라는 클로저는 self.name을 참조합니다.
var student1: Student? = Student(name: "팀쿡") //Student(팀쿡) init
print(student1!.greeting())
student1 = nil
즉 student1과 () -> String 사이에 강한 순환 참조가 발생하게 됩니다.
위 예시에서도 nil을 할당해도 deinit이 호출되지 않습니다.
위 흐름은 이런 그림으로 표현할 수 있습니다. (똥 손 그림 죄송 ㅎㅎ;;)
이렇게 클로저에 의한 강한 순환 참조는 어떻게 해결할 수 있을까요?
직접 해보기 - unowned References
weak 또는 unowned References를 이용해 클로저에 의한 강한 순환 참조를 끊을 수 있습니다.
weak로 캡처를 정의하는 것은 캡처된 참조가 어느 시점에 nil이 될 가능성이 있을 때 합니다.
unowned로 캡처를 정의하는 것은 항상 서로를 참조하고 동시에 해제될 때 사용합니다.
지금은 항상 서로를 참조하고 동시에 해제되는 상황이므로 unowned가 적절합니다.
class Student {
let name: String
lazy var greeting: () -> String = { [unowned self] in
return "Hi, [\(self.name)]"
}
...
}
unowned를 이용해 self를 미소유 참조로 캡처하였습니다.
위 그림처럼 양방향으로 Strong이 아니라 한쪽이 unowned로 바뀌었기 때문에
강한 순환 참조가 깨졌습니다.
var student1: Student? = Student(name: "팀쿡") //Student(팀쿡) init
print(student1!.greeting())
student1 = nil //Student(팀쿡) deinit
따라서 deinit도 정상적으로 호출됩니다.
마무리
오늘은 ARC의 간단한 개념과 실습을 진행해보았습니다.
생각 없이 쓰던 참조에 대한 내용, weak self에 대해 조금 더 알게 된 느낌입니다.
앱의 성능을 위해서는 메모리 최적화는 필수입니다.
이 말은 ARC를 제대로 이해하고 메모리 할당과 해제의 흐름을 제대로 파악해야 한다는 의미입니다.
어렵겠지만 열심히 해야겠죠?
잘못된 점이 있다면 댓글로 알려주시면 감사하겠습니다.
감사합니다!
아직은 초보 개발자입니다.
더 효율적인 코드 훈수 환영합니다!
공감과 댓글 부탁드립니다.