본문 바로가기
앱/iOS

Swift - ARC

by msm1029 2022. 1. 31.
반응형

ARC(Automatic Reference Counting)란?

ARC는 자동으로 메모리를 관리해주는 방식으로 더 이상 필요하지 않은 클래스의 인스턴스를 메모리에서 해제하는 방식으로 동작한다. 따라서, 프로그래머가 메모리 관리에 신경을 덜 쓸 수 있어 편리함을 제공한다.

 

ARC가 관리하는 참조 횟수 계산(Reference Counting)은 참조 타입인 클래스의 인스턴스에만 적용된다. 구조체와 열거형같은 값 타입은 참조 횟수 계산과 무관하다.

 

Swift에서는 ARC를 사용하지만, 자바 등 다른 프로그래밍 언어에서는 메모리 관리 기법으로 가비지 컬렉션(Garbage Collection)을 사용한다. 아래는 ARC와 가비지 컬렉션의 차이를 표로 나타낸 것이다. 가장 큰 차이점은 참조를 계산하는 시점이다. 이로 인해 각각의 장단점이 명확하다.

 

메모리 관리 기법 ARC 가비지 컬렉션
참조 카운팅 시점 컴파일 시 프로그램 동작 중
장점 컴파일 당시 이미 인스턴스의 해제 시점이 정해져있어 인스턴스가 언제 메모리에서 해제될지 예측할 수 있다. 또한, 메모리 관리를 위한 시스템 자원을 추가할 필요가 없다.  - 상호 참조 상황 등의 복잡한 상황에서도 인스턴스를 해제할 수 있는 가능성이 더 높다.
 - 특별히 규칙에 신경 쓸 필요가 없다.
단점 ARC의 작동 규칙을 모르고 사용하면 인스턴스가 메모리에서 영원히 해제되지 않을 수 있다.  - 프로그램 동작 외에 메모리 감시를 위한 추가 자원이 필요하기 때문에 한정적인 자원 환경에서는 성능 저하가 발생할 수 있다.
 - 명확한 규칙이 없기 때문에 인스턴스가 정확히 언제 메모리에서 해제될지 예측하기 어렵다.

 

클래스의 인스턴스를 생성할 때마다 ARC는 그 인스턴스에 대한 정보를 저장하기 위한 메모리 공간을 따로 할당한다. 그 메모리 공간에는 인스턴스의 타입 정보와 함께 관련된 저장 프로퍼티의 값 등을 저장한다. 그 후에 인스턴스가 더 이상 필요없는 상태가 되면 인스턴스가 차지하던 메모리 공간을 다른 용도로 활용할 수 있도록 ARC가 메모리에서 인스턴스를 제거한다.

 

그런데 만약 아직 더 사용해야 할 인스턴스를 메모리에서 해제시킨다면 인스턴스와 관련된 프로퍼티, 메서드를 호출할 수 없다. 강제로 접근하려고 할 경우 잘못된 메모리 접근으로 프로그램이 강제 종료될 수 있다. 따라서, ARC는 인스턴스가 필요한 상황에 메모리에서 해제되지 않도록 인스턴스 참조 여부를 계속 추적한다. 

 

강한 참조

위와 같이 인스턴스가 계속 메모리에 유지되어야할 이유를 만들어주는 것이 바로 강한 참조(Strong Reference)이다. 인스턴스는 참조 횟수가 0이 되는 순간 메모리에서 해제되는데, 다른 인스턴스의 프로퍼티나 변수, 상수 등에 할당할 때 강한 참조를 사용하면 참조 횟수가 1 증가한다. 또, 강한 참조를 사용하는 프로퍼티, 변수, 상수 등에 nil을 할당해주면 원래 자신에게 할당되어 있던 인스턴스의 참조 횟수가 1 감소한다. 참조의 기본은 강한 참조이므로 클래스 타입의 프로퍼티, 변수, 상수 등을 선언할 때 별도의 식별자가 없으면 강한 참조를 한다. 아래는 강한 참조의 참조 횟수를 확인하는 코드이다.

 

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: "Moon")
//yagom is being initialized
//인스턴스의 현재 참조 횟수는 1

reference2 = reference1 //참조 횟수 2
reference3 = reference1 //참조 횟수 3

reference3 = nil //참조 횟수 2
reference2 = nil //참조 횟수 1
reference1 = nil //참조 횟수 0
// yagom is being deinitialized

 

인스턴스끼리 서로가 서로를 강한 참조할 때 강한참조 순환(Strong Reference Cycle)이 발생할 수 있다.

아래의 코드를 통해 살펴보자.

class Person {
    let name: String
    
