unowned self vs weak self (캡쳐 리스트)-(1)

2021. 1. 11. 21:19iOS

Swift로 개발하면서 클로저 안에서 [weak self][unowned self]를 쓸 경우가 많습니다. 메번 클로저에 넣어 주기는 하지만 왜 쓰이는지, 언제 weak를 쓸지 unowned를 쓸지 제대로 모르고 쓰는 경우가 많았던 것 같습니다. 그래서 이번 기회에 제대로 알아보고 적절히 사용하기 위해 알아보았습니다.

weak와 unowned는 강한 순환 참조(Strong Reference Cycle)발생을 막기 위해 사용됩니다. 둘 다 레퍼런스 카운팅을 하지 않기 때문이죠. [weak self][unowned self] 또한 클로저의 강한 순환 참조를 막기 위해 사용되는 캡쳐 리스트(Capture List)입니다.

캡쳐 리스트란?(Capture List)

캡쳐리스트는 클로저 안에서 한개 이상의 참조 타입(reference type)을 어떤 참조(strong, weak, unowned)로 캡쳐 할지를 정의하는 리스트입니다. 이는 두 개(사용할 참조타입과 클로저)의 인스턴스가 강한 순환 참조가 생기는 것을 방지하기 위해 사용합니다. 대괄호([])를 통해서 weak, unowned 키워드와 함께 외부에 있는 참조타입 변수를 캡쳐합니다.

 

weak와 unowned의 차이

weak

  • weak는 옵셔널 타입입니다. 그래서 nil이 될 수 있으며 만약 참조하고 있는 인스턴스가 메모리에서 해제될 시, ARC가 nil로 참조값을 대체합니다. 따라서 참조하고 있는 객체가 생명주기가 짧은 경우(weak를 선언한 scope의 객체보다) 사용합니다. (수명이 더 긴 쪽에서 선언)

unowned

  • unowned는 weak와 달리 참조하고 있는 인스턴스가 메모리에서 해제될 시, nil이 되지 않습니다. 만약 참조하고 있는 객체가 매모리에서 해제된 후 접근할 시, crash가 날 수 있습니다. 따라서 참조하고 있는 객체의 생명주기가 현재 scope의 객체보다 생명주기가 더 길거나 같을 경우 사용합니다. (수명이 더 짧은쪽에서 선언)

 

실험

언제 unowned와 weak를 사용할지를 알았으니 여러 예시코드들 보겠습니다.

class NextViewController: UIViewController {
    @IBOutlet weak var normalButton: UIButton!
    @IBOutlet weak var weakButton: UIButton!
    @IBOutlet weak var unownedButton: UIButton!

    override func viewDidLoad() {
        super.viewDidLoad()
    }

    deinit {
        print("NextViewController deinit")
    }

    // MARK: - 강한 참조
    @IBAction func tapNormal(_ sender: Any) {
        UIView.animate(withDuration: 2.0, animations: {
            self.normalButton.frame = self.normalButton.frame.offsetBy(dx: -400, dy: 0)
        }, completion: { _ in
            self.completion()
        })

        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: {
            self.navigationController?.popViewController(animated: true)
        })
    }

        func completion() {
        print("completion")
    }

// Print : NextViewController deinit
// Print : completion

가장 먼저 실험할 클로저는 UIView.animate()입니다. 이 코드는 버튼을 왼쪽으로 움직이는 animate를 하는 도중 ViewController를 pop()하여 ViewController의 메모리 해제를 실험하는 겁니다.

결론적으로 말하면 이 코드에서는 강한 순환 참조는 일어나지 않으며, completion()까지 동작합니다. 하지만 강한순환참조를 떠나 상황에 따라 strong, weak, unowend를 적절히 사용해야 합니다. 이유는 animate() 코드의 정의를 보면 알 수 있습니다.

class func animate(withDuration duration: TimeInterval, animations: @escaping () -> Void)

animate()는 class 메소드입니다. UIView.animate()를 자세히 보시면 UIView의 인스턴스를 생성하지 않고 바로 animate()를 호출합니다. 타입 메소드(Type Method)이기 때문에 인스턴스를 생성하지 않고 바로 접근할 수 있습니다. static메소드와 동일한 기능을 합니다.

그래서 animate 메소드는 따로 인스턴스 생성을 하지 않고 call하기 때문에 animate를 호출한 인스턴스(ViewController)는 animate()의 parameter에 있는 클로저를 참조하지 않습니다.(reference counting이 증가하지 않습니다.) 그래서 클로저에서 self를 강한 참조 할 수 있고, 클로져가 끝이 나면 ViewController의 레퍼런스 카운팅이 0이 되어 deinit이 호출됩니다.

 

lazy var animation: () -> Void = {
        self.normalButton.frame = self.normalButton.frame.offsetBy(dx: -400, dy: 0)
    }

UIView.animate(withDuration: 2.0, animations: animation,
                       completion: { _ in
                        self.completion()
                       })

