본문 바로가기
Swift

순환참조에 관하여

by iOS 개린이 2022. 9. 28.

-강한 순환 참조란 두 객체가 서로를 strong(강한) 참조하고 있을 때, 서로가 서로를 참조하기 때문에 둘 다 메모리가 해제되지 않는 현상을 말한다. 

-강한 순환 참조는 메모리 누수를 발생시킨다. 메모리 누수란 필요 없는 메모리가 해제되지 않고, 계속 남아있는 상황을 말한다. 이것은 앱의 성능을 저하시킨다.

 

-Swift는 ARC를 통해 Reference counting을 해서 메모리를 자동으로 해제시켜준다고 했다.

하지만 ARC는 강한 순환 참조까지 방지해주는 기능은 없다. 따라서 이 문제를 피하기 위한 학습을 해야한다.

 

 

strong reference(강한 참조)

 

-강한 참조는 Swift에서 가장 기본적인 참조 유형이다.

class MyClass {
    var name: String
    
    init(name: String) {
        self.name = name
        print("\(name) is being initialized")
    }
    
    deinit {
        print("\(name) is being deinitialized")
    }
}

var reference1: MyClass? = MyClass(name: "MyClass instance")

 

위 코드에서 'reference1' 은 'MyClass'의 인스턴스를 강하게 참조하고 있다. (RC + 1)

인스턴스는 'reference1' 이 nil이 될 때까지 메모리에서 강하게 유지시켜주는 강한 참조를 하고 있다는 의미.

 

 

위 코드와 같이 'MyClass' 의 인스턴스를 만들어줄 때,

별도의 식별자를 생성하지 않으면 강한 참조(strong)를 해주는 것이다.

우리가 평소에 아무렇지 않게 쓰는 코드들이 모두 강한참조를 해주고 있던 것이다.

 

 

강한 순환 참조

 

-강한 순환 참조란 두 객체가 서로를 강하게 참조하는 상황.

class ClassA {
    var classBInstance: ClassB?
    deinit { //인스턴스가 메모리에서 해제되기 직전에 호출되는 메서드.
        print("ClassA Instance Deinitialized") 
    }
}

class ClassB {
    var classAInstance: ClassA?
    deinit { //인스턴스가 메모리에서 해제되기 직전에 호출되는 메서드.
        print("ClassB Instance Deinitialized")
    }
}

var instanceA: ClassA? = ClassA()
var instanceB: ClassB? = ClassB()

instanceA?.classBInstance = instanceB
instanceB?.classAInstance = instanceA

instanceA = nil
instanceB = nil

 

 

 

위 코드를 각 시점에 맞게 분리해보자.

 

1. 각각 클래스의 인스턴스를 생성해준다.

var instanceA: ClassA? = ClassA()
var instanceB: ClassB? = ClassB()

 

이 시점에서 할당된 메모리를 보면 스택영역에는 'instanceA' 와 'instanceB' 가 할당되고,

힙 영역에는 'ClassA' 와 ''ClassB' 의 인스턴스가 할당된다.

따라서, 각 참조 카운트는 1씩 증가한다.

이 시점에서는 'instanceA' 와 'instanceB' 에 nil을 할당해도 메모리에서 잘 해제된다.

 

 

2. 각 객체의 프로퍼티에 값을 할당했을 경우

instanceA?.classBInstance = instanceB
instanceB?.classAInstance = instanceA

 

 

이 시점에서 문제가 발생한다.

두 인스턴스의 프로퍼티 변수에 값을 할당함으로써

'ClassA' 의 인스턴스 'instanceA' 는 'ClassB'의 인스턴스 'instanceB'를 참조하고,

반대로 'ClassB'의 인스턴스 'instanceB' 는 'ClassA' 의 인스턴스 'instanceA' 를 참조하는 형태가 된다.

즉 ,두 인스턴스의 프로퍼티 변수가 서로를 강하게 참조하고 있는 "순환 참조" 상태가 된다.

메모리는 서로를 참조하고 있기 때문에 두 객체의 참조카운트는 +1 증가된다. (각각 총 RC = 2) 

 

'instanceA' 나 'instanceB' 는 인스턴스의 일부이기 때문에 힙 메모리에 할당된다.

 

 