    init(name: String) {
    	self.name = name
    }
    
    var room: Room?
    
    deinit {
    	print("\(name) is being deinitialized")
    }
}

class Room {
    let number: String
    
    init(number: String) {
    	self.number = number
    }
    
    var host: Person?
    
    deinit {
    	print("\(name) is being deinitialized")
    }
}

var moon: Person? = Person(name: "moon") // Person 인스턴스 참조 횟수 1
var room: Room? = Room(number: "101") // Room 인스턴스 참조 횟수 1

room?.host = moon // Person 인스턴스 참조 횟수 2
moon?.room = room // Room 인스턴스 참조 횟수 2

moon = nil // Person 인스턴스 참조 횟수 1
room = nil // Room 인스턴스 참조 횟수 1

// 각 인스턴스를 참조할 방법이 없지만, 메모리에 존재하는 상태이다

 

약한 참조

약한 참조(Weak Reference)는 강한 참조와 달리 자신이 참조하는 인스턴스의 참조 횟수를 증가시키지 않는다. 약한 참조는 weak 키워드를 사용하며 상수에서 쓰일 수 없다. 만약 자신이 참조하던 인스턴스가 메모리에서 해제된다면 nil이 할당되어야 하기 때문에 항상 변수이며 옵셔널이어야 한다. 위에서 발생한 강한 참조 순환 문제를 약한 참조로 해결해보자

 

class Room {
    let number: String
    
    init(number: String) {
    	self.number = number
    }
    
    weak var host: Person?
    
    deinit {
    	print("\(name) is being deinitialized")
    }
}

var moon: Person? = Person(name: "moon") // Person 인스턴스 참조 횟수 1
var room: Room? = Room(number: "101") // Room 인스턴스 참조 횟수 1

room?.host = moon // Person 인스턴스 참조 횟수 1
moon?.room = room // Room 인스턴스 참조 횟수 2

moon = nil // Person 인스턴스 참조 횟수 0, Room 인스턴스 참조 횟수 1
//moon is being deinitialized

print(room?.host) // nil

room = nil // Room 인스턴스 참조 횟수 0
//101 is being deinitialized

 

미소유 참조

약한 참조와 마찬가지로 미소유 참조(Unowned Reference)도 인스턴스의 참조 횟수를 증가시키지 않는다. 미소유 참조는 약한 참조와는 다르게 자신이 참조하는 인스턴스가 항상 메모리에 존재할 것이라는 전제로 동작한다. 따라서, 인스턴스가 메모리에서 해제되더라도 nil을 할당해주지 않으며 옵셔널이나 변수가 아니어도 된다. 사용하기 위해 unowned 키워드를 사용한다. 아래의 코드로 미소유참조를 더 살펴보자

 

class Person {
	let name: String
    
    // 카드를 소지할 수도, 소지하지 않을 수도 있기 때문에 옵셔널로 정의한다.
    // 카드를 한 번 가지면 잃어버리지 않는다고 가정하여 강한 참조를 한다.
    var card: CreditCard?
    
    init(name: String){
    	self.name = name
    }
    
    deinit { print("\(name) is being initialized") }
}

class CreditCard {
    let number: UInt
    unowned let owner: Person // 카드는 소유자가 반드시 존재해야 한다.
    
    init(number: UInt, owner: Person) {
    	self.number = number
        self.owner = owner
    }
    
    deinit { print("Card #\(number) is being deinitialized") }
}

var moon: Person? = Person(name: "Moon") // Person 인스턴스 참조 횟수 1

if let person: Person = moon {
	person.card = CreditCard(number: 1, owner: person)
    //CreditCard 인스턴스 참조 횟수 1
    //Person 인스턴스 참조 횟수 1
}

moon = nil //Person, CreditCard 인스턴스 참조 횟수 0
//moon is being deinitialized
//Card #1 is being deinitialized

 

클로저의 강한 참조 순환

강한 참조 순환 문제는 두 인스턴스끼리의 참조일 때만 발생하는 것이 아니다. 클로저가 인스턴스의 프로퍼티일 때나 클로저의 값 획득 특성때문에도 발생한다. 클로저는 클래스와 같은 참조 타입이다. 아래의 코드로 강한 참조 순환 문제가 어떻게 발생하는지 알아보자.

 

class Person {
    let name: String
    let hobby: String?
    
    lazy var introduce: () -> String {
        var introduction: String = "My name is \(self.name)."
        
        guard let hobby = self.hobby else {
            return introduction
        }
        
        introduction += " "
        introduction += "My hobby is \(hobby)."
        
        return introduction
    }
    
