ARC가 도대체 뭐야?

Tabber·2021년 10월 8일
0

Swift

목록 보기
4/5
post-thumbnail

iOS공부를 하다보면 필수적으로 나오는 것 중 하나인 ARC.
이 ARC에 대해 알아보자!

시작하기 전에

ARC를 알아보기전에 ARC를 어디에서 사용하는지를 알아야 한다.
그러기엔 일단 값 타입과, 참조 타입의 차이점에 대해 알아야 한다.
차이점을 모른다면, 값 타입과 참조 타입의 차이점 에서 알아보자.

이렇게 매번 전달할 때마다 값을 복사하여 전달하는 값 타입과 달리, 참조 타입은 하나의 인스턴스가 참조를 통해 여러 곳에서 접근할 수 있기 때문에 언제 메모리에서 해제되는지가 중요한 문제이다.

인스턴스가 적절한 시점에 메모리에서 해제되지 않으면, 한정적인 메모리 자원을 낭비하게 되며, 이렇게 될 경우 성능 저하로 이어지기 때문이다.

그래서 스위프트는 메모리 사용을 관리하기 위해 ARC를 사용한다.

ARC란?

ARC는 Auto Reference Counting의 준말로 자동으로 메모리를 관리해주는 방식이다.
ARC는 더이상 필요하지 않은 클래스의 인스턴스를 메모리에서 해제하는 방식으로 동작하게 된다. (여담으로 Objective-C 에서도 ARC 방식을 사용하고 있다.)

🤔 여기서 알아두어야 할 점은 ARC는 참조타입인 Class의 인스턴스에서만 작동한다는 점이다.
Struct나 enum은 값 타입이기에 ARC와 무관하다.

ARC와 GC의 차이점

ARC는 GC와 달리 카운팅을 하는 시점이 컴파일을 할 때 이루어진다. 또한 컴파일을 할때 인스턴스의 해제시점을 정해놓고 시작하게 된다.

그래서 단점은 동작 규칙을 모르고 사용하다가 잘못하면 영원히 해제시킬 수 없는 상황까지 갈 수 있는것이다.

ARC 작동 규칙이란?

따라서, 우리가 ARC를 이용하여 자동으로 메모리 관리를 받기 위해서는 몇 가지의 규칙을 알아야 한다.

왜냐하면, 위에서 말했듯 GC와 다르게 ARC는 컴파일과 동시에 인스턴스를 메모리에서 해제하는 시점을 결정하기 때문이다. 그러니까 컴파일 할때 언제 메모리에서 빼낼지를 먼저 정해놓는다는 말이다.

그래서 우리가 원하는 해제시점을 관리하려면, ARC에게 명확한 힌트를 제공해주어야 한다.

Class의 인스턴스를 생성할 때마다 ARC는 그 인스턴스에 대한 정보를 저장하기 위한 메모리 공간을 따로 할당한다. 그 공간에 인스턴스 타입 정보와 함께, 인스턴스와 관련된 저장 프로퍼티 값을 저장하게 된다.

그 후에, 인스턴스가 더이상 필요없는 상태가 되면 인스턴스가 차지하던 메모리 공간을 다른 용도로 사용할 수 있도록 ARC가 메모리에서 인스턴스를 없앤다. 그러나, 아직 사용을 해야하는 인스턴스가 중간에 해제된다면 인스턴스와 관련된 프로퍼티나 메서드들을 접근하고 호출할 수 없게 된다.

또한, 이미 해제된 인스턴스에 강제로 접근하려고 하면 잘못된 메모리 접근으로 인해 프로그램에 종료될 수도 있는 치명적인 상태로 이어지게 된다.

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

인스턴스를 메모리에서 유지시키려면 이런 명분을 ARC에게 제공해야 한다는 것을 명심 해야한다.

인스턴스가 언제 메모리에서 해제될지 예측할 수 있도록 ARC에서 적용되는 몇 가지 규칙을 알아보자.

ARC 작동 규칙 1 - 강한참조

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

인스턴스는 참조 횟수(Reference Count)가 0이 되는 순간 메모리에서 해제되는데, 인스턴스를 다른 인스턴스의 프로퍼티나 변수, 상수 등에 할당할 때 강한 참조를 사용하면 참조 횟수가 1 증가하게 된다.

또한, 강한 참조를 사용하는 프로퍼티, 변수, 상수등에 nil을 할당하게 될 경우 참조 횟수가 1 감소하게 된다.

참조의 기본은 강한 참조이기 때문에, Class 타입의 프로퍼티, 변수, 상수를 선언할 때 별도의 식별자를 명시하지 않았으면 강한참조를 하게 된다.

그러니까 이제까지 우리가 Class 인스턴스를 선언할 때 사용했던 규칙이 강한참조였던 것이다.

[코드 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: "IBY")
// IBY is being initialized
// 인스턴스 참조 횟수 : 1

