Swift의 class instance는 Automatic Reference Counting, 즉 ARC가 수명을 관리한다. ARC는 strong reference 수를 추적하다가 더는 강하게 참조되지 않는 instance를 해제한다.
여기까지만 보면 직접 memory를 해제할 일이 없어 보인다. 하지만 두 instance가 서로를 strong으로 잡거나, instance가 자신을 잡는 closure를 보관하면 reference count가 0이 되지 않는 cycle이 생길 수 있다.
weak와 unowned는 이 cycle을 끊는 도구다. 둘 중 무엇을 고를지는 문법 취향이 아니라 참조 대상이 먼저 사라질 수 있는가로 결정해야 한다.
strong은 기본 소유 관계다
별도 keyword 없이 class instance를 property에 담으면 strong reference다.
final class Person {
let name: String
var apartment: Apartment?
init(name: String) {
self.name = name
}
}
final class Apartment {
let unit: String
var tenant: Person?
init(unit: String) {
self.unit = unit
}
}
다음처럼 서로를 연결하면 Person과 Apartment가 상대를 계속 소유한다.
var person: Person? = Person(name: "Ben")
var apartment: Apartment? = Apartment(unit: "101")
person?.apartment = apartment
apartment?.tenant = person
person = nil
apartment = nil
외부 변수 두 개를 nil로 바꿔도 두 instance 사이의 strong reference가 남는다. 이것이 strong reference cycle이다.
weak: 대상이 먼저 사라질 수 있을 때
임차인은 이사할 수 있고 apartment보다 먼저 사라질 수 있다. 이런 관계는 weak가 잘 맞는다.
final class Apartment {
let unit: String
weak var tenant: Person?
init(unit: String) {
self.unit = unit
}
}
weak reference의 성질은 세 가지다.
- 참조 대상의 strong reference count를 올리지 않는다.
- 대상이 해제되면 ARC가 자동으로
nil로 바꾼다. - 실행 중
nil이 될 수 있으므로 일반적으로 optionalvar로 선언한다.
delegate가 owner보다 먼저 없어질 수 있는 UI code, 비동기 callback에서 호출자가 이미 화면을 떠날 수 있는 경우처럼 수명이 독립적이면 weak가 안전한 기본 선택이다.
protocol DownloadDelegate: AnyObject {
func downloadDidFinish()
}
final class Downloader {
weak var delegate: DownloadDelegate?
}
AnyObject 제약은 delegate가 class reference여야 weak로 보관할 수 있다는 뜻이다.
unowned: 대상이 반드시 더 오래 살아 있을 때
unowned도 strong count를 올리지 않는다. 차이는 대상이 해제돼도 자동으로 nil이 되지 않는다는 점이다. 이미 해제된 instance에 접근하면 runtime error가 발생한다.
final class Customer {
let name: String
var card: CreditCard?
init(name: String) {
self.name = name
}
}
final class CreditCard {
let number: UInt64
unowned let customer: Customer
init(number: UInt64, customer: Customer) {
self.number = number
self.customer = customer
}
}
이 model은 “card는 언제나 customer에 속하고 customer보다 오래 살지 않는다”는 invariant가 지켜질 때만 안전하다. 다른 곳에서 CreditCard를 strong으로 오래 보관한 뒤 Customer가 먼저 해제되면 card.customer 접근은 crash로 이어진다.
따라서 unowned는 optional을 피하기 위한 편의 기능이 아니다. 다음 질문에 확실히 답할 수 있을 때 쓴다.
이 참조를 읽는 모든 순간에 대상이 살아 있다는 것을 구조적으로 보장할 수 있는가?
보장하기 어렵다면 weak가 낫다.
weak와 unowned 비교
| 기준 | weak | unowned |
|---|---|---|
| strong count 증가 | 하지 않음 | 하지 않음 |
| 대상 해제 뒤 값 | 자동으로 nil |
유효하지 않은 참조가 됨 |
| 일반적인 type | optional var |
non-optional로 자주 사용 |
| 잘못 접근했을 때 | optional 처리 경로 | runtime error |
| 적합한 관계 | 대상이 먼저 사라질 수 있음 | 대상이 같거나 더 긴 수명임을 보장 |
Swift는 optional unowned reference도 지원한다. 하지만 weak처럼 ARC가 자동으로 nil로 바꿔 주는 것은 아니다. 값이 가리키는 instance가 살아 있다는 invariant를 개발자가 계속 지켜야 하므로, 단지 optional이라는 이유로 weak와 같다고 보면 안 된다.
unowned(unsafe)는 해제된 대상 접근에 대한 runtime safety check까지 끈다. dangling memory를 읽을 수 있어 특별한 low-level 이유와 별도 검증이 없다면 사용하지 않는 편이 맞다.
closure가 self를 강하게 잡는 경우
property에 저장된 escaping closure가 self를 사용하면 cycle이 생길 수 있다.
final class ImageLoader {
var onComplete: (() -> Void)?
func start() {
onComplete = { [weak self] in
guard let self else { return }
self.finish()
}
}
private func finish() {
print("finished")
}
}
ImageLoader가 onComplete를 strong으로 소유하고, closure가 다시 self를 strong으로 capture하면 cycle이 된다. [weak self] capture list는 closure 안의 self를 optional weak reference로 바꾼다.
[unowned self]도 cycle을 끊지만 closure가 instance보다 오래 남는 순간 crash할 수 있다. network callback, timer, task, delegate처럼 cancellation과 실행 시점이 외부 상태에 좌우되면 weak를 우선 검토하는 편이 안전하다.
반대로 closure와 instance가 반드시 함께 해제된다는 lifetime invariant가 명확하면 unowned를 선택할 수 있다. 중요한 것은 closure가 “곧 실행될 것 같다”는 기대가 아니라 code 구조가 수명을 보장하는지다.
선택 순서
reference cycle을 발견했을 때는 모든 연결을 weak로 바꾸기보다 ownership을 먼저 그려 보는 편이 낫다.
- 누가 누구를 소유해야 하는가?
- 두 객체 중 어느 쪽이 먼저 사라질 수 있는가?
- 대상이 사라졌을 때
nil을 정상 상태로 처리할 수 있는가? - 비동기 closure가 cancel되거나 늦게 실행될 수 있는가?
- unowned 대상의 수명을 test로 깨뜨려 보았는가?
수명이 독립적이면 weak, 대상이 반드시 더 오래 산다는 invariant가 있으면 unowned, 실제 ownership이면 strong이다.
Swift closure와 callback 정리에서 capture 동작을 먼저 확인하고, SwiftUI에서 class와 struct를 고르는 기준까지 이어 보면 reference semantics가 필요한 위치도 함께 정리할 수 있다.
참고 자료
'배움과 성장 > 소프트웨어 개발' 카테고리의 다른 글
| Swift mutating: struct·enum에서 self를 바꾸는 규칙 (4) | 2024.11.11 |
|---|---|
| SwiftUI에서 struct와 class 고르기: @State·@Observable 기준 (1) | 2024.11.11 |
| Swift closure와 callback: capture·escaping·호출 시점 구분하기 (4) | 2024.11.09 |
| Swift 초기화와 @main: 저장 프로퍼티·App 진입점·onAppear 구분 (2) | 2024.11.09 |
| SwiftUI some View 이해하기: Opaque Return Type과 #Preview (8) | 2024.10.28 |
댓글