🔥 클래스 인스턴스 사이의 강한 참조 순환 해결하기

2084자
24분

Swift는 클래스 타입의 속성으로 작업할 때 강한 참조 순환을 해결하기 위해 두 가지 방법을 제공합니다: 약한 참조(weak reference)와 미소유 참조(unowned reference)입니다.

약한 참조와 미소유 참조는 참조 순환 내의 한 인스턴스가 다른 인스턴스를 강하게 잡지 않고 참조할 수 있도록 해줍니다. 그러면 인스턴스들이 강한 참조 순환을 만들지 않고도 서로를 참조할 수 있게 됩니다.

다른 인스턴스의 수명이 더 짧을 때, 즉 다른 인스턴스가 먼저 할당 해제될 수 있을 때는 약한 참조를 사용하세요. Apartment 예제에서, 아파트는 수명 주기 동안 어떤 시점에는 세입자가 없을 수도 있으므로 이 경우 약한 참조가 참조 순환을 끊기에 적절한 방법입니다. 반대로 다른 인스턴스의 수명이 같거나 더 길 때는 미소유 참조를 사용하세요.

약한 참조(weak reference)

약한 참조는 참조하는 인스턴스를 강하게 잡지 않는 참조이므로 ARC가 참조된 인스턴스를 처분하는 것을 막지 않습니다. 이런 동작 덕분에 참조가 강한 참조 순환의 일부가 되는 것을 방지할 수 있습니다. 속성이나 변수 선언 앞에 weak 키워드를 붙여서 약한 참조를 나타낼 수 있습니다.

약한 참조는 참조하는 인스턴스를 강하게 잡지 않기 때문에, 약한 참조가 여전히 인스턴스를 참조하고 있는 동안 그 인스턴스가 할당 해제될 가능성이 있습니다. 그러므로 ARC는 참조하는 인스턴스가 할당 해제되면 자동으로 약한 참조를 nil로 설정합니다. 그리고 약한 참조는 런타임에 nil로 값이 변경될 수 있어야 하기 때문에, 항상 옵셔널 타입의 변수로 선언되며 상수로는 선언되지 않습니다.

다른 옵셔널 값과 마찬가지로 약한 참조에 값이 존재하는지 확인할 수 있으며, 더 이상 존재하지 않는 유효하지 않은 인스턴스를 참조하게 되는 일은 절대 없을 것입니다.

아래 예제는 위의 PersonApartment 예제와 동일하지만, 한 가지 중요한 차이점이 있습니다. 이번에는 Apartment 타입의 tenant 속성이 약한 참조로 선언됩니다:

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") }
}
swift

두 변수(johnunit4A)로부터의 강한 참조와 두 인스턴스 사이의 연결은 이전과 같이 만들어집니다:

var john: Person?
var unit4A: Apartment?
 
 
john = Person(name: "John Appleseed")
unit4A = Apartment(unit: "4A")
 
 
john!.apartment = unit4A
unit4A!.tenant = john
swift

두 인스턴스를 서로 연결한 후의 참조는 다음과 같습니다:

lecture image

Person 인스턴스는 여전히 Apartment 인스턴스에 대한 강한 참조를 가지고 있지만, Apartment 인스턴스는 이제 Person 인스턴스에 대한 약한 참조를 가지고 있습니다. 이것은 john 변수가 가진 강한 참조를 끊어 nil로 설정하면, Person 인스턴스에 대한 강한 참조가 더 이상 없다는 것을 의미합니다:

john = nil
// Prints "John Appleseed is being deinitialized"
swift

Person 인스턴스에 대한 강한 참조가 더 이상 없기 때문에, 할당이 해제되고 tenant 속성은 nil로 설정됩니다:

lecture image

Apartment 인스턴스에 대한 유일한 남은 강한 참조는 unit4A 변수로부터 옵니다. 만약 강한 참조를 끊으면, Apartment 인스턴스에 대한 강한 참조는 더 이상 없습니다:

unit4A = nil
// Prints "Apartment 4A is being deinitialized"
swift

Apartment 인스턴스에 대한 강한 참조가 더 이상 없기 때문에, 그것도 할당 해제됩니다:

lecture image

미소유 참조(unowned reference)

약한 참조와 마찬가지로 미소유 참조도 참조하는 인스턴스를 강하게 잡지 않습니다. 그러나 약한 참조와 달리, 미소유 참조는 다른 인스턴스의 수명이 같거나 더 길 때 사용됩니다. 속성이나 변수 선언 앞에 unowned 키워드를 붙여서 미소유 참조를 나타낼 수 있습니다.

