[Swift] [weak self] 이젠 제대로 알고 사용하자! (feat. ARC 2탄)

2024. 7. 8. 21:46Swift, iOS Foundation

아직 ARC 1탄 글을 읽지 않고 오셨다면, 아래 링크로 넘어가서 읽고 오길 권장합니다:)

 

[Swift] ARC (Automatic Reference Counting) 완전 정복하기

1️⃣ ARC (Automatic Reference Counting)를 배우기 위해 알아야 하는 기초 개념1. 값 타입 (Call by Value)과 참조 타입 (Call by Reference)값 타입 (Call by Value)은 데이터를 복사해서 전달하는 경우, 참조 타입 (Call

mini-min-dev.tistory.com

 

1️⃣ 클로저(Closure)에서도 발생하는 강한 참조 사이클(Strong Reference Cycle) 문제

지난 [Swift] ARC (Automatic Reference Counting) 완전 정복하기 글에서는 클래스의 인스턴스가 참조 타입에 해당하기 때문에,
두 클래스의 인스턴스가 서로를 강하게 참조하고 있는 경우 (Strong Reference Cycle) 영구적으로 메모리에서 해제되지 않는 문제 (Memory Leckage)가 발생할 수 있다고 언급했었다.

그리고 이를 해결하기 위한 두 가지 방법으로 약한 참조 (weak)와 미소유 참조 (unowned)를 각각 설명했었다.

하지만 사실 지난 글에서 언급하지 않았던 또 하나의 대표적인 Swift 참조 타입이 있었는데, 그것은 바로 클로저 (Closure)이다.
즉 이 말은, 클로저 구문 안에서도 강한 순환 참조 문제가 발생할 수 있다는 의미이다.

어떤 상황에서 해당 문제가 생길 수 있는지 간단한 HTML 문서를 작성하는 것을 보여주는 아래 예시 클래스 코드를 살펴보자.

name에는 "h1"이나 "p"와 같이 HTML 요소가 대입되며, text에는 HTML 요소가 렌더링 될 문자열을 정의하도록 만든다.

주목해서 봐야할 것은 asHTML이라는 지연 프로퍼티 (lazy property)이다.
보게 되면 "() -> String" 타입의 클로저를 참조하고 있는데,
lazy로 선언되어 있기 때문에 name과 text의 프로퍼티의 초기화가 이루어지고 난 이후에 접근 가능하고, 이 말은 곧 클로저 내에서 자기 자신 (self)를 참조할 수 있으며 인스턴스가 메모리에 있는 동안은 언제든지 호출 가능한 클로저 (= 즉, 탈출 클로저! 이 부분은 아래에서 더 자세하게 설명합니다!)를 참조하고 있는 의미라고 해석하면 되겠다.

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)이 메모리에서 정상적으로 해제되었습니다.")
    }
}

그럼 위에서 만든 클래스 타입의 인스턴스를 생성해보자. (메모리 해제가 이루어지는지를 확인하기 위해 옵셔널로 선언했다.)

위에서도 말했지만, 클로저는 클래스의 인스턴스와 마찬가지로 참조 타입에 해당되기 때문에 생성된 인스턴스는 () -> String 타입의 클로저에 대해 강한 참조를 갖게 된다.
클로저 내부에서도 마찬가지로, 인스턴스에 대해 self.text이나 self.name과 같이 self (HTML Element)를 참조하고 있기 때문에 (=클로저의 값 캡처라 부르며 이 부분 역시 아래에서 더 자세하게 살펴보겠다.) 인스턴스와 클로저 사이에 강한 참조 사이클 (Strong Reference Cycle)이 형성된다. 

⚠️ 클로저가 위 코드 상황과 같이 self.text와 self.name 등 여러 번 self를 캡처하는 상황에서도, 인스턴스에 대해 생기는 참조 카운트는 1개이다.
var element: HTMLElement? = HTMLElement(name: "h1", text: "Swift")
print(element!.asHTML())    // <h1>Swift</h1>

Closure Reference Cycle 01. 클래스 인스턴스와 클로저가 서로를 참조하게 된 상황

이때 기존 변수를 nil로 선언하고, 인스턴스에 대한 강한 참조 관계를 끊더라도 클래스의 deinit 구문에서 작성했던 print문이 출력되지 않는다.

