티스토리 뷰

ARC는 Automatic Reference Counting의 약자로 Swift의 메모리 관리 방식입니다. ARC는 컴파일러가 인스턴스의 참조 횟수를 카운팅하여 참조 횟수가 0인 인스턴스를 메모리에서 해제시키는 방식으로 동작합니다. ARC의 도입으로 인스턴스를 참조하고(retain) 해제하는(release) 코드를 개발자가 직접 작성해야 했던 방식(MRR, Manual Retain-Release)을 벗어날 수 있게 되었습니다.

 

이 글에서는 ARC를 이해할 때 필요한 몇 가지 개념들과 Swift 공식 문서의 내용을 번역 및 요약하였습니다. 몇 가지 설명을 제외하거나 의역한 부분이 있으니 꼭 공식 문서를 읽어보시는 것을 추천합니다 :)

 


 

먼저 Swift 공식 문서의 ARC 소개를 살펴보겠습니다.

 

Swift는 ARC(Automatic Reference Counting)을 통해 앱의 메모리 사용 상태를 추적 및 관리합니다. 이 말은 사용자가 메모리 관리를 고민할 필요가 없다는 뜻입니다. ARC는 클래스의 인스턴스가 더 이상 필요하지 않게 되면 자동으로 메모리에서 해제시킵니다.

 

Reference Counting(이하 RC)은 참조 타입인 클래스 인스턴스 및 클로저에만 적용됩니다. 구조체와 열거형(enumeration)은 참조 타입이 아닌 값 타입이기 때문에 참조를 사용하지 않기 때문입니다.

 

즉, ARC는 참조 타입에 대하여 Reference Counting 방법을 사용하여 자동으로 메모리를 관리해 주는 기술입니다.

ARC 이전의 메모리 관리

ARC를 이해하기 위해, 이 기술이 나온 배경을 알아봤습니다. ARC가 나오기 전에는 개발자가 직접 메모리 관리에 신경을 써야 했습니다. 메모리 관리를 직접 한다는 것은 인스턴스를 메모리에 할당하고 필요하지 않을 때 메모리에서 해제시키는 코드를 적절하게 사용해야 한다는 의미입니다. ARC 이전에는 Retain Cycle을 추적하여 인스턴스를 retain 한 만큼 명시적으로 release 시켜 주어야 했습니다. 이 방법을 MRR(Manual Retain-Release) 또는 MRC(Manual Reference Counting)이라고 합니다.

MRR 또는 MRC 방식을 사용한 메모리 관리

 

그런데, 개발자가 직접 RC를 관리하게 되면 문제가 발생하기 쉽습니다. Retain과 Release가 정확하게 쌍을 이루어야 하는데, 어딘가에서 Retain 또는 Release를 빼먹거나 더 많이 하게 된다면 Memory Leak이나 Dangling Pointer 같은 문제가 발생할 수 있습니다. Retain이 더 많으면 RC가 0이 되지 않아서 인스턴스가 메모리에서 영원히 해제되지 않고 남아있게 될 것이고(Memory Leak), Release가 더 많다면 RC가 0일 때도 release를 시도하여 존재하지 않는 메모리에 접근하게 될 것입니다(Dangling Pointer).

 

ARC는 인스턴스를 참조하게 되는 시점에 컴파일러가 사용자 대신 Retain/Release code를 자동으로 생성합니다.

ARC 도입으로 Retain/Release 코드가 사라지면서 코드가 더욱 간단해집니다

다음과 같이 Point 클래스를 만들고 객체를 생성하여 사용하는 코드(왼쪽)는 컴파일 타임에 RC 관련 코드가 추가됩니다(오른쪽). 객체가 인스턴스를 참조할 때는 retain method를 추가하고, 모두 사용하고 난 뒤 메모리에서 정리할 때는 release method를 추가합니다. Point 클래스에는 클래스의 참조 횟수를 저장하기 위한 refCount 프로퍼티가 추가됩니다.

 

이렇게 코드가 추가된 상태로 컴파일이 진행됩니다. 이 과정을 메모리 영역에서 바라보면 다음과 같습니다.

