[TIL] 메모리 관리는 낭만적이다. - 1

Valse·2022년 7월 9일
0

Swift

목록 보기
2/8
post-thumbnail

메모리 관리?

스위프트는 값 타입과 참조 타입을 사용한다.

  • 값 타입은 필요할 때마다 '값'이 복사(copy of value)되어서 Stack에 저장된다.
    스택 프레임이 끝날 때마다 메모리에서 자동 제거된다.

  • 참조 형식은 클래스와 클로저이다. '값'은 Heap에 저장되고 '주소'를 Stack에 저장한다.
    이 메모리는 ARC 모델을 통해 관리한다. Refernce Counting 참조 숫자를 자동으로 관리!
    - Retain Counting 이라는 표현과 혼동되기도 하는데, 이는 MRC 시절 수동으로 메모리를 관리하며 호출하던 retain() 함수 때문인 것으로 보인다.


메모리 구조의 재고찰

  • 코드 영역 : 텍스트 영역이라고도 부른다. Read-Only이며, CPU에 전달될 명령어가 위치한다.
    코드의 명령어가 요구하는 특정 값 또는 주소는 데이터, 힙, 스택 영역에 각각 저장된다.

  • 데이터 영역 : 전역 변수, 타입 변수가 저장된다. 물론 전역 상수와 타입 상수도 이곳에 저장된다.
    앱이 실행되는 동안 불변하며 메모리를 공유하는 모든 영역에서의 사용을 위한 목적으로 존재한다.

  • 힙 영역 : 힙 영역의 데이터는 상대적으로 길게 저장된다.
    차곡차곡 쌓이는 게 아니라 동적할당된다.
    힙 영역에 할당되는 데이터들 중 특별 관리가 필요한 데이터 타입이 있다. 대표적으론 캡쳐를 일으키는 클로저.
    강한 참조 사이클로 인해 RC가 0이 되지 않으면 힙에서 자동으로 해제되지 않는다.
    이로 인해 메모리 누수가 발생한다.

  • 스택 영역 : 함수를 실행할 때 스택 프레임이 호출되어 크기가 작은 데이터들을 빠르게 사용하는 영역
    함수 실행을 위한 임시 공간이며 자동으로 알아서 메모리 관리가 된다.
    하나의 앱은 여러 개의 스택을 가질 수 있고 이를 소프트웨어 쓰레드로 부른다.

그래서 힙 영역의 메모리를 어떻게 관리할 것인가?


ARC Model

메모리를 관리해야 하는 대표적인 언어는 Java다.
Java는 가비지 콜렉터를 통해 힙을 스캔하고 낭비되는 메모리를 감시한다.

JavaObjCSwift
GC
Garbage Collector
MRC
ARC
ARC
  1. Java GC : 힙을 주기적으로 스캔해서 쓰지 않는, 쓰지 않게 될 메모리를 검사하여 메모리를 감시
    이 스캔 주기와 스캔 시간 때문에 그 작동이 느린 편이다.
  2. Objc MRC,ARC : MRC는 개발자가 직접 코드를 넣어서 참조 숫자를 카운트했다.
    ARC는 참조 숫자를 자동으로 카운트해서 메모리 관리, 컴파일 시에 메모리 해체 시점을 결정한다.
    물론 '완전히 자동'이 아니라 참조 숫자를 세어줄 뿐이다.
    어떤 참조 관계를 어떻게 세야 할지는 개발자가 구현해줘야 한다.

그럼 어떤 메커니즘이 자동으로 숫자를 센다는 건가?


RC Model

인스턴스는 최소 하나 이상의 RC를 갖고 있을 때, 메모리에 유지된다.

RC는 Reference Count이다.
최소 1개 이상의 레퍼런스 카운트가 있을 때 A 인스턴스는 메모리에 유지된다.
얼마나 많은 속성, 변수, 상수 따위가 A 인스턴스의 주소값을 갖고 있는지를 카운트한다고 보면 좋다.

