메모리 관리는 모든 프로그래밍 언어에서 중요한 부분입니다. 특히, iOS 앱은 제한된 디바이스 자원을 사용하기 때문에 메모리를 효율적으로 사용하는 것이 매우 중요합니다. Swift는 C나 C++처럼 개발자가 직접 메모리를 할당하고 해제할 필요 없이, ARC라는 시스템을 통해 자동으로 메모리를 관리해 줍니다. 덕분에 우리는 메모리 관리에 대한 부담을 덜고 앱의 기능 구현에 더 집중할 수 있지만, ARC의 기본 원리를 이해하는 것은 여전히 중요합니다. 특히 순환 참조(Retain Cycle)와 같은 특정 문제 상황을 방지하기 위해서 말이죠.
1. ARC(Automatic Reference Counting)란 무엇인가요?
ARC는 Swift에서 클래스 인스턴스의 메모리를 자동으로 관리하는 시스템입니다. ARC는 특정 클래스 인스턴스가 더 이상 필요 없을 때 메모리에서 해제하여, 해당 인스턴스가 사용했던 메모리 자원을 다른 용도로 재활용할 수 있도록 해줍니다.
💡 ARC의 작동 원리:
ARC는 클래스 인스턴스에 대한 참조 횟수(Reference Count)를 추적합니다. 어떤 인스턴스에 대한 새로운 강력 참조(Strong Reference)가 생길 때마다 해당 인스턴스의 참조 횟수가 1 증가하고, 강력 참조가 사라질 때마다 참조 횟수가 1 감소합니다. 어떤 인스턴스의 참조 횟수가 0이 되면, ARC는 해당 인스턴스를 메모리에서 안전하게 해제합니다.
구조체와 열거형은 값 타입(Value Type)이므로 참조 타입인 클래스와 달리 ARC의 영향을 받지 않습니다. 복사될 때마다 완전히 새로운 인스턴스가 생성되기 때문입니다.
1.1. 강력 참조 (Strong Reference)의 기본
변수나 상수에 클래스 인스턴스를 할당하면 기본적으로 강력 참조가 생성됩니다.
class Person {
let name: String
init(name: String) {
self.name = name
print("\(name) is being initialized")
}
deinit {
print("\(name) is being deinitialized")
}
}
var reference1: Person? // 옵셔널 변수로 선언하여 nil 할당 가능하게 함
var reference2: Person?
var reference3: Person?
print("--- Person 인스턴스 생성 전 ---")
reference1 = Person(name: "John Appleseed") // John 인스턴스 생성, reference1이 강력 참조 (참조 횟수: 1)
// 출력: John Appleseed is being initialized
print("--- reference2와 reference3이 참조 시작 ---")
reference2 = reference1 // reference2가 강력 참조 (참조 횟수: 2)
reference3 = reference1 // reference3이 강력 참조 (참조 횟수: 3)
print("--- reference2 참조 해제 ---")
reference2 = nil // reference2의 강력 참조 해제 (참조 횟수: 2)
print("--- reference3 참조 해제 ---")
reference3 = nil // reference3의 강력 참조 해제 (참조 횟수: 1)
print("--- reference1 참조 해제 ---")
reference1 = nil // reference1의 강력 참조 해제 (참조 횟수: 0)
// 출력: John Appleseed is being deinitialized (모든 강력 참조가 사라져 메모리 해제)
print("--- 모든 참조 해제 후 ---")
이 예시에서 Person 인스턴스는 세 개의 강력 참조(reference1, reference2, reference3)를 가졌다가, 이 참조들이 하나씩 nil로 설정되면서 참조 횟수가 줄어들고, 결국 0이 되어 deinit이 호출됩니다.
2. 순환 참조(Retain Cycle) 문제와 해결 방법
ARC는 대부분의 메모리 관리 문제를 자동으로 해결하지만, 순환 참조(Retain Cycle)는 ARC가 스스로 해결할 수 없는 예외적인 상황입니다. 순환 참조는 두 클래스 인스턴스가 서로를 강력 참조하여, 어느 한쪽도 참조 횟수가 0이 되지 않아 메모리에서 해제되지 않고 영원히 남아있는 현상입니다. 이는 메모리 누수(Memory Leak)를 발생시킵니다.
2.1. 순환 참조의 전형적인 예시
Person 인스턴스가 Apartment 인스턴스를 소유하고, Apartment 인스턴스가 다시 Person 인스턴스를 소유하는 관계를 생각해 봅시다.
class PersonForCycle {
let name: String
var apartment: ApartmentForCycle? // Person은 Apartment를 소유할 수 있음
init(name: String) {
self.name = name
print("\(name) is being initialized")
}
deinit {
print("\(name) is being deinitialized")
}
}
class ApartmentForCycle {
let unit: String
var tenant: PersonForCycle? // Apartment는 tenant (Person)를 소유할 수 있음
init(unit: String) {
self.unit = unit
print("Apartment \(unit) is being initialized")
}
deinit {
print("Apartment \(unit) is being deinitialized")
}
}
var john: PersonForCycle?
var unit4A: ApartmentForCycle?
print("--- 순환 참조 발생 시나리오 시작 ---")
john = PersonForCycle(name: "John Appleseed")
unit4A = ApartmentForCycle(unit: "4A")
john!.apartment = unit4A // John이 4A 아파트를 강력 참조 (Apartment 참조 횟수: 1 -> 2)
unit4A!.tenant = john // 4A 아파트가 John을 강력 참조 (Person 참조 횟수: 1 -> 2)
print("--- 외부 참조 nil 할당 시작 ---")
john = nil // John에 대한 외부 참조 해제 (Person 참조 횟수: 2 -> 1)
unit4A = nil // 4A에 대한 외부 참조 해제 (Apartment 참조 횟수: 2 -> 1)
// deinit 메시지가 출력되지 않음 -> 순환 참조로 인해 메모리에서 해제되지 않고 남아있음
print("--- 순환 참조 발생 시나리오 종료 ---")
john과 unit4A를 nil로 만들었음에도 불구하고 deinit 메시지가 출력되지 않습니다. 이는 PersonForCycle 인스턴스와 ApartmentForCycle 인스턴스가 서로를 강력 참조하고 있기 때문입니다.
2.2. 순환 참조 해결 방법: 약한 참조 (weak)와 미소유 참조 (unowned)
Swift는 순환 참조를 해결하기 위해 약한 참조(Weak References)와 미소유 참조(Unowned References)라는 두 가지 키워드를 제공합니다. 이들은 강력 참조와 달리 참조 횟수를 증가시키지 않습니다.
weak와 unowned의 선택 기준:
- weak (약한 참조): 참조하는 인스턴스의 수명이 더 짧거나, 언제든지 nil이 될 수 있는 경우에 사용합니다. weak 참조는 옵셔널 타입으로 선언되어, 참조하는 인스턴스가 해제되면 자동으로 nil로 설정됩니다. (델리게이트 패턴, 클로저에서 self 참조 시 자주 사용)
- unowned (미소유 참조): 참조하는 인스턴스가 자신보다 수명이 같거나 길다는 것이 확실할 때 사용합니다. unowned 참조는 비-옵셔널 타입으로 선언되며, 참조하는 인스턴스가 항상 존재할 것이라고 가정합니다. (인스턴스 간에 확실한 생명주기 관계가 있을 때 사용)
Person과 Apartment 예시에서 Apartment의 tenant 프로퍼티는 Person을 참조하지만, Apartment 인스턴스 자체가 Person 인스턴스보다 오래 존재할 필요는 없습니다. 오히려 Person이 Apartment를 소유하는 것이 더 자연스러운 관계입니다. 따라서 tenant 프로퍼티를 약한 참조 (weak)로 만들어 순환 참조를 끊을 수 있습니다.
class PersonFixed {
let name: String
var apartment: ApartmentFixed?
init(name: String) {
self.name = name
print("\(name) is being initialized")
}
deinit {
print("\(name) is being deinitialized")
}
}
class ApartmentFixed {
let unit: String
// weak: Apartment가 Person을 참조하지만, Person의 수명이 더 짧을 수 있음
weak var tenant: PersonFixed?
init(unit: String) {
self.unit = unit
print("Apartment \(unit) is being initialized")
}
deinit {
print("Apartment \(unit) is being deinitialized")
}
}
var johnFixed: PersonFixed?
var unit4AFixed: ApartmentFixed?
print("\n--- 순환 참조 해결 시나리오 시작 ---")
johnFixed = PersonFixed(name: "John Appleseed") // 참조 횟수: 1 (johnFixed)
unit4AFixed = ApartmentFixed(unit: "4A") // 참조 횟수: 1 (unit4AFixed)
johnFixed!.apartment = unit4AFixed // johnFixed가 unit4AFixed를 강력 참조 (unit4AFixed 참조 횟수: 2)
unit4AFixed!.tenant = johnFixed // unit4AFixed가 johnFixed를 약하게 참조 (johnFixed 참조 횟수 변화 없음: 1)
print("--- 외부 참조 nil 할당 시작 (해결됨) ---")
johnFixed = nil // johnFixed의 참조 해제 (johnFixed 참조 횟수: 1 -> 0)
// 출력: John Appleseed is being deinitialized (tenant가 약한 참조였으므로 John 해제)
unit4AFixed = nil // unit4AFixed의 참조 해제 (unit4AFixed 참조 횟수: 2 -> 1 -> 0)
// 출력: Apartment 4A is being deinitialized (johnFixed 해제 시 tenant도 nil 되고, 이후 외부 참조가 해제되어 0이 됨)
print("--- 순환 참조 해결 시나리오 종료 ---")
tenant 프로퍼티를 weak var로 선언함으로써, ApartmentFixed 인스턴스가 PersonFixed 인스턴스를 강력 참조하지 않게 되어 순환 참조가 발생하지 않고, 두 인스턴스 모두 정상적으로 메모리에서 해제됩니다.
정리하며
오늘은 Swift 앱의 메모리를 자동으로 관리해주는 ARC(Automatic Reference Counting)의 기본 원리와, ARC가 해결하지 못하는 특별한 문제인 순환 참조(Retain Cycle), 그리고 이를 해결하는 약한 참조(weak)와 미소유 참조(unowned)에 대해 자세히 알아보았습니다.
- ARC: 클래스 인스턴스에 대한 강력 참조 횟수를 추적하여, 참조 횟수가 0이 되면 자동으로 메모리에서 해제합니다.
- 순환 참조: 두 개 이상의 인스턴스가 서로를 강력 참조하여 메모리에서 영원히 해제되지 않는 문제입니다 (메모리 누수).
- 해결 방법:
- weak: 참조하는 인스턴스의 수명이 더 짧거나 언제든 nil이 될 수 있을 때 사용합니다. 옵셔널 타입으로 선언됩니다.
- unowned: 참조하는 인스턴스가 자신보다 수명이 같거나 길다는 것이 확실할 때 사용합니다. 비-옵셔널 타입으로 선언됩니다.
ARC는 Swift의 강력한 기능이지만, 순환 참조는 개발자가 직접 해결해야 하는 문제입니다. 따라서 클래스 간의 관계를 설계할 때 항상 순환 참조의 가능성을 염두에 두고, 필요한 경우 weak나 unowned를 적절히 사용하여 견고하고 메모리 효율적인 앱을 만드는 습관을 들이는 것이 중요합니다.
궁금한 점이 있다면 언제든지 댓글로 남겨주세요. 감사합니다.
0 comments:
댓글 쓰기