하지만 이렇게 ViewController가 참조를 하게 되는 클로저 변수(animation)를 파라미터로 넣으면 이야기가 달라집니다. 이러면 animate()가 타입 메소드라도 파라미터에 참조가 된 클로저를 넣으므로 서로 강한 참조를 하게 됩니다. 그래서 ViewController를 pop()해도 클로저와 ViewController끼리 강한 순환 참조가 일어나 레퍼런스 카운팅이 0이 되지 않아 메모리 해제가 되지 않습니다.

weak를 써보자

@IBAction func tapWeak(_ sender: Any) {
        
		UIView.animate(withDuration: 2.0, animations: { [weak self] in
			self?.weakButton.frame = self?.weakButton.frame.offsetBy(dx: -400, dy: 0) ?? CGRect.zero
		}, completion: { [weak self]  _ in
			self?.completion()
		})
		
		
		DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: { in
			self.navigationController?.popViewController(animated: true)
		})
	}
// Print : NextViewController deinit

weak는 당연히 deinit이 됩니다. 하지만 completion()은 호출되지 않습니다. completion 클로저가 호출 됐을 시점에 self를 약한 참조를 하기 때문에 이미 self는 메모리에 해제된 상태라 self는 nil이 되었습니다.

정확히 ViewController가 메모리에서 해제되는 시점은 모르겠지만 completion 클로저가 호출된 후 레퍼런스 카운팅이 0이되어 해제가 된 것 같습니다.

strong과 weak를 비교해보면?

animate()는 타입 메소드이기 때문에 강한순환참조는 일어날 일이 없으므로 weak self를 굳이 써주지 않아도 메모리 누수는 일어나지 않습니다. 하지만 self를 캡쳐링을 클로저에서 유지할지 말지를 weak와 strong으로 결정할 수 있으므로 상황에 따라 사용하면 될 것 같습니다.

self?와 guard let self = self else { return }

weak self를 사용할 때에도 주의할 점이 있습니다. 바로 옵셔널 바인딩과 체이닝에도 self캡쳐에 차이가 있습니다. 체이닝(? 사용)을 할 경우에는 매번 self를 nil체크를 하므로 원치 않은 결과를 만들 수 있습니다. 하지만 self의 메모리 해제를 지연시키지는 않습니다.

반면에 옵셔널 바인딩(guard let 사용)을 사용하면 해제되기 전 self를 캡쳐해버리기 때문에 클로저가 끝날 때까지 self의 해제를 지연시킵니다.

그래서 weak self를 사용할 때에도 이런 사항들을 고려하면서 사용해야합니다.

unowned를 써보자.

unowned는 위의 weak이야기해서 예상하시다시피 당연히 crash가 납니다. completion시점에서 self를 약한참조를 하므로 레퍼런스 카운팅이 증가하지 않아 self는 메모리 해제가 되어 nil이 되고 nil이 된 self를 접근하여 crash가 납니다.

 

실험 결과

UIView.animate()는 타입 메소드이기 때문에 강한 순환 참조가 일어날 우려가 없습니다. 코드를 자세히 보시면 DispatchQueue.main도 클로저 안에서 self를 캡쳐하지만 순환 참조가 일어날 우려가 없습니다.

 

하지만 self를 클로저에서 강한 참조를 캡처링하게 되면 클로저가 끝이 날 때까지 메모리 해제가 지연이 됩니다. 그래서 클로저 안에 무거운 작업이 있으면 그 작업을 캡처링한 self가 기다리게 됩니다.

따라서 캡처링된 self가 작업에 의해 지연되도 되는지, 모든 작업을 완료하고 메모리 해야되는지 등을 고려하여 클로저 내부를 작성해야합니다.

 

간단히 UIView.animate의 예시를 실험하고 다른 실험들의 결과도 보여주려 했지만 생각보다 많은 점을 알게 되었고 글이 길어져서 weak와 unowend에 대한 비교가 되는 실험들을 다음 글에서 보여드리겠습니다 :)

 

결론

  • weak self는 self의 생명주기가 짧을 때, unowned self는 self의 생명주기가 같거나 긴게 보장이 될 때 사용합니다.
  • 타입 메소드나 타입 프로퍼티를 접근하여 클로저 안에서 self를 캡처링 할 때, 강한 순환 참조가 일어날 우려가 없습니다.(아마...?)
  • unowend self는 생명주기를 많이 고려해야 합니다. 그래서 완벽하게 테스트가 안 된다면 weak를 사용하는게 좋을 것 같습니다.
  • weak self에서 self?와 guard let self = self도 생명주기를 고려하면서 작업해야 합니다.

참고

Automatic Reference Counting - The Swift Programming Language (Swift 5.3)

ConfigureCell using [unowned self] can crash the app · Issue #169 · RxSwiftCommunity/RxDataSources

You don't (always) need [weak self]

Is it necessary to use [unowned self] in closures of UIView.animateWithDuration(...)?

'iOS' 카테고리의 다른 글

프로퍼티 래퍼란? (Property Wrapper)  (0) 2020.12.09
클로저 캡쳐란? (Closure Capture)  (0) 2020.07.25