iOS (스파르타)

ARC (Automatic Reference Counting) 이해하기

가애야 2024. 7. 12. 20:44

ARC (Automatic Reference Counting)는 말그대로 자동으로 인스턴스가 현재 참조되고 있는 횟수(Reference Count)를 '숫자로' 카운팅하여 0이될때 힙(Heap) 메모리에서 해제해주는 메모리 관리 방식이다.

ARC는 이름에서도 알 수 있듯이 Reference 즉, 참조 타입인 class 인스턴스의 참조 횟수를 카운팅 해주고, 자동으로 메모리 해제를 해주고 있는 것이다.

 

ARC를 다루기 전에 메모리 공간에 대해 먼저 알고 갑시다

 

1. 메모리 구조

프로그램이 실행되면 운영체제(OS)는 메모리(RAM)에 이 프로그램을 위한 공간을 할당해 준다.

그 공간은 총 4가지(Code, Data, Heap, Stack)으로 나누어져 있다.

 

 

  • Code (코드) 섹션:
    • 실행할 프로그램의 코드가 기계어 형태(0 또는 1)로 저장되는 공간
    • 읽기 전용 메모리로 저장되며, 프로그램의 명령어들이 포함된다.
    • 컴파일 타임에 결정되고, 중간에 코드가 변경되지 않도록 Read-Only형태로 저장된다
  • Data (데이터) 섹션:
    • 전역 변수 및 static 변수가 저장되는 공간 (Swift 에서 static은 기본 동작이 lazy)
    • 프로그램이 실행되는 동안 고정된 크기로 존재하며, 초기화된 변수와 초기화되지 않은 변수를 포함합니다.
  • Heap (힙) 섹션:
    • 프로그래머가 할당/해제 하는 메모리 영억
    • 동적으로 할당되는 메모리가 저장되는 공간입니다.
    • 런타임 중에 메모리를 할당하고 해제할 수 있으며, 프로그램의 실행 과정에서 크기가 변할 수 있습니다.
    • 사용하고 난 후에는 반드시 메모리 해제를 해줘야 한다. 그렇지 않으면 메모리누수 현상이 발생한다.
    • 힙은 일반적으로 데이터와 스택 사이에 위치, 위로 확장
    • Swift에서 클래스 인스턴스(Class Instance), 클로저 같은 참조 타입의 값들을 통해 힙에 자동으로 할당이 된다.
  • Stack (스택) 섹션:
    • 함수 호출 시 함수의 지역 변수 및 매개 변수, 리턴 값 등등이 저장되는 공간
    • 함수 호출 시 새로운 프레임이 추가되며, 함수가 반환되면 해당 프레임이 제거됩니다.
    • 스택은 일반적으로 데이터와 힙 사이에 위치, 아래로 확장
    • 컴파일 타임에 결정되기 때문에 무한히 할당할 수 없다.

 

메모리 구조를 봤으니 이제 ARC!

 

ARC는 메모리 영역 중 힙 영역을 관리

힙의 특징 중 하나는 사용하고 난 후에는 반드시 메모리 해제를 해줘야 한다는 것이었는데, 지금까지 인스턴스를 할당하고 사용하면서 해제하기 위한 release, free등의 함수들을 통해 인스턴스를 직접 메모리에서 해제해 준 적이 없다? 그럼 메모리 누수가 생긴 것일까?

 

아니고 ARC가 해제 해줬다.

 

 

Swift 문서를 보면 'ARC는 클래스 인스턴스가 더 이상 필요하지 않을 때 메모리를 자동으로 해제한다.' 라는 내용이 있고

이 말은 여태 힙에 메모리를 자동으로 할당하며 사용했지만 직접 메모리를 해제해주지 않아도 됐던 이유는 ARC가 메모리를 자동으로 해제해주기 때문이라는 것이다.

 

RC(Reference Count)가 0이될 때 더 이상 사용하지 않는 메모리라 생각하여 해제한다.

RC는 인스턴스를 누가 참조하는지 안하는지를 숫자로 나타낸 것이고 만약 참조 횟수가 5라면 해당 인스턴스가 5군데에서 참조되고 있다는 뜻이고 참조 횟수가 0이라면 아무데서도 참조되지 않으니 더이상 필요가 없다고 판단하고 메모리를 해제한다는 뜻이다.

참고로 모든 인스턴스는 자신의 RC(Reference Count) 값을 가지고 있다.

 

ARC의 단점은 순환참조가 발생 시 영구적으로 메모리가 해제되지 않을 수 있다는 점이다.

 

예시는 소들이님꺼를 가지고 살짝 변형했다 ㅋ 진짜 잘해놓음,, 미쳣음 이해를 위한 살짝의 코드 변경 있음 (난 예시 만드는것도 어려워서,,ㅜ)

