2015년 1월 9일 금요일

Swift Memory Management #4 클로져(Closure)의 경우

클로져 이용 시에도 메모리 관리와 관련된 지식이 필요 할 때가 있다. 일반적인 변수 대입 과정에서 리테인이 발생하는 요소 외에도 클로져 내부에서 외부의 변수를 참조 할 때 발생하는 리테인을 깜빡 할 수도 있기 때문이다.

캡쳐 리스트 (Capture List)

이 블로그에서 클로져의 캡쳐리스트에 관해 언급한 적이 없어서 일단 짚고 넘어가야 할 것 같다. 우선은 왜 이런게 필요한 상황이 오는지 부터 살펴보자.

클로져는 아래 예 같은 문제가 있을 수도 있다.
var funcs: [() -> Int] = []
for var i=0; i < 10; i++ {
    funcs.append({
        () in
        return i
    })
}

funcs[0]()  // 10
마지막 라인의 결과가 생각과는 다르게 0이 아니라 10이 나온다. 다른 모든 인덱스를 넘겨봐도 모두 10이 나올 것이다.

이유는 단순한데, i 라는 인스턴스가 고정되어 있기 때문이다. 즉 하나의 정수 인스턴스를 만들고 이 인스턴스의 값을 0에서 9 까지 바꾸어가며 클로져를 생성하기 때문에 모든 클로져는 하나의 i 인스턴스 만으로 생성된다. 그래서 클로져 내부에서 참조하는 i는 for루프 탈출 조건인 i가 10이 된 순간의 값만이 복사되어 있다.
혹시 앞서 이야기한 struct와 class의 차이에서 struct는 인스턴스를 만들어서 복사한다는걸 여기서 생각한다면 잘못된 생각이다. struct는 대입 과정에서만 인스턴스를 새로 생성한다.
여기서 추가로 알아야 할 사항은 클로져 내부에서 참조하는 외부 변수는 모두 강하게 참조(strong)하게 된다는 점이다. 위에서 생성되는 모든 클로져는 i에 리테인을 건다. 그리고 해당 클로져가 모두 사라지기 전 까지 for 루프에서 생성된 i 라는 지역변수는 메모리에서 해제되지 않는다.

이제 문제를 해결해 보자.

이 문제를 해결하려면 클로져 생성 당시의 i의 값을 캡쳐(Capture) 해야한다. Swift에서는 이런 기능을 캡쳐리스트(Capture List) 라는 이름으로 제공한다. 클로져 파라미터 선언 앞에 대괄호로 묶어서 변수 이름을 적어주면 된다.
var funcs: [() -> Int] = []
for var i=0; i < 10; i++ {
    funcs.append({
        [i] () in     // i의 값을 캡쳐한다.
        return i
    })
}

funcs[0]()  // 0
이제 의도한 대로 마지막 라인의 결과는 0이다. 각 인덱스를 순환해보면 제대로 된 값을 돌려줌을 확인 할 수 있을 것이다.

캡쳐리스트에 선언된 i는 클로져가 생성될 때 클로져 내부에 복사가 발생한다. 여기서 i는 Int 즉 정수형을 의미하는 구조체(struct) 타입이다. 따라서 클로져 생성 순간에 정수 인스턴스가 새롭게 생성되어 i의 값을 복사한 후 이 새 인스턴스에 리테인을 거는 식으로 동작한다. 이제 for 루프에 의해 생성된 i는 클로져와 상관 없이 메모리에서 해제가 가능해진다.
만약 Int 타입이 struct가 아닌 class로 만들어져 있었다면 캡쳐리스트를 써도 해결이 안되었을 것이다. 왜 그런지는 직접 생각해 보자.
참고로 위의 문제는 캡쳐리스트를 쓰지 않고도 해결하는 방법이 있다. for 루프를 아래와 같은 식으로 바꾸면 된다.
for i in 0..<10 {
    ...
}
이유는 단순하다. 위의 식으로 바꾸면 i라는 값은 0에서 9 까지 별개의 정수 인스턴스가 들어가게 된다. for문에서 Range를 사용하게 되면 매 아이템이 새로운 인스턴스로 생성되기 때문이다. 그래서 캡쳐리스트가 필요 없어진다. 물론 이와 동일하게 블럭 내부에서 i값을 복사한 새로운 인스턴스를 생성하면 문제는 해결된다.

약한 self?

