안녕하세요, 루피입니다.
오늘은 Swift 메모리 관리의 핵심인 ARC에 대해 정리해보겠습니다. Swift 공식 문서를 바탕으로 ARC의 기본 동작 원리부터, 메모리 누수의 주범인 강한 참조 사이클이 왜 발생하는지, 그리고 weak와 unowned를 사용해 이를 어떻게 해결하는지 총정리해 보겠습니다. 바로 시작합니다.
1. ARC란?
ARC는 Automatic Reference Counting(자동 참조 카운팅)의 약자로, Swift가 앱의 메모리를 추적하고 관리하는 핵심 메커니즘입니다. 클래스 인스턴스처럼 참조 타입 데이터는 Heap이라는 메모리 영역에 저장되는데, ARC는 이 인스턴스가 더는 필요 없을 때 자동으로 메모리에서 해제해주는 역할을 합니다.
잠깐 그러면 struct 나 enum 같은 값타입은 ARC가 관리해주지 않는건가요?
네 맞습니다. 값타입은 복사될 때마다 별도의 인스턴스가 생성되어 참조 카운트 관리가 필요하지 않습니다.
2. ARC의 동작 원리
ARC는 각 인스턴스에 대한 참조 카운트를 추적합니다.
- 새로운 인스턴스를 생성하면, 해당 인스턴스의 참조 카운트는 1이 됩니다.
- 다른 변수나 프로퍼티가 이 인스턴스를 참조할 때마다 카운트는 1씩 증가합니다.
- 인스턴스를 참조하던 변수에 nil을 할당하는 등 참조 관계가 끊어지면 카운트는 1씩 감소합니다.
- 참조 카운트가 0이 되면, ARC는 이 인스턴스가 더 이상 사용되지 않는다고 판단하고 메모리에서 해제합니다.
class Person {
let name: String
init(name: String) { self.name = name; print("\(name) is initialized") }
deinit { print("\(name) is deinitialized") }
}
var ref1: Person?
var ref2: Person?
var ref3: Person?
// 1. 인스턴스 생성 및 할당 (참조 카운트: 1)
ref1 = Person(name: "Luffy")
// 출력: Luffy is initialized
// 2. 다른 변수들이 같은 인스턴스를 참조 (참조 카운트: 3)
ref2 = ref1
ref3 = ref1
// 3. 참조 하나 제거 (참조 카운트: 2)
ref1 = nil
// deinit 호출 안 됨
// 4. 또 다른 참조 제거 (참조 카운트: 1)
ref2 = nil
// deinit 호출 안 됨
// 5. 마지막 참조 제거 (참조 카운트: 0)
ref3 = nil
// deinit 호출됨
// 출력: Luffy is deinitialized
이처럼 ARC 덕분에 개발자는 메모리 관리에 대한 부담을 덜고 비즈니스 로직에 집중할 수 있습니다. 하지만 이 편리함에는 한 가지 함정이 있는데, 바로 강한 참조 사이클입니다.
3. 강한 참조 사이클 (Strong Reference Cycle)
강한 참조 사이클은 두 개 이상의 인스턴스가 서로를 강하게 참조하여, 어느 쪽의 참조 카운트도 0에 도달하지 못하는 교착 상태를 의미합니다. 이 상태에 빠진 인스턴스들은 앱이 종료될 때까지 메모리를 계속 차지하여 메모리 누수를 유발합니다.
1) 클래스 인스턴스 간의 사이클
가장 고전적인 예시는 두 클래스가 서로를 프로퍼티로 갖는 경우입니다.
class Person {
let name: String
var apartment: Apartment?
init(name: String) { self.name = name }
deinit { print("\(name) is deinitialized") }
}
class Apartment {
let unit: String
var tenant: Person?
init(unit: String) { self.unit = unit }
deinit { print("Apartment \(unit) is deinitialized") }
}
var luffy: Person? = Person(name: "Luffy")
var sunnyGo: Apartment? = Apartment(unit: "1000")
// 강한 참조 사이클 발생
luffy?.apartment = sunnyGo // Luffy -> SunnyGo 참조 (강함)
sunnyGo?.tenant = luffy // SunnyGo -> Luffy 참조 (강함)
이 상태는 다음과 같이 표현할 수 있습니다.
[luffy 인스턴스] <--- 강한 참조 ---> [sunnyGo 인스턴스]
이제 luffy와 sunnyGo 변수 자체는 더 이상 인스턴스를 가리키지 않도록 nil을 할당해 보겠습니다.
luffy = nil
sunnyGo = nil
하지만 deinit은 호출되지 않습니다. 강하게 참조하고 있습니다(참조 카운트 1).
2) 클로저에서의 사이클
클로저는 참조 타입이며, 자신이 정의된 컨텍스트의 변수나 상수를 캡처(Capture) 할 수 있습니다. 이 과정에서 의도치 않은 강한 참조 사이클이 쉽게 발생합니다. 클래스의 프로퍼티로 클로저를 갖고, 그 클로저 내부에서 self를 사용하면 사이클이 발생하기 쉽습니다.
class HTMLElement {
let name: String
lazy var asHTML: () -> String = {
// 클로저가 self를 강하게 캡처
return "<\(self.name)/>"
}
init(name: String) { self.name = name; print("HTMLElement initialized") }
deinit { print("HTMLElement deinitialized") }
}
var paragraph: HTMLElement? = HTMLElement(name: "p")
print(paragraph!.asHTML())
paragraph = nil
// deinit이 호출되지 않음! 메모리 누수 발생!
이 경우, [paragraph 인스턴스]는 asHTML 클로저를 강하게 참조하고, asHTML 클로저는 self(paragraph 인스턴스)를 강하게 참조하여 사이클이 완성됩니다.
4. 해결책: weak와 unowned로 사이클 끊기
Swift는 참조 사이클을 끊기 위해 두 가지 해결책을 제공합니다. 약한 참조(weak)와 미소유 참조(unowned). 이들은 참조 카운트를 증가시키지 않는 특별한 참조 방식입니다.
1) 약한 참조 (Weak References)
weak는 참조하는 인스턴스가 먼저 메모리에서 해제될 수 있는 상황에 사용합니다.
- 참조 카운트를 증가시키지 않습니다.
- 참조하던 인스턴스가 해제되면, ARC가 자동으로 해당 프로퍼티에 nil을 할당합니다.
- 따라서 weak 참조는 항상 옵셔널 타입의 var로 선언해야 합니다.
아까의 Person과 Apartment 예제에 weak를 적용해 보겠습니다. Apartment는 tenant가 없을 수 있지만, Person은 apartment가 없을 수 있다고 가정하는 것이 자연스럽습니다.
class Person {
let name: String
var apartment: Apartment?
// ...
}
class Apartment {
let unit: String
weak var tenant: Person? // tenant가 먼저 사라질 수 있으므로 weak로 선언
// ...
}
이제 Apartment의 tenant 프로퍼티는 Person 인스턴스를 강하게 붙잡지 않습니다. luffy = nil이 호출되면 luffy 인스턴스의 참조 카운트가 0이 되어 즉시 해제되고, 이후 sunnyGo 인스턴스의 참조 카운트도 0이 되어 순차적으로 모두 메모리에서 해제됩니다.
2) 미소유 참조 (Unowned References)
unowned는 참조하는 인스턴스와 생명주기가 같거나 더 길다고 100% 확신할 수 있을 때 사용합니다.
- 참조 카운트를 증가시키지 않습니다.
- 참조하던 인스턴스가 해제되어도 nil로 만들지 않고, 해제된 메모리 주소를 계속 가리킵니다.
- 따라서 항상 값이 있다고 가정하므로 옵셔널이 아닌 타입으로 선언합니다.
- 만약 인스턴스가 해제된 후에 접근을 시도하면 런타임 에러(Crash)가 발생합니다. 🚨
가장 좋은 예시는 고객과 신용카드 관계입니다. 카드는 고객 없이는 존재할 수 없으며, 고객의 생명주기와 같거나 고객이 더 깁니다.
class Customer {
let name: String
var card: CreditCard?
// ...
}
class CreditCard {
let number: UInt64
unowned let customer: Customer // 카드는 항상 고객이 있다고 보장
init(number: UInt64, customer: Customer) {
self.number = number
self.customer = customer
}
// ...
}
이 경우, CreditCard는 customer가 항상 존재한다고 확신하므로 unowned를 사용해 불필요한 옵셔널 처리를 피하고 관계를 명확히 할 수 있습니다.
3) 클로저 캡처 리스트로 사이클 해결하기
클로저의 참조 사이클은 캡처 리스트(Capture List)를 사용해 해결합니다.
class HTMLElement {
let name: String
lazy var asHTML: () -> String = {
// [weak self]를 캡처 리스트에 추가
[weak self] in
// self가 옵셔널이 되므로, 안전하게 사용하기 위해 guard let 사용
guard let self = self else {
return "Instance is deallocated"
}
return "<\(self.name)/>"
}
//...
}
[weak self]를 추가하면 클로저는 self를 약하게 참조하여 강한 참조 사이클을 끊습니다. self가 weak 참조이므로 옵셔널이 되며, 클로저 내부에서 사용할 때는 guard let 이나 if let으로 안전하게 언래핑해서 사용하는 것이 좋습니다.
오늘도 화이팅입니다.
'iOS > Swift' 카테고리의 다른 글
| [Swift] Where절 (0) | 2025.02.05 |
|---|---|
| [Swift] 제네릭(Generics) (1) | 2025.02.04 |
| [Swift] Delegate 패턴을 구현해 사용해 보자 (0) | 2025.01.17 |
| [Swift] Delegate를 사용해 객체의 동작을 커스텀하기 (0) | 2025.01.16 |
| [Swift] 스위프트에서 KVO 사용하기 (0) | 2025.01.16 |