클래스 객체로 예시를 들어보자.
특정 변수에 init()으로 인스턴스가 생성되어 저장되면, 힙에는 실제 값이 스택에는 주소가 저장된다.
'힙'에 저장된 실제 값에 접근하기 위해 주소가 필요한데, 일반적으로 이 주소는 스택에 있다.
즉, 힙은 변수 혹은 상수, 속성 등등 주소를 가질 수 있는 모든 것에 포함된 주소 값으로 열리는 보물상자 꾸러미다.
세상에 이렇게 낭만적일 수가!

열쇠(주소)가 최소 하나 있으면 보물상자(힙의 실제 값)는 언제든 발견할 수 있다.
그렇다면 보물상자가 없다고 가정할 필요가 없다.
반대로, 열쇠가 없다고 뉴스에 공표된다면? 이제 그 열쇠로 열 수 있는 보물상자도 없는 것이다.
이런 구조로 힙의 실제 값은 열쇠의 갯수(RC)에 따라 유지된다.

그런데 문제는 이 열쇠가 보물상자 안에도 들어가버리는 경우다.
저 보물상자를 열기 위해선 이 보물상자 안에 있는 열쇠를 써야 하는데... 그럼 어떻게 해야 할까?
이 상황이 강한 참조 사이클과 깊게 연관된다.
아 그리고, 강한 참조 자체는 죄가 없다.
이걸 오해하는 사람들이 있는데, 강한 참조가 일으키는 '사이클'이 메모리 누수를 일으키는 게 문제일 뿐이다.

우선은 일반적인 예시부터 살펴보고 강한 참조 사이클 설명을 이어가보겠다.

class Dog {
    var name: String
    var weight: Double
    init(name: String, weight: Double) {
        self.name = name
        self.weight = weight
    }

    deinit {
        print("메모리 해제")
    }
}

// 원래는 retain이 필요했으나, ARC 모델을 사용하며 자동으로 해준다.
// cream 이라는 변수가 스택에 주소를 갖게 된다. 인스턴스 값은 힙에 저장되었다.
// 열쇠와 보물상자가 있으니, 열쇠의 갯수는 한 개가 된다. 
var cream: Dog? = Dog(name: "크림", weight: 15.0) // retain(cream) RC: 1

// 그런데 열쇠가 사라졌다!
// 보물상자도 다시 열 수 없으니 메모리에서 해제한다.
cream = nil // release(cream) RC: 0

// 이번에는 동시에 3개의 인스턴스를 생성해 볼 것이다.
// 우선 어떤 타입인지만 정의했다.
var dog1: Dog?
var dog2: Dog?
var dog3: Dog?

// 그 다음 Dog.init()으로 dog1 인스턴스를 힙에, 주소 값을 스택에 생성한다.
// dog1이 갖고 있는 열쇠는 dog2, dog3 에 복사했다.
// 보물상자는 한 개고 열쇠는 3개인 상황이다.
dog1 = Dog(name:"도지", weight: 10.0)
dog2 = dog1
dog3 = dog1

// RC가 0이 될 때, 참조 관계의 인스턴스들은 '동시에' 힙에서 해제된다.
// 첫번째 열쇠가 사라져도 2개가 남아있고(RC == 2), 마지막 열쇠가 사라져야 보물상자를 열 수 없게 된다.
dog3 = nil
dog2 = nil

// 이 시점에, deinit() 이 호출된다.
dog1 = nil // 출력 : "메모리 해제"

참조 카운트는... 낭만적이다..

열쇠가 있어야 보물상자를 열 수 있다.
그런데 열쇠가 보물상자 안에 있는 경우가 문제다.
이 낭만적인 사회는 열쇠가 어느 보물상자에 담겨 있다고 말하면 그 열쇠 때문에라도 보물상자를 포기할 수 없게 만든다.
있는지 없는지도 모를 그 열쇠 때문에 보물상자를 잊지 못하고.. 메모리가 낭비되는 것이다.
보물상자를 포기하지 못하는.. 그 마음 잘 받아갑니다..

class ATreasure {
    var keyForB: BTreasure?
    var treasure: String

    init(treasure: String) {
        self.treasure = treasure
    }

    deinit {
        print("A 보물상자 사라짐")
    }
}