reference2 = reference1 // 인스턴스 참조 횟수 : 2
reference3 = reference1 // 인스턴스 참조 횟수 : 3

reference3 = nil // 인스턴스 참조 횟수 : 2
reference2 = nil // 인스턴스 참조 횟수 : 1
reference1 = nil // 인스턴스 참조 횟수 : 0
// IBY is being deinitialized

코드를 한줄 한줄 살펴보자.

reference1 = Person(name: "IBY")

reference1 에 할당된 Person Class 타입 인스턴스는 메모리에 성생된 후에 강한참조reference1에 할당되기 때문에 참조횟수가 1 증가하게 된다.
그리고 Class에서 init을 정의해주었기에 출력문이 나온다.

reference2 = reference1 // 인스턴스 참조 횟수 : 2
reference3 = reference1 // 인스턴스 참조 횟수 : 3

그 후에 reference2강한참조로 할당되기 때문에 참조횟수가 1 증가하고, reference3 또한 동일하게 증가한다.

reference3 = nil // 인스턴스 참조 횟수 : 2
reference2 = nil // 인스턴스 참조 횟수 : 1
reference1 = nil // 인스턴스 참조 횟수 : 0
// IBY is being deinitialized

다음으로 nil 값을 적용했을 때이다.
마지막으로 참조하였던 reference3 에서 제일 먼저 참조를 그만두고 nil값을 넣어줬다. 따라서 참조 횟수는 1 감소한다.

순차적으로 nil값을 적용해주고, 참조횟수가 감소하게 되고, 참조횟수가 0이 되는 순간 인스턴스는 ARC 규칙에 의해 메모리에서 해제되며, 메모리에서 해제되기 직전에 deinit 을 호출하게 된다.

다른 상황을 보자.

[코드 2]
func foo() {
    let iby: Person = Person(name: "iby") // iby is being initialized
    // 인스턴스의 참조 횟수 : 1
    
    globalReference = iby // 인스턴스의 참조 횟수 : 2
    
    // 함수 종료 시점
    // 인스턴스의 참조 횟수 : 1
}

foo()

위의 상황과는 조금은 다른 상황이다.
함수 내부에서 생성된 강한참조 상수가 있다.
그리고 전역변수 globalReference 에 강한참조가 되면서 참조 횟수가 1 증가했다.

그리고, 함수 특성상 사용범위(함수의끝)가 종료되면 그 지역변수가 참조하던 인스턴스 참조 횟수를 감소시키게 되는데, 위 코드에서는 전역변수에 강한참조를 한 상황이라 참조횟수 1이 남는 상황으로 프로그램이 종료가 된다.

결과적으로 메모리에서 해제되지 않은 상태로 프로그램이 끝이 나게 된 것이다.

이렇게 아까 ARC의 단점을 보았듯이 규칙을 모르고 사용하면 메모리에서 해제되지 않는 문제가 생기게 된다.

대표적인 예로 위 코드도 맞지만, 인스턴스 서로가 서로를 강한참조할 때를 대표적인 예로 들 수 있다.

이를 강한참조 순환 문제 라고 한다.

강한참조 순환 문제

[코드 3]
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("Room \(number) is being deinitialized")
    }
}

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

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

iby = nil // Person 인스턴스의 참조 횟수 : 1
room = nil // Room 인스턴스의 참조 횟수 : 1

// Person 인스턴스를 참조할 방법 상실 - 메모리에 잔존
// Room 인스턴스를 참조할 방법 상실 - 메모리에 잔존

[코드 3] 의 Person Class 는 강한참조를 하는 Room 타입의 room 프로퍼티를 가지고, Room Class는 강한참조를 하는 Person 타입의 host를 갖게된다.

사람에게 필요한 방이 없을수도 있고, 방에 사는 사람이 없을 수도 있기 때문에 두 프로퍼티 모두 옵셔널로 정의했다.

그리고 두 인스턴스 모두 메모리에서 해제되는 시점을 확인하기 위해서 deinit 을 정의했다.

그러나, 이 코드를 실행하면 deinit은 실행되지 않는다. 왜그럴까?

두 클래스 모두 각각 메모리에 할당될 때 강한참조를 하므로 참조 횟수가 1씩 증가한다.

그리고 모두 참조 횟수가 1인 상태에서 각각의 프로퍼티에 알맞는 클래스를 강한참조한다. 그렇게 되면 각각 참조횟수는 1 증가하여 2가 된다. 현재 두 인스턴스 모두 참조횟수는 2다.

자, 그리고 여기서 iby 변수에 nil 을 할당하면 iby 이 참조하는 인스턴스의 횟수는 1 감소하게 된다.

이제 room을 참조할 수 있는 방법은 host 프로퍼티로 접근하는 방법밖에 남아있지 않다.

다행히도 아직 room 변수가 인스턴스를 강한 참조로 붙들고 있기 때문에 인스턴스는 메모리에서 해제되지 않은 상태이다.