약한 참조와 달리, 미소유 참조는 항상 값을 가질 것으로 예상됩니다. 결과적으로 값을 미소유로 표시하는 것은 그 값을 옵셔널로 만들지 않으며, ARC는 절대로 미소유 참조의 값을 nil로 설정하지 않습니다.

다음 예제는 은행 고객과 그 고객의 가능한 신용카드를 모델링하는 CustomerCreditCard라는 두 클래스를 정의합니다. 이 두 클래스는 각각 다른 클래스의 인스턴스를 속성으로 저장합니다. 이 관계는 강한 참조 순환을 만들 가능성이 있습니다.

CustomerCreditCard 사이의 관계는 위의 약한 참조 예제에서 본 ApartmentPerson 사이의 관계와 약간 다릅니다. 이 데이터 모델에서 고객은 신용카드를 가지고 있을 수도 있고 없을 수도 있지만, 신용카드는 항상 고객과 연관됩니다. CreditCard 인스턴스는 절대 참조하는 Customer보다 오래 살아남지 않습니다. 이를 나타내기 위해, Customer 클래스는 옵셔널 card 속성을 가지고 있고, CreditCard 클래스는 미소유(그리고 non-optional)의 customer 속성을 가지고 있습니다.

게다가 새로운 CreditCard 인스턴스는 number 값과 customer 인스턴스를 커스텀 CreditCard 이니셜라이저에 전달해야만 생성될 수 있습니다. 이는 CreditCard 인스턴스가 생성될 때 항상 customer 인스턴스와 연관되도록 보장합니다.

신용카드는 항상 고객을 가질 것이기 때문에, 강한 참조 순환을 피하기 위해 customer 속성을 미소유 참조로 정의합니다:

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") }
}
swift

다음 코드 스니펫은 특정 고객에 대한 참조를 저장하는 데 사용될 john이라는 옵셔널 Customer 변수를 정의합니다. 이 변수는 옵셔널이기 때문에 초기값이 nil입니다:

이제 Customer 인스턴스를 생성하고, 그것을 사용하여 새로운 CreditCard 인스턴스를 초기화하고 해당 고객의 card 속성으로 할당할 수 있습니다:

john = Customer(name: "John Appleseed")
john!.card = CreditCard(number: 1234_5678_9012_3456, customer: john!)
swift

두 인스턴스를 연결한 후의 참조는 다음과 같습니다:

lecture image

Customer 인스턴스는 이제 CreditCard 인스턴스에 대한 강한 참조를 가지고 있고, CreditCard 인스턴스는 Customer 인스턴스에 대한 미소유 참조를 가지고 있습니다.

미소유 customer 참조 때문에, john 변수가 가진 강한 참조를 끊으면, Customer 인스턴스에 대한 강한 참조는 더 이상 없습니다:

lecture image

Customer 인스턴스에 대한 강한 참조가 더 이상 없기 때문에, 할당이 해제됩니다. 이 후에는 CreditCard 인스턴스에 대한 강한 참조도 더 이상 없으므로, 그것도 할당 해제됩니다:

john = nil
// Prints "John Appleseed is being deinitialized"
// Prints "Card #1234567890123456 is being deinitialized"
swift

위의 마지막 코드 스니펫은 john 변수가 nil로 설정된 후에 Customer 인스턴스와 CreditCard 인스턴스의 디이니셜라이저가 각각 "deinitialized" 메시지를 출력하는 것을 보여줍니다.

미소유 옵셔널 참조

클래스에 대한 옵셔널 참조를 미소유로 표시할 수 있습니다. ARC 소유권 모델에서, 미소유 옵셔널 참조와 약한 참조는 둘 다 같은 맥락에서 사용될 수 있습니다. 차이점은 미소유 옵셔널 참조를 사용할 때, 그것이 항상 유효한 객체를 참조하거나 nil로 설정되도록 만드는 것은 사용자의 책임이라는 것입니다.

다음은 학교의 특정 학과에서 제공하는 과목을 추적하는 예제입니다:

class Department {
    var name: String
    var courses: [Course]
    init(name: String) {
        self.name = name
        self.courses = []
    }
}
 
 
class Course {
    var name: String
    unowned var department: Department
    unowned var nextCourse: Course?
    init(name: String, in department: Department) {
        self.name = name
        self.department = department
        self.nextCourse = nil
    }
}
swift

