Swift - ARC(Automatic Reference Counting)


ARC가 무엇인가?


참조 타입은 하나의 인스턴스가 참조를 통해 여러 곳에서 접근되기 때문에 언제 메모리에서 해제되는지가 중요하다. 인스턴스가 적절한 시점에 메모리에서 해제되지 않으면 한정적인 메모리 자원을 낭비하게 되는 것이고, 이는 성능의 저하로 이어지기 때문이다.

스위프트는 메모리 사용을 관리하기 위해 메모리 관리 기법은 ARC를 사용한다. Automatic Reference Counting이 관리해주는 참조 횟수 계산(reference counting)은 참조 타입인 클래스의 인스턴스에만 적용되기 때문에 구조체나 열거형은 다른 곳에서 참조하지 않아 ARC로 관리할 필요가 없다. 대부분의 경우에 메모리 관리는 Swift에서 “그냥 동작하고”, 스스로 메모리 관리에 대해 고민할 필요는 없다. ARC는 자동으로 더 이상 필요하지 않은 클래스의 인스턴스들의 메모리를 해제한다.

ARC란?

ARC는 자동으로 메모리를 관리해주는 방식이고,, 프로그래머가 메모리 관리에 신경을 덜 쓸 수 있기 때문에 편리하다. ARC는 더이상 필요하지 않은 클래스의 인스턴스를 메모리에서 해제하는 방식으로 동작한다. 다른 프로그래밍 언어에서 사용되는 메모리 관리 기법은 Garbage Collection 기법과의 가장 큰 차이는 참조를 계산하는 시점이다. ARC는 인스턴스가 언제 메모리에서 해제되어야 할 지를 컴파일과 동시에 결정한다.

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

ARC를 이용해 메모리를 자동으로 관리하기 위해서는 몇가지 규칙을 알아야 하는데, ARC는 컴파일과 동시에 인스턴스를 메모리에서 해제하는 시점이 결정되기 때문이다. 원하는 대로 메모리 관리를 하기 위해서는 ARC에 명시적으로 알려줘야 한다.

클래스의 인스턴스를 생성할 때마다 ARC는 인스턴스에 대한 정보를 저장하기 위한 메모리 공간을 추가로 할당한다. 이 고간에는 인스턴스의 타입 정보, 관련된 저장 프로퍼티의 값 등을 저장한다. 그리고 필요 없는 상태가 되면 이 공간을 다른 용도로 활용할 수 있게 ARC가 메모리에서 인스턴스를 없앤다. 그런데 이렇게 메모리를 해제한 뒤 이 인스턴스의 프로퍼티에 접근하거나 인스턴스의 메서드를 호출하려고 하면 잘못된 메모리 접근으로 인해 프로그램이 강제 종료될 수 있다. 인스턴스가 지속적으로 필요한 경우 ARC는 인스턴스가 메모리에서 해제되지 않도록 인스턴스 참조 여부를 계속 추적한다.

강한 참조

인스턴스가 계속해서 메모리에 남아있어야 하는 명분을 주는 것이 바로 Strong Reference, 강한참조다. 인스턴스는 참조 횟수가 0이 되는 순간 메모리에서 해제되는데, 인스턴스를 할당할 때 강한참조를 사용하면 참조 횟수가 1 증가한다. 그리고 강한참조를 사용하는 곳에 nil을 할당하면 해당 인스턴스의 참조 횟수가 1 감소한다.

참조의 기본은 강한참조로, 별도의 식별자를 명시하지 않으면 강한참조를 한다.

강한참조 순환 문제

인스턴스끼리 서로가 서로를 강한참조할 때가 대표적으로 강한참조 순환, Strong reference cycle이 생기게 된다.

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

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

var yagom: Person? = Person(name: "yagom") // 1
var room: Room? = Room(number: "505") // 1

room?.host = yagom // 2
yagom?.room = room // 2

yagom = nil // 1
room = nil // 1

위 코드에서 Person과 Room 인스턴스에 대한 reference count가 각각 1이어서 메모리에 남아있지만, 접근할 수 있는 방법은 없어진 상태다. 이렇게 메모리에 계속 남아있어서 메모리 누수가 발생하는 것이다. 이렇게 두 인스턴스가 서로를 참조하는 상황에서 강한참조 순환 문제가 발생할 수 있다.

약한 참조

약한 참조(Weak Reference)는 강한참조와 달리 자신이 참조하는 인스턴스의 참조 횟수를 증가시키지 않는다. weak 키워드를 써주면 그 프로퍼티나 변수는 자신이 참조하는 인스턴스를 약한 참조한다. 약한 참조는 다른 인스턴스의 생명 주기가 나보다 짧을 때 사용한다.

약한 참조를 사용하면 자신이 참조하는 인스턴스가 메모리에서 해제될 수도 있다는 것을 예상할 수 있어야 한다. 약한 참조는 상수에서 쓰일 수 없다. 또한 약한 참조는 참조하는 인스턴스를 강하게 쥐고 있지 않기 때문에, 약한 참조가 아직 참조하고 있는 동안에도 해당 인스턴스를 해제하는 것이 가능하다. ARC는 참조하는 인스턴스를 해제할 때 자동으로 약한 참조를 nil로 설정한다. 그래서 약한 참조의 값은 실행 시간에 nil로 바뀌는 것을 허용해야 하기 때문에, 상수에서는 사용되지 못하고 옵셔널 타입의 변수로 선언한다.

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

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