RC가 1이기 때문에 HTMLElement 인스턴스와 () -> String 타입의 클로저는 메모리에서 해제되지 않는 것!
(HTMLElement 인스턴스 RC 분석 : element의 강한 참조 +1, () -> String 타입의 클로저가 갖고 있는 강한 참조 +1, element nil 선언 -1)

element = nil

Closure Reference Cycle 02. 순환 참조 (Retain Cycle) 문제에 따른 메모리 누수 (Memory Leckage) 발생

 

2️⃣ 캡처 리스트 (Capture List)를 정의해 강한 참조 사이클 (Strong Reference Cycle)을 해결하기

위와 같은 강한 순환 참조 상황을 지난 글과 마찬가지로 약한 참조 (Weak Reference) 또는 미소유 참조 (Unowned Refernece)로 해결할 수 있다.
단 클로저의 경우 이 참조를 다르게 정의하는 방법이 조금 다른데, 캡처 리스트 (Capture List)라고 부르는 방식이다.

💡 클로저의 값 캡처 (Capturing Value) 개념에 대해 이해하고 넘어가자면?

1) 클로저는 자신이 속해있는 클래스 또는 함수의 상수나 변수의 값을 가져와서 사용할 수 있는데, 이 경우를 값이 캡처되었다고 부른다.
2) 캡처된 값은 클로저의 실행 범위가 클래스 또는 함수의 실행범위를 벗어난 이후에도 계속 유지되는 것이 특징이다.
3) 클로저 내에서 self의 프로퍼티를 참조할 때, "self.프로퍼티 이름"의 형태로 값을 캡처하는 것을 권장한다.


캡처 리스트 (Capture List)
클로저가 특정 변수를 캡처하는 방식을 지정할 수 있게 해준다.
클로저의 파라미터 리스트 앞에 대괄호 ([ ])로 작성하며, 대괄호 안에 "참조 방식과 특정 변수" 순으로 지정하면 되겠다.

💡 즉 우리가 흔하게 사용했던 [weak self]클로저가 self를 참조할 때, 약한 참조 (Weak Reference) 방식을 따르겠다는 것을 의미했다.


위 코드에서 발생했던 순환 참조 문제를 캡처 리스트 (Capture List)로 해결하는 코드를 살펴보자.

나머지 부분은 다 똑같고 asHTML 클로저 부분만 살펴보게 되면, self (HTMLElement의 프로퍼티)를 캡처할 때 약한 참조로 가져오겠다고 캡처 리스트로 지정한 것을 확인할 수 있다. -> [weak self] 부분
지난 글에서 설명했던 것처럼 약한 참조 (Weak Reference) 관계에서는 nil일 수도 있기 때문에 guard-let문으로 옵셔널을 풀어주기도 했다.

class HTMLElement {
    let name: String
    let text: String?
    
    lazy var asHTML: () -> String = { [weak self] in
        guard let self else { return "" }   // weak 참조일 때는 self가 nil일 수도 있다.
        
        if let text = self.text {
            return "<\(self.name)>\(text)</\(self.name)>"
        } else {
            return "<\(self.name) />"
        }
    }
    ...
}

// 클래스 인스턴스 생성 + 강한 순환 참조 관계 형성
var element: HTMLElement? = HTMLElement(name: "h1", text: "Swift")
print(element!.asHTML())    // <h1>Swift</h1>

Capture List 01. 캡처 리스트를 통해 클로저는 self에 대해 약한 참조를 가진다.

이제 클래스 인스턴스를 참조하고 있는 element 변수를 nil로 설정하면, 순환참조 관계가 아닌 상황에서 RC는 0으로 인스턴스가 정상적으로 메모리에서 해제된다. 
(HTMLElement 인스턴스 RC 분석 : element의 강한 참조 +1, () -> String 타입의 클로저가 갖고 있는 약한 참조 0, element nil 선언 -1)

이때 클로저의 self에는 nil이 자동으로 대입될 것이다.

element = nil   // h1이 메모리에서 정상적으로 해제되었습니다.

 

3️⃣ [weak self]는 탈출 클로저 (Escaping Closure)에서만 사용한다

지금까지 살펴본 것으로는 "모든 클로저에서 self를 참조한다면, [weak self] 처리를 해줘야하는 것인가?"라고 받아들일지 모르겠다.

하지만, 정확히 말하면 클로저 중에서 탈출 클로저 (Escaping Closure)에 대해서만 약한 참조 처리를 해주면 된다.
*탈출 클로저 (Escaping Closure)는 함수의 실행이 끝난 이후에도 실행 가능한 클로저를 의미한다. 자세한 설명은 여기 클릭할 것.

