ARC

피터·2022년 10월 11일
0
post-thumbnail

스위프트는 프로그램의 메모리 사용을 관리하기 위하여 메모리 관리 기법인 ARC를 사용합니다.

ARC란

ARC(Auto Reference Counting) 기능은 자동으로 메모리를 관리해주는 방식입니다. ARC는 더이상 필요하지 않은 클래스의 인스턴스를 메모리에서 해제하는 방식으로 동작합니다.

ARC와 가비지 컬렉션의 가장 큰 차이는 참조를 계산하는 시점입니다.

ARC는 인스턴스가 언제 메모리에서 해제되어야 할지를 컴파일과 동시에 결정합니다. 가비지 컬렉션은 그렇지 않습니다.

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

클래스 인스턴스를 생성할 때마다 ARC는 그 인스턴스에 대한 정보를 저장하기 위한 메모리 공간을 따로 할당합니다. 그리고 더이상 필요 없는 상태가 되면 인스턴스가 차지하던 메모리 공간을 다른 용도로 활용할 수 있도록 ARC가 메모리에서 인스턴스를 없앱니다.

그런데 만약 인스턴스의 프로퍼티 혹은 인스턴스 메서드를 호출하려고 하는데 메모리에서 해제가 된 후라면, 그리고 이 인스턴스에 강제로 접근하려고 하면 잘못된 메모리 접근으로 인해 프로그램이 강제 종료될 확률이 큽니다.

다른 인스턴스의 프로퍼티나 변수, 상수 등 어느 한 곳에서 인스턴스를 참조한다면 ARC가 해당 인스턴스를 해제하지 않고 유지해야 하는 명분이 됩니다.

강한 참조(Strong Reference)

인스턴스가 계속해서 메모리에 남아있어야 하는 명분을 만들어 주는 것이 바로 강한참조(Strong Reference)입니다.

인스턴스는 참조 횟수가 0이 되는 순간 메모리에서 해제되는데, 인스턴스를 다른 인스턴스의 프로퍼티나 변수, 상수 등에 할당할 때 강한참조를 사용하면 참조 횟수가 1 증가합니다. 또, 강한참조를 사용하는 프로퍼티, 변수, 상수 등에 nil을 할당해주면 원래 자신에게 할당되어 있던 인스턴스의 참조 횟수가 1 감소합니다.

참조의 기본은 강한참조이므로 클래스 타입의 프로퍼티, 변수, 상수 등을 선언할 때 별도의 식별자를 명시하지 않으면 강한참조를 합니다.

강한참조 순환 문제

복합적으로 강한참조가 일어나는 상황에서 강한참조의 규칙을 모르고 사용하게 되면 문제가 발생할 수 있습니다. 가장 대표적인 예로는 인스턴스끼리 서로를 강한참조할 때 강한참조 순환이 발생합니다.

class Person {
    var name: String
    var pet: Dog?
    
    init(name: String) {
        self.name = name
    }
    
    deinit {
        print("Person이 메모리에서 해제가 되었습니다.")
    }
}

class Dog {
    let name: String
    var owner: Person?
    
    init(name: String) {
        self.name = name
    }
    
    deinit {
        print("Dog가 메모리에서 해제가 되었습니다.")
    }
}

var peter: Person? = Person(name: "peter")
var puppy: Dog? = Dog(name: "강아지")

peter?.pet = puppy
puppy?.owner = peter

peter = nil
puppy = nil

위의 경우 디이니셜라이저가 호출되지 않습니다. 즉 메모리에서 해제가 되고 있지 않다는 뜻입니다. 메모리 누수가 발생하는 거죠.

이를 해결하기 위해서는 어떻게 해야할까요?

약한참조(Weak Reference)

약한참조는 강한참조와 달리 자신이 참조하는 인스턴스의 참조 횟수를 증가시키지 않습니다. 약한참조를 사용한다면 자신이 참조하는 인스턴스가 메모리에서 해제될 수도 있다는 것을 예상해볼 수 있어야합니다.

사용법

참조 타입의 프로퍼티나 변수의 선언 앞에 weak 키워드를 써주면 그 프로퍼티나 변수는 자신이 참조하는 인스턴스를 약한참조합니다.