init과 retain이 모두 호출되어야 refCount가 참조 횟수인 2로 갱신됩니다

point1과 point2는 지역 변수로 Stack 영역에 저장되고 Point 인스턴스는 Heap 영역에 저장됩니다. 그리고 point1은 Point 인스턴스의 주소값을 할당받으면서 해당 인스턴스를 참조하고, point2는 point1이 가진 주소값을 할당받으면서 같은 인스턴스를 참조합니다. 클래스의 refCount는 init 및 retain method가 호출될 때 1씩 증가하고 있습니다.

 

point1과 point2 사용이 끝나고 나면 release method가 호출되면서 Point 인스턴스 참조가 해제됩니다. refCount도 1씩 감소하여 0이 됩니다.

이렇게 refCount가 0이 되면 인스턴스가 소멸됩니다.

이렇게 ARC가 메모리 관리를 맡음으로써, 개발자는 더 이상 메모리 관리에 신경을 않고 더 중요한 작업에 집중할 수 있게 되었습니다. 또, 실수를 줄여 메모리 관련 이슈가 발생할 가능성도 낮아졌습니다

How ARC Works

다시 공식 문서를 보겠습니다.

 

클래스 인스턴스를 만들 때 마다 ARC는 클래스 인스턴스의 정보를 저장하기 위해 메모리에 일정 공간을 할당합니다. 이 메모리 공간은 인스턴스의 타입 정보와 저장 프로퍼티의 값 등을 저장합니다. 

 

추가적으로, 더 이상 인스턴스가 필요하지 않게 되면, ARC는 그 인스턴스를 메모리에서 해제하여 다른 곳에서 사용할 수 있도록 합니다. 이렇게 하면 클래스 인스턴스를 더 이상 사용하지 않을 때 메모리 공간을 차지하고 있지 않게 됩니다.

 

하지만, ARC가 아직 사용하고 있는 인스턴스를 메모리에서 해제한다면 더 이상 해당 인스턴스의 프로퍼티에 접근하거나 메서드를 호출할 수 없게 됩니다. 게다가, 이 상태에서 인스턴스에 접근하려고 하면 충돌이 발생합니다.

 

인스턴스가 아직 사용해야 할 때 없어지지 않도록 하기 위해서, ARC는 인스턴스를 참조하고 있는 프로퍼티, 변수, 상수 등을 추적하여 참조되는 횟수를 추적합니다. 그리고 한 개라도 활성화된 참조가 존재한다면 인스턴스를 메모리에서 해제시키지 않습니다.

 

이런 동작을 위해 프로퍼티, 변수, 상수 등에 인스턴스를 할당할 때 마다 그 변수들이 인스턴스를 강하게 참조하도록 합니다. 해당 인스턴스를 확실하게 잡고서 해제되지 않게 만들기 때문에, 이런 참조를 강한 참조라고 합니다. 강한 참조는 활성화된 RC가 존재할 때 인스턴스를 메모리에서 해제하지 않도록 합니다.

 

즉, ARC는 인스턴스가 강한 참조된 횟수(RC)를 추적하여 인스턴스를 메모리에서 해제할지 말지 결정한다는 것입니다.

 

ARC in Action

ARC가 어떻게 동작하는지 알았으니, 실제 코드를 통해 확인해 보겠습니다. Person 클래스를 정의하고 Person 타입의 객체 세 개를 선언합니다. 옵셔널 타입이기 때문에 nil로 초기화 된 상태이고, 아직 인스턴스를 참조하지 않습니다.

 

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?


Person 클래스의 인스턴스를 생성하고 referenc1 객체에 할당하면 reference1 객체는 Person 인스턴스를 강한 참조 합니다. 강한 참조를 했기 때문에 ARC는 생성한 Person 인스턴스에 대해 RC를 증가시킬 것이고, 인스턴스가 메모리에서 해제되지 않을 것입니다.

reference1 = Person(name: "John Appleseed")
// Prints "John Appleseed is begin initialized"

 