3. 메모리에서 해제하기 위해 각 참조 포인터에 nil을 할당한다.

instanceA = nil       //deinit 메서드 발생안함.
instanceB = nil       //deinit 메서드 발생안함.

 

각각 nil을 할당했지만 메모리에서 해제되지 않는다. 이유는?

 

'instanceA' 와 'instanceB' 에 nil값을 주는 순간 스택 영역에 존재하는 참조 포인터가 사라지면서

힙 영역에 있는 'ClassA' 와 'ClassB'  객체의 참조 카운트는 각각 -1 이 된다.

이제 참조 카운트는 각각 1씩 남음.

 

하지만 남은 참조 카운트는 감소시킬 수가 없다.

왜냐하면 서로를 참조하고 있는 'classBInstance' 와 'classAInstance' 에 nil값을 줘야 참조 카운트를 감소시킬 수 있는데,

이 프로퍼티에 접근하려면 해당 인스턴스를 통해 접근해야 한다.

하지만 이미 접근할 수 있는 인스턴스에 nil값을 주고 사라져버렸다.

 

따라서 각각 남은 참조카운트로 인해 메모리에서 해제되지 못하고 메모리 누수가 발생하는 것이다.

이것이 강한 순환 참조로 인한 메모리 누수이다.

 

이제부터 이것을 해결하는 방법에 대해 알아보자.

 

weak reference(약한 참조)

class ClassA {
    weak var classBInstance: ClassB?
    deinit { 
        print("ClassA Instance Deinitialized") 
    }
}

class ClassB {
    var classAInstance: ClassA?
    deinit {
        print("ClassB Instance Deinitialized")
    }
}

var instanceA: ClassA? = ClassA()
var instanceB: ClassB? = ClassB()

instanceA?.classBInstance = instanceB
instanceB?.classAInstance = instanceA

instanceA = nil //deinit 실행
instanceB = nil //deinit 실행

 

'ClassA' 의 'classBInstance'  속성을 'weak' 키워드를 사용하여 약한 참조로 선언했다.

 

'weak' 의 효과는 객체를 참조해도 참조 카운트에 영향을 주지 않는다.(참조 카운트가 증가하지 않음.)

따라서 참조하던 인스턴스가 소멸하면 자동으로 nil이 할당되어 메모리 해제가 가능하다.

자동으로 nil값이 할당되어야 하기 때문에 옵셔널 타입의 변수에만 'weak' 키워드 선언이 가능하다.

 

'weak' 의 사용방법은 두 객체 중에 더 빨리 해제되는 객체를 참조하는 변수에 선언해주면 된다.

위의 코드에서는 두 객체가 해제되는 시점이 동일하기 때문에 어느 곳에 사용해도 상관없다.

 

코드를 메모리의 관점에서 파악해보자.

instanceA?.classBInstance = instanceB
instanceB?.classAInstance = instanceA

 

각 인스턴스의 프로퍼티 변수에 값을 할당했다.

순환참조가 발생했던 상황에서는 서로의 참조 카운트를 증가시켰었다.

하지만 'classBInstance' 는 weak으로 선언했죠?

때문에 'ClassA' 인스턴스의 'classBInstance' 속성이 'ClassB' 인스턴스를 약한 참조한다. 

즉, 'ClassB' 인스턴스의 참조 카운트에는 변화가 없다.

 

'ClassA' 인스턴스의 참조 카운트 = 2

'ClassB' 인스턴스의 참조 카운트 = 1

 

다음으로

instanceA = nil //deinit 실행
instanceB = nil //deinit 실행

 

 

1. 'instanceA' 에 nil을 할당함으로써 'ClassA' 인스턴스에 대한 강한 참조가 사라지고, 참조 카운트가 1 감소하게 된다. 

'ClassA' 인스턴스의 참조 카운트 = 1

'ClassB' 인스턴스의 참조 카운트 = 1

 

2. 이어서 'instanceB' 에 nil을 할당함으로써 'ClassB' 인스턴스에 대한 강한 참조가 사라지고, 참조 카운트가 1 감소하게 된다. 'ClassB' 인스턴스에 대한 참조 카운트가 0이 되면서 메모리에서 해제된다.

