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

Valse·2022년 7월 13일
0

Swift

목록 보기
3/8
post-thumbnail

저번 시간에 이어서...

클로저의 메모리 구조와 캡쳐, 캡쳐리스트는 어떻게 메모리 상에 구성되는지 살펴보자.

커스텀 타입 내에 정의된 클로저

커스텀 타입 내의 클로저가 객체 속성 및 메소드에 접근할 때는 self 키워드를 써주어야 한다.
클래스나 구조체에서 클로저가 선언되더라도 클로저 자체의 인스턴스는 해당 커스텀 타입의 외부에 존재한다.
즉, 클래스/구조체와 그 내부에서 정의된 클로저는 RC를 공유하지 않는다.

클로저가 자기 자신의 실행을 위해 클래스/구조체의 속성을 참조해야 한다면, 해당 클로저는 클래스/구조체의 속성을 참조하는 주소값을 갖는다. 주소값을 갖는다..?
그렇다면.. 이 시점에서 벌써 RC가 1이 올라버렸다. 캡쳐가 발생하기 때문이다.

주소값을 가질 때 약한 참조 혹은 미소유 참조를 따로 선언하지 않았기 때문에 기본적으로 강한 참조로 클래스/구조체의 인스턴스 주소값을 참조하는 것이다.
이렇게 줄글로만 쓰면 이해가 잘 되지 않으니까 예시로 보자.


클로저의 캡쳐와 메모리 누수 현상

아래 예시에서 참조가 일어날 경우의 수는 3가지다.

  1. 클래스 속성에 클로저가 할당되며 해당 속성이 갖게 될 클로저 주소(강한 참조)
  2. 클로저가 자신의 실행을 위해 클래스 속성을 캡쳐하며 참조할 클래스 속성(강한 참조)
  3. 인스턴스를 생성할 때 할당받는 변수의 주소 값이 참조하게 될 클래스 인스턴스의 주소(강한 참조)
class Example {
    var name = "멍멍이"
    var weight = 0.0

    // 옵셔널 함수 정의
    var run: (() -> Void)?

    func walk() {
        print("\(name) : 걷는다!")
    }

    func saveClosure() {
        // 클로저를 인스턴스 속성에 저장하는 함수
        // 클래스 내에 접근하기 때문에 self 키워드를 꼭 사용
        run = {
            print("\(self.name) : 뛴다!")
        }
    }
    
    convenience init(weight: Double) {
        self.init()
        self.weight = weight
    }

    deinit {
        print("\(self.name) : 메모리 해제")
    }
}

func doSth() {
	// dog 변수가 클래스 인스턴스를 참조 => 클래스 인스턴스의 RC += 1
    var dog: Example? = Example()
    
    // 클로저가 클래스 인스턴스 속성에 저장되며, 인스턴스 속성의 주소 값을 캡쳐
    // => 클래스 인스턴스의 RC += 1
    // 동시에, 클래스 인스턴스의 속성일 클로저의 주소 값을 참조 => 클로저의 RC += 1
    dog?.saveClosure()
    dog?.run?() // 멍멍이 : "뛴다!"
    
	// dog 변수의 클래스 인스턴스 속성 참조가 해제 => 클래스 인스턴스의 RC -= 1
    dog = nil
    
    // 클래스 인스턴스의 RC == 1(클로저가 참조하여 발생한 RC)
    // 클로저의 RC == 1(클래스의 인스턴스 속성 참조로 인한 RC)
}

// 클로저와 클래스 인스턴스가 서로를 강한 참조하여 강한 참조 사이클 발생
doSth()

그럼 어떻게 해결할 수 있을까?

클로저가 클래스의 속성을 '참조'하는 게 문제가 된다.
캡쳐는 클로저가 자기 자신의 실행을 위해 외부 변수를 참조하는 현상인데, 강한 참조가 기본이기 때문에 RC가 꼬이는 것이다.
그렇다면 자신의 실행을 위해 필요한 외부 변수를 참조하지 않고, 값을 복사하여 클로저가 갖게 하면 어떨까?
이러한 발상에서 탄생한 개념이 캡쳐리스트 이다.
캡쳐리스트를 통해 외부 변수는 더이상 클로저 내부에서 참조되지 않고 클로저가 소유한 변수처럼 취급된다.
따라서 클로저 내부에서도 약한 참조와 비소유 참조 선언이 가능하게 된다.

그런데 값 타입인 변수가 아니라 참조 타입이 클로저 내부에서 캡쳐될 수도 있다.
따라서 클로저를 사용할 때 캡쳐와 캡쳐리스트가 발생할 수 있는 경우의 수는 아래와 같다.

분류캡쳐캡쳐리스트
값 타입값의 주소를 참조값 자체를 복사하여 클로저 힙에 함께 저장
참조 타입클로저가 캡쳐한 변수를 스택에서 참조한 후,
해당 변수가 참조하는 인스턴스를 다시 참조
클로저가 필요로 하는 인스턴스를
힙에서 직접 참조

적어도 클로저를 클래스 혹은 구조체와 함께 쓰고자 한다면 강한 참조 싸이클이나 참조를 찾아다니는 연산을 최소화 하기 위해 캡쳐리스트를 사용할 필요가 있다.

캡쳐리스트는 아래 형태로 쓰인다.

// 파라미터가 없는 경우
{ [capture List] in 
    // code
}

// 파라미터가 있는 경우
{ [capture List] (param) -> returnType in
    return 0
}

var num = 1
let valueCpt = {
    print(num)
}

// 캡쳐리스트를 통해 num에 할당된 1이 '복사'되어 클로저 힙에 함께 저장된다.
let valueCpt2 = { [num] in
    print("캡쳐리스트 출력 - \(num)")
}

valueCpt() // 1
num = 7
valueCpt() // 7
num = 8

// 외부 스코프에서 num의 값이 변해도, 캡쳐리스트로 인해 복사된 값에는 영향을 주지 않는다.
valueCpt2() // 캡쳐리스트 출력 - 1


// 참조 타입을 캡쳐리스트로 넣어보자.
var x = Example(weight: 10.1)
var y = Example(weight: 20.1)

let captureList = { [x] in
    print("x만 캡쳐리스트로 담아본다.")
    print(x.weight, y.weight)
}

/*
x만 캡쳐리스트로 담아본다.
10.1 20.1
*/
captureList()

클로저의 캡쳐리스트와 메모리 누수의 해결

단순히 캡쳐리스트를 쓰는 것으로 강한 참조 사이클이 해결되지는 않는다.
일반적인 클래스나 구조체에서 해결했던 방식과 마찬가지로
weak unowned 키워드를 통해 RC가 오르지 않도록 조치해주면 된다.

import Foundation

class Ex {
    var name = "예시예시야"

    func doThis() {
        DispatchQueue.global().async { [weak self] in
            if let weakSelf = self {
                print("if let 바인딩으로 출력 : \(weakSelf.name)")
            }
        }
    }

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

var a: Ex? = Ex()
a?.doThis() // if let 바인딩으로 출력 : 예시예시야
a = nil // 메모리 해제해제야

여기까지 메모리 관리와 관련된 내용들을 다뤄보았다.
옵셔널을 쓰는 게 아직 좀 덜 익숙한 모양이다.
220713

profile
🦶🏻🦉(발새 아님)

0개의 댓글