왜인지 살펴볼까? 먼저 Escaping 클로저가 아닌 경우부터 살펴보자.

공통적으로 버튼을 클릭하면 addTarget 메서드로 buttonTapped 오브젝트 함수와 연결이 되는 코드이다.
탈출 클로저로 선언하지 않은 nonEscapingButton을 눌렀을 때는 performNonEscapingAction 메서드를 호출하게 되는데, 이 메서드는 () -> Void 타입의 non-Escaping 클로저를 파라미터로 받아 전달된 클로저를 즉시 실행하는 작업을 한다.

나는 이 클로저에 자기 자신(self = ViewController)의 titleLabel을 업데이트하는 작업을 추가했다.
해당 클로저는 performNonEscapingAction이라는 메서드의 호출과 동시에 순차적으로 수행되고 -> 이후 메서드의 동작이 끝나기 때문에 self를 참조하더라도 위에서 말했던 강한 참조 순환 문제를 신경 쓸 필요가 없다.

💡 Non-Escaping 클로저의 강한 참조 순환 문제 정리

: Non-Escaping 클로저에서는 self를 강하게 참조하더라도, 함수의 호출 종료 이후 클로저는 self를 참조하지 않으니 (= 애초에 클로저가 동작하는 동안 self가 해제될 수가 없으니 = 클로저가 인스턴스를 참조하는 동안 인스턴스의 메모리 해제가 발생하는 순환 참조의 상황이 생길 수 없으니) 강한 순환 참조 문제 (Strong Reference Cycle)를 신경쓰지 않아도 된다! 
extension ConcurrencyViewController {
    @objc
    func buttonTapped(_ sender: UIButton) {
        switch sender {
        case nonEscapingbutton:
            performNonEscapingAction {
                self.titleLabel.text = "Non Escaping - 업데이트된 텍스트입니다."
            }
        ...
    }
    
    func performNonEscapingAction(action: () -> Void) {
        action()
    }
}


하지만, 탈출 클로저 (Escaping Closure)의 경우에는 상황이 달라진다.

탈출 클로저의 의미 자체가 함수 실행이 끝난 이후에도 함수를 탈출해서 클로저 구문의 실행이 가능하다는 의미.
즉 다시 말해, 함수가 종료되었고 클로저 구문이 실행되려고 할 때, 그 사이에 기존 변수에 nil이 할당되어 클래스 인스턴스와의 강한 참조 관계가 끊기게 된 경우? -> 어쩔 도리 없이 강한 참조 순환 문제가 발생할 수 있다는 뜻이다.

*이 글 소제목 2번 상황의 코드에서도 lazy 속성의 클로저는 "인스턴스가 메모리에 있는 동안 언제든지 호출될 수 있는 클로저", 즉 Escaping 클로저로 간주되는 상황이었기에 문제 상황을 표현할 수 있었다.

탈출 클로저를 사용하는 모든 상황은 순환 참조 문제가 발생한다라고 말할 수는 없겠지만,
아래와 같은 상황에서 우리는 [weak self] 캡처 리스트로 위와 같은 문제가 발생할 수 있는 상황에 대비를 할 필요가 있겠다.

💡 Escaping 클로저에서 강한 참조 순환 문제를 생각해야 하는 경우

1) 클로저가 인스턴스 프로퍼티에 값의 저장 또는 수정에 관여하거나, 다른 클로저로 전달되는 경우
2) 클로저 안에 있는 인스턴스가 클로저에 대한 강한 참조를 생성, 유지하는 경우
3) Task를 사용하는 경우 (비동기 작업을 수행하는 경우) 
extension ConcurrencyViewController {
    @objc
    func buttonTapped(_ sender: UIButton) {
        switch sender {
        case escapingbutton:
            performEscapingAction { [weak self] in
                self?.titleLabel.text = "Escaping - 업데이트된 텍스트입니다."
            }
        ...
    }
    
    func performEscapingAction(completion: @escaping () -> Void) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            completion()
        }
    }
}

이제 [weak self]를 무지성으로 사용하는 것이 아니라,
그 쓸모와 역할에 대해 이 글을 통해 이해할 수 있었을테니 클로저 내부에서 사용하는 self의 생명주기를 바탕으로 메모리 안전하게 개발하는 여러분이 되길 바란다!

길고 길었던 ARC 글은 여기까지! 댓글 지적 환영 ^__^