'ClassA' 인스턴스의 참조 카운트 = 1

 

3. 마지막으로 'ClassB'가 메모리에서 해제되면서

'ClassB' 인스턴스를 약한 참조하고 있던 'classBInstance' 프로퍼티에 nil값이 할당되고,

'ClassA' 인스턴스를 강한 참조하고 있는 것이 없어지므로 'ClassA' 인스턴스 또한 메모리에서 해제된다. 

 

 

unowned reference(미소유 참조)

class ClassA {
    var classBInstance: ClassB?

    deinit {
        print("ClassA instance is being deinitialized")
    }
}

class ClassB {
    unowned var classAInstance: ClassA

    init(classAInstance: ClassA) {
        self.classAInstance = classAInstance
    }

    deinit {
        print("ClassB instance is being deinitialized")
    }
}

var instanceA: ClassA? = ClassA()
instanceA?.classBInstance = ClassB(classAInstance: instanceA!)

instanceA = nil

 

'unowned' 은 'weak' 와 동일하게 순환 참조를 피하게 해주는 역할을 한다.

하지만 약한 참조가 아닌 미소유 참조를 생성한다.

 

'unowned' 항상 값이 있다고 가정하며 옵셔널이 아니다.

그렇기 때문에 'unowned' 을 통해 참조하는 인스턴스가 메모리에서 제거된 후에 접근하려고 하면 런타입 오류가 발생한다.

따라서 참조되는 인스턴스의 생명주기가 참조하는 인스턴스보다 길거나 같을 때만 'unowned' 사용해야 한다.

즉, 두 가지 객체 중 더 늦게 해제되는(오래 남아있는) 객체를 참조하는 곳에 'unowned'을 지정해야 함.

참조하는 인스턴스 원본이 사라질 때 자신도 같이 사라질 수 있도록 설계해야 한다.

 

 

코드를 메모리의 관점에서 보자.

var instanceA: ClassA? = ClassA()
instanceA?.classBInstance = ClassB(classAInstance: instanceA!)

 

1. 'ClassA' 인스턴스가 메모리에 할당되고 'instanceA' 가 그 인스턴스를 강한 참조한다.

'ClassA' 인스턴스의 참조 카운트 = 1

 

2. 'instanceA' 가 'ClassB' 를 할당과 동시에, 'ClassB' 의 인스턴스는 'unowned' 참조로 'ClasssA' 의 인스턴스를 참조하게 된다. 하지만 'unowned' 참조는 참조 카운트에 영향을 미치지 않기 때문에 'ClasssA' 의 참조 카운트는 여전히 1이다.

'ClassA' 인스턴스의 참조 카운트 = 1

'ClassB' 인스턴스의 참조 카운트 = 1

 

 

instanceA = nil

 

참조 포인터에 nil값을 할당함으로써 'ClassA' 의 인스턴에 대한 강한 참조를 없애준다.

'ClassA' 가 메모리에서 해제되면서 'ClassB'를 강한 참조하고 있던 classBInstance도 메모리에서 해제되고,

결국 'ClassB' 도 메모리에서 해제된다.

 

 

만약 'ClassB' 인스턴스가 여전히 메모리에 남아있고, 'classAInstance' 를 통해 'ClassA' 인스턴에서 접근하려고 하면 런타입 오류가 발생한다.

왜냐하면 이미 메모리에서 해제된 'ClassA' 인스턴스에 접근하려고 하기 때문이다. 

'weak' 에서는 참조하고 있는 인스턴스가 메모리에서 해제되면 자동으로 nil 값을 할당했었지만,

'unowned' 참조는 옵셔널이 아니라 그런 기능이 없다.

따라서 'unowned' 참조하는 인스턴스의 생명주기를 반드시 고려해야 한다.

 

 

weak 참조와 unowned 참조의 차이

 

weak 참조

-참조를 가지지만 참조 카운트를 증가시키지 않는다.

-참조되는 인스턴스가 메모리에서 해제된 후에는 자동으로 nil 값이 할당된다. (옵셔널 타입)

 

-'weak' 으로 참조된 인스턴스가 메모리에서 사라지면, 그를 참조하고 있던 'weak' 참조를 가지는 인스턴스의 프로퍼티는 nil 값이 할당되어야 한다.