💡 약한참조는 상수에서 쓰일 수 없습니다. 만약 자신이 참조하던 인스턴스가 메모리에서 해제된다면 nil이 할당될 수 있어야 하기 때문입니다. 그래서 약한참조를 할 때는 자신의 값을 변경할 수 있는 변수로 선언해야 합니다. 더불어 nil이 할당될 수 있어야 하므로 약한참조는 항상 옵셔널이어야 합니다. 즉, 옵셔널 변수만 약한참조를 할 수 있습니다.

그래서 위의 문제를 해결해보자면

class Person {
    var name: String
    weak var pet: Dog?
    
    init(name: String) {
        self.name = name
    }
    
    deinit {
        print("Person이 메모리에서 해제가 되었습니다.")
    }
}

class Dog {
    let name: String
    var owner: Person?
    
    init(name: String) {
        self.name = name
    }
    
    deinit {
        print("Dog가 메모리에서 해제가 되었습니다.")
    }
}

var peter: Person? = Person(name: "peter")
var puppy: Dog? = Dog(name: "강아지")

peter?.pet = puppy
puppy?.owner = peter

peter = nil
puppy = nil

// Person이 메모리에서 해제가 되었습니다.
// Dog가 메모리에서 해제가 되었습니다.

미소유참조(Unowned Reference)

미소유참조는 약한참조와 다르게 자신이 참조하는 인스턴스가 항상 메모리에 존재할 것이라는 전제를 기반으로 동작합니다. 즉, 자신이 참조하는 인스턴스가 메모리에서 해제되더라도 스스로 nil을 할당해주지 않다는 뜻입니다. 그렇기 때문에 미소유참조를 하는 변수나 프로퍼티는 옵셔널이나 변수가 아니어도 됩니다.

그렇지만 미소유참조를 하면서 메모리에서 해제된 인스턴스에 접근하려 한다면 잘못된 메모리 접근으로 런타임 오류가 발생해 프로세스가 강제로 종료됩니다. 따라서 미소유참조는 참조는 동안 해당 인스턴스가 메모리에서 해제되지 않으리라는 확신이 있을 때만 사용해야 합니다.

사용법

참조 타입의 변수나 프로퍼티의 정의 앞에 unowned 키워드를 써주면 그 변수(상수)나 프로퍼티는 자신이 참조하는 인스턴스를 미소유참조하게 됩니다.

예시

class Person {
    var name: String
    var creditCard: CreditCard?
    
    init(name: String) {
        self.name = name
    }
    
    deinit {
        print("\(name) 메모리에서 해제 되었습니다.")
    }
}

class CreditCard {
    let number: Int
    unowned let owner: Person
    
    init(number: Int, owner: Person) {
        self.number = number
        self.owner = owner
    }
    
    deinit {
        print("카드 \(number) 메모리에서 해제 되었습니다.")
    }
}

var hero: Person? = Person(name: "히로")

if let hero = hero {
    hero.creditCard = CreditCard(number: 100, owner: hero)
}

hero = nil

// 히로 메모리에서 해제 되었습니다.
// 카드 100 메모리에서 해제 되었습니다.

미소유 옵셔널 참조

클래스를 참조하는 옵셔널을 미소유로 표시할 수 있습니다.

ARC 소유 모델에 따르면 미소유 옵셔널 참조와 약한참조를 같은 상황에 사용할 수 있습니다. 차이는 미소유 옵셔널 참조는 항상 유효한 객체를 가리키거나 그렇지 않다면 nil을 직접 할당해주어야 합니다.

💡 옵셔널은 값타입 아닌가요?
옵셔널 값의 기본 타입은 스위프트 표준 라이브러리에 열거형으로 정의된 Optional 타입입니다. 즉, 값 타입이란 뜻입니다. 값 타입은 참조 타입이 아니므로 unowned 등으로 참조 관리를 할 수 없습니다. 그러나 옵셔널은 값 타입일지라도 예외적으로 unowned 등을 활용해 참조 관리를 할 수 있습니다. 또, 클래스를 감싸는 옵셔널은 참조 횟수 계산을 하지 않기 때문에 강한참조로 관리할 필요가 없습니다.

클로저의 강한참조 순환

클로저또한 참조타입이기 때문에 강한참조 순환이 일어날 수 있습니다.

예시

class Person {
    var name: String
    
    init(name: String) {
        self.name = name
    }
    
