[iOS] 클로저(Closure) 순환 참조 문제

eung7_·2022년 5월 5일
0

iOS

목록 보기
1/17
post-thumbnail

Closure를 작성할 때, 우리는 지역변수를 참조할 때마다 순환참조 문제에 부딪히게 된다.

순환 참조의 이슈는 근본적으로 strong으로 서로 동시에 참조하고 있기 때문에 ARC가 0으로 안내려 가는 것이다. 원래 클로저가 종료되면 self로 선언해준 Reference Count도 0으로 내려가야 하지만, 다른 곳에서 클로저를 불러준 시점에서 Reference Count가 1이 증가하여 발생한 문제이다.

간혹 지역변수가 아니더라도 해당 Class에서 정의된 Property나 Method에 접근할 때도 마찬가지다.

그래서 그 해결방안으로

{ [unowned self] _ in 
	/// Logic in Closure
}

다음과 같은 코드로 순환참조를 해결할 수 있다는 것은 널리 알려진 사실이다.

하지만 근본적인 이유에 들어가서 왜 이것이 순환참조를 일으키고 도대체 [unowned self]라는 것은 어떤 것인가를 알아보도록 하자.

우선 이것을 알기 위해서는 ARC의 개념과 Strong, Weak 변수의 의미를 정확하게 알고 있어야 한다. 인스턴스의 참조 방식이 어떻게 이루어지는지 알기 위함이라고 할 수 있다.

클로저의 Reference Capture

클로저는 기본적으로 Value Capture보다 Reference Capture를 기본적으로 수행한다.

이게 무슨 말이냐하면, 아래와 같은 NotifyPrice 함수가 있다고 해보자.

func notifyPrice() {

	let percent = 10
	/// 클로저 값 캡쳐 시작
    var price = 200
    
    let notify: () -> Void {
    	print("Price is \(price).")
    }
    
    price = 300
	notify()
    /// 클로저 값 캡쳐 종료
    
    print("Function is Done")
}

결과 
// Price is 300.
// 300

코드에서 notify라는 클로저가 정의되어 있다.

이 클로저는 함수에서 정의된 price를 참조하고 있는 것을 알 수 있다. 따라서 클로저의 값 캡쳐 범위는 price가 정의된 순간부터 클로저가 불릴때까지 라고 할 수 있다.

만약 클로저가 Value Capture라고 한다면 클로저를 정의 해주기 전에 초기화 된 200을 출력해야할 것이다. 하지만 클로저는 Reference Capture기 때문에 Price는 300을 출력하게 되는 것이다 !

클로저의 Capture List

그렇다면 위의 Reference Capture를 Value Capture로 받을 수 있을까?

이때 사용하는 것이 클로저의 캡쳐 리스트(Capture List)이다. 바로 예시를 보자.

func notifyPrice() {

	let percent = 10
	/// 클로저 값 캡쳐 시작
    var price = 200
    
    let notify: () -> Void { [price] in
    	print("Price is \(price).")
    }
    
    price = 300
	notify()
    /// 클로저 값 캡쳐 종료
    
	print(price)
}

결과 
// Price is 200.
// 300

잘 보면 클로저의 [price] 가 추가된 것을 확인할 수 있다. 결국 [ ] 안에 들어가는 멤버는 클로저의 캡쳐 리스트에 들어감으로써 Reference Capture를 Value Capture로 받을 수 있게 된다.

결과를 보면 이게 어떤 의미인지 정확하게 알 수 있다. price 변수를 Reference Capture할 경우에는 클로저 밖에서 price 값이 변경되어도 클로저의 실행결과는 변경된 값이 나왔다.

하지만 Value Capture인 경우에는 클로저가 정의된 시점 이전의 price를 하나의 값으로 Capture하면서 200으로 되고, 그 이후에 price 값이 변경되어도 클로저의 결과값은 변하지 않는다.

이처럼 클로저에서 Value Capture를 받고 싶다면 그 멤버들을 [ _ _ ] in 이라는 틀 안에 넣으면 된다.

예외 : Reference Type의 Value Capture

하지만 유의할 것이 있다. 바로 대표적인 Reference Type인 class같은 경우에는 클로저의 캡쳐리스트의 멤버로 선언해도 Reference Capture를 한다. 이 점을 꼭 유의했으면 한다.

클로저의 순환 참조 이슈

그렇다면 클로저는 어떤 요인으로 순환 참조 이슈가 발생하는 것일까?

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")
    }
}

여기 HTMLElement라는 class를 보여주는 코드가 있다. 여기서 눈에 띄는 것은 지연 저장 프로퍼티를 선언한 asHTML 클로저라고 할 수 있다. 이 클로저는 text가 nil이 아닐 경우와 nil일 경우를 나눠서 Property 값을 접근하고 있다.

문제는 아래의 코드에서 발생한다.

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

지역 변수인 paragraph는 HTMLElement 인스턴스를 갖게 된다. 따라서 인스턴스의 RC값이 1이 증가하게 된다. 그리고 print로 인스턴스로 접근하여 asHTML이라는 클로저에 접근하고 있다.

따라서 아래의 그림과 같은 강한 순환 참조가 발생하고 마는 것이다.

이것은 클로저를 어떤 특정 프로퍼티에 선언해줌과 동시에 클로저에서 self를 접근해서 나타나는 이슈이다.

따라서 방금같은 클로저의 Capture List에 참조하는 self를 weak나 unowned로 설정해주면 이 강한 사슬을 끊어줄 수 있게 된다.

lazy var asHTML: () -> String = { [weak self] in
	guard let self = self else { return } 
    if let text = self.text {
        return "<\(self.name)>\(text)</\(self.name)>"
    } else {
        return "<\(self.name) />"
    }
}
profile
안녕하세요. iOS 개발자 eung7입니다.

0개의 댓글