Reference Count 가 +1이 될 때

1. 인스턴스를 새로 생성할 때

let gahye = Student(name: "Gahye", age: 31)

2. 기존 인스턴스를 다른 변수에 대입할 때

let clone = gahye

 

Reference Count 가 -1이 될 때

 

1. 인스턴스를 가리키던 변수가 메모리에서 해제되었을 때

func makeClone(_ origin: Student) {
    let clone = origin                          // ② Instance RC : 1 + 1 = 2
}
 
let gahye = Student(name: "Gahye", age: 31)     // ① Instance RC : 1
makeClone(gahye)
                                                // ③ Instance RC : 2 - 1 = 1

gahye이 생성되는 순간 인스턴스의 RC +1

makeClone 함수가 실행되어 gahye를 참조하는 clone 변수가 생성되는 순간 인스턴스의 RC +1

함수가 종료되어 지역변수 clone이 스택에서 해제되는 순간 인스턴스 RC -1

그렇다면 이 코드에서 현재 RC는 1이 남아있는 상태이다.

 

2. nil이 지정되었을 때

var gahye: Student? = .init(name: "Gahye", age: 31)      // ① Instance RC : 1
var clone = gahye                                       // ② Instance RC : 1 + 1 = 2
 
clone = nil                                              // ③ Instance RC : 1
gahye = nil                                             // ④ Instance RC : 0 (메모리 해제)

nil이 지정된다는 것은 당연히 옵셔널 타입이라는 것.

nil을 지정해줄 때마다 RC -1 

 

3. 변수에 다른 값을 대입한 경우

var gahye: Student? = .init(name: "Gahye", age: 31)    // ① Gahye Instance RC : 1
var clone: Student? = .init(name: "Gahye2", age: 31)    // ② Clone Instance RC  : 1
 
gahye = clone                                         // ③ Clone Instance RC  : 2, Gahye Instance RC : 0 (메모리 해제)

gahye에 clone의 값을 대입하게 되면

gahye의 RC -1

clone의 RC +1

gahye의 변수에 저장된 주소값이 바껴서 참조 카운터도 변하는 것이다.

gahye이 가리키던 인스턴스는 RC가 0이 되었으므로 ARC에 의해 자동으로 메모리에서 해제된다.

 

4. 프로퍼티의 경우, 속해있는 클래스 인스턴스가 메모리에서 해제될 때

class Person {
    var name: String
    var pet: Pet? // Person 인스턴스는 Pet 인스턴스를 참조할 수 있습니다.

    init(name: String) {
        self.name = name
        print("\(name) is initialized")
    }

    deinit {
        print("\(name) is being deinitialized")
    }
}

class Pet {
    var name: String

    init(name: String) {
        self.name = name
        print("\(name) is initialized")
    }

    deinit {
        print("\(name) is being deinitialized")
    }
}

// Person 인스턴스를 생성합니다.
var person: Person? = Person(name: "John")  // John RC +1

// Pet 인스턴스를 생성합니다.
var pet: Pet? = Pet(name: "Buddy")  // Buddy RC +1

// Person 인스턴스 John이 pet 인스턴스 Buddy를 참조
person?.pet = pet   // Buddy RC 1 + 1 = 2

// 참조 카운트를 증가시키기 위해 Pet 인스턴스를 참조합니다.
// Buddy의 참조가 또 다른 변수에 저장.
var anotherReferenceToPet: Pet? = person?.pet  // Buddy RC 2 + 1 = 3

// Person 인스턴스의 참조를 해제합니다.
person = nil // John RC 1 - 1 = 0 , Buddy RC 3 - 1 = 2

// Pet 인스턴스의 참조를 해제합니다. 아직 Pet은 여전히 anotherReferenceToPet에 의해 참조되고 있다.
pet = nil  // Buddy RC 2 - 1 = 1

// anotherReferenceToPet의 참조를 해제합니다.
anotherReferenceToPet = nil  // Buddy RC 1 - 1 = 0

Person 클래스 안에 pet 클래스 인스턴스가 프로퍼티로 존재.

person에 nil을 할당하는 순간 person이 가리키고 있던 Person 인스턴스(John)의 RC가 1 감소

Person 인스턴스의 RC가 0이 되었으니 메모리에서 해제되고

Person 인스턴스가 품고있던 pet 프로퍼티도 같이 메모리에서 해제되니, pet 프로퍼티가 가리키고 있던 Pet 인스턴스(Buddy)의 RC가 1 감소.

Pet 인스턴스는 아직 anotherReferenceToPet 에 의해 참조되고있음 

anotherReferenceToPet에 nil을 할당하는 순간 anotherReferenceToPet가 가리키고 있던 Pet 인스턴스(Buddy)의 RC가 1 감소

이제 Pet 인스턴스의 RC가 0이 되어 메모리에서 해제.

 

 

