[Swift] ARC (Automatic Reference Counting) 완전 정복하기

2024. 7. 6. 17:26Swift, iOS Foundation

1️⃣ ARC (Automatic Reference Counting)를 배우기 위해 알아야 하는 기초 개념

1. 값 타입 (Call by Value)과 참조 타입 (Call by Reference)

값 타입 (Call by Value)은 데이터를 복사해서 전달하는 경우, 참조 타입 (Call by Reference)은 데이터의 메모리 주소를 전달하는 경우이다.
클래스(class)는 대표적인 참조 타입이고, 구조체(struct)나 열거형(enum)을 비롯한 Swift의 기본 데이터 타입(Int, Bool, String 등)은 값 타입에 속한다.

2. 메모리의 스택 영역 (Stack Area)과 힙 영역 (Heap Area)

스택 영역 (Stack)은 함수 호출과 관련된 메모리를 관리하는 메모리 영역이다.
함수에서 사용되는 파라미터, 로컬 변수, 리턴 값 등이 이곳에 저장되며, 함수가 종료됨과 동시에 자동으로 해제되는 것이 특징이다. (빠른 속도, But 제한된 크기로 인해 Stack overflow가 발생할 수 있다.)

반면, 힙 영역 (Heap)프로그래머가 동적으로 할당하거나 해제하는 메모리를 관리하는 영역이다.
크기가 고정되어 있던 스택 영역과는 다르게 런타임 시 크기가 동적으로 늘어나는 것이 특징이고, 스택에 비해 비교적 속도가 느리다.
동적으로 크기가 늘어나기 때문에 메모리를 사용한 이후에는 반드시 메모리 해제를 해줘야 한다. 그렇지 않으면 메모리 누수 (Memory Leakage)가 발생할 수 있다는 점!

"동적으로 할당하거나 해제하는 메모리가 뭐가 있지?"라고 생각했지만, 위에서 잠깐 설명했던 참조 타입에 해당했던 클래스의 인스턴스가 바로 이 힙 영역 (Heap)에 할당하는 대표적인 녀석이었던 것!

3. 참조 카운트 (Reference Count)

참조 카운트 (Reference Count)는 직관적으로 현재 메모리를 참조(Reference)하고 있는 인스턴스의 개수(Count)를 의미한다.
다시 조금 더 자세하게 말하면, Reference Type의 인스턴스가 생성이 되어 메모리의 Heap 영역에 할당되어 있는 수라고 이해하면 된다.

 

2️⃣ ARC(Automatic Reference Counting)가 그래서 뭔데?

ARC(Automatic Reference Counting)는 앱의 메모리 사용을 자동으로 관리해 주는 기능이다.

앱의 메모리 사용을 자동으로 관리해준다는 것이 무슨 말이지?
ARC를 이름에 따라 직관적으로 해석해보면, 참조 카운트 (Reference Counting)를 자동(Automatic)으로 해주는 기능이라는 뜻이다.

위의 스택 영역과 힙 영역 설명을 다시 올라가보면, 
스택 영역은 함수가 종료됨과 동시에 메모리 해제가 자동으로 이루어졌던 것에 비해, 힙 영역에서는 반드시 사용 이후에 메모리 해제를 해줘야 한다고 했었다.
그런데 우리의 코딩을 생각해보면, 언제 뷰 컨트롤러 같은 클래스를 사용하고 난 이후에 수동으로 메모리 해제를 시켜줬던 적이 있던가?
그렇다. 우리의 Swift는 이런 메모리 해제부터 메모리 참조 개수를 자동으로 카운트하고 있었던 것이다.

1. 인스턴스가 생성되면, 해당 객체에 대한 RC(Reference Count)를 자동으로 1 증가시킨다.
2. 인스턴스에 대한 참조가 더 이상 필요하지 않게 된다면 메모리에서 해제시켜주며, 해당 객체에 대한 RC(Reference Count)를 자동으로 1 감소시킨다.
💡 ARC(Automatic Reference Counting)자동으로 RC(Reference Counting)를 관리하여 메모리 해제에 대한 개발자의 부담을 덜어준다.

 

3️⃣ ARC에 따른 클래스 인스턴스의 강한 참조 (Strong Reference) 과정 살펴보기

인스턴스를 선언할 때 아무것도 적어주지 않으면, 기본적으로 강한 참조(Strong Reference)로 생성이 된다. (RC도 1 증가)

