ARC

이원희·2021년 2월 19일
0

 🐧 Swift

목록 보기
21/32
post-thumbnail

오늘은 ARC에 대해 알아보자.
공식문서를 기준으로 작성한다.


ARC

Swift는 ARC (Automatic Reference Counting)을 이용해 메모리 관리를 한다.
이름에서 알 수 있듯 자동으로 참조 횟수를 관리하며 동작한다.
따라서 개발자는 메모리 관리에서 해방되어 코드를 작성하는데에만 집중할 수 있고, ARC가 알아서 더이상 사용하지 않는 인스턴스를 메모리에서 해제한다.

참조 횟수(Reference Counting)은 클래스 타입의 인스턴스에만 적용되고 값 타입인 구조체, 열거형 등에는 적용되지 않는다.
즉, ARC는 클래스 타입의 인스턴스만 관리한다.


ARC 동작 방식

클래스 인스턴스가 만들어질 때마다 ARC는 인스턴스를 저장하기 위한 메모리를 할당한다.
(이 메모리는 인스턴스에 대한 정보와 관련된 저장 프로퍼티 값들도 가지고 있다.)
인스턴스가 더이상 사용되지 않을 때 ARC는 인스턴스가 차지하고 있는 메모리를 해제한다.

이때의 '더이상 사용되지 않는다'의 기준은 참조 횟수이다.
ARC에서는 아직 사용중인 인스턴스를 해제하지 않기 위해 얼마나 많은 프로퍼티, 상수 혹은 변수가 인스턴스에 대한 참조를 갖고 있는지 추적한다.
최소 하나라도 인스턴스에 대한 참조가 있는 경우 인스턴스를 메모리에서 해제하지 않는다.


ARC의 사용

class Test {
    let testMessage: String
    
    init(testMessage: String) {
        self.testMessage = testMessage
        print("\(testMessage) init")
    }
    deinit {
        print("\(testMessage) deinit")
    }
}

var test1: Test? 
var test2: Test?
var test3: Test?

test1 = Test(testMessage: "1")

test2 = test1
test3 = test1

test1 = nil
test2 = nil
test3 = nil

// 출력
1 init
1 deinit

인스턴스가 생성/해지 될때마다 지정된 구문을 출력하는 Test 클래스를 선언했다.
test1, test2, test3은 모두 Optional 변수로서 초기값을 지정해주지 않으면 초기값으로 nil을 갖는다.


test1 = Test(testMessage: "1")

test1에 testMessage로 1을 갖는 Test 인스턴스를 생성했다.
testMessage로 1을 갖는 인스턴스가 생성되었으므로 메모리에 적재되므로 init()에 작성해둔 구문이 출력된다.
test1Test(testMessage: "1") 인스턴스를 참조하고 있으므로 해당 rc가 1 증가한다.
(rc: 1)


test2 = test1

test2test1 즉, Test(testMessage: "1")을 참조하고 있으므로 해당 rc가 1 증가한다.
(rc: 2)


test3 = test1

test3test1 즉, Test(testMessage: "1")을 참조하고 있으므로 해당 rc가 1 증가한다.
(rc: 3)


test1 = nil

test1참조를 해제해 rc가 1 감소한다.
(rc: 2)


test2 = nil

test2참조를 해제해 rc가 1 감소한다.
(rc: 1)


test3 = nil

test2참조를 해제해 rc가 1 감소한다.
(rc: 0)