strong, weak, unowned, 순환 참조

1. Strong Reference  (강한 참조)

인스턴스의 주소값이 변수에 할당될 때, RC가 증가하면 강한 참조(strong).

지금까지 자연스럽게 인스턴스를 생성하고 사용하던 것들이 모두 강한참조 였던 것.

strong선언을 한적이 없지만, 별다른 선언이 없다면 default 값이 strong 즉 강한참조

강한참조에서 문제가 되는것은 순환참조이다.

 

2. 순환참조

ARC의 단점은 위에 적었듯이 순환참조 발생 시 영구적으로 메모리가 해제되지 않을 수 있다는 점이다.

참조는 디폴트로 강한 참조(Strong Reference)를 사용하는데, 이 강한 참조를 잘못 사용하면 메모리 누수(Memory Leak) 문제가 발생할 수 있다. 가장 대표적인 예가 두 개 이상의 인스턴스가 서로가 서로를 강한 참조일 때 발생하는데, 이 문제를 강한 참조 순환(Strong Reference Cycle or Retain Cycle)이라고 한다.

class Man {
    var name: String
    var girlfriend: Woman?

    init(name: String) {
        self.name = name
    }
    deinit { print("Man Deinit!") }
}

class Woman {
    var name: String
    var boyfriend: Man?

    init(name: String) {
        self.name = name
    }
    deinit { print("Woman Deinit!") }
}

var cheolsu: Man? = .init(name: "철수")      // cheolsu(Man 인스턴스) RC 1 (chelosu가 Man 객체를 강하게 참조)
var yeonghee: Woman? = .init(name: "영희")   // yeonghee(Woman 인스턴스) RC 1 (yeonghee가 Woman 객체를 강하게 참조)


cheolsu?.girlfriend = yeonghee  // yeonghee(Woman 인스턴스) RC 1+1=2 (cheolsu가 yeonghee를 강하게 참조)
yeonghee?.boyfriend = chelosu  // chelosu (Man 인스턴스) RC 1+1=2 (yeonghee가 chelosu를 강하게 참조)

cheolsu = nil   // chelosu (Man 인스턴스) RC 2-1=1 (yeonghee의 boyfriend가 chelosu를 강하게 참조)
yeonghee = nil  // yeonghee (Woman 인스턴스) RC 2-1=1 (cheolsu의 girlfriend가 yeonghee를 강하게 참조)
// RC 각자 1로 남아있어 두 객체는 해제되지 않음. deinit호출되지 않음

소름돋는사실 하나 발견한게 우리 강의자료 소들이꺼 본듯(예시랑 오타 똑같음ㅋㅋㅋ)

 

먼저 철수와 영희 변수에 nil을 대입한 순간, 각각 가리키던 인스턴스의 RC 값을 1씩 감소 시킨다

그런데 힙에 있는 Man&Woman 인스턴스들의  RC는 0이 아니고 1이다

왜냐면 순환참조 때문에 아까 서로의 RC가 1씩 증가 돼서

따라서 RC가 0이 아니기 때문에 해제되지 않고 계속 힙에 남아 있다

 

이렇듯, strong으로 선언된 변수들이 순환참조 됐을 시 큰 문제점은

서로가 서로를 참조하고 있어서 RC가 0이 되지 못한다는 것이다.

심지어 해당 인스턴스를 가리키던 변수(철수, 영희)도 nil이 지정됐기 때문에

인스턴스에 접근 할 수 있는 방법도 없어 메모리 해제도 못한다

 

따라서 어플이 죽기 전까지 메모리 누수(memory leak)가 계속 발생하는 것이다.

 

강한 참조 순환 문제 해결 방법은 약한 참조(Weak Reference)와 비소유 참조(Unowownde Reference)를 사용하면 된다.

 

3. 약한 참조(Weak Reference), 비소유 참조(Unowownde Reference)

 

1) 약한 참조(Weak Reference): 참조되는 대상을 약하게 참조하여 순환 참조를 방지

weak는 옵셔널로 선언되는 참조이다.

약한 참조는 참조하는 객체를 강제로 유지하지 않고, 참조 대상이 메모리에서 해제되면 자동으로 nil로 설정된다.

주로 순환 참조를 방지하기 위해 사용된다. 두 객체가 서로를 강하게 참조하는 경우, 순환 참조로 인해 메모리 누구가 발생할 수 있는 상황에서 한쪽을 weak로 선언하여 순환 참조 문제를 해결할 수 있다.

class Man {
    var name: String
    weak var girlfriend: Woman?     //weak쓸때 레퍼런스 카운트 올리지 않음

    init(name: String) {
        self.name = name
    }
    deinit { print("Man Deinit!") }
}

class Woman {
    var name: String
    var boyfriend: Man?