var yagom: Person? = Person(name: "yagom") // 1
var room: Room? = Room(number: "505") // 1

room?.host = yagom // 1
yagom?.room = room // 2

yagom = nil // 0, room 1
room = nil // 0

yagom = nil을 했을 때 room 인스턴스의 referece count는 왜 1이 줄었을까? Person 인스턴스의 참조 횟수가 0이 되면서 메모리에서 해제될 때, 자신의 프로퍼티가 강한참조를 하던 인스턴스의 참조 횟수를 1 감소시키기 때문이다. 그리고 host 프로퍼티는 약한 참조를 하고 있었으므로 자신이 참조하는 인스턴스가 메모리에서 해제되면 자동으로 nil을 할당한다.

미소유참조

Unowned Reference(미소유참조)는 인스턴스의 참조 횟수를 증가시키지 않는다. 미소유참조는 약한참조와 다르게 자신이 참조하는 인스턴스가 항상 메모리에 존재할 것이라는 전제를 깔고 있다. 즉 자신이 참조하는 인스턴스가 메모리에서 해제되도 nil을 할당해주지 않는다는 것이다. 그래서 미소유 참조를 하는 애들은 옵셔널이나 변수가 아니어도 된다. 참조 타입의 변수나 프로퍼티 정의 앞에 unowned를 써주면 자신이 참조하는 인스턴스를 미소유참조하게 된다.

즉 미소유참조는 다른 인스턴스의 생명주기가 나보다 길 때 사용한다.

미소유참조는 참조하는 동안 해당 인스턴스가 메모리에서 해제되지 않으리라는 확신이 있을 때만 사용한다.

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

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

var yagom: Person? = Person(name: "yagom") // 1

if let person: Person = yagom {
    person.card = CreditCard(number: 1004, owner: person) // credit 1 person 1
}

yagom = nil // 0 credit 0

미소유 옵셔널 참조

클래스를 참조하는 옵셔널을 미소유로 표시할 수 있다. 약한 참조랑 차이가 있다면 미소유 옵셔널 참조는 항상 유효한 객체를 가리키거나 그렇지 않으면 직접 nil을 할당해서 신경을 써야 하는 것이다.

class Company {
    let name: String
    var ceo: CEO!
    
    init(name: String, ceoName: String) {
        self.name = name
        self.ceo = CEO(name: ceoName, company: self)
    }
    
    func introducte() {
        print("\(name)의 CEO는 \(ceo.name)이다.")
    }
}

class CEO {
    let name: String
    unowned let company: Company
    
    init(name: String, company: Company) {
        self.name = name
        self.company = company
    }
}
let compnay: Company = Company(name: "무한상사", ceoName: "김태호")

이 경우 ceo 프로퍼티에 암시적 추출 옵셔널을 사용한 이유는 Company 타입의 인스턴스를 초기화할 때, CEO 타입의 인스턴스를 생성하는 과정에서 자기 자신을 참조핟록 보내줘야 하기 때문이다.

클로저의 강한참조

강한 참조 순환 문제는 두 인스턴스끼리의 참조일 때만 발생하는 것 외에 클로저가 인스턴스의 프로퍼티일 때나, 클로저의 값 획득 특성 때문에 발생한다.

강한참조 순환이 발생하는 이유는 클로저가 클래스와 같은 참조 타입이기 때문이다. 클로저를 클래스 인스턴스의 프로퍼티로 할당하면 클로저의 참조가 할당된다. 이때 참조 타입과 참조 타입이 서로 강한 참조를 할 때(클로저 안에서 self.~ 처럼 인스턴스를 self로 획득할 때) 강한참조 순환 문제가 발생한다.

클로저의 강한참조 순환 문제는 클로저의 획득 목록을 통해 해결할 수 있다. 먼저 강한 참조 순환이 어떻게 이루어지는 지 보도록 하겠다.

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

var yagom: Person? = Person(name: "yagom", hobby: "eating")
print(yagom?.introduce())
yagom = nil 

마지막에 yagom = nil을 할당했지만 메모리가 해제되지 않았다. Person 클래스의 introduce 프로퍼티에 클로저를 할당한 후 내부에서 self를 할당할 수 있던 이유는 지연 저장 프로퍼티이기 때문이다. introduce를 통해 클로저를 호출하면 클로저는 자신이 언제든지 자신 내부의 참조들을 사용할 수 있도록 참조 횟수를 증가시켜 메모리에서 해제되는 것을 방지하는데, 이때 자신을 프로퍼티로 갖는 인스턴스의 참조 횟수도 증가시킨다.

획득 목록

앞의 문제를 획득 목록(Capture list)을 통해 해결할 수 있다. 획득목록은 클로저 내부에서 참조 타입을 획득하는 규칙을 제시할 수 있는 기능이다.