class BTreasure {
    var keyForA: ATreasure?
    var treasure: String

    init(treasure: String) {
        self.treasure = treasure
    }

    deinit {
        print("B 보물상자 사라짐")
    }
}

var aTreasureBox: ATreasure? = ATreasure(treasure: "B 열쇠")
var bTreasureBox: BTreasure? = BTreasure(treasure: "A 열쇠")

// a 보물상자에 B 열쇠가 있고, b 보물상자에 A 열쇠가 있대!
print(aTreasureBox!.treasure, bTreasureBox!.treasure)

// 그럼 b 보물상자를 열려면 a 보물상자를 열 수 있는 A 열쇠가 필요하네?
aTreasureBox?.keyForB = bTreasureBox
bTreasureBox?.keyForA = aTreasureBox

// 그럼 보물상자가 통째로 사라져도.. 열쇠는 그 보물상자 안에 있으니까
// 언젠가 다시 보물상자를 찾으면 두 개의 보물상자를 모두 열 수 있겠네?
// ===> 각 인스턴스에 nil을 할당해도 deinit이 실행되지 않는다.
aTreasureBox = nil
bTreasureBox = nil

// 열쇠도 없대.. 그럼 보물상자를 포기해야지
// ...야 근데 이미 보물상자가 없다며. 그 안에 열쇠도 이미 사라진거 아니야?
// .. 그럼 보물상자를 포기해야지...
// 야 그럼.. (반복)
// ===> 이미 인스턴스에 nil이 할당되어 버렸기 때문에 그 내부의 열쇠에 접근하지 못함
// ====> 강한 참조 사이클의 문제
aTreasureBox?.keyForB = nil
bTreasureBox?.keyForA = nil

강한 참조 사이클의 해결

없는 열쇠, 없는 보물상자로 씨름하지 않도록 하려면 실제로 그 열쇠가 없다고 말할 줄 아는 용기가 필요하다.
이때 필요한 게 약한 참조와 미소유 참조다.
우리가 예시로 쓴 보물상자 예시에는 미소유 참조가 더 적합하다.
그런데 더 중요한 건 약한 참조니까 이제 보물상자 따위는 잊어버리자.

  1. weak
    약한 참조 키워드는 변수, 상수가 주소를 갖고 있어도 아닌 것으로 셈한다. 즉, RC를 증가시키지 않는다.
    덕분에 서로가 서로를 참조하는 경우에도 어느 한쪽이 약한 참조로 정의되었다면, 참조의 사이클이 강하게 유지되지 않는다.
    각 객체가 서로를 참조할 때, 주기가 더 짧거나 상대적으로 덜 중요한 쪽에 '약한' 참조를 사용한다.
    내가 참조하던 속성, 변수, 상수의 주소 값에 nil이 할당되면, 약하게 참조하고 있었기 때문에 RC는 0이 되어서 인스턴스도 할당해제된다.
    그러면 내가 참조하고 있던 값도 사라지게 되는데, weak 의 경우에는 기본값을 nil로 다시 설정해준다.
    따라서 weak은 꼭 옵셔널 변수로 선언해야 한다.

  2. unowned
    미소유 참조는 비교적 사용 빈도가 적다.
    weak이 주기가 더 짧거나 상대적으로 덜 중요한 쪽을 약한 참조한다고 했으나, 미소유 참조는 그 반대다.
    생애주기가 거의 동일하거나 중요한 쪽으로 참조한다.
    참조하고 있던 인스턴스가 사라지면 nil로 기본값을 다시 설정해주지 않는다.
    swift 5.0 부터는 미소유 참조도 옵셔널을 지원하기 때문에 변수로 선언할 수 있다.
    크래시를 굉장히 잘 일으킬 수 있기 때문에... 사용에 유의해야 한다.


사실.. 클로저의 캡쳐와 캡쳐리스트도 정리하려 했는데, 그 분량도 그렇고 내용도 그렇고 아직 공부가 더 필요할 것 같다.
공식문서로 다시 공부해야겠다.
220709

profile
🦶🏻🦉(발새 아님)

0개의 댓글