어떻게 ARC가 동작하는지 코드로 이해해보자.
Person이라는 클래스를 만들었고, 해당 클래스의 인스턴스가 생성(init)될 때와 해제(deinit)될 때 각각 print문으로 나타날 수 있도록 했다.

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

우선 인스턴스 생성에 따른 Reference Count의 증가를 확인하기 위해 Person? 타입의 변수를 정의했다.
옵셔널 타입으로 정의했기 때문에 nil값으로 변수에 초기화되며, 생성자의 print 출력이 이루어지지 않고 Reference Count가 증가되지 않는다.

var reference1: Person?
var reference2: Person?

이제 Person 인스턴스를 생성해 보겠다.

reference1이라는 변수는 Person 인스턴스에 대한 강한 참조가 생기게 되고, ARC는 이 인스턴스에 대한 Reference Count가 1 증가시킨다.

reference1 = Person(name: "Mini")   // RC: 1 - "Mini is being initialized"

클래스는 이 글 처음에 말했던 것처럼 Call by Reference로 전달되고 때문에,

위에처럼 인스턴스를 처음 생성할 때도 참조 카운트(Reference Count)가 1 증가되지만,
아래와 같이 기존 인스턴스가 다른 변수에 대입될 때도 동일하게 Person 인스턴스에 대한 강한 참조가 이루어지고 Reference Count도 1 증가된다.

reference2 = reference1             // RC: 2

이제 메모리가 해제되는 상황을 살펴보자.

nil을 대입하면, Reference Count가 1 감소된다.
Person 인스턴스가 메모리 해제되는 상황은 2개의 참조 카운트 (Reference Count)가 0이 되는 상황에 이루어지는 것을 확인할 수 있다.

*인스턴스를 가리키는 변수가 Stack 영역에서 해제되거나 다른 인스턴스를 참조하게 되는 경우도 RC가 1 감소된다.

reference1 = nil    // RC: 1
reference2 = nil    // RC: 0 - "Mini is being deinitialized"

 

4️⃣ 강한 참조 (Strong Reference)에 따라 발생할 수 있는 문제점 : 순환 참조 (Retain Cycle, Reference Cycles)

위의 코드를 통해 Swift의 ARC (Automatic Reference Count)가 참조 카운트 (Reference Count)를 증가/감소시키고, 메모리를 자동으로 해제시키는 과정을 살펴볼 수 있었다.

하지만 강한 참조 (Strong Reference) 상황에서 생길 수 있는 한 가지 문제가 있다.

⚠️ 두 클래스 인스턴스가 서로를 강하게 참조(strong)하고 있는 경우에는 영구적으로 메모리에서 해제되지 않을 수 있다.

위와 같은 상황을 순환 참조 (Retain Cycle), 혹은 강한 참조 사이클 (Strong Reference Cycle)이라 부른다. 코드로 한번 이해해 보자.

Boy라는 클래스와 Girl이라는 클래스를 만들었다.
Boy는 Girl 타입의 girlFriend 프로퍼티를, Girl은 Boy 타입의 boyFriend 프로퍼티를 갖고 있는 상황이다.
각각 Boy와 Girl 타입의 클래스 인스턴스를 만들었고 name 값을 초기값으로 지정했다. (Boy 인스턴스와 Girl 인스턴스에 대한 Reference Count는 각각 1씩 증가한다.)

class Boy {
    let name: String
    var girlFriend: Girl?
    init(name: String) { self.name = name }
    deinit { print("\(name) is being deinitialized") }
}
class Girl {
    let name: String
    var boyFriend: Boy?
    init(name: String) { self.name = name }
    deinit { print("\(name) is being deinitialized") }
}

var boy: Boy? = Boy(name: "민군")
var girl: Girl? = Girl(name: "민순")

Reference Cycle 01. 클래스 인스턴스가 생성된 상황

이때, 민군이랑 민순이가 만나기로 해서 각 프로퍼티에 서로를 할당하게 되는 경우를 생각해 보자.

Boy 인스턴스 안에 Girl 타입 프로퍼티는 girl 인스턴스가 할당, Girl 인스턴스 안에 Boy 타입 프로퍼티는 boy 인스턴스가 할당되었기에,
Boy 인스턴스와 Girl 인스턴스에 대한 Reference Count가 또 1씩 증가해 2가 될 것이다.