reference1을 reference2와 reference3에 각각 할당하면 나머지 두 객체도 처음 생성했던 Person 인스턴스를 강한 참조하고, RC도 2개 증가합니다. 여기까지 총 3개의 강한 참조가 존재하게 되고, RC는 3을 기록합니다.

 

reference2 = reference1
reference3 = reference1

 

여기서 reference1, reference2에 각각 nil을 할당하면 Person 인스턴스의 강한 참조 2개가 해제되고 RC도 1이 됩니다. 아직 활성화된 RC가 남아있으므로 Person 인스턴스는 메모리에서 해제되지 않습니다.

 

reference1 = nil
reference2 = nil

 

마지막 남은 reference3까지 nil을 할당하면 reference1,2,3 모두 사용하지 않는 객체가 됩니다. 즉, 이 객체들을 사용해서 Person 인스턴스의 프로퍼티나 메서드에 접근할 수 없는 것입니다. 이 때 Person 인스턴스의 강한 참조 횟수(RC)가 0이 되고, ARC는 Person 인스턴스를 메모리에서 해제합니다. Person 클래스의 deinit이 호출되는 것으로 확인할 수 있습니다.

 

reference3 = nil
// Prints "John Appleseed is being deinitialized"

 

여기까지 RC가 증가하고 감소하는 경우를 정리하면 다음과 같습니다.

 

1. Count Up

 

객체에 인스턴스의 참조가 할당되는 경우 RC가 증가합니다.

 

2. Count Down

 

인스턴스를 참조하고 있는 객체에 nil을 할당하는 경우 RC가 감소합니다.

 

RC가 감소하는 경우 좀 더 일반적으로 말하면 객체가 소멸하거나 저장하고 있던 참조값을 잃어버릴 때 입니다. 위에서 알아본 nil이 할당되는 경우 외에 두 가지가 더 있습니다.

 

첫 번째는 객체가 지역 변수로 선언되었을 때, 객체가 포함된 block의 실행이 끝나는 경우입니다. 즉, 실행 흐름이 변수가 선언된 함수의 scope를 벗어나면 함수나 클로저 안애서 선언 및 초기화된 변수는 함수의 실행이 끝나면서 모두 소멸합니다. 이 때 소멸하는 변수가 갖고 있던 참조값이 사라지면서 해당 인스턴스의 RC도 감소합니다.

 

func execute() {
    let reference1 = Person(name: "John Appleseed")  // RC 1
}
execute()  // 실행 종료후 RC 0

 

두 번째는 객체가 일반 지역 변수가 아닌 클래스의 프로퍼티로 선언되었을 때, 해당 클래스의 인스턴스가 소멸되어 메모리에서 해제되는 경우입니다. 이 경우는 프로퍼티의 입장에서 첫 번째 경우와 비슷합니다.

 

class Car {
    var owner: Person
    
    init(owner: Person) {
        self.owner = owner
        print("Car is being initialized by owner \(owner.name)")
    }
    
    deinit {
        print("\(owner.name)'s car is being deinitialized")
    }
}

var car: Car? = Car(owner: Person(name: "John Appleseed"))
// Prints "John Appleseed is being initialized"
// Prints "Car is being initialized by owner John Appleseed"

car = nil
// Prints "John Appleseed's car is being deinitialized"
// Prints "John Appleseed is being deinitialized"

 

 

Strong Reference Cycles(강한 참조 순환) Between Class Instances

위에서 알아본 것 처럼, ARC는 클래스 인스턴스에 대해 참조되는 횟수를 카운팅하여 더 이상 사용되지 않을 때(RC가 0일 때) 인스턴스를 메모리에서 해제시킵니다. 하지만, 인스턴스의 참조 횟수(RC)가 절대로 0이 될 수 없는 상황이 발생할 수 있습니다. 두개의 인스턴스가 서로를 강한 참조로 잡고 있으면 각 인스턴스가 다른 인스턴스를 메모리에 남아있게 만듭니다.

 