    lazy var introduce: () -> String = {
        return "나의 이름은 \(self.name)입니다."
    }
    
    deinit {
        print("메모리에서 해제가 되었습니다.")
    }
}

var peter: Person? = Person(name: "피터")

print(peter?.introduce())

peter = nil

// Optional("나의 이름은 피터입니다.")

위 예시를 통해 알 수 있는 것은 Person이 메모리에서 해제가 되지 않았다는 것입니다.

내부에 self가 있기 때문에 서로가 서로를 참조하고 있는 상황입니다.

이를 해결하기 위해서는 캡처리스트(획득목록, Capture list)를 통해 해결할 수 있습니다.

캡처리스트(획득목록, Capture list)

캡처리스트는 클로저 내부에서 참조 타입을 획득하는 규칙을 제시해줄 수 있는 기능입니다.

예를 들면,


lazy var introduce: () -> String = {
	return "나의 이름은 \(self.name)입니다."
}

위 코드의 self를 약한참조로 지정할 수도, 강한참조로 지정할 수도 있다는 뜻입니다. 위 코드는 self를 약한참조로 지정할 경우 문제를 해결할 수 있습니다.

사용법

캡처리스트는 클로저 내부의 매개변수 목록 이전에 위치에 작성해줍니다. 캡처리스트는 참조 방식과 참조할 대상을 대괄호로 둘러싼 목록 형식으로 작성하며 획득 목록 뒤에는 in 키워드를 써줍니다. 획득목록에 명시한 요소가 참조 타입이 아니라면 해당 요소들은 클로저가 생성될 때, 초기화됩니다.

var a = 0
var b = 0

let closure = { [a] in
    print("a는 \(a),", "b는 \(b)")
    b = 20
}

a = 10
b = 10
closure()
print(b)

// a는 0, b는 10
// 20

결과를 살펴보면 a는 클로저의 캡처리스트를 통해 클로저가 생성될 때 값 0을 획득했지만 b는 따로 값을 획득하지 않았습니다.

그 이후 a와 b의 값을 변경을 했지만 a는 클로저가 생성되었을 때 획득한 값을 갖지만, b는 변경된 값을 사용하는 것을 확인할 수 있습니다.

a 변수는 클로저가 생성됨과 동시에 캡처리스트 내에 다시 a라는 이름의 상수로 초기화된 것입니다. 그렇기 때문에 a의 값을 변경하더라도 클로저의 캡처리스트를 통한 a와는 별개가 되는 것입니다.

그러나 만약 캡처리스트에 해당하는 요소가 참조 타입이라면 조금 다른 결과를 가져옵니다. 그 이유는 참조 타입은 캡처리스트에서 값 복사가 아니고 주소값을 복사하기 때문입니다.

참조타입은 캡처리스트에서 어떤 방식으로 참조할 것인지, 즉 강한획득(strong capture), 약한획득(weak capture), 미소유획득(unowned capture)을 할 것인지 정해줄 수 있습니다. 또 획득의 종류에 따라 참조 횟수를 증가시킬지 결정할 수 있습니다.

단 약한획득은 상수가 옵셔널 상수로 지정됩니다.

class SomeClass {
    var value: Int = 0
}

var x: SomeClass? = SomeClass()
var y = SomeClass()

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

x = nil
y.value = 10

closure()

// nil 10

그러면 이전에 강한참조를 일으켰던 예시를 해결해봅시다.

class Person {
    var name: String
    
    init(name: String) {
        self.name = name
    }
    
    lazy var introduce: () -> String = { [weak self] in
        return "나의 이름은 \(self?.name)입니다."
    }
    
    deinit {
        print("메모리에서 해제가 되었습니다.")
    }
}

var peter: Person? = Person(name: "피터")

print(peter?.introduce())

peter = nil

// Optional("나의 이름은 Optional(\"피터\")입니다.")
// 메모리에서 해제가 되었습니다.

약한획득을 통해서 해결했습니다. 미소유 참조를 하게된다면, 해당 인스턴스가 존재하지 않을 경우에 잘못된 메모리 접근을 야기할 수 있으므로 미소유참조는 신중히 사용해야 하며, 문제가 될 소지가 있다면 약한참조를 사용하는게 좋습니다.

자료 출처: 야곰 스위프트 프로그래밍 3판

"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다."
profile
iOS 개발자입니다.

0개의 댓글