포스트

자동 참조 카운팅(ARC) 알아보기

힙(Heap)은 런-타임에 동적으로 메모리를 할당하고 해제할 수 있는 메모리 영역입니다. 개발자가 원할 때 언제든지 필요한 만큼 메모리를 할당해 사용할 수 있습니다. 그리고 할당한 메모리 영역을 더 이상 사용하지 않는다면 반드시 해제를 해주어야 메모리 누수가 발생하지 않습니다. 힙은 굉장히 자유분방한 메모리 영역이지만, 그 자유에는 책임이 따르는 법입니다.

스위프트는 클래스 인스턴스나 클로저와 같은 참조 타입(Reference Type)을 힙 영역에 저장합니다. 그러므로 우리는 참조 타입을 생성하면 반드시 해제를 해주어야 메모리 누수를 막을 수 있습니다. 그런데, 한 가지 이상한 점이 있습니다. 우리는 한 번도 free()와 같은 메서드를 호출해 메모리 해제를 해준 적이 없습니다. 이게 어떻게 가능한 걸까요?

자동 참조 카운팅(ARC)

스위프트는 힙 메모리 영역을 추적하고 관리하는 방법으로 자동 참조 카운팅(ARC, Automatic Reference Counting)을 사용하고 있습니다. 자동 참조 카운팅은 참조 타입을 참조하고 있는 횟수를 추적하여 메모리를 관리하는 기법으로, 참조 타입을 참조하고 있는 횟수가 0이 되면 더 이상 불필요한 메모리라 간주하여 해제를 하게 됩니다. 이러한 횟수를 카운팅하기 위해 모든 참조 타입은 고유란 참조 횟수(RC, Reference Counting)을 가지고 있습니다.

자동 참조 카운팅은 자바의 가비지 컬렉터(GC, Garbage Collector)와 정 반대의 성격을 가진 녀석입니다. 아래는 자동 참조 카운팅과 가비지 컬렉터의 특징을 비교한 표입니다.

 자동 참조 카운팅가비지 컬렉터
특징- 컴파일-타임에 메모리 할당과 해제 시점이 결정됨- 런-타임에 메모리 할당과 해제가 이루어짐
장점- 개발자가 참조 타입의 해제 시점을 파악할 수 있음
- 런-타임 중에 추가로 소모되는 리소스가 없음
- 참조 타입이 정상적으로 해제될 확률이 (ARC에 비해) 높음
단점- 순환 참조로 인해 메모리 누수가 발생할 수 있음- 개발자가 참조 타입의 해제 시점을 파악할 수 없음
- 성능 저하를 불러올 수 있음

이와 별개로, C/C++는 개발자가 직접 관련 메서드를 호출해 메모리 할당과 해제를 해주어야 하지만, 속도가 매우 빠르다는 장점이 있습니다. 자동 참조 카운팅은 C/C++ 방식의 복잡성을 없애고, 성능이 낮은 가비지 컬렉터의 단점을 보완한 방식이라 생각됩니다.

강한 참조(Strong Reference)

강한 참조는 참조 타입을 참조할 때, 참조 타입의 RC를 증가시켜 메모리에 계속 유지하게 합니다.

우리가 아무 생각없이 참조 타입의 인스턴스를 생성하여 변수에 할당하는 일이 바로 강한 참조(String Reference)입니다. 프로퍼티 앞에 weak 키워드를 붙여주지 않는다면 모두 강한 참조로 간주합니다. 참조 타입의 인스턴스를 생성하여 변수에 할당하면 해당 인스턴스의 RC가 1 증가하게 됩니다. 아래는 RC가 증가하는 가장 일반적인 예제 코드입니다.

1
2
3
4
5
6
7
8
class Person {
    var name: String
    
    init(name: String) { self.name = name }
    deinit { print("Person - \(name) - deinit") }
}
// Person 클래스 타입의 인스턴스 RC가 1 증가
var person1: Person? = Person(name: "김건우")

위 예제 코드에서는 Person 클래스 타입의 인스턴스를 생성하여 변수 person1에 할당하고 있습니다. 이 과정에서 해당 인스턴스 RC가 1 증가하게 됩니다. 위 예제 코드를 실행하면 소멸자(deinit)가 호출되지 않습니다. 실제로 프로그램이 종료되기 전까지 인스턴스 RC가 1이기 때문에 메모리에서 해제되지 않았기 때문이죠. (물론, 프로그램이 종료되면 해제됩니다😃) 위 예제 상황을 그림으로 그려보면 아래와 같습니다. (이때, 설명의 편의를 위해 변수 person는 지역 변수라 가정하겠습니다)

2

그럼 Person 클래스의 인스턴스의 참조값이 담긴 변수 person1를 변수 person2에 할당하면 어떻게 될까요?

1
2
// Person 클래스 타입의 인스턴스 RC가 1 증가
var person2: Person? = person1

3