boy?.girlFriend = girl
girl?.boyFriend = boy

Reference Cycle 02. 클래스 인스턴스가 서로가 서로를 참조하게 된 상황

그리고 원래 위에서 했던 것처럼 클래스의 인스턴스를 메모리에서 해제시키고자 nil을 할당했다.
만약 메모리에서 해제되었다면, 정상적으로 deinit 메서드 부분에 있는 print문이 출력되어야 함에도 불구하고 아무것도 출력이 되지 않을 것이다.

바로 그 이유는 아래 그림과 같이 인스턴스에 대한 Heap 메모리 참조는 끊기게 되었지만,
서로가 서로를 참조하고 있는 강한 참조가 1개씩 있기 때문에, 참조 카운트 (Reference Count)가 각각 1인 상황 = 참조 카운트가 0이 아니기 때문에 메모리에서 해제되지 못한 상황이 생긴 것이다.

해당 인스턴스에는 이제 더 이상 접근할 수 없으며, 메모리에서도 영영 해제되지 못하는 메모리 누수 (Memory Leckage) 문제가 발생하게 된다.

boy = nil
girl = nil

Reference Cycle 03. 순환 참조 (Retain Cycle) 문제에 따른 메모리 누수 (Memory Leckage) 발생

 

5️⃣ 순환 참조를 해결하기 위한 방법 (1) : 약한 참조 (Weak Reference)

지금부터는 위와 같이 강한 참조 (strong) 상황에서 발생했던 순환 참조 (Retain Cycle) 문제를 해결하기 위한 두 가지 방법을 살펴보겠다.

먼저 첫 번째로 약한 참조 (Weak Reference) 방법이 있다.
약한 참조는 weak 키워드를 사용해서 선언하고, 인스턴스를 참조할 때 참조 카운트를 증가시키지 않는 참조 방법이다.

가장 큰 특징은 참조하고 있던 클래스의 인스턴스가 메모리에서 해제되면, 어찌할 도리가 없던 위의 경우 (강한 참조 순환, Strong Refetence Cycle)와 다르게 자동으로 nil이 할당되는 것이 특징이다.
그렇기 때문에 weak 속성을 사용하는 참조 객체는 항상 Optional 타입이어야 한다. -> 해당 객체가 nil이 될 수 있기 때문!

아래 코드를 살펴보자.
이번에는 위와 다르게 Girl 클래스에서 사용되는 Boy 클래스의 참조 프로퍼티를 약한 참조(weak)로 선언했다. (약한 참조이기 때문에 Boy? 옵셔널로 선언)

class Girl {
    let name: String
    weak var boyFriend: Boy?
    init(name: String) { self.name = name }
    deinit { print("\(name) is being deinitialized") }
}

그리고 동일하게 클래스 인스턴스를 각각 선언하고, 서로를 참조하도록 지정해 줬다.

위와 달라진 점이 있다면, Boy는 Girl에 대해 아직 강한 참조를 갖고 있지만 Girl 인스턴스는 Boy 인스턴스에 대해 약한 참조를 갖고 있다는 점이다.

var boy: Boy? = Boy(name: "민군")
var girl: Girl? = Girl(name: "민순")

boy?.girlFriend = girl
girl?.boyFriend = boy

Weak Reference 01. 클래스 인스턴스가 생성된 상황

이때 Boy 인스턴스에 대한 참조를 nil로 변경해 보면, 정상적으로 Boy 인스턴스에 대한 메모리 해제가 이루어지게 된다.

다시 한번 약한 참조 (Weak Reference)의 특징으로 돌아가서,
약한 참조(weak)는 참조하고 있던 클래스의 인스턴스가 메모리에서 해제되면, 자동으로 nil이 할당된다.
즉, Girl instance에서 참조하고 있던 클래스의 인스턴스 (= Boy instance)가 메모리에서 해제되었기 때문에 weak 프로퍼티인 Girl 인스턴스의 boyFriend에는 자동으로 nil이 할당된 것이다.

boy = nil   // 민군 is being deinitialized

Weak Reference 02. 클래스의 인스턴스가 메모리에서 해제되면 자동으로 nil이 할당된다.

Girl 인스턴스에는 더 이상 Boy Instance와 어떠한 강한 참조 관계도 없기 때문에 정상적으로 Girl Instance도 메모리에서 해제할 수 있다.