reference counting이 보로소 0이 되었다.
즉, 더 이상 참조하는 곳이 없다.
이때에 Test(testMessage: "1)은 메모리에서 해제된다.


강한 참조 순환

ARC는 참조 횟수를 추적하고 더이상 참조되지 않는(사용되지 않는) 인스턴스는 자동으로 메모리에서 해제된다.
하지만 참조되지 않지만(사용되지 않지만) 메모리에서 해제되지 않는 상황이 있다.
이런 경우를 강한 참조 순환이라고 한다.

class Person {
    let name: String
    var apartment: Apartment?
    init(name: String) {
        self.name = name
        print("\(name) is init")
    }
    deinit{
        print("\(name) is deinit")
    }
}

class Apartment {
    let unit: String
    var tenant: Person?
    init(unit: String) {
        self.unit = unit
        print("\(unit) is init")
    }
    deinit{
        print("\(unit) is deinit")
    }
}

var John: Person?
var unit4A: Apartment?

John = Person(name: "John")
unit4A = Apartment(unit: "4A")

John!.apartment = unit4A
unit4A!.tenant = John

John = nil
unit4A = nil

// 출력
John is init
4A is init

Person, Apartment 클래스를 정의했다.
Person 클래스는 Apartment 클래스 인스턴스를 참조하는 프로퍼티를
Apartment 클래스는 Person 클래스 인스턴스를 참조하는 프로퍼티를 포함하여 작성했다.


var John: Person?
var unit4A: Apartment?

John = Person(name: "John")
unit4A = Apartment(unit: "4A")

John!.apartment = unit4A
unit4A!.tenant = John

클래스 인스턴스를 생성한 후
서로 참조하도록 프로퍼티를 지정했다.

위의 사진과 같은 참조 관계를 갖게 된다.
rc를 살펴보자.

Person instance의 rc는 2이다.
john 변수가 Person instance를 참조하고 (+1)
Apartment instance의 tenant 프로퍼티가 Person instance를 참조하고 있다.(+1)

Apartment instance의 rc 또한 2이다.
unit4A 변수가 Apartment instance를 참조하고 (+1)
Person instance의 apartment 프로퍼티가 Apartment instance를 참조하고 있다.(+1)


John = nil
unit4A = nil

Johnunit4A 변수가 참조하고 있던 인스턴스를 nil로 지정했다.
보통 생각엔은 변수가 참조하고 있던 인스턴스가 nil이 되면 메모리에서 해제될 것 같다.

하지만 위의 그림 처럼 Johnunit4A 변수의 참조는 삭제되었지만 인스턴스끼리의 참조는 남아있다.
따라서 rc가 0이 되지 않으므로 메모리에서 해제되지 않는다.

이러한 경우를 강한 참조 순환이라고 한다.

강한 참조 순환에 대해 얘기하는 이유는 메모리 누수때문이다.

더이상 사용하지 않는 인스턴스가 메모리에 계속 남아 있다면...
이런 누수가 많다면...
앱은 금방 죽어버릴 것이다.


강한 참조 (Strong References)

그렇다면 왜 강한 참조라고 할까?
별다른 지시어를 설정하지 않으면 Swift에서는 Strong이 기본이다.

강한 참조는 rc를 증가시킨다.
즉, 강한 참조는 인스턴스들을 Heap에 머물도록 하는 강한 힘이라고 할 수 있다.

위에서 알아본 강한 참조 순환을 생각해보면 PersonApartment 클래스 인스턴스가 서로를 너무 강하게 잡고 있어 Heap에서 떠날 수 없는 상황이다.

그렇다면 약한 힘도 있지 않을까?라는 의문을 가지고 아래를 살펴보자.


강한 참조 순환 해결법

강한 참조 순환을 해결하는데에는 weakunowned를 사용할 수 있다.
weakunowned 참조는 모두 ARC에서 rc를 증가시키지 않고 인스턴스를 참조한다.

weak는 참조하고 있는 인스턴스가 먼저 메모리에서 해제될때 사용하고
unowned는 참조하고 있는 인스턴스가 같은 시점 혹은 더 뒤에 해제될때 사용한다.


약한 참조 (Weak References)

약한 참조는 rc를 증가시키지 않는다.
weak는 해당 객체가 nil일 수 있다.
즉, weak는 반드시 Optional이고 var 키워드와 함께 사용해야한다.

약한 참조로 선언하면 참조하고 있는 것이 먼저 메모리에서 해제되기 때문에 ARC는 약한 참조로 선언된 참조 대상이 해제되면 런타임에 자동으로 참조하고 있는 변수에 nil을 할당한다.
(ARC에서 약한 참조에 nil을 할당하면 프로퍼티 옵저버는 실행되지 않는다.)

class Person {
    let name: String
    var apartment: Apartment?
    init(name: String) {
        self.name = name
        print("\(name) is init")
    }
    deinit{
        print("\(name) is deinit")
    }
}

class Apartment {
    let unit: String
    weak var tenant: Person?
    init(unit: String) {
        self.unit = unit
        print("\(unit) is init")
    }
    deinit{
        print("\(unit) is deinit")
    }
}

var John: Person?
var unit4A: Apartment?

John = Person(name: "John")
unit4A = Apartment(unit: "4A")

John!.apartment = unit4A
unit4A!.tenant = John

John = nil
unit4A = nil

// 출력
John is init
4A is init
John is deinit
4A is deinit

강한 참조 순환 예시 코드와 달리 Apartment 클래스의 tenant 프로퍼티를 약한 참조로 바꿔줬다.

참조 관계는 위와 같이 변경된다.

그렇다면 rc를 생각해보자.
강한 참조 순환에서는 Person instance의 rc가 2였다.

Person instance의 rc는 1이다.
john 변수가 Person instance를 참조한다. (+1)
Apartment instance의 tenant 프로퍼티가 Person instance를 참조하고는 있지만 weak이므로 rc는 증가하지 않는다.


John = nil
unit4A = nil

Johnnil을 할당하면 Person instance는 메모리에서 해제된다.
Person instance의 rc는 1였는데 John의 참조가 사라지면서 rc가 0이되기 때문이다.

Person instance가 메모리에서 해제됨과 동시에 Apartment instance의 tenant 프로퍼티가 nil이 되었다.

이렇게 nil이 되는 것을 zeroing이라고 부른다.

약한 참조로 선언하면 참조하고 있는 것이 먼저 메모리에서 해제되기 때문에 ARC는 약한 참조로 선언된 참조 대상이 해제되면 런타임에 자동으로 참조하고 있는 변수에 nil을 할당한다.
따라서 변수(var)로 선언해야 한다.

Person instance가 메모리에서 해제됨에 따라 Person instance의 apartment 프로퍼티의 Apartment instance 참조가 사라지게 되므로 Apartment instance 또한 메모리에서 잘 해제된다.


미소유 참조 (Unowned References)

미소유 참조는 참조 대상이 되는 인스턴스가 현재 참조하고 있는 것과 같은 생애주기를 갖거나 더 긴 생애 주기를 갖기 때문에 항상 참조에 값이 있다고 기대한다.
따라서 ARC는 미소유 참조에는 nil을 할당하지 않는다.

class Customer {
    let name: String
    var card: CreditCard?
    init(name: String) {
        self.name = name
        print("\(name) is init")
    }
    deinit {
        print("\(name) is deinit")
    }
}

class CreditCard {
    let number: UInt64
    unowned let customer: Customer
    init(number: UInt64, customer: Customer) {
        self.number = number
        self.customer = customer
        print("\(number) is init")
    }
    deinit {
        print("\(number) is deinit")
    }
}

var john: Customer?
john = Customer(name: "John")
john!.card = CreditCard(number: 1, customer: john!)
john = nil

// 출력
John is init
1 - John is init
John is deinit
1 is deinit

참조 관계는 아래와 같다.

rc를 살펴보자.

Person instance의 rc는 1이다.
john 변수가 Person instance를 참조한다. (+1)
CreditCard instance의 customer 프로퍼티가 Person instance를 참조하지만 unowned 참조로 rc는 증가하지 않는다.

CreditCard instance의 rc는 1이다.
Customer instance의 card 프로퍼티가 참조한다. (+1)


john = nil

john 변수의 Person instance 참조를 해제하면 아래와 같이 참조 관계가 변한다.

Person instance의 rc는 0이 된다.
따라서 Person instance가 먼저 메모리에서 해제된다.

Person instance가 해제됨에 따라 Apartment instance의 rc도 1 감소한다.
따라서 Apartment instance도 메모리에서 해제된다.


그렇다면 코드를 살짝 바꿔보자.

class Customer {
    let name: String
    var card: CreditCard?
    init(name: String) {
        self.name = name
        print("\(name) is init")
    }
    deinit {
        print("\(name) is deinit")
    }
}

class CreditCard {
    let number: UInt64
    unowned let customer: Customer
    init(number: UInt64, customer: Customer) {
        self.number = number
        self.customer = customer
        print("\(number) - \(customer.name) is init")
    }
    deinit {
        print("\(number) - \(customer.name) is deinit")
    }
}

var john: Customer?
john = Customer(name: "John")
john!.card = CreditCard(number: 1, customer: john!)
john = nil

CreditCarddeinit()의 출력문을 바꿨다.
그대로 실행해보면

이와 같은 상황을 맞이할 수 있다.

unowned 참조는 nil이 불가하다.
즉, uniowned 참조는 값이 항상 존재할거라고 생각하고 접근한다.

하지만 위의 코드에서는 john = nil 해주므로 customer 프로퍼티가 nil인 상태에서 접근하므로 크래쉬가 난다.


미소유 참조를 더 알아보자

위에서 알아본 미소유 참조 (Unowned Reference)에 대해서 좀 더 알아보자.

미소유 참조와 암시적 옵셔널 프로퍼티 언래핑(Unowned References and Implicitly Unwrapped Optional Properties)

이 부분은 코드를 먼저 보는게 좋을거 같다.

class Country {
    let name: String
    var capitalCity: City!
    init(name: String, capitalName: String) {
        self.name = name
        self.capitalCity = City(name: capitalName, country: self)
    }
    deinit {
        print("Country \(name) is deinit")
    }
}

class City {
    let name: String
    unowned let country: Country
    init(name: String, country: Country) {
        self.name = name
        self.country = country
    }
    deinit {
        print("City \(name) is deinit")
    }
}

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

country = nil

// 출력
Canada's capital city is called Ottawa
Country Canada is deinit
City Ottawa is deinit

Swift의 클래스는 안전성을 위해 프로퍼티가 세팅되기 전까지는 사용할 수 없다.

Country 클래스의 init()을 확인해보자.
name 프로퍼티는 세팅을 해주었는데 capitalCity 프로퍼티는 뭔가 이상하다.
City 클래스 인스턴스를 생성하는데 Country 인스턴스(self)를 사용하고 있다.
그리고 생성된 City 클래스 인스턴스를 capitalCity 프로퍼티에 세팅해주고 있다.

분명 위에서 프로퍼티가 세팅되기 전까지는 클래스를 사용할 수 없다고 했는데?!
우리가 주목해야할 부분은 var capitalCity: City!이다.
City! 이 구문을 보면 생각나는게 있는데...
Optional에 대하여 포스팅에서 한 번 다뤘었다.

City!암시적 언래핑으로 암시적으로 해당 값은 nil이 아니라고 생각한다.
따라서 nil일 수는 있지만 변수가 nil일때 접근하면 error를 받는다.

그렇다면 위의 코드에서 봐보자.
우리가 알고 있는대로라면 Country 클래스는 capitalCity 프로퍼티가 세팅되지 않아서 City 클래스 인스턴스 생성시 self를 사용할 수 없다.
하지만 capitalCity 프로퍼티의 타입을 암시적 언래핑 타입으로 선언함으로써 name 프로퍼티만 세팅해주면 Country 클래스(self) 를 사용할 수 있다.

미소유 참조와 옵셔널

위에서 weakunowned의 차이를 옵셔널 여부라고 말했다.
그런데 왜 뜬금없이 미소유 참조와 옵셔널?! 이라고 생각할 수 있다.
Swift5 이전까지는 unowned에서 옵셔널이 불가했다.

Swift5부터 unowned에 옵셔널을 지정할 수 있게 되면서 weak 참조와 같은 맥락에서 사용할 수 있게 되었다.
차이점은 unowned optional 참조를 사용할 때는 항상 유효한 객체를 참조하는지 혹은 nil로 설정되어 있는지 확인해야한다.

unowned optional 참조는 내부적으로 strong 참조를 하지 않는다.
즉, 클래스 인스턴스를 strong하게 유지하지 않으므로 ARC가 인스턴스 할당을 해제하는 것을 방해하지 않는다.
unowned optional 참조가 nil이 될 수 있다는 점을 제외하면 unowned 참조와 같은 동작 프로세스를 거친다.


마무리

ARC에 대해 알아보는데 넘 많다...!ㅋㅋㅋ
나눠서 정리해야겠다!
그럼 이만👋

0개의 댓글