문제는 여기서 시작된다.
강한참조로 붙들고 있던 room 마저 nil 을 주어버리면 room 변수가 참조하던 인스턴스는 참조횟수가 1 감소하여 1이 된다. 그렇지만 이제 iby 변수가 참조하던 Person 클래스의 인스턴스에 접근할 방법도, room 변수가 참조하던 Room 클래스의 인스턴스에 접근할 방법도 사라지게 됐다.

또한 참조 횟수가 0이 되지 않는 한 ARC가 해제시키지 않기 때문에 메모리에 좀비처럼 남게 되는 것이다.

이렇게 두 인스턴스가 서로를 참조하는 상황에서 강한참조 순환 문제가 발생할 수 있다.

이런 문제를 해결해보자!

수동으로 해결

[코드 4]
var iby: Person? = Person(name: "iby") // Person 인스턴스의 참조 횟수 : 1
var room: Room? = Room(number: "595") // Room 인스턴스의 참조 횟수 : 1

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

iby?.room = nil // Room 인스턴스 참조 횟수 : 1
iby = nil       // Person 인스턴스 참조 횟수 : 1

room?.host = nil // Person 인스턴스의 참조 횟수 : 0
// iby is being deinitialized

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

변수 또는 프로퍼티에 nil을 할당하면 참조횟수가 1 감소한다는 규칙을 생각하면 각각의 프로퍼티에 nil 값을 순서대로 할당하면 된다.

근데, 이렇게 귀찮게 해야하는 것일까?

ARC 작동 규칙 2 - 약한참조

약한참조는 강한참조와 다르게 자신이 참조하는 인스턴스의 참조횟수를 증가시키지 않는다.
참조 타입의 프로퍼티나 변수의 선언 앞에 weak키워드를 사용하면 약한참조를 하게 된다.

약한참조를 사용한다는 것은 자신이 참조하는 인스턴스가 메모리에서 해제될 수도 있다는 것을 예상해 볼 수 있어야 한다.

자신이 참조횟수를 증가시키지 않았기 때문에, 그 인스턴스를 강한 참조하던 프로퍼티나 변수에서 참조 횟수를 감소시켜 0으로 만들면 자신이 참조하고 있던 인스턴스가 메모리에서 해제되기 때문이다.

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

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

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

iby = nil // Person 인스턴스의 참조 횟수 : 0, Room 인스턴스의 참조 횟수 : 1
// iby is being deinitialized
print(room?.host) // nil

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

강한참조 순환 문제를 해결하기 위해서 Room 클래스의 host 프로퍼티를 약한참조를 하도록 정의하였다.


room 변수가 참조하는 인스턴스의 host 프로퍼티가 약한참조를 하므로, iby 가 참조하는 인스턴스를 host 프로퍼티에 참조하도록 할 때 참조횟수는 증가하지 않는다.

그렇지만, iby가 참조하는 인스턴스의 room 프로퍼티는 강한참조를 하므로 참조횟수가 증가하게 된다.

우리가 여기서 눈여겨 봐야 할 것은 iby 변수가 참조했던 인스턴스의 참조횟수가 0이 되면 메모리에서 해제될 때, 인스턴스 room 프로퍼티가 참조하는 인스턴스의 참조횟수도 1 감소하는 것이다.

인스턴스가 메모리에서 해제될 때, 자신의 프로퍼티가 강한 참조를 하던 인스턴스의 참조횟수를 1 감소시킨다는 것을 알 수 있다.

그리고 한가지 더 iby 변수가 참조하던 인스턴스가 메모리에서 해제되었다라는 뜻은 room 변수가 참조하는 인스턴스의 프로퍼티인 host 가 참조하는 인스턴스가 메모리에서 해제되었다라는 뜻이다. 우리는 분명히 room?.host = iby 이라는 코드를 통해 host 프로퍼티에 인스턴스를 참조하도록 했지만 print(room?.host) 를 통해 확인한 결과 nil 이 출력되었다.

host 프로퍼티는 약한참조를 하기 때문에 자신이 참조하는 인스턴스가 메모리에서 해제되면 자동으로 nil 을 할당한다는 것을 알 수 있다.

마지막으로 room 변수가 참조하던 인스턴스는 room = nil 이라는 코드를 통해 더 이상 자신을 참조하는 곳이 없는 상태이다. 즉, 참조 횟수가 0이 되고 메모리에서 해제되는 것이다.

정리

오늘은 ARC의 정의와 ARC와 GC의 차이점,
ARC를 사용할 때의 규칙인 강한참조와 약한 참조에 대해 알아보았다.

다음 포스팅에서는 이외의 참조인 미소유참조, 클로저의 강한참조 순환과 같은 것을 작성해보겠다.

profile
iOS 정복중인 Tabber 입니다.

0개의 댓글