Swift Programming Language 공식 문서를 통해 공부한 내용을 정리하는 글입니다!
1. ARC란?
Automatic Reference Counting의 약자로, Swift에서 앱의 메모리 사용량을 추적하고 관리하기 위한 기법입니다.
클래스의 인스턴스가 생성될 때마다 ARC는 인스턴스에 대한 정보(인스턴스 타입에 대한 정보, 인스턴스에 저장된 프로퍼티 값 등)를 저장하기 위해 메모리에 할당하게 됩니다.
그리고 ARC는 인스턴스가 필요로 하는 동안 메모리에서 사라지지 않도록 얼마나 많은 프로퍼티, 상수 또는 변수가 각 인스턴스에 참조하고 있는지를 추적합니다.
만약 인스턴스를 참조하고 있는 요소가 하나라도 존재하지 않는다면 ARC는 해당 인스턴스를 메모리에서 해제시키게 됩니다.
예제 코드로 간단히 살펴보겠습니다.
class Person {
let name: String
init(name: String) {
self.name = name
}
deinit {
print("\(name) is being deinitialized")
}
}
var person1: Person?
var person2: Person?
// Person 인스턴스 생성
// (정확히는 name = Swift인 Person 클래스 인스턴스의 참조 카운트가 1 증가한 것이지만,
// 간단한 표기를 위해 Person RC: 1로 표기하겠습니다.)
person1 = Person(name: "Swift") // (Person RC: 1)
// person2 변수도 Person 인스턴스를 참조하게 되면서 참조 카운트 증가
person2 = person1 // (Person RC: 2)
// Person 인스턴스를 참조하고 있던 person1 변수에 nil을 할당하게 되면서
// 참조 카운트를 감소시킴
person1 = nil // (Person RC: 1)
// Person의 참조 카운트가 0이 되면서 ARC는 해당 인스턴스를 메모리에서 해제시킴
person2 = nil // (Person RC: 0) -> "John Appleseed is being deinitialized"
위 예제에서는 변수가 인스턴스를 참조하는 상황이고 이를 강하게 참조하고 있다고 불립니다.
ARC는 인스턴스를 참조하는 시점에 참조 카운트를 증가시키고 참조를 해제하는 시점에 참조 카운트를 감소시키는 과정을 통해 메모리를 관리하고 있습니다.
위 예제 코드를 보았듯이 ARC 덕분에 개발자는 직접적으로 메모리를 신경 쓰지 않아도 되게 됩니다.
2. 강한 참조 사이클
하지만, 모든 메모리 관리를 해줄 것만 같던 ARC에서도 인스턴스가 메모리에서 영원히 해제되지 않는 문제 또한 발생할 수 있습니다.
이는 두 클래스 인스턴스가 서로를 강하게 참조하는 경우에 발생할 수 있는데 이를 강한 참조 사이클이라고 합니다.
아래 예제 코드를 살펴보겠습니다.
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 person: Person?
var apartment: Apartment?
person = Person(name: "Swift") // (Person RC: 1, Apartment RC: 0)
apartment = Apartment(unit: "APT") // (Person RC: 1, Apartment RC: 1)
person?.apartment = apartment // (Person RC: 1, Apartment RC: 2)
apartment?.tenant = person // (Person RC: 2, Apartment RC: 2)
person = nil // (Person RC: 1, Apartment RC: 2) (apartment가 현재 person을 강하게 참조하고 있음)
apartment = nil // (Person RC: 1, Apartment RC: 1) (person이 현재 apartment를 강하게 참조하고 있음)
위 예제 코드는 각 인스턴스의 프로퍼티를 통해 서로의 인스턴스를 참조하고 있는 상황입니다.
마지막 부분에서 person, apartment 변수에 nil을 할당해 주면서 참조 카운트를 1씩 낮추게 되더라도 인스턴스의 프로퍼티가 서로의 인스턴스를 참조하고 있었기 때문에 두 인스턴스의 참조 카운트는 1을 유지할 수 있게 되었습니다.
결과적으로 해당 프로퍼티에 접근하여 nil을 할당할 수 있는 방법이 존재하지 않게 되며 앱이 종료되기 전까지 영원히 메모리에서 해제되지 않는 메모리 누수 문제가 발생하게 됩니다.
3. 강한 참조 사이클 해결
그래서 강한 참조 사이클 문제의 경우는 개발자가 신경을 써줘야 하는 부분이며 아래 3가지 방법으로 이를 해결할 수 있습니다.
인스턴스를 참조하고 있는 프로퍼티를 먼저 해제
아까 상황의 경우 인스턴스를 참조하고 있는 변수 먼저 참조를 해제하게 되면서 프로퍼티의 인스턴스 참조를 해제하지 못하는 문제였는데 이 순서를 반대로 하는 것입니다.
var person: Person?
var apartment: Apartment?
person = Person(name: "Swift") // (Person RC: 1, Apartment RC: 0)
apartment = Apartment(unit: "APT") // (Person RC: 1, Apartment RC: 1)
person?.apartment = apartment // (Person RC: 1, Apartment RC: 2)
apartment?.tenant = person // (Person RC: 2, Apartment RC: 2)
// 프로퍼티의 인스턴스 참조를 먼저 해제
person?.apartment = nil // (Person RC: 2, Apartment RC: 1)
apartment?.tenant = nil // (Person RC: 1, Apartment RC: 1)
person = nil // (Person RC: 0, Apartment RC: 1) (Person deinit)
apartment = nil // (Person RC: 0, Apartment RC: 0) (Apartment deinit)
하지만, 인스턴스를 메모리에서 해제시키기 위해 프로퍼티의 인스턴스 참조까지 해제해야 하는 과정이 귀찮기도 하고 복잡할 수 있습니다.
그래서 강한 참조 사이클 문제를 좀 더 쉽게 해결할 수 있도록 Swift에서는 약한 참조(weak reference)와 미소유 참조(unowned reference)라는 키워드를 제공하고 있습니다.
약한 참조
약한 참조는 강한 참조와는 달리 참조 카운트를 증가시키지 않습니다.
참조 카운트를 증가시키지 않음에 따라 중간에 약하게 참조하고 있던 인스턴스가 해제되는 시점이 생길 수 있습니다.
ARC는 약하게 참조하고 있던 인스턴스가 메모리에서 해제되면 자동으로 nil을 할당해주게 되는데 그렇기 때문에 약한 참조를 사용하는 경우에는 반드시 옵셔널 타입으로 선언을 해주어야 합니다.
아래 예제로 살펴보겠습니다.
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 person: Person?
var apartment: Apartment?
person = Person(name: "Swift") // (Person RC: 1, Apartment RC: 0)
apartment = Apartment(unit: "APT") // (Person RC: 1, Apartment RC: 1)
person?.apartment = apartment // (Person RC: 1, Apartment RC: 2)
// Apartment.tenant는 약한 참조로 설정했기 때문에 Person의 참조 카운트가 증가하지 않음
apartment?.tenant = person // (Person RC: 1, Apartment RC: 2)
// Person의 참조 카운트가 0이 되면서 메모리에서 해제되고
// 그와 동시에 프로퍼티의 인스턴스(apartment) 참조 카운트도 감소
person = nil // (Person RC: 0, Apartment RC: 1)
apartment = nil // (Person RC: 0, Apartment RC: 0)
공식 문서에서는 약한 참조는 인스턴스의 수명이 더 짧은 쪽에 사용하는 것이 적합하다고 설명이 되어있습니다.
위 예제에서는 Apartment의 tenant 프로퍼티에 weak을 설정해 주었는데 아파트에 세입자가 존재하지 않더라도 아파트는 존재할 수 있기 때문입니다.
달리 말하면 Apartment의 인스턴스 수명이 Person의 인스턴스 수명보다 더 길 것을 예상하고 있기 때문입니다.
하지만 반대로 사람이 살아가는 동안 아파트를 소유하고 있지 않을 수 있기 때문에 Person 클래스의 apartment 프로퍼티에 weak을 설정할 수도 있습니다.
이와 같은 상황으로 볼 수 있듯이 인스턴스의 수명을 신경 쓰면서 약한 참조를 사용하지 않아도 프로그램이 정상적으로 동작하는 데에는 큰 문제가 없습니다.
그러나, 약한 참조를 인스턴스의 수명이 더 짧은 쪽에 적용하면 해당 인스턴스를 조금 더 빠르게 메모리에서 해제할 수 있어 메모리 관리를 보다 효율적으로 할 수 있습니다.
또한, 약한 참조를 사용한 쪽의 인스턴스 수명이 상대적으로 짧다는 점을 명확하게 드러낼 수 있어 코드의 가독성 측면에서도 도움이 됩니다.
미소유 참조
약한 참조와 마찬가지로 참조하는 인스턴스를 강하게 유지하지 않아 참조 카운트를 증가시키지 않습니다.
단, 약한 참조와는 달리 참조하고 있는 인스턴스가 항상 유지됨을 예상하고 있다는 점 입니다.
그렇기 때문에 참조하고 있던 인스턴스가 메모리에서 해제될 시 자동으로 nil로 할당이 되었던 약한 참조와 달리 인스턴스가 해제되도 nil로 할당이 되지 않습니다.
만약 미소유 참조로 가리키는 인스턴스가 해제된 후에 접근을 하게 된다면 런타임 에러가 발생할 수 있습니다.
그래서 공식 문서에서는 미소유 참조는 참조하고 있는 인스턴스의 수명이 같거나 더 긴 경우에 사용하는 것이 적합하다고 설명하고 있습니다.
아래 고객과 신용 카드의 관계를 통해 알아보도록 하겠습니다.
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") }
}
var john: Customer?
john = Customer(name: "John") // (Customer RC: 1, CreditCard RC: 0)
// CreditCard 인스턴스를 생성하고 생성된 인스턴스를 Customer의 card 프로퍼티에 할당
// CreditCard의 customer 프로퍼티를 미소유 참조로 정의했기 때문에 Customer의 참조 카운트를 증가시키지 않음
john?.card = CreditCard(number: 1234_5678_9012_3456, customer: john!) // (Customer RC: 1, CreditCard RC: 1)
// Customer의 참조 카운트가 0이 되면서 메모리에서 해제되고
// 그와 동시에 Customer가 참조하고 있던 CreditCart 참조 카운트도 감소되면서 메모리에서 같이 해제됨
john = nil // (Customer RC: 0, CreditCard RC: 0)
미소유 참조 대신 약한 참조로 정의해도 프로그램이 정상적으로 동작하는 데에는 문제가 없습니다.
오히려 미소유 참조로 정의하고 난 뒤에 해제된 인스턴스에 접근할 경우 런타임 에러가 발생할 수 있다는 문제가 있기 때문에 모든 강한 참조 사이클 문제를 약한 참조로 해결하는 방법이 괜찮을 수 있습니다.
다만, 이것 또한 약한 참조와 마찬가지로 가독성 측면에서 미소유 참조를 사용한 쪽의 인스턴스의 수명이 같거나 더 길 수 있다는 점을 명확히 드러낼 수 있다는 측면에서 도움이 됩니다.
그리고 해당 인스턴스가 항상 존재함을 예상하고 있기 때문에 약한 참조와는 달리 옵셔널을 통해 접근하지 않아도 된다는 점 또한 존재합니다.
4. 클로저에 대한 강한 참조 사이클
지금까지는 두 클래스 인스턴스 간의 강한 참조 사이클의 상황을 살펴보았는데, 인스턴스와 인스턴스 내에서 호출되는 클로저 간에도 강한 참조 사이클이 발생할 수 있습니다.
클로저를 통해서도 강한 참조 사이클이 발생할 수 있는 이유는 클로저 또한 클래스와 마찬가지로 참조 타입이기 때문입니다.
class HTMLElement {
let name: String
let text: String?
// 지연 저장 프로퍼티 사용
// 처음 사용될 때까지 초기값은 계산되지 않음.
// 즉, 처음 사용되는 시점에 초기값이 계산되어 저장됨
lazy var asHTML: () -> String = {
if let text = self.text {
return "<\(self.name)>\(text)</\(self.name)>"
} else {
return "<\(self.name) />"
}
}
init(name: String, text: String? = nil) {
self.name = name
self.text = text
}
deinit {
print("\(name) is being deinitialized")
}
}
// (HTMLElement RC: 1, asHTML RC: 0)
var paragraph: HTMLElement? = HTMLElement(name: "p", text: "hello, world")
// asHTML()을 처음 호출하면서 초기값이 계산됨
// 이때 클로저 내부에서 self(HTMLElement 인스턴스)를 참조하고 있고,
// self를 참조하는 경우에는 자동으로 self를 캡처하게 됨
// (HTMLElement RC: 2, asHTML RC: 1)
print(paragraph!.asHTML())
// HTMLElement 인스턴스 참조를 해제시켜도
// 클로저 내부에서 인스턴스를 참조했던 참조 카운트로 인해 HTMLElement는 메모리에서 해제되지 않음
// (HTMLElement RC: 1, asHTML RC: 1)
paragraph = nil
캡처 리스트와 약한 참조, 미소유 참조 활용
클로저에서 제공하는 기능인 캡처 리스트와 약한 참조, 미소유 참조를 활용해서 강한 참조 사이클의 문제를 해결할 수 있습니다.
class HTMLElement {
let name: String
let text: String?
// 캡처 리스트를 통해 self에 약한 참조를 설정함으로써
// self에 대한 참조 카운트를 증가시키지 않음
// 또한 옵셔널 바인딩을 통해 self가 메모리에서 해제될 경우
// 클로저를 실행시키지 않고 조기 종료 시키도록 구현
lazy var asHTML: () -> String = { [weak self] in
guard let self = self else { return "None" }
if let text = self.text {
return "<\(self.name)>\(text)</\(self.name)>"
} else {
return "<\(self.name) />"
}
}
init(name: String, text: String? = nil) {
self.name = name
self.text = text
}
deinit {
print("\(name) is being deinitialized")
}
}
// (HTMLElement RC: 1, asHTML RC: 0)
var paragraph: HTMLElement? = HTMLElement(name: "p", text: "hello, world")
// asHTML 클로저 내부에서 self(HTMLElement)를 약하게 참조하고 있기 때문에 참조 카운트를 증가시키지 않음
// (HTMLElement RC: 1, asHTML RC: 1)
print(paragraph!.asHTML())
paragraph = nil // (HTMLElement RC: 0, asHTML RC: 0)