당연하게도, 앞서 예제 코드와 마찬가지로 해당 인스턴스 RC가 1 증가하여 2가 됩니다. 참 쉽죠? 그럼 이제 인스턴스를 해제해보겠습니다. 인스턴스 RC를 감소시키기 위해서 해당 인스턴스의 참조값이 담긴 변수에 nil을 할당해주면 됩니다. RC가 0이 되는 순간, 자동 참조 카운팅은 해당 인스턴스를 더 이상 사용하지 않는 불필요한 메모리로 간주하여 해제하게 됩니다.

1
2
3
4
// Person 클래스 타입의 인스턴스 RC가 1 감소
person1 = nil
// Person 클래스 타입의 인스턴스 RC가 1 감소
person2 = nil

강한 순환 참조(Strong Reference Cycle)

강한 참조의 문제점 중 하나는 강한 순환 참조(Strong Refence Cycle)를 조심해야 한다는 점입니다. 자칫 강한 순환 참조가 발생하면 더 이상 인스턴스를 사용하지 않음에도 메모리에서 영영 해제되지 않는 불상사가 발생할 수 있습니다.

강한 순환 참조는 두 개의 참조 타입이 서로를 강하게 참조하는 상황에서 발생합니다. 즉, A가 B도 가리키고, B도 A를 가리키는 상황이죠. 바로 예제 코드부터 살펴보겠습니다.

4

Git 주인장은 애완 동물로 흰둥이를 키우고 있습니다. 주인장(Person)은 흰둥이(Dog)를 잘 돌봐주며 이뻐하고, 흰둥이(Dog)도 주인장(Person)에게 충성합니다. 이를 코드로 나타내면 아래 예제 코드와 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Person {
    var name: String
    var dog: Dog?
    
    init(name: String) { self.name = name }
    deinit { print("Person - \(name) - deinit") }
}

class Dog {
    var name: String
    var person: Person?
    
    init(name: String) { self.name = name }
    deinit { print("Dog - \(name) - deinit") }
}

// Person 클래스 타입의 인스턴스 RC 1 증가
var person: Person? = Person(name: "김건우")
// Dog 클래스 타입의 인스턴스 RC 1 증가
var dog: Dog? = Dog(name: "흰둥이")

// person은 dog를 가리킴
// Dog 클래스 타입의 인스턴스 RC 1 증가
person?.dog = dog
// dog는 person을 가리킴
// Person 클래스 타입의 인스턴스 RC 1 증가
dog?.person = person

5

위 예제 코드에서 변수 person과 변수 dog에 nil을 할당하여도 인스턴스가 메모리에서 해제되지 않습니다.

1
2
3
4
// Person 클래스 타입의 인스턴스의 RC 1 감소
person = nil
// Dog 클래스 타입의 인스턴스의 RC 1 감소
dog = nil

왜 그럴까요? 변수 person과 변수 dog에 nil을 할당하여 RC를 1 감소시키더라도, 각 인스턴스 내 프로퍼티가 여전히 서로를 가리키고 있어 RC가 (0이 아니라) 1이 되기 때문입니다. 위 예제 코드를 그림으로 그려보면 아래와 같습니다.

6

개발자는 분명히 각 인스턴스를 메모리에서 해제하고자 변수 person과 변수 dog에 nil을 할당했음에도 여전히 메모리에 남이있는 문제가 발생합니다. 이 사실을 뒤늦게 알고 무언가를 해보려고 해도 이미 변수 person과 변수 dog에 저장된 인스턴스 참조값이 지워져서 다시 해당 인스턴스에 접근이 불가능합니다. 결국 프로그램이 종료될 때까지 쓸데없는 리소스만 차지하게 됩니다.

약한 참조(Weak Reference)

약한 참조는 참조 타입을 참조할 때, 참조 타입의 RC를 증가시키지 않으며, 참조가 메모리에서 해제되면 변수에 자동으로 nil이 할당됩니다.

강한 순환 참조의 문제를 해결하기 위한 방법으로 약한 참조가 있습니다. 약한 참조는 강한 참조와는 다르게 RC를 증가시키지 않습니다. 프로퍼티 앞에 아무런 키워드를 붙여주지 않는다면 강한 참조라고 하였죠? 약한 참조를 위해서는 프로퍼티 앞에 weak키워드를 붙여주면 됩니다. 아래는 주인장(person)과 흰둥이(dog) 간 강한 순환 참조 문제를 해결한 코드입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
 class Person {
    var name: String
    weak var dog: Dog? //❗️
    
    init(name: String) { self.name = name }
    deinit { print("Person - \(name) - deinit") }
}

class Dog {
    var name: String
    var person: Person?
    
    init(name: String) { self.name = name }
    deinit { print("Dog - \(name) - deinit") }
}

// Person 클래스 타입의 인스턴스 RC가 1 증가
var person: Person? = Person(name: "김건우")
// Dog 클래스 타입의 인스턴스 RC가 1 증가
var dog: Dog? = Dog(name: "흰둥이")

// person은 dog를 가리킴
// Dog 클래스 타입의 인스턴스 RC 증가 X
person?.dog = dog
// dog는 person을 가리킴
// Person 클래스 타입의 인스턴스 RC 1 증가
dog?.person = person

7