따라서 'weak' 참조를 가지는 인스턴스의 생명주기가 'weak' 으로 참조된 인스턴스의 생명주기와 동일하거나 더 길 때 사용된다.

근데 사용사례를 보면 꼭 생명주기를 생각하지 않고 사용하는 경우가 있다.

 

예를 들어 UIViewController와 UIView에 delegate 패턴을 사용하는 경우, 

 

'weak' 참조를 가지는 인스턴스 = UIView (동일하거나 길어야 함.)

'weak' 으로 참조된 인스턴스 = UIViewController 

 

'weak' 참조를 가지는 인스턴스가 'weak' 으로 참조된 인스턴스의 생명주기와 동일하거나 더 길 때 사용된다고 했는데

UIView는 UIViewController에 비해 생명주기가 짧고, UIViewController가 메모리에서 해제된 후에 UIView가 해제된다.

 

따라서 결국 'weak' 참조는 순환참조의 방지가 목적이기 때문에.

"꼭 나와있는 사용상황에 맞게 사용해야해!" 말고 두 가지 객체의 라이프 사이클과 먼저 소멸되어야 하는 순서 등의 상황을 고려하여 'weak' 을 사용하자.

 

 

unowned 참조

-참조를 가지지만 참조 카운트를 증가시키지 않는다.

-참조되는 인스턴스가 메모리에서 해제된 후에도 자동으로 nil 값이 할당되지 않는다. (논 옵셔널 타입)

 

-'unowned' 참조를 통해 접근하려는 인스턴스가 이미 메모리에서 해제된 경우, 런타임 오류가 발생하기 때문에

'unowned' 참조를 가지는 인스턴스의 생명주기는 'unowned' 참조된 인스턴스보다 짧아야 한다.

 

class MyClass {
    var value: String = "Hello, World!"
    
    lazy var printValue: () -> Void = { [unowned self] in
        print(self.value)
    }
    
    deinit {
        print("MyClass deinitialized")
    }
}

var myClassInstance: MyClass? = MyClass()
myClassInstance?.printValue()
myClassInstance = nil //deinit 실행

 

'unowned' 참조를 가지는 인스턴스 = 클로저 (먼저 해제되야 함)

'unowned' 참조된 인스턴스 = MyClass

 

위 코드에서 'printValue' 라는 클로저는 self를 참조하고 있다.

여기서 self 는 MyClass 인스턴스를 나타내며, 클로저가 MyClass를 unowned 참조하면서 참조 카운트에 영향을 주지 않는다.

또한 클로저는 MyClass 인스턴스보다 먼저 해제되기 때문에 unowned를 사용하기 적합하다.

 

하지만!!

비동기 프로그래밍에서 클로저 내부에서 self 를 참조할 때는 주의를 해야한다. 

만약 클로저 작업이 비동기로 시간이 걸리는 작업이며, 그로 인해 MyClass가 먼저 메모리에서 해제된다면?

해제된 메모리에 접근하기 때문에 런타임 오류가 발생할 수도 있겠죠?

따라서 라이프사이클을 잘 고려하는 것이 중요하다.

 

 

결국 'weak' 'unowned' 의 차이는 옵셔널의 처리 방식에 있다.

이 차이에 대해 잘 이해하고, 라이프 사이클을 고려하여 메모리의 안전성을 확보하자!

 

 

 

 

 

참고

[아이폰 개발 | Swift 3] 메모리 관리 (ARC) : 네이버 블로그 (naver.com)

[Swift] 순환참조에 대해 알아보자 (feat. strong, weak, unowned reference) (tistory.com)

[Swift] ARC (2); 메모리 누수, Retain Cycle (tistory.com)

iOS) 메모리 관리 (2/3) - strong , weak, unowned, 순환 참조 (tistory.com)

 

'Swift' 카테고리의 다른 글

Protocol에 관하여  (0) 2022.11.14
lazy variables에 관하여  (0) 2022.10.07
@escaping에 관하여  (0) 2022.08.25
Class와 Struct  (0) 2022.07.04
getter와 setter 그리고 willSet과 didSet  (0) 2022.07.01