다음 예시는 Person과 Apartment 클래스를 통해 강한 참조 순환 문제가 어떻게 발생할 수 있는지 보여줍니다. 각 클래스는 또 다른 클래스 타입의 프로퍼티를 갖고 있습니다.

 

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

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

 

Person 타입의 john과 Apartment 타입의 unit4A 을 생성하고 각각 Person 인스턴스와 Apartment 인스턴스를 생성하여 할당합니다. john은 Person을, unit4A는 Apartment를 강한 참조합니다.

 

var john: Person?
var unit4A: Apartment?

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

 

이제 john으로 접근할 수 있는 Apartment 타입의 apartment 프로퍼티와 unit4A로 접근할 수 있는 Person 타입의 tenant 프로퍼티에 각각 unit4A와 john을 할당합니다. 즉, 생성한 두 객체를 서로 연결합니다.

 

john?.apartment = unit4A
unit4A.tenant = john

 

이렇게 되면 두 인스턴스 사이에 강한 참조 관계는 다음과 같이 됩니다. Person 인스턴스와 Apartment 인스턴스가 상호 강한 참조를 갖습니다.

이 상황에서 john과 unit4A 객체의 참조를 끊어버리면 각각의 인스턴스의 RC는 1씩 감소할 것입니다.

 

john = nil
unit4A = nil

 

하지만 이렇게 되면 apartment 객체와 tenant 객체의 참조를 끊을 방법이 없어집니다. 두 객체는 각각 Person과 Apartment 클래스의 프로퍼티이므로 john과 unit4A 객체를 통해 접근해야 하는데, john과 unit4A에 nil을 할당했기 때문에 프로퍼티에 접근할 방법이 없습니다. 결국, 두 인스턴스는 RC가 0이 될 수 없으므로 메모리에 영원히 남게 되고 메모리 누수가 발생합니다.

Resolving Strong Reference Cycles

강한 참조 순환 문제를 해결하기 위해 Swift가 제공하는 방법에는 weak referenceunowned reference 두 가지가 있습니다. Weak와 unowned reference는 순환 참조 상황에 있는 인스턴스가 다른 인스턴스를 강하게 잡고 있지 않도록 만들어서 강한 참조 순환을 만들지 않도록 합니다.

 

여기서 인스턴스를 강하게 잡고 있지 않는다는 것은 인스턴스를 참조하지만 RC를 증가시키지 않는다는 의미입니다. ARC가 인스턴스를 메모리에서 해제시키는 때는 RC가 0인 순간을 기준으로 하기 때문입니다.

 

약한 참조(Weak Reference)

약한 참조는 인스턴스를 강하게 잡고 있지 않기 때문에, 약한 참조가 남아있더라도 ARC는 인스턴스를 메모리에서 해제시킬 수 있습니다. 이런 경우, ARC는 자동으로 약한 참조를 하는 객체에 nil을 할당합니다. 런타임에 객체에 nil을 할당해야 하므로 옵셔널 타입의 변수(var)로 선언해야 합니다.

 

약한 참조는 강한 참조 순환 문제를 만드는 두 인스턴스에서 다른 한쪽의 인스턴스가 상대적으로 먼저 메모리에서 해제될 가능성이 있을 때 사용하면 됩니다. 위에서 봤던 Person과 Apartment 클래스를 보면, 아파트(Apartment)는 특정 순간에는 세입자(tenant)가 없을 수도 있기 때문에 Apartment의 tenant 프로퍼티를 약한 참조로 설정하는 것이 적절합니다.

 

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

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

 

이렇게 되면 Apartment가 Person을 약한 참조하게 되고 이 때는 RC를 증가시키지 않습니다.

이제 john에 nil을 할당하면 남아있단 RC가 0이 되고 Person 인스턴스는 메모리에서 해제될 수 있습니다. Person 인스턴스가 메모리에서 해제되면서 apartment 프로퍼티의 강한 참조도 해제됩니다. 결과적으로, unit4A의 강한 참조 한 개만 남게 됩니다.

 

john = nil
// Print "John Appleseed is being deiniitialized"

 