person 인스턴스 내 dog 프로퍼티 앞에 weak 키워드를 붙여줌으로써 강한 순환 참조 문제를 해결할 수 있습니다. 그러면 Dog 인스턴스의 RC는 1이 되고, Person 인스턴스의 RC는 2가 됩니다. 그런데 왜 dog 프로퍼티 앞에만 weak 키워드를 붙여주는 걸까요? 사실 양쪽 모두 weak 키워드를 붙여주어도 상관은 없으나, 내부 동작 흐름을 살펴보면 그 이유를 알 수 있습니다.

① 변수 dog에 nil을 할당

1
2
// Dog 클래스 타입의 인스턴스 RC 1 감소
dog = nil

8

변수 dog에 nil을 할당해주는 간단한 일만으로도 아주 많은 일이 일어납니다. 앞서 강한 순환 참조 문제를 해결한 예제 코드에서 Dog 인스턴스의 RC가 1이라는 점을 기억하고 계신가요? 여기서 변수 dog에 nil을 할당하면 RC가 0이 됨과 동시에 메모리에서 해제됩니다. 이때, dog 인스턴스 내 person 프로퍼티도 함께 해제됩니다. 그래서 person 인스턴스의 RC가 (2에서) 1로 줄어들게 됩니다. 여기서 한 가지 짚고 넘어가야 할 점은 dog 인스턴스가 메모리에서 해제됨과 동시에 person 인스턴스 내 변수 dog에 nil이 자동으로 할당된다는 사실입니다. 그래서 weak 키워드로 선언한 프로퍼티는 반드시 옵셔널 타입이어야 합니다.

② 변수 person에 nil을 할당

1
2
// Person 클래스 타입의 인스턴스 RC 1 감소
person = nil

9

변수 person에 nil을 할당하면, person 인스턴스의 RC가 (1에서) 0이 됨과 동시에 메모리에서 해제됩니다. 비로소 개발자가 의도한대로 모든 인스턴스가 정상적으로 메모리에서 해제되었습니다.

weak 키워드는 어디에 붙여야 할까?

앞서 예제 코드에서는 person 인스턴스 내 dog 프로퍼티에만 weak 키워드를 붙여주었습니다. 그렇다면 아무데나 weak 키워드를 붙여도 상관없는 걸가요? 그렇지는 않습니다. 일반적으로 수명이 더 짧은 인스턴스를 가리키는 프로퍼티에 weak 키워드를 붙여야 합니다. 왜냐하면 weak으로 선언한 프로퍼티는 가리키는 인스턴스가 메모리에서 해제된다면 자동으로 nil이 할당되기 때문이죠.

dog 인스턴스가 person 인스턴스보다 먼저 메모리에서 해제될 확률이 높기에 dog 프로퍼티에 weak 키워드를 붙여주었습니다. dog 인스턴스가 메모리에서 해제된다면 dog 프로퍼티는 자동으로 nil이 할당되고, 개발자는 메모리에서 해제된 인서턴스에 접근하는 실수를 막을 수 있습니다.

그렇다면 person 인스턴스가 먼저 메모리에서 해제될 확률이 있다면 어떻게 하면 좋을까요? person 프로퍼티에도 weak 키워드를 붙여주면 됩니다. 무조건 한쪽에만 weak 키워드를 붙이라는 법은 없으니까요.

미소유 참조(Unowned Reference)

미소유 참조는 참조 타입을 참조할 때, 참조 타입의 RC를 증가시키지 않으며, 참조가 메모리에서 해제되면 변수에 자동으로 nil을 할당해주지 않습니다.

미소유 참조는 약한 참조와 기능이 동일합니다. 약한 참조를 위해서는 프로퍼티 앞에 unowned키워드를 붙여주면 됩니다. 다만, 약한 참조와는 달리 unowned 키워드로 선언한 프로퍼티가 가리키는 인스턴스가 메모리에서 해제된다면 자동으로 nil을 할당해주지 않습니다. 인스턴스가 메모리에서 해제되더라도 unowned 프로퍼티는 해제된 인스턴스의 참조값을 그대로 가지고 있습니다. 자칫하면 개발자가 잘못된 접근을 할 수 도 있어 사용에 주의를 해야 합니다. 미소유 참조는 약한 참조와는 다르게 수명이 더 긴 인스턴스를 가리키는 프로퍼티에 unowned 키워드를 붙여야 합니다.

10

결론

강한 참조약한 참조(weak)미소유 참조(unowned)
- 참조 카운트를 올림
- 강한 순환 참조가 발생할 가능성이 있음
- 참조 카운트를 올리지 않음
- 수명이 짧은 인스턴스를 가리키는 프로퍼티에 사용함
- 참조 카운트를 올리지 않음
- 수명이 긴 인스턴스를 가리키는 프로퍼티에 사용함

자동 참조 카운팅은 참조 타입이 참조되는 횟수를 추적해 메모리를 관리하는 기법입니다. 어느 한 지역 변수에 인스턴스를 할당하면 RC는 증가하고, 그렇지 않다면 RC는 감소합니다. 자동 참조 카운팅은 컴파일-타임에 메모리 할당과 해제 시점이 결정되며, 런-타임에 작동하는 가비지 컬렉터와 비교해 성능 상 이점이 있습니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.