Department는 학과에서 제공하는 각 과목에 대해 강한 참조를 유지합니다. ARC 소유권 모델에서, 학과는 과목들을 소유합니다. Course는 학과와 학생이 다음에 수강해야 할 과목에 대해 두 개의 미소유 참조를 가집니다; 과목은 이 두 객체 중 어느 것도 소유하지 않습니다. 모든 과목은 어떤 학과의 일부이므로 department 속성은 옵셔널이 아닙니다. 그러나 일부 과목은 추천되는 후속 과목이 없기 때문에, nextCourse 속성은 옵셔널입니다.

다음은 이런 클래스들을 사용하는 예제입니다:

let department = Department(name: "Horticulture")
 
 
let intro = Course(name: "Survey of Plants", in: department)
let intermediate = Course(name: "Growing Common Herbs", in: department)
let advanced = Course(name: "Caring for Tropical Plants", in: department)
 
 
intro.nextCourse = intermediate
intermediate.nextCourse = advanced
department.courses = [intro, intermediate, advanced]
swift

위의 코드는 학과와 그 학과의 세 과목을 생성합니다. intro와 intermediate 과목은 둘 다 nextCourse 속성에 저장된 추천 후속 과목을 가지고 있는데, 이는 학생이 이 과목을 완료한 후 수강해야 할 과목에 대한 미소유 옵셔널 참조를 유지합니다.

lecture image

미소유 옵셔널 참조는 래핑하는 클래스의 인스턴스를 강하게 잡지 않으므로, ARC가 인스턴스를 할당 해제하는 것을 막지 않습니다. 미소유 옵셔널 참조는 nil이 될 수 있다는 점을 제외하면 ARC 하에서 미소유 참조와 동일하게 동작합니다.

비옵셔널 미소유 참조와 마찬가지로, nextCourse가 항상 할당 해제되지 않은 과목을 참조하도록 보장하는 것은 사용자의 책임입니다. 예를 들어, 이 경우 department.courses에서 과목을 삭제할 때, 다른 과목이 그 과목에 대해 가질 수 있는 참조도 제거해야 합니다.

미소유 참조와 암시적 언래핑 옵셔널 속성

위의 약한 참조와 미소유 참조 예제는 강한 참조 순환을 끊어야 하는 더 일반적인 시나리오 두 가지를 다룹니다.

PersonApartment 예제는 둘 다 nil이 될 수 있는 두 속성이 강한 참조 순환을 일으킬 가능성이 있는 상황을 보여줍니다. 이 시나리오는 약한 참조로 가장 잘 해결됩니다.

CustomerCreditCard 예제는 nil이 될 수 있는 속성 하나와 nil이 될 수 없는 속성 하나가 강한 참조 순환을 일으킬 가능성이 있는 상황을 보여줍니다. 이 시나리오는 미소유 참조로 가장 잘 해결됩니다.

그러나 세 번째 시나리오가 있는데, 여기서는 초기화가 완료되면 둘 다 속성이 항상 값을 가져야 하고 nil이 되어서는 안 됩니다. 이 시나리오에서는 한 클래스의 미소유 속성과 다른 클래스의 암시적 언래핑 옵셔널 속성을 결합하는 것이 유용합니다.

이는 초기화가 완료되면 두 속성 모두 직접(옵셔널 언래핑 없이) 액세스될 수 있도록 하면서도 여전히 참조 순환을 방지할 수 있게 해줍니다. 이 섹션에서는 그런 관계를 설정하는 방법을 보여줍니다.

아래 예제는 각각 다른 클래스의 인스턴스를 속성으로 저장하는 CountryCity라는 두 클래스를 정의합니다. 이 데이터 모델에서, 모든 국가는 항상 수도를 가져야 하고, 모든 도시는 항상 국가에 속해야 합니다. 이를 나타내기 위해, Country 클래스는 capitalCity 속성을, City 클래스는 country 속성을 가집니다:

class Country {
    let name: String
    var capitalCity: City!
    init(name: String, capitalName: String) {
        self.name = name
        self.capitalCity = City(name: capitalName, country: self)
    }
}
 
 
class City {
    let name: String
    unowned let country: Country
    init(name: String, country: Country) {
        self.name = name
        self.country = country
    }
}
swift

두 클래스 사이의 상호의존성을 설정하기 위해, City의 이니셜라이저는 Country 인스턴스를 받아서 그 인스턴스를 country 속성에 저장합니다.

City의 이니셜라이저는 Country의 이니셜라이저 내에서 호출됩니다. 그러나 Two-Phase Initialization에 설명된 대로, 새로운 Country 인스턴스가 완전히 초기화될 때까지 Country의 이니셜라이저는 selfCity 이니셜라이저에 전달할 수 없습니다.