이제 실제로 메모리 관리와 관련된 이슈를 보자. Objective-C 에서 블럭 프로그래밍을 해 봤다면 weak self(혹은 unsafe Unretained self) 라는 개념에 대해 들어 본 적이 있을지도 모르겠다. Swift도 ARC를 쓰기 때문에 비슷한 용도의 단어가 등장하는게 그것디 바로 약한 참조의 self(weak self 혹은 unowned self)이다.

아래 예제를 보자.
class SomeClass {
    let name: String
   
    init(name: String) {
        self.name = name
    }
   
    lazy var someClosure: () -> String = {
        () in
        return self.name
    }
}

let some1 = SomeClass(name: "Some 1")
let cl = some1.someClosure
cl()
이 코드 만으로 문제를 찾자는 것은 아니다. 일단 설명이 필요할 것 같다.

cl 이라는 상수는 some1 오브젝트에서 someClosure 라는 lazy한 프로퍼티 getter를 통해 클로져를 생성해서 받는다. (위 예제에서는 굳이 lazy로 할 필요는 없다. 다만 생성자가 아닌 곳에서 name의 값을 바꾸게 되는 경우를 대비하기 위함이다)

이 cl이 받게되는 클로져는 생성될 때 self를 참조하기 때문에 self에 리테인을 건다(즉 self를 강하게(strong) 참조한다). 그래서 cl이 메모리에서 해제되기 전 까지 some1 이라는 오브젝트 인스턴스(클로져 내부에서 self)도 리테인에 묶이게 되어서 메모리가 해제되지 않는다.

그렇다면 문제가 되는 케이스가 있다면? 만약 cl이 이런 지역 변수가 아니라 특정 클래스의 strong 프로퍼티였다면 어떻게 될까. 예를 들어 iOS 앱의 중추 클래스인 AppDelegate의 프로퍼티 중에 위의 cl같은 녀석이 있다면 어떻게 될까. 앱이 종료되기 전 까지 some1 같은 인스턴스가 영원히 해제되지 않는다는 말이 된다.

물론 이런 특수한 케이스의 경우에만 문제가 된다. 아니 사실 문제는 아니다. 왜냐하면 위에서 리테인을 건 self는 cl이 살아있는 한 언제든지 참조가 가능해야 되니 결코 메모리에서 해제되어서는 안된다. 따라서 정상적인 동작으로 봐야 한다.

만약 위의 경우에서 SomeClass의 인스턴스인 some1이 메모리에서 해제되어야만 하는 경우가 있다면 캡쳐리스트를 이용 할 수 있다. someClosure 프로퍼티의 구현을 아래처럼 바꾸면 된다.
class SomeClass {
    ...
    lazy var someClosure: () -> String = {
        [unowned self] () in
        return self.name
    }
    ...
}
이제 someClosure로 받게되는 클로져가 내부에서 참조하는 self는 unowned가 되었다. 사실 이는 weak self와 비슷한 의미인데, weak의 경우 Optional이 되어야 한다. Swift에서 옵셔널 처리는 문법상 좀 귀찮으니 일부러 unowned를 썼다고 치자. 원한다면 weak로 명시하고 nil 확인을 할 수도 있다.
unowned의 경우 unsafe unretained 라는 표헌을 쓰기도 하는데, nil이 될 수 없기 때문에 메모리에서 해제되었는지 여부를 파악 할 수가 없기 때문이다. 안전하게 구현하려면 unretained 라는 표현으로 불리는 weak를 써서 nil 체크를 하는 것이 좋다.
어쨌든 이제 cl이 살아있어도 some1 인스턴스는 해제되는 것이 가능하다.

마무리

명확하게 문제가 되는 예제를 보여주고 싶지만, 억지로 그런 예제를 만드는 건 뭔가 아닌 것 같다. 그래서 이번에는 그냥 문제가 될 만한 상황을 글로만 정리를 했다. 예제가 없는건 너무 아쉽지만 내 예제코드 작성 능력의 한계는 여기까지 인 것 같다.

코드 작성 방법에 따라 이런 weak self 같은건 쓸 일이 없을지도 모른다. 사실 내 경우도 위와 같이 클로져를 남이 실행시킬 수 있게 던져주는 걸 굉장히 싫어한다. 그래서 모르는 것 보다야 낫지 않을까.

[이전글] Swift Memory Management #3 구조체(struct)와 클래스(class)

댓글 없음 :