ARC(Automatic Reference Count)
애플 공식문서를 참고해서 공부한 내용을 작성해보았습니다.
https://bbiguduk.gitbook.io/swift/language-guide-1/automatic-reference-counting
자동 참조 카운팅 (Automatic Reference Counting) - Swift
이것을 가능하게 하려면 프로퍼티, 상수, 또는 변수에 클래스 인스턴스를 할당할 때마다 해당 프로퍼티, 상수, 또는 변수는 인스턴스에 강한 참조 (strong reference) 를 만듭니다. 참조는 해당 인스
bbiguduk.gitbook.io
ARC(Auto Reference counting)는 단어 그대로 '자동으로 참조 개수'를 추적하고 관리하여 메모리 사용량을 관리하는 개념입니다. 메로리 관리 모델 개념은 프로그래밍 언어마다 있지만 조금씩 차이가 있습니다.
- C: 직접 관리(malloc, free)
- Java: Garbage Collector(가비지 컬렉터)
- Objective-C: MRC(Manual RC): 수동, ARC(Automatic RC): 자동
- Swift: ARC(Automatic RC): 자동
직접 메모리 참조를 관리한다면 MRC(Manual Reference Counting)방식이다. MRC방식은 직접 메모리 관리를 하기 때문에 실수할 가능성이 있고, 관리가 꼬일 가능성이 높아진다. 그렇게 되면 메모리 누수(Memory Leak)현상이 발생하게 된다.
그럼 오늘의 주제인 Swift에서는 ARC가 어떻게 작동하고 무엇을 중요하게 살펴봐야하는지 한번 보겠습니다.
ARC를 사용하고 있는 Swift에서는 대부분의 경우 메모리 관리에 대해 생각할 필요가 없습니다.
ARC는 인스턴스가 더이상 필요하지 않을 때 자동으로 클래스 인스턴스에 의해 사용된 메모리를 할당 해제하게 됩니다.
그러나, 몇몇의 경우에 ARC는 메모리를 관리하기 위해 코드 상에서 추가 구현을 요구하기도 합니다.
ARC의 작동원리
클래스의 새로운 인스턴스가 생성될때마다 ARC는 인스턴스에 대한 정보를 저장하기 위해 메모리의 청크(Chunk)에 할당합니다. 여기서 '청크'는 동적으로 메모리를 할당할 때 사용되는 일정한 크기의 메모리 블록을 의미합니다. 이 메모리는 해당 인스턴스와 관련해 저장된 프로퍼티의 값과 인스턴스의 타입에 대한 정보를 가지고 있습니다. (Heap영역)
또한 인스턴스가 더 이상 필요해지지 않을 때(RC의 개수가 0일 때) 해당 메모리가 다른 곳에서 사용될 수 있도록 메모리에서 할당 해제를 하게됩니다.
만약 RC이 1 이상인 인스턴스의 할당을 해제(nil 할당)하게 되면 인스턴스의 프로퍼티 접근하거나 메서드를 호출할 수 없게 됩니다. 이 때 인스턴스에 접근하려고 하면 앱은 크래시가 발생하게됩니다.
인스턴스가 아직 필요한지 확인하고 사라지지 않도록 ARC는 각 프로퍼티, 상수, 변수각 해당 인스턴스를 참조하고있는지 파악하고 추적합니다. 파악한 결과 인스턴스의 참조 개수가 하나라도 존재한다면 해당 인스턴스를 할당 해제하지 않습니다.
강한 참조(Strong Reference)
- 상수, 변수에 인스턴스를 할당하게 되면 Heap영역에 존재하는 클래스 인스턴스의 참조 개수가 +1이 되는데 이를 '강한 참조'라고 합니다.
- 참조는 해당 인스턴스를 유지하고 강한 참조가 남아있는 한 할당 해제를 하지않기 때문에 '강한 참조'라고 합니다.
ARC 작동
Swift 공식문서에 나와있는 간단한 예제로 ARC가 어떻게 작동하는지 한번 알아보겠습니다.
class Person {
let name: String
init(name: String) {
self.name = name
print("\(name) is being initialized")
}
deinit {
print("\(name) is being deinitialized")
}
}
Person이라는 간단한 클래스를 만들었습니다. 소멸자(Deinitializer)를 구현해 메모리 할당과 해제가 어떤 상황에서 일어나는지 살펴보죠.
var reference1: Person?
var reference2: Person?
var reference3: Person?
Person 클래스를 옵셔널로 설정한 후 nil 값으로 자동 초기화되도록하였습니다. 아직 Person 클래스 인스턴스가 reference변수에 할당되지 않았기 때문에 아무일도 일어나지 않습니다.
reference1 = Person(name: "John Appleseed")
// Prints "John Appleseed is being initialized"
Person 클래스의 인스턴스를 생성후 변수에 할당하게 되었습니다. 생성자가 실행되었고 Person 클래스의 인스턴스는 현재 Heap영역에 저장되었고 reference 변수에는 참조 타입인 Person을 할당하였으므로 변수에는 해당 인스턴스의 메모리 주소가 저장되어 있습니다. 또한 Person 인스턴스를 강하게 참조하여 현재 Person 인스턴스의 RC는 +1 인 상태입니다.
reference1 = '0x123123' // Person 인스턴스 메모리 주소 RC +1
이어서 나머지 reference2, reference3에 person 인스턴스가 저장된 reference1을 할당해보겠습니다.
reference2 = reference1
reference3 = reference1
그럼 위에서 말한대로 변수에 인스턴스를 할당했으니 RC는 1씩 증가합니다. 그래서 현재 Person 인스턴스의 총 RC는 '+3'인 상태입니다.
그럼 이제 메모리에서 할당 해제 실험을 위해 차례대로 변수에 nil을 할당해보겠습니다.
reference1 = nil
reference2 = nil
역시 예상대로 소멸자는 실행되지 않았습니다. RC가 총 '+3'인 상태인데 변수 2개만 nil을 할당하였으니 아직 RC는 '+1'인 상태입니다. 그럼 진짜 ARC가 제대로 동작하는지 남아있는 마지막 변수까지 nil을 할당해보겠습니다.
reference3 = nil
// Prints "John Appleseed is being deinitialized"
네 소멸자가 제대로 실행된 것을 보니 현재 Person 인스턴스의 RC는 '+0'인 것을 알 수 있었습니다.
코드상 눈에는 안보이지만 컴파일 시점에 참조관계가 정해지면 컴파일코드는 다음 이미지를 보면 이해가 더 잘 될 것 같습니다.
클래스 인스턴스 간의 강한 참조 사이클
사실 위의 사례에서는 참조가 어떻게 일어나고 어떤 상황에서 메모리에서 해제가 되는지 보여주는 가장 간단한 예제입니다.
그래서 이번에는 인스턴스간에 참조를 유발해서 '강한참조 사이클(Strong Reference Cycle)'을 유발하여 어떤 상황에서 메모리 누수가 발생하고 해결할 수 있는 방법은 어떤 것이 있는지 알아보겠습니다.
class Person {
let name: String
init(name: String) { self.name = name }
var apartment: Apartment?
deinit { print("\(name) is being deinitialized") }
}
class Apartment {
let unit: String
init(unit: String) { self.unit = unit }
var tenant: Person?
deinit { print("Apartment \(unit) is being deinitialized") }
}
코드의 사례는 '아파트'와 '사람'간의 관계를 가지고 있고 '사람'은 '아파트'를 가질 수도 있고 '아파트' 또한 '사람(세입자)'를 가질 수도 있고 아닐 수도 있어 서로를 인스턴스로 가지는 프로퍼티는 옵셔널로 정의했습니다.
var john: Person? // 초기값 nil
var unit4A: Apartment? // 초기값 nil
이후 각 변수에 인스턴스를 생성해서 할당해보겠습니다.
john = Person(name: "John Appleseed")
unit4A = Apartment(unit: "4A")
현재 각 변수에 인스턴스를 할당했기 때문에 각 인스턴스의 RC를 '+1'을 증가시킵니다.
그럼 이제 각 인스턴스 프로퍼티에 서로의 인스턴스를 할당해보겠습니다.
john!.apartment = unit4A
unit4A!.tenant = john
인제 인스턴스 서로를 각각 가르키게 되어 RC를 '+1'씩 해주게 되었습니다. 그럼 현재 각 인스턴스의 RC 개수를 한번 살펴보겠습니다.
- Person 인스턴스의 총 RC = '+2'
- Apartment 인스턴스의 총 RC = '+2'
해당 인스턴스를 생성하면서 RC +1, 해당 인스턴스의 멤버변수에 서로의 인스턴스를 참조하며 RC +1 이되면 각 인스턴스의 현재 RC는 +2 씩인 것이다.
이렇게 강한 참조가 일어나게 되고 해당 인스턴스를 할당했던 변수에 nil을 할당해도 RC는 0이 되지 않기 때문에 각 인스턴스는 할당 해제되지 않습니다.
john = nil
unit4A = nil
이렇듯 강한 참조 사이클은 Person과 Apartment 인스턴스가 할당 해제되는 것을 방지하여 앱에서 메모르 누수를 유발하게 됩니다.
클래스 인스턴스 간의 강한 참조 사이클 해결(Resolving Strong Reference Cycles Between Class Instance)
swift에서는 강한 참조 사이클을 해결하기 위해 2가지 방법을 제공합니다.
- 약한 참조(Weak References)
- 미소유 참조(unowned references)
이 방법들의 특성을 살펴보겠습니다.
- 공통점
- 이 2가지 방법은 모두 가르키는 인스턴스의 RC를 올리가지 않게 합니다.(인스턴스 사이의 강함 참조를 제거)
- weak, unowned로 선언한 변수를 통해 인스턴스에 접근은 가능하지만, 인스턴스를 유지시키는 것은 불가능하게됩니다.
- 차이점
- Weak: 소유자에 비해, 보다 짧은 생명주기를 가진 인스턴스를 참조할 때 주로 사용
- Unowned: 소유자보다 인스턴스의 생명주기가 더 길거나, 같은 경우에 사용
💡 unowned 사용시 한번 더 고려해야할 것이 있기 때문에, 실제로는 weak 키워드를 사용하는 약한 참조를 셀제 프로젝트를 많이 사용함
약한 참조(Weak References)
Weak을 사용할 때 한가지 주의사항이 있는데 Weak은 소유자보다 짧은 생명주기를 가지므로 nil로 할당될 가능성이 존재하게 됩니다.
그렇기 때문에 Weak은 항상 var(변수)로 선언되어야 한다는 점입니다.
약한 참조는 참조하는 인스턴스를 강하게 유지하지 않기 때문에 약한 참조가 참조하는 동안 해당 인스턴스가 할당 해제될 수 있습니다. 따라서 ARC는 참조하는 인스턴스가 할당 해제되면 nil로 약한 참조를 자동으로 설정합니다. 그리고 약한 참조는 런타임임 값을 nil로 변경하는 것을 허락해야하므로 항상 옵셔널 타입의 상수가 아닌 변수로 선언해야합니다.
약한 참조(weak references)를 어떻게 활용할 수 있는지 한번 알아보겠습니다.
class Person {
let name: String
init(name: String) { self.name = name }
var apartment: Apartment?
deinit { print("\(name) is being deinitialized") }
}
class Apartment {
let unit: String
init(unit: String) { self.unit = unit }
weak var tenant: Person? // 약한 참조
deinit { print("Apartment \(unit) is being deinitialized") }
}
var john: Person?
var unit4A: Apartment?
john = Person(name: "John Appleseed")
unit4A = Apartment(unit: "4A")
john!.apartment = unit4A
unit4A!.tenant = john
이전 예제와 같지만 tenant 변수에 weak 키워드를 사용하여 약한 참조를 하도록 하였습니다.
그러면 인제 그림과 같이 tenant가 Person 인스턴스를 약하게 참조하고 있으므로 RC를 증가 시키지 않게됩니다.
그러면 현재 Person 인스턴스의 RC는 +1 상태일 것입니다. 이 상태에서 Person인스턴스를 가지고 있는 john 변수에 nil 할당하여 RC를 -1 해주도록 해보겠습니다.
john = nil
// Prints "John Appleseed is being deinitialized"
그러면 이번엔 deinit 구문이 실행되면 Person 클래스가 할당 해제되어 메모리에서 내려간 것을 알 수 있습니다.
이렇게 강한 참조로 인한 메모리 누수문제를 해결해 볼 수 있을 것 같습니다.
색깔이 연하게 나와있는데 할당 해제되어 있다는 뜻입니다.
그럼 현재 남아있는 Apartment 인스턴스 또한 nil을 할당해서 메모리가 해제가 되는지 살펴보겠습니다. 여기서 헷갈리지 말아야할 부분은 현재 Person클래스가 nil로 할당해제되어 있으므로 Apartment를 참조하는 있는 개수 1이 내려가게되어서 최종적으로 RC가 1이 되며, Apartment 인스턴스에 nil할당하게되면 RC가 0이되면서 메모리 해제되는 것입니다.
unit4A = nil
// Prints "Apartment 4A is being deinitialized"
미소유 참조(Unowned References)
약한 참조와 마찬가지로 미소유 참조 또한 참조하는 인스턴스를 강하게 유지하지 않습니다. 그러나 약한 참조와 다르게 미소유 참조는 다른 인스턴스의 수명이 같거나 더 긴 것이 보장되는 경우에 사용됩니다.
약한 참조와 다르게 미소유 참조는 항상 값을 갖는 것을 예상할 수 있습니다. 그렇기 때문에 var, let 둘 다 선언이 가능이 점이 차이점입니다. 결과적으로 미소유로 만들어진 값은 옵셔널로 만들어 지지 않고 ARC는 미소유 참조의 값을 nil로 설정하지 않습니다.
위에 설명은 했지만 사실 프로젝트를 진행하면서 인스턴스의 수명이 길거나 같은 것을 판단하는 일은 쉽지 않습니다. 처음 모델을 그렇게 설계하였다 하더라도 프로젝트가 커질 경우 변경될 여지 또한 존재하므로 왠만한 경우엔 weak으로 선언하지 않을까 생각됩니다.
- 참조가 항상 할당 해제되지 않은 인스턴스를 참조한다고 확신하는 경우에만 미소유 참조를 사용합니다.
- 인스턴스가 할당 해제된 후에 미소유 참조의 값에 접근하려고 하면 런타임 에러가 발생합니다.
이번에도 애플 예제를 통해 한번 살펴보겠습니다.
class Customer {
let name: String
var card: CreditCard?
init(name: String) {
self.name = name
}
deinit { print("\(name) is being deinitialized") }
}
class CreditCard {
let number: UInt64
unowned let customer: Customer
init(number: UInt64, customer: Customer) {
self.number = number
self.customer = customer
}
deinit { print("Card #\(number) is being deinitialized") }
}
간단하게 설명하면 고객(Customer)가 있고 고객이 가지고 있는 카드(CreditCard)를 등록하는 모델 설계입니다. 여기서 고객은 카드를 가지고 있을 수도 있고 아닐 수도 있지만 신용카드는 항상 고객을 가지고 있습니다. 이럴 경우 미소유 참조가 가능해지는 것입니다. 고객 클래스의 생명주기가 같거나 더 길다는 예시에 부합하기 떄문입니다.
var john = Customer(name: "John Appleseed")
john!.card = CreditCard(number: 1234_5678_9012_3456, customer: john!)
이번에는 john에 nil을 할당해보겠습니다.
john = nil
그렇다면 Customer 인스턴스를 가르키던 john의 RC가 1 차감되고 먼저 메모리에서 내려가게됩니다. 메모리에서 내려가게되면 CreditCard를 강하게 참조하던 RC또한 내려가면서 CreaditCart또한 메모리에서 해제되게 되면서 다음과 같이 출력되게 됩니다.
// Prints "John Appleseed is being deinitialized"
// Prints "Card #1234567890123456 is being deinitialized"
미소유 옵셔널 참조(Unowned Optional References)
두 인스턴스 상호간에 미소유 참조를 옵셔널 하는 경우 다른 한쪽의 인스턴스가 할당 해제된다면 미소유 참조는 nil로 할당되지 않는다고 했습니다. 그래서 만약 할당 해제된 인스턴스를 참조하게 되면 nil이 출력되는 것이 아니라 런타임에러가 발생하게 됩니다.
이번에는 애플 예제말고 조금 더 간단한 예제로 바꿔서 학습해보겠습니다.
class Dog {
var name: String
unowned var owner: Person? // unowned 키워드(미소유 참조)
init(name: String) {
self.name = name
}
deinit {
print("\(name) instance 메모리 해제")
}
}
class Person {
var name: String
unowned var pet: Dog? // unowned 키워드(미소유 참조)
init(name: String) {
self.name = name
}
deinit {
print("\(name) instance 메모리 해제")
}
}
var dog: Dog? = Dog(name: "만두")
var person: Person? = Person(name: "철수")
// 여기서 unowned 키워드를 사용하였기 때문에 객체 상호 간 강한 참조가 일어나지 않고 RC가 증가하지 않음
dog?.owner = person
person?.pet = dog
여기서 해당 인스턴스 nil할당하게 되면 당연히 상호간 미소유 참조로 인해 RC가 올라가지 않으므로 바로 메모리에서 해제되는 것을 볼 수 있습니다.
dog = nil
person = nil
//만두 instance 메모리 해제
//철수 instance 메모리 해제
그렇다면 person의 인스턴스에 nil을 할당하고 미소유 참조의 경우, 참조하고 있던 인스턴스가 사라지면 nil로 할당되지 않는다던 원칙이 맞는지 확인해보겠습니다.
person = nil
dog?.owner
//>>> error: Execution was interrupted, reason: signal SIGABRT.
weak와 다르게 자동으로 `nil`이 할당되지 않고 에러가 발생하고 app이 종료가 되는 상황이 발생합니다.
💡 IMPORTANT
- 참조가 항상 할당 해제되지 않은 인스턴스를 참조한다고 확신하는 경우에만 미소유 참조를 사용합니다.
- 인스턴스가 할당 해제된 후에 미소유 참조의 값에 접근하려고 하면 런타임 에러가 발생합니다.
여기까지 ARC와 강한 참조 사이클로 인한 메모리 누수를 어떻게 해결할 수 있는지에 대해 알아봤습니다. 애플 예제에서는 미소유에 대한 챕터가 한 개 더 있긴한데 중요한 뼈대를 이해하는 것이 중요한 것 같다고 생각해 중요한 부분만 다뤄봤습니다.
이후 인스턴스 서로에 대한 강한 참조 사이클도 있지만 인스턴스내에서 클로저를 활용해서 클로저 또한 RC를 올리는 특성이 있습니다. 이 주제는 다른 글에서 자세히 다뤄보도록 하겠습니다.
'iOS > Swift' 카테고리의 다른 글
Swift - Swift에서는 왜 문자열을 Subscript로 접근할 수 없을까? (1) | 2023.12.12 |
---|---|
Swift - Method Swizzling (1) | 2023.12.01 |
Swift - Protocol (0) | 2023.08.07 |
Swift - Type Casting (0) | 2023.07.29 |
Swift - @Property Wrapper (1) | 2023.07.27 |