🔥 클로저로 인한 강한 참조 순환

929자
12분

클로저를 사용할 때 강한 참조 순환이 발생할 수 있다는 점을 앞서 살펴봤어요. 이번에는 클로저로 인해 강한 참조 순환이 발생하는 상황과 이를 해결하는 방법에 대해 자세히 알아보도록 할게요.

클로저를 클래스 인스턴스의 속성에 할당하고, 해당 클로저의 본문에서 인스턴스를 캡처하면 강한 참조 순환이 발생할 수 있어요. 이러한 캡처는 클로저의 본문에서 self.someProperty와 같이 인스턴스의 속성에 접근하거나, self.someMethod()와 같이 인스턴스의 메서드를 호출할 때 발생하죠. 이 경우 클로저는 self를 캡처하여 강한 참조 순환을 만들게 된답니다.

강한 참조 순환이 발생하는 이유는 클로저가 클래스와 마찬가지로 참조 타입이기 때문이에요. 클로저를 속성에 할당할 때, 실제로는 해당 클로저에 대한 참조를 할당하는 거예요. 이는 앞서 살펴본 문제와 본질적으로 동일한데, 두 개의 강한 참조가 서로를 살아있게 만드는 거죠. 다만 이번에는 두 개의 클래스 인스턴스 대신, 클래스 인스턴스와 클로저가 서로를 살아있게 만드는 상황이랍니다.

Swift는 이 문제를 해결하기 위해 클로저 캡처 리스트라는 우아한 방법을 제공해요. 하지만 클로저 캡처 리스트로 강한 참조 순환을 해결하는 방법을 배우기 전에, 어떻게 이런 순환이 발생하는지 이해하는 것이 도움될 거예요.

다음 예제는 self를 참조하는 클로저를 사용할 때 강한 참조 순환이 발생하는 방식을 보여준답니다. 이 예제는 HTML 문서 내의 개별 요소에 대한 간단한 모델을 제공하는 HTMLElement라는 클래스를 정의하고 있어요:

class HTMLElement {
    let name: String
    let text: String?
 
    lazy var asHTML: () -> String = {
        if let text = self.text {
            return "<\(self.name)>\(text)</\(self.name)>"
        } else {
            return "<\(self.name) />"
        }
    }
 
    init(name: String, text: String? = nil) {
        self.name = name
        self.text = text
    }
 
    deinit {
        print("\(name) is being deinitialized")
    }
}
swift

HTMLElement 클래스는 요소의 이름을 나타내는 name 속성과 해당 HTML 요소 내에 렌더링할 텍스트를 나타내는 문자열로 설정할 수 있는 옵셔널 text 속성을 정의하고 있죠.

이 두 가지 간단한 속성 외에도, HTMLElement 클래스는 asHTML이라는 지연 속성을 정의하고 있어요. 이 속성은 nametext를 HTML 문자열 조각으로 결합하는 클로저를 참조하고 있답니다. asHTML 속성은 () -> String 타입, 즉 "매개변수를 받지 않고 String 값을 반환하는 함수" 타입이에요.

기본적으로 asHTML 속성에는 HTML 태그의 문자열 표현을 반환하는 클로저가 할당돼요. 이 태그는 text 값이 존재하면 해당 값을 포함하고, text가 존재하지 않으면 텍스트 내용이 없어요. 문단 요소의 경우, text 속성이 "some text"와 같거나 nil이냐에 따라 클로저는 "<p>some text</p>" 또는 "<p />"를 반환하게 되죠.

asHTML 속성은 인스턴스 메서드처럼 명명되고 사용되지만, 사실 asHTML은 인스턴스 메서드가 아닌 클로저 속성랍니다. 그래서 특정 HTML 요소에 대한 HTML 렌더링을 변경하고 싶다면 asHTML 속성의 기본값을 사용자 정의 클로저로 대체할 수 있어요.

예를 들어, text 속성이 nil인 경우 기본 텍스트를 사용하도록 asHTML 속성을 설정하여 빈 HTML 태그가 반환되는 것을 방지할 수 있답니다:

let heading = HTMLElement(name: "h1")
let defaultText = "some default text"
heading.asHTML = {
    return "<\(heading.name)>\(heading.text ?? defaultText)</\(heading.name)>"
}
print(heading.asHTML())
// "<h1>some default text</h1>" 출력
swift

HTMLElement 클래스는 새 요소를 초기화하기 위해 name 인자와 (원하는 경우) text 인자를 받는 단일 이니셜라이저를 제공해요. 또한 이 클래스는 HTMLElement 인스턴스가 할당 해제될 때 메시지를 출력하는 디이니셜라이저를 정의하고 있죠.

다음은 HTMLElement 클래스를 사용하여 새 인스턴스를 생성하고 출력하는 방법이에요:

var paragraph: HTMLElement? = HTMLElement(name: "p", text: "hello, world")
print(paragraph!.asHTML())
// "<p>hello, world</p>" 출력
swift

강한 참조 순환 발생

그런데 위에서 작성한 HTMLElement 클래스는 HTMLElement 인스턴스와 기본 asHTML 값에 사용되는 클로저 사이에 강한 참조 순환을 만들고 있어요. 이 순환은 다음과 같이 보인답니다:

lecture image

인스턴스의 asHTML 속성은 클로저에 대한 강한 참조를 가지고 있죠. 하지만 클로저의 본문에서 self.nameself.text를 참조하는 방식으로 self를 참조하기 때문에, 클로저는 self를 캡처하여 HTMLElement 인스턴스에 대한 강한 참조를 다시 가지게 된답니다. 이로 인해 둘 사이에 강한 참조 순환이 생기는 거예요. (클로저에서 값을 캡처하는 것에 대한 자세한 내용은 Capturing Values를 참고해 보세요.)

paragraph 변수를 nil로 설정하여 HTMLElement 인스턴스에 대한 강한 참조를 끊더라도, 강한 참조 순환으로 인해 HTMLElement 인스턴스와 클로저가 모두 할당 해제되지 않아요:

paragraph = nil
// "<p>hello, world</p>" 출력
swift

HTMLElement 디이니셜라이저의 메시지가 출력되지 않는 것을 확인할 수 있는데, 이는 HTMLElement 인스턴스가 할당 해제되지 않았음을 보여주죠.

클로저 캡처 리스트로 강한 참조 순환 해결하기

강한 참조 순환이 존재하는 경우에도 메모리 관리를 편리하게 하고자 한다면 클로저 캡처 리스트(closure capture list)를 사용할 수 있어요. 캡처 리스트는 클로저 내에서 하나 이상의 참조 타입을 약한(weak) 참조나 미소유(unowned) 참조로 선언하여 강한 참조 순환을 방지하는 기능이랍니다.

약한 참조는 참조하고 있는 인스턴스가 먼저 해제되어 nil이 될 수 있어요. 따라서 약한 참조로 선언된 참조는 항상 옵셔널 타입이어야 해요.

반면에 미소유 참조는 참조하고 있는 인스턴스의 생명주기를 클로저와 동일하게 보장할 때 사용해요. 즉, 참조하는 인스턴스가 클로저보다 먼저 해제되지 않을 것임을 확신할 때 미소유 참조를 사용할 수 있죠.

클로저의 캡처 리스트는 클로저의 매개변수 목록 전, in 키워드 앞에 대괄호([])로 작성해요. 예를 들면 다음과 같아요:

lazy var someClosure = { [unowned self, weak delegate = self.delegate] in
    // 클로저 본문
}
swift

이 예제의 경우 unowned selfweak delegate = self.delegate로 캡처 리스트를 정의했어요. 약한(weak) 참조로 캡처할 때는 등호(=)를 사용하여 약한 참조의 초기값을 지정해야 한다는 점 잊지 마세요!

클로저 캡처 리스트 적용 예제

앞선 HTMLElement 예제에서 발생한 강한 참조 순환을 클로저 캡처 리스트를 사용하여 해결해 볼게요. HTMLElement 클래스의 asHTML 속성을 다음과 같이 정의해요:

lazy var asHTML: () -> String = { [unowned self] in
    if let text = self.text {
        return "<\(self.name)>\(text)</\(self.name)>"
    } else {
        return "<\(self.name) />"
    }
}
swift

이번에는 [unowned self] 캡처 리스트를 사용하여 self를 미소유 참조로 캡처했어요. asHTML 클로저와 HTMLElement 인스턴스의 생명주기가 동일하다는 것을 보장할 수 있기 때문에 미소유 참조를 사용할 수 있죠. HTMLElement가 해제되면 asHTML 클로저 역시 해제될 거예요.

이제 HTMLElement 인스턴스를 생성하고 asHTML 속성을 사용하는 코드를 실행해 볼게요. 이전과 마찬가지로 <p>hello, world</p>가 출력될 거예요.

var paragraph: HTMLElement? = HTMLElement(name: "p", text: "hello, world")
print(paragraph!.asHTML())
// "<p>hello, world</p>" 출력
swift

하지만 이번에는 paragraph 변수를 nil로 설정한 후에도 HTMLElement 인스턴스와 asHTML 클로저가 제대로 할당 해제된답니다.

paragraph = nil
// "p is being deinitialized" 출력
swift

디이니셜라이저의 메시지가 출력되는 것을 확인할 수 있죠? 클로저 캡처 리스트를 사용하여 강한 참조 순환을 끊었기 때문에 HTMLElement 인스턴스가 정상적으로 할당 해제되었어요.

이렇게 Swift의 클로저 캡처 리스트를 사용하면 클로저로 인한 강한 참조 순환을 우아하게 해결할 수 있습니다.