마지막으로 unit4A에 nil을 할당하면 남아 있던 강한 참조가 해제되고 Apartment 인스턴스도 메모리에서 해제됩니다.

 

unit4A = nil
// Print "Apartment 4A is being deinitialized"

 

이렇게 강한 참조 순환 문제가 해결되었습니다!

미소유 참조(Unowned Reference)

미소유 참조는 약한 참조와 같은 역할을 하지만, 사용하는 방법에서 차이가 있습니다. 약한 참조는 참조하던 인스턴스가 메모리에서 먼저 해제되면 nil을 할당하여 값이 없음을 나타내지만, 미소유 참조는 항상 값이 있다고 가정합니다. 즉, 참조하던 인스턴스가 먼저 메모리에서 해제될 일이 없다고 생각하는 것입니다. 그래서 실제로 인스턴스를 참조하는 중간에 해당 인스턴스가 메모리에서 해제되더라도, ARC는 객체에 nil을 할당하지 않습니다. 

 

이런 이유로, 미소유 참조는 반드시 인스턴스가 참조되는 중간에 메모리에서 해제되지 않음이 확실한 경우에만 사용해야 합니다. 이미 메모리에서 해제된 인스턴스를 미소유 참조하는 객체에 접근하려고 하면 런타임 에러가 발생합니다.

 

미소유 참조는 두 인스턴스가 같은 생명 주기를 가질 때 사용합니다. 미소유 참조의 설명을 위해 Customer와 CreditCard 클래스를 보겠습니다. Customer와 CreditCard의 관계는 위에서 봤던 Person과 Apartment 클래스의 관계와는 다릅니다. Customer는 CreditCard를 가질 수도 있고 아닐 수도 있지만, CreditCard는 Customer 없이는 존재할 수 없는 것입니다. 그래서 Customer 클래스는 CreditCard를 강한 참조하는 옵셔널 타입의 card 프로퍼티를 갖지만, CreditCard는 Customer를 미소유 참조하는 customer 프로퍼티를 갖습니다. 강한 참조 순환이 발생할 수 있는 상황을 피하기 위해 반드시 값을 가져야 하는 경우에 미소유 참조를 하도록 만든 것입니다.

 

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

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

 

Customer와 CreditCard가 상호 참조 관계를 형성하도록 인스턴스를 할당합니다.

 

var john: Customer?

john = Customer(name: "John Appleseed")
john?.card = CreditCard(number: 1234_5678_9012_3456, customer: john)

 

이 상황에서 john에 nil을 할당하면 Customer 인스턴스는 강한 참조가 더 이상 남아있지 않게 되어(RC가 0) 메모리에서 해제됩니다. 연달아서 card 프로퍼티가 소멸하면서 CreditCard 인스턴스의 강한 참조도 해제되고, CreditCard 인스턴스도 메모리에서 해제됩니다.

 

john = nil
// Prints "John Appleseed is being deinitialized"
// Prints "Card #1234567890123456 is being deinitialized"

 

Customer와 CreditCard 클래스를 사용한 예시에서 unonwed 대신 weak를 사용해도 문제는 없습니다. 다만, weak를 사용하면 옵셔널 타입의 변수로 선언해야 하므로 의미상 맞지 않는 설계가 될 수도 있습니다. 안전성을 보장할 수 있다면 용도에 맞게 사용하는 것이 좋습니다.

 

지금까지 알아본 강한참조(Strong), 약한참조(Weak), 미소유 참조(Unowned)를 정리하면 다음과 같습니다.

 

  강한 참조 약한 참조 미소유 참조
Reference Counting O X X
Variable(var) O O O
Constant(let) O X O
Optional O O O
Non-Optional O X O
Problem 강한 참조 순환 문제 - 인스턴스가 해제된 후 객체에 접근하려고 하면 런타임 에러 발생
(Dangling Pointer)

 


 

지금까지 ARC의 동작 방식과 Strong, Weak, Unowned 참조에 대해 알아봤습니다. 다음 글에서는 미소유 참조에 대헤 조금 더 살펴보겠습니다.

댓글
댓글쓰기 폼