    init(name: String, hobby: String? = nil) {
        self.name = name
        self.hobby = hobby
    }
    
    deinit {
        print("\(name) is being deinitialized")
    }
}

var moon: Person? = Person(name: "Moon", hobby: "Programming")
print(moon?.introduce()) //My name is Moon. My hobby is Programming.
moon = nil

코드를 잘 살펴보면 deinit이 호출되지 않은 것을 알 수 있다. Person 클래스의 introduce 프로퍼티에 클로저를 할당한 후 클로저 내부에서 self 프로퍼티를 사용할 수 있었던 이유는 introduce가 지연 저장 프로퍼티이기 때문이다. lazy 프로퍼티로 할당한 클로저 내부에서 Person 클래스의 인스턴스가 모두 초기화되어 사용 가능한 상태에서만 클로저에 접근할 수 있다. 따라서, 클로저 내부에서는 self 프로퍼티를 통해서만 다른 프로퍼티에 접근할 수 있다.

 

introduce 프로퍼티를 통해 클로저를 호출하면 자신의 내부에 있는 참조 타입 변수 등을 획득한다. 클로저는 언제든지 자신 내부의 참조들을 사용할 수 있도록 참조 횟수를 증가시켜 메모리에서 해제되는 것을 방지하는데, 이 때 자신을 프로퍼티로 갖는 인스턴스의 참조 횟수도 증가시킨다. 이렇게 강한 참조 순환이 발생하면 인스턴스가 메모리에서 해제될 수 없다.

 

획득 목록

앞의 문제를 획득 목록(Capture list)를 통해 해결할 수 있다. 획득 목록은 클로저 내부에서 참조 타입을 획득하는 규칙을 제시해줄 수 있는 기능이다. 예를 들어, 앞선 코드에서 클로저 내부의 self를 약한 참조/강한 참조로 지정할 수 있는 기능이다. 획득 목록은 클로저 내부의 매개변수 목록 이전에 작성해주며, 참조 방식과 참조할 대상을 대괄호로 둘러싼 목록 형식으로 작성하고 획득목록 뒤에는 in 키워드를 써준다. 획득 목록에 명시한 요소가 참조 타입이 나리마녀 해당 요소들은 클로저가 생성될 때 초기화된다. 아래는 예시 코드이다.

 

var a = 0
var b = 0
let closure = { [a] in
    print(a, b)
    b = 20
}

a = 10
b = 10
closure() // 0 10
print(b) // 20

 

변수 a는 클로저의 획득 목록을 통해 값 0을 획득했지만, b는 값을 획득하지 않았다. a와 b의 값을 변경한 후 클로저를 실행하면 a는 클로저가 생성되었을 때 획득한 값을 갖지만 b는 변경된 값을 사용하는 것을 볼 수 있다. 그러나 만약 획득 목록에 해당하는 요소가 참조 타입이라면 조금 다른 결과를 볼 수 있다. 

 

class SimpleClass {
    var value: Int = 0
}

var x = simpleClass()
var y = simpleClass()

let closure = { [x] in
    print(x.value, y.value)
}

x.value = 10
y.value = 10

closure() // 10 10

 

변수 x는 획득 목록을 통해 값을 획득했지만, y는 획득 목록에 별도로 명시하지 않았다. 하지만 두 변수 모두 참조 타입의 인스턴스이기 때문에 동작은 같다. 참조 타입은 획득 목록에서 어떤 방식으로 참조할 지 명시할 수 있다. 약한 획득을 하게 되면 획득하는 상수가 옵셔널 상수로 지정된다. 앞서 클로저의 강한 참조 순환 문제를 해결해보자

 

class Person { 
    let name: String
    let hobby: String?
    
    lazy var introduce: () -> String { [unowned self] in
        var introduce: String = "My name is \(self.name)."
        
        guard let hobby = self.hobby else {
            return introduction
        }
        
        introduction += " "
        introduction += "My hobby is \(hobby)."
        return introduction
    }
    
    init(name: String, hobby: String? = nil) {
        self.name = name
        self.hobby = hobby
    }
    
    deinit {
        print("\(name) is being deinitialized")
    }
}

var moon: Person? = Person(name: "Moon", hobby: "Programming")
print(moon?.introduce()) //My name is Moon. My hobby is Programming
moon = nil // Moon is being deinitialized
반응형

' > iOS' 카테고리의 다른 글

Swift - 모나드  (0) 2022.01.22
Swift의 연산자  (0) 2022.01.13
iOS - 객체 제어  (0) 2021.11.07
iOS와 코코아 터치 프레임워크  (0) 2021.11.06
iOS 앱의 구조  (0) 2021.11.05