    init(name: String) {
        self.name = name
    }
    deinit { print("Woman Deinit!") }
}


var cheolsu: Man? = .init(name: "철수")  // cheolsu (Man 인스턴스) RC 1
var yeonghee: Woman? = .init(name: "영희")  // yeonghee (Woman 인스턴스) RC 1

cheolsu?.girlfriend = yeonghee  // yeonghee (Woman 인스턴스) RC 변화 없음 RC 1 (chelosu가 weak 참조)
yeonghee?.boyfriend = cheolsu  // chelosu (Man 인스턴스) RC 2 ②RC 1로 변화


cheolsu = nil  // ③chelosu (Man 인스턴스) RC: 1 -> 0, Man 인스턴스 deinit 호출
yeonghee = nil  // ①yeonghee (Woman 인스턴스) RC: 1 -> 0, Woman 인스턴스 deinit 호출
cheolsu?.girlfriend   //nil (chelosu는 이미 해제되었음)

어떤 인스턴스(Woman Instance)의 프로퍼티(boyfriend)가 다른 인스턴스(Man Instance)를 가리키고 있을 때, 그 프로퍼티(boyfriend)가 속한 인스턴스(Woman Instance)가 메모리에서 해제되면 그 프로퍼티(boyfriend)가 가리키고 있던 인스턴스(Man Instance)의 RC가 -1 감소한다.

 weak로 선언된 girlfriend가 참조하던 인스턴스가 메모리에서 해제 되었으니, girlfriend의 값이 nil로 할당된다 

(weak의 특징! 가리키던 인스턴스가 메모리에서 해제될 경우 nil이 할당된다!)

Man Instance의 RC 값도 0이 되었으니 메모리에서 해제한다 

이제 deinit 함수도 정상 작동 한다.

 

그럼 철수와 영희 중에 누구든 weak로 선언해도 상관 없나!?

-> 예제에선 철수와 영희의 수명이 "동일"하기 때문에 아무나 weak로 선언 했지만 강한 순환 참조가 난 경우,

둘 중에 수명이 더 짧은 인스턴스를 가리키는 애를 약한 참조로 선언함

 

철수가 먼저 죽는다 -> 영희의 boyfriend가 nil이 될 수 있다 -> 영희의 boyfriend를 weak로 선언한다

영희가 먼저 죽는다 -> 철수의 girlfriend가 nil이 될 수 있다 -> 철수의 girlfriend를 weak로 선언한다

 

2) 비소유 참조(Unowownde Reference): 참조되는 대상이 항상 유효한 경우에만 사용하며, 해당 대상이 해제될 수 있는 상황에는 사용하지 않습니다.

unowned는 옵셔널이 아닌 비소유 참조를 나타낸다.

비소유 참조는 항상 값이 있다고 가정하며, 참조하는 객체가 해제되면 런타임 에러가 발생할 수 있다.

unowned 참조는 참조 대상이 해제될 수 있는 경우에만 사용해야 한다.

그리고 그 객체가 메모리에서 해제되지 않은 상태에서만 해당 unowned 참조를 사용해야 한다.

class Man {
    var name: String
    unowned var girlfriend: Woman?

    init(name: String) {
        self.name = name
    }
    deinit { print("Man Deinit!") }
}

class Woman {
    var name: String
    var boyfriend: Man?

    init(name: String) {
        self.name = name
    }
    deinit { print("Woman Deinit!") }
}

var cheolsu: Man? = .init(name: "철수")
var yeonghee: Woman? = .init(name: "영희")

cheolsu?.girlfriend = yeonghee
yeonghee?.boyfriend = chelosu


yeonghee = nil    //영희의 인스턴스는 해제
cheolsu?.girlfriend // 에러 발생 철수남아있고 걸프렌드는 메모리 해지된 상태

 

unowned가 붙은 철수의 grilfriend가 가리키는 영희(Woman) 인스턴스는,

철수(Man) 인스턴스가 메모리에서 해제되기 전까진 절대 절대 먼저 해제되어선 안된다.

(철수가 먼저 죽고 난 후에 영희가 죽어야 함)

yeonghee = nil

영희의 인스턴스가 철수의 인스턴스보다 먼저 메모리에서 해제되면 

weak의 경우엔 자동으로 girlfriend의 값이 nil로 지정 되겠지만,

unowned의 경우 nil을 할당받지 못해 이미 해제된 메모리 주소값을 계속 들고 있음

chelosu?.girlfriend // 에러 발생 철수남아있고 걸프렌드는 메모리 해지된 상태

이미 메모리에서 해제된 포인터 값에 접근하려 해서 에러가 발생함

이것이 바로  weak와 unowned의 차이점이다.

unowned는 에러를 발생시킬 위험이 있어서 웬만해선 weak를 사용하는 것을 권장한다.