이 과정을 보면, 약한 순환 참조는 두 인스턴스 중 생명주기가 더 짧은 녀석을 가리키는 곳에 선언한다는 것을 이해할 수 있을 거다.
핵심은 가리키는 인스턴스가 메모리에서 해제되면, 참조하는 곳도 자동으로 nil이 된다는 것! 그리고 RC도 1을 증가시키지 않는다는 것!
딱 이 점만 기억하면 되겠다.

girl = nil  // 민순 is being deinitialized

Weak Reference 03. 약한 참조 (Weak Reference)는 순환 참조 (Retain Cycle) 문제를 해결할 수 있다.

 

6️⃣ 순환 참조를 해결하기 위한 방법 (2) : 미소유 참조 (Unowned Reference)

순환 참조를 해결할 수 있는 두 번째 방법은 미소유 참조 (Unowned Reference)이다.
unowned라는 키워드를 사용해서 참조 관계를 선언하며, 말 그대로 인스턴스의 소유권을 가지지 않고 + 그렇기 때문의 RC값도 증가시키지 않는다.

그럼 약한 참조와 어떤 점이 다른 건가요?라고 물어본다면,
미소유 참조는 "참조하는 다른 인스턴스가 나보다 수명이 더 길다" = "내가 인스턴스를 참조하는 중에는 해당 인스턴스는 절대 사라지지 않는다"라는 가정을 두고 참조 관계를 설정한다는 점에서 차이가 있다.

⚠️ 미소유 참조는 순환 참조를 해결하는 방법이라기보다, 절대 순환 참조가 발생할 일이 없어!라는 가정하에 참조 관계를 만드는 셈. 

그렇기에, 미소유 참조 (Unowned Reference)는 nil이 될 수 없으며, Optional로 선언되지 않는다.

코드로 살펴보자.
Girl 클래스에서 Boy에 대한 참조를 미소유로 선언했기 때문에 "Girl보다 Boy의 수명이 더 길다"는 가정을 두고 선언했다는 의미다.

class Boy {
    let name: String
    var girlFriend: Girl?
    init(name: String) { self.name = name }
    deinit { print("\(name) is being deinitialized") }
}
class Girl {
    let name: String
    unowned var boyFriend: Boy
    init(name: String, boyFriend: Boy) {
        self.name = name
        self.boyFriend = boyFriend
    }
    deinit { print("\(name) is being deinitialized") }
}

var boy: Boy? = Boy(name: "민군")
boy!.girlFriend = Girl(name: "민순", boyFriend: boy!)

Unowned Reference

Boy 인스턴스에 대한 강한 참조를 끊게 되면,
이때도 역시 양방향 참조가 있음에도 불구하고 두 인스턴스 모두 정상적으로 메모리 해제가 이루어지는 것을 확인할 수 있다.

boy = nil
// 민군 is being deinitialized
// 민순 is being deinitialized
⁉️ weak와 unowned 차이점?

weak는 객체를 계속 추적하면서 객체가 사라지게 되면 nil로 바꾼다.
하지만, unowned는 객체가 사라지게 되면 댕글링 포인터가 남는다. (nil이 아님!)
댕글링 포인터를 참조하게 되면 crash가 나는데, 이 때문에 unowned는 사라지지 않을 거라고 보장되는 객체에만 설정하여야 한다.
여기서 말하는 댕글링 포인터(Dangling pointer)란? 원래 바라보던 객체가 해제되면서 할당되지 않는 공간을 바라보는 포인터를 의미한다.

여기까지가 ARC에 대한 기본 개념 마무리!
혹시나 잘못된 내용이나 질문사항, 추가해야 할 사항 (아! 클로저 [weak self] 관한 내용 이어서 쓰겠습니다.) 있으면 댓글로 부탁드리겠습니다 ^__^

ARC 2탄 글도 작성완료했습니다!

 

[Swift] [weak self] 이젠 제대로 알고 사용하자! (feat. ARC 2탄)

아직 ARC 1탄 글을 읽지 않고 오셨다면, 아래 링크로 넘어가서 읽고 오길 권장합니다:) [Swift] ARC (Automatic Reference Counting) 완전 정복하기1️⃣ ARC (Automatic Reference Counting)를 배우기 위해 알아야 하는

mini-min-dev.tistory.com