이 요구사항을 처리하기 위해, CountrycapitalCity 속성을 타입 어노테이션 끝에 느낌표가 붙은 암시적 언래핑 옵셔널 속성으로 선언합니다(City!). 이는 capitalCity 속성이 다른 옵셔널처럼 nil을 기본값으로 가지지만, Implicitly Unwrapped Optionals에 설명된 대로 값을 언래핑할 필요 없이 액세스될 수 있다는 것을 의미합니다.

capitalCitynil의 기본값을 가지기 때문에, Country 인스턴스는 이니셜라이저 내에서 name 속성을 설정하는 즉시 완전히 초기화된 것으로 간주됩니다. 이는 Country 이니셜라이저가 name 속성을 설정하는 즉시 암시적인 self 속성을 참조하고 전달하기 시작할 수 있다는 것을 의미합니다. 따라서 Country 이니셜라이저는 자신의 capitalCity 속성을 설정할 때 City 이니셜라이저에 대한 매개변수 중 하나로 self를 전달할 수 있습니다.

이 모든 것은 강한 참조 순환을 만들지 않고 단일 문에서 CountryCity 인스턴스를 생성할 수 있으며, capitalCity 속성은 옵셔널 값을 언래핑할 필요 없이 직접 액세스될 수 있다는 것을 의미합니다:

var country = Country(name: "Canada", capitalName: "Ottawa")
print("\(country.name)'s capital city is called \(country.capitalCity.name)")
// Prints "Canada's capital city is called Ottawa"
 
swift

위 예제에서 암시적 언래핑 옵셔널을 사용한 것은 두 단계 클래스 이니셜라이저의 모든 요구사항이 충족된다는 것을 의미합니다. capitalCity 속성은 초기화가 완료되면 비옵셔널 값처럼 사용되고 액세스될 수 있으면서도 여전히 강한 참조 순환을 피할 수 있습니다.

참조 개수 증가 및 감소로 각 참조를 정리해 볼게요.

강한 참조(strong reference)

강한 참조는 참조 카운트를 증가시킵니다. 즉, 어떤 객체에 대한 강한 참조를 생성하면, 해당 객체의 참조 카운트가 1 증가합니다. 이는 ARC가 해당 객체를 메모리에 유지하도록 보장합니다. 객체에 대한 모든 강한 참조가 제거되면, 즉 참조 카운트가 0이 되면, ARC는 해당 객체를 메모리에서 해제합니다.

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?
var reference2: Person?
var reference3: Person?
 
reference1 = Person(name: "John Appleseed")
// Prints "John Appleseed is being initialized"
// 참조 카운트: 1
 
reference2 = reference1
// 참조 카운트: 2
 
reference3 = reference1
// 참조 카운트: 3
 
reference1 = nil
// 참조 카운트: 2
 
reference2 = nil
// 참조 카운트: 1
 
reference3 = nil
// 참조 카운트: 0
// Prints "John Appleseed is being deinitialized"
swift

약한 참조(weak reference)

약한 참조는 참조 카운트를 증가시키지 않습니다. 즉, 어떤 객체에 대한 약한 참조를 생성해도, 해당 객체의 참조 카운트는 변하지 않습니다. 이는 ARC가 해당 객체를 메모리에 유지하도록 보장하지 않는다는 것을 의미합니다. 객체에 대한 모든 강한 참조가 제거되면, 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 john: Person?
var unit4A: Apartment?
 
john = Person(name: "John Appleseed")
unit4A = Apartment(unit: "4A")
 
john!.apartment = unit4A
unit4A!.tenant = john
 
john = nil
// Prints "John Appleseed is being deinitialized"
 
unit4A = nil
// Prints "Apartment 4A is being deinitialized"
swift

미소유 참조(unowned reference)

미소유 참조도 약한 참조와 마찬가지로 참조 카운트를 증가시키지 않습니다. 그러나 약한 참조와 달리, 미소유 참조는 절대 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 Appleseed")
john!.card = CreditCard(number: 1234_5678_9012_3456, customer: john!)
 
john = nil
// Prints "John Appleseed is being deinitialized"
// Prints "Card #1234567890123456 is being deinitialized"
swift

이런 식으로, 강한 참조, 약한 참조, 미소유 참조는 ARC와 상호작용하여 메모리 관리를 수행하는 방식에 차이가 있습니다. 각 참조 타입은 우리가 코드에서 객체들 사이의 관계를 모델링하는 방식에 따라 적절히 사용될 수 있습니다.