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

업데이트:

You don’t (always) need [weak self] 를 번역한 글입니다.

영어 실력이 많이 부족합니다ㅠ 수정 제안 환영합니다 🙌

Cycles… 자전거처럼 재미있는 것이 아닙니다. iOS 앱에서 메모리 누수의 원인 중 하나인 강한 참조 사이클(strong reference cycles)을 의미합니다. 더 구체적으로는, 순환 참조(reference or retain cycle)를 피하기 위해 Swift 클로저 내부에 [weak self]사용하는 것에 대해 이야기하고, self를 약한 참조하는 것이 필요한 경우에 대해 살펴보려합니다.

Apple 문서와 다양한 블로그 포스트튜토리얼 그리고 시행착오와 실험을 통해 이 글에서 이야기하는 주제에 대해 배웠습니다. 실수를 발견하면 댓글이나 트위터로 편하게 연락주세요.

또한 다양한 메모리 누수 시나리오를 시연하고, [weak self] 사용이 불필요한 곳을 보여주는 작은 앱도 만들었습니다:

https://github.com/almaleh/weak-self

ARC(Automatic Reference Counting)

Swift의 메모리 관리는 ARC에 의해 처리됩니다. ARC는 더 이상 필요하지 않은 클래스 인스턴스가 사용하는 메모리를 확보하기 위해 뒤에서 작동합니다. ARC는 대부분 자체적으로 작동하지만, 때로는 객체 간의 관계를 명확히 하기 위한 몇가지 추가 정보를 제공해야 합니다.

예를 들어, 프로퍼티에 owner / parent 에 대한 참조를 저장하는 child controller가 있는 경우, 해당 프로퍼티는 순환 참조 / retain cycle 을 방지하는 weak 키워드로 표시되어야 합니다.

메모리 누수가 의심되는 경우, 다음을 수행할 수 있습니다:

  • 객체가 사라진 후 deinitializer callback이 호출되는지 살펴보세요. 호출되지 않는다면 문제가 발생할 수 있습니다.
  • 옵셔널 객체가 있는 경우 사라진 후 nil인지 확인하세요.
  • 앱 메모리 소비(app memory consumption)를 관찰하여 꾸준히 증가하는지 확인하세요.
  • Leak, Allocation Instruments를 사용하세요.

클로저의 경우 다음 코드를 살펴보겠습니다:

let changeColorToRed = DispatchWorkItem { [weak self] in 
	self?.view.backgroundColor = .red
}

self가 클로저에서 어떻게 약하게 캡쳐되었는지 주목하세요. 캡쳐로 인해 self는 클로저 내부에서 optional이 되었습니다.

여기서 정말 [weak self]가 필요할까요? 만약 사용하지 않는다면, 메모리 누수가 발생할까요?

정답은 밝혀진대로 “상황에 따라 다르다” 이지만, 먼저 히스토리를 공유하겠습니다.

Unowned, Weak, and the Strong-Weak Dance

클로저는 정의된 컨텍스트에서 모든 상수 또는 변수를 강력하게 캡쳐하거나 닫을 수(close over) 있습니다. 예를 들어 클로저 내에서 self를 사용하는 경우, 클로저 스코프는 스코프가 끝날 때까지(scope’s life) self에 대한 강한 참조를 유지합니다.

self가 클로저에 대한 참조를 유지하는 경우(미래의 어느 시점에 호출하기 위해), 강한 참조 사이클(strong reference cycle)로 끝납니다.

다행히 순환 참조를 피하기 위해 사용할 수 있는 unowned, weak 키워드와 같은 도구(아래에 설명된 다른 도구도 포함)가 있습니다.

처음 Swift를 배울 때, 모든 클로저에 [unwoend self]를 사용했습니다. 시간이 조금 흐른 후(몇 번의 충돌 이후😅), 이는 self를 강제 언레핑하는 것과 같고, 객체가 해제된 후에도 접근을 시도한다는 것을 발견했습니다. 다른 말로, 이는 매우 안전하지 않습니다!

[weak self]는 훨씬 더 안전한 방식으로, 동일한 작업(순환 참조 방지)을 수행하지만, 그 과정에서 self를옵셔널로 만들기도 합니다. 이 optionality를 처리하기 위해, 옵셔널 체이닝을 활용해 self?. 를 앞에 붙일 수 있습니다. 그러나 더 유명한 접근 방식은 guard let 구문을 사용해 클로저 시작 시 self에 대한 강력한 임시 참조를 만드는 것입니다.

Swift 초기에는, 다음과 같이 self를 임시로 non-optional인 strongSelf 상수에 할당하는 Strong-Weak dance로 알려진 것을 수행하는 것이 일반적이었습니다:

let changeColorToRed = DispatchWorkItem { [weak self] in
    guard let strongSelf = self else { return }
    strongSelf.view.backgroundColor = .red
}

시간이 조금 흐른 후에는 다음 코드와 같이 코드를 단순화하기 위해 백틱(`)을 사용한 컴파일러 버그를 사용(또는 남용 😛)하기 시작했습니다:

let changeColorToRed = DispatchWorkItem { [weak self] in
    guard let `self` = self else { return } // note the back ticks around self
    self.view.backgroundColor = .red
}

결국 Swift 4.2에서는 guard let self = self 구문을 공식적으로 지원하여 다음 코드를 사용할 수 있게 되었습니다:

let changeColorToRed = DispatchWorkItem { [weak self] in
    guard let self = self else { return }
    self.view.backgroundColor = .red
}

Erica Sadun은 자신의 책 Swift Style, Second Edition에서 guard let self = self 패턴을 추천하고 있기에, 저는 이 패턴을 사용하는 것이 꽤 안전하다고 말하고 싶습니다.

옵셔널 처리를 하지않기 위해 weak보다 unowned를 사용하고 싶을 수도 있지만, 일반적으로 말해 클로저가 실행되는 동안 참조가 절대 nil이 되지 않을 것이라고 확신할 때만 unowned를 사용하세요. 다시 말하지만, 이는 옵셔널 강제 언래핑과 같고, nil이 되는 순간 충돌이 발생합니다. [weak self] 가 훨씬 더 안전한 대안입니다.

다음은 unowned으로 인해 발생한 충돌입니다:

note that 'WeakSelf' was the name of the app that crashed

이제 [weak self]의 이점을 확인했으므로, 모든 클로저에서 이를 사용하기 시작해야 할까요?

저는 잠시동안 다음 사진과 같은 상태였습니다:

Image 2

그러나 알고보니, 저는 제 코드에서 정말 필요하지 않은 많은 곳에 optionality를 사용하고 있었습니다. 그리고 그 이유는 제가 처리했던 클로저의 성격으로 귀결됩니다.

Escaping vs non-escaping closures

escaping과 non-escaping 두가지 유형의 클로저가 있습니다. Non-escaping 클로저는 스코프 내에서 실행됩니다. 즉, 코드를 즉시 실행하고, 나중에 저장되거나 실행할 수 없습니다. 반면에 Escaping 클로저 저장될 수 있고, 다른 클로저로 전달될 수 있으며, 미래의 어느 시점에 실행될 수 있습니다.

Non-escaping closures(예: compactMap과 같은 고차함수)는 강한 참조 사이클이 발생할 위험이 없으며 따라서 weak 또는 unowned를 사용할 필요가 없습니다.

Escaping closuresweak 또는 unowned를 사용하지 않을 때 순환 참조가 발생할 수 있지만, 다음 두 조건이 모두 충족되는 경우에만 발생합니다:

  • 클로저가 프로퍼티로 저장되거나 다른 클로저로 전달됩니다.
  • 클로저 내부의 객체(예를 들어 self)는 클로저(또는 전달된 다른 클로저)에 대한 강한 참조를 유지합니다.

개념을 설명하는데 도움이 되도록 다음과 같은 순서도를 만들었습니다:

Image 3

Delayed Deallocation

순서도 왼쪽의 상자에서 지연된 할당 해제(delayed deallocation)을 보았을 것입니다. 이것은 escaping과 non-escaping 클로저 모두에서 발생할 수 있는 사이드 이펙트입니다. 정확히 메모리 누수는 아니지만, 원하지 않는 동작으로 이어질 수 있습니다(예: 컨트롤러를 닫았지만, 보류 중인 모든 클로저/작업이 완료될 때까지 메모리가 해제되지 않음)

기본적으로 클로저는, 본문(body)에서 참조하는 모든 객체를 강력하게 캡처하므로 클로저 본문이나 스코프가 살아있는 한 객체가 메모리에서 해제되는 것을 방해합니다.

클로저 범위의 수명은 밀리세컨드 미만에서 최대 몇 분 이상까지 다양합니다.

스코프를 활성 상태로 유지할 수 있는 몇 가지 시나리오는 다음과 같습니다:

  1. 클로저(escaping 또는 non-escaping)가 비용이 많이 드는 직렬 작업을 수행하여 모든 작업이 완료될 때까지 해당 스코프가 반환되는 것을 지연시킬 수 있습니다.
  2. 클로저(escaping 또는 non-escaping)는 스코프 반환을 지연하거나 방지할 수 있는 일부 메커니즘(예: DispatchSemaphore)을 사용할 수 있습니다.
  3. Escaping 클로저는 지연 후 실행되도록 예약될 수 있습니다(예: DispatchQueue.asyncAfter 또는 UIViewPropertyAnimator.startAnimation(afterDelay:))
  4. Escaping 클로저는 시간 초과가 긴 콜백을 기다릴 수 있습니다(예: URLSession timeoutIntervalForResource).

제가 놓친 다른 케이스가 있을 수 있지만 이것은 최소한 어떤 일이 일어날 수 있는지에 대한 아이디어를 줄 수 있을 것입니다. 다음은 할당 해제를 지연시키는 URLSession을 보여주는 내 데모 앱의 예제입니다:

func delayedAllocAsyncCall() {
    let url = URL(string: "https://www.google.com:81")!

    let sessionConfig = URLSessionConfiguration.default
    sessionConfig.timeoutIntervalForRequest = 999.0
    sessionConfig.timeoutIntervalForResource = 999.0
    let session = URLSession(configuration: sessionConfig)

    let task = session.downloadTask(with: url) { localURL, _, error in
        guard let localURL = localURL else { return }
        let contents = (try? String(contentsOf: localURL)) ?? "No contents"
        print(contents)
        print(self.view.description)
    }
    task.resume()
}

다음 예제를 분석해보겠습니다:

  • request timeout을 흉내내기 위해 의도적으로 81번 포트(차단된 포트)를 호출합니다.
  • 요청은 999초의 timeout Interval을 가집니다.
  • weak 또는 unowned 키워드는 사용되지 않습니다.
  • task 클로저 내부에서 self가 참조됩니다.
  • task는 다른 어느 곳에서 저장되지 않습니다; 바로 실행됩니다.

위의 마지막 항목에 따르면, 이 task는 강한 참조 사이클을 일으키지 않아야 합니다. 하지만 위의 시나리오의 데모 앱을 실행한 다음, 해당 다운로드 작업을 취소하지 않은 채로 컨트롤러를 닫으면, 컨트롤러가 메모리에서 해제되지 않았다는 얼럿이 나타날 것입니다.

여기서 정확히 무슨 일이 발생한걸까요?

앞에서 언급한 목록에서 시나리오 #4를 실행하고 있습니다. 즉, 다시 호출될 것으로 예상되는 escaping 클로저가 있고, 긴 timeout interval을 주었습니다. 이 클로저는 호출되거나 timeout deadline에 도달되거나, 또는 작업이 취소될 때까지 내부에 참조된 모든 객체에 대한 강한 참조를 유지합니다(이 경우, self).

(URLSession이 뒤에서 어떻게 동작하는지는 잘 모르겠지만 실행, 취소 또는 deadline에 도달할 때까지 작업에 대한 강한 참조를 유지한다고 추측합니다.)

여기에는 강한 참조 사이클이 없지만, 이 클로저는 필요한 동안 자체 활성 상태를 유지하므로, 다운로드 작업이 보류 중인 동안 컨트롤러가 사라지면 잠재적으로 자신의 할당 해제가 지연됩니다.

[weak self]를 사용해(옵셔널 체이닝 또는 guard let 구문과 함께) 딜레이를 방지하면 지연이 방지되어 self가 즉시 할당 해제될 수 있습니다. 반면에 [unowned self]는 여기서 충돌을 일으킬 것입니다.

‘guard let self = self’ vs Optional Chaining

weak self를 사용할 때 옵셔널 체이닝 self?. 구문을 사용해 self에 접근하는 대신 guard let self = self를 사용하면 잠재적인 부작용이 있습니다.

비용이 많이 드는 직렬 작업을 수행하거나 세마포어와 같은 스레드 차단 메커니즘을 사용하여 할당 해제가 지연될 수 있는 클로저에서(앞서 언급한 목록의 시나리오 #1 및 #2), 시작 부분에 guard let self = self else { return }을 사용하면 이 할당 해제 지연이 방지되지 않습니다.

UIImage에서 비용이 많이 드는 작업을 연속적으로 수행하는 클로저가 있다고 가정해보겠습니다:

func process(image: UIImage, completion: @escaping (UIImage?) -> Void) {
    DispatchQueue.global(qos: .userInteractive).async { [weak self] in
        guard let self = self else { return }
        // perform expensive sequential work on the image
        let rotated = self.rotate(image: image)
        let cropped = self.crop(image: rotated)
        let scaled = self.scale(image: cropped)
        let processedImage = self.filter(image: scaled)
        completion(processedImage)
    }
}

클로저의 시작 부분에 guard let 구문과 함께 [weak self]를 사용하였습니다. 여기서 guard let이 실제로 하는 것은 selfnil인지 확인하고, nil이 아닌 경우, 스코프의 기간 동안 self에 대한 강한 참조를 생성하는 것입니다.

비용이 많이 드는 작업에 도달할 때(5번째 라인과 그 아래), 이미 self에 대한 강한 참조를 생성했으며, 이는 클로저 스코프의 끝에 도달할 때까지 self가 할당 해제되는 것을 방지합니다. 다르게 말하면, guard let은 클로저의 수명 동안 self가 유지되는 것을 보장합니다.

guard let 구문을 사용하지 않는 대신, self의 메서드에 접근하기 위해 self?. 표기법과 함께 옵셔널 체이닝을 사용하는 경우, 처음에 강한 참조를 생성하는 대신 모든 메서드 호출에서 self에 대한 nil 검사가 일어납니다. 즉, 클로저가 실행되는 동안 어느 시점에서 self가 nil이 되면, 자동으로 해당 메서드 호출을 건너뛰고 다음 라인으로 이동합니다.

func process(image: UIImage, completion: @escaping (UIImage?) -> Void) {
  DispatchQueue.global(qos: .userInteractive).async { [weak self] in 
      // perform expensive sequential work on the image
      let rotated = self?.rotate(image: image)
      let cropped = self?.crop(image: rotated)
      let scaled = self?.scale(image: cropped)
      let processedImage = self?.filter(image: scaled)
      completion(processedImage)
  }
}

다소 미묘한 차이지만, 뷰 컨트롤러가 사라진 후 불필요한 작업을 피하려는 경우와, 반대로 객체가 할당 해제되기 전에 모든 작업의 완료를 보장하려는 경우(예: 데이터 손상 방지)에는 주목할 가치가 있다고 생각합니다.

예시

[weak self]가 필요할 수도 있고 필요하지 않을 수도 있는 일반적인 상황을 보여주는 데모 앱의 몇 가지 예를 살펴보겠습니다.

Grand Central Dispatch

GCD 호출은 나중에 실행하기 위해 저장하지 않는 한 일반적으로 순환 참조의 위험이 없습니다.

예를 들어, [weak self] 없이도 즉시 실행되기 때문에, 다음 호출 중 어느 것도 메모리 누수를 일으키지 않습니다:

func nonLeakyDispatchQueue() {
    DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
        self.view.backgroundColor = .red
    }

    DispatchQueue.main.async {
        self.view.backgroundColor = .red
    }

    DispatchQueue.global(qos: .background).async {
        print(self.navigationItem.description)
    }
}

하지만 다음 DispatchWorkItem은 로컬 프로퍼티에 저장하고, [weak self] 없이 클로저 내부에서 self를 참조하기 때문에 누수가 발생합니다.

func leakyDispatchQueue() {
    let workItem = DispatchWorkItem { self.view.backgroundColor = .red }
    DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: workItem)
    self.workItem = workItem // stored in a property
}

UIView.Animate and UIViewPropertyAnimator

GCD와 비슷하게 프로퍼티에 UIViewPropertyAnimator를 저장하지 않는 한 애니메이션 호출은 일반적으로 순환 참조의 위험이 없습니다.

예를 들어, 다음 호출은 안전합니다:

func animteToRed() {
    UIView.animate(withDuration: 3.0) { 
        self.view.backgroundColor = .red 
    }
}
func setupAnimation() {
    let anim = UIViewPropertyAnimator(duration: 2.0, curve: .linear) { 
        self.view.backgroundColor = .red 
    }
    anim.addCompletion { _ in 
        self.view.backgroundColor = .white 
    }
    anim.startAnimation()
}

반면에, 다음 메서드는 [weak self] 없이 나중에 사용할 수 있도록 애니메이션을 저장하기 때문에 강한 참조 사이클을 발생시킵니다:

func setupAnimation() {
    let anim = UIViewPropertyAnimator(duration: 2.0, curve: .linear) {
        self.view.backgroundColor = .red
    }
    anim.addCompletion { _ in
        self.view.backgroundColor = .white
    }
    self.animationStorage = anim
}

프로퍼티에 함수 저장

다음 예제는 눈에 띄지 않게 숨길 수 있는 교묘한 메모리 누수를 보여줍니다.

한 객체의 클로저나 함수를 다른 객체에 전달하여 프로퍼티에 저장하는 것이 유용할 수 있습니다. 객체 B를 A에 노출시키지 않고 객체 A가 객체 B의 일부 메서드를 익명으로 호출하기를 원한다고 가정할 때 이를 위임(Delegation)에 대한 가벼운 대안으로 생각하세요.

예를 들어, 프로퍼티에 클로저를 저장하는 presented controller가 있습니다:

class PresentedController: UIViewController {
  var closure: (() -> Void)?
}

또한 main controller(위의 컨트롤러를 소유하는)를 가지고 있으며, main controller의 메서드 중 하나를 presented controller의 클로저에 저장하기를 원합니다.

class MainViewController: UIViewController {
  
  var presented = PresentedController()

  func setupClosure() {
    presented.closure = printer
  }

  func printer() {
    print(self.view.description) 
  }
}

printer()는 main controller의 함수이고, 이 함수를 클로저 프로퍼티에 할당했습니다. 함수의 반환 값이 아닌 함수 자체를 할당하기 때문에 6번째 라인에 () 괄호를 포함하지 않은 것에 주목하세요. presented controller 내부에서 클로저를 호출하면 main의 description이 프린트됩니다.

교묘하게도, 이 코드는 명시적으로 self를 사용하지 않았음에도 강한 참조 사이클이 발생합니다. self는 여기에서 암시되어 있으므로(self.printer와 같이 생각하세요), 클로저는 self.printer에 대한 강한 참조를 유지하는 반면 self 또한 클로저를 소유하는 presented controller를 소유합니다.

이 사이클을 없애려면, setupClosure가 [weak self]를 포함하도록 수정해야합니다.

func setupClosure() {
  self.presented.closure = { [weak self] in 
    self?.printer() 
  }
}

스코프 내에서 해당 함수 호출을 원하기 때문에, 이번에는 printer 다음에 괄호를 포함합니다.

Timers

타이머는 프로퍼티에 저장하지 않더라도 문제를 일으킬 수 있어 흥미롭습니다. 이 타이머를 예로 들어보겠습니다:

func leakyTimer() {
    let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
        let currentColor = self.view.backgroundColor
        self.view.backgroundColor = currentColor == .red ? .blue : .red
    }
    timer.tolerance = 0.1
    RunLoop.current.add(timer, forMode: RunLoop.Mode.common)
}
  1. 타이머는 반복됩니다.
  2. 클로저 내부에서 [weak self]를 사용하지 않고 self가 참조됩니다.

이 두 조건이 충족되는 한, 타이머는 참조된 컨트롤러/객체의 할당 해제를 막습니다. 따라서 기술적으로 이것은 메모리 누수보다는 지연된 할당에 가깝습니다.(지연은 무한정 지속됩니다)

참조된 객체를 무기한 활성 상태로 유지하는 것을 피하기 위해, 더 이상 필요하지 않을 때 타이머를 무효화하고, 참조된 객체가 타이머에 대한 강한 참조를 유지하는 경우 [weak self]를 사용하는 것을 잊지 마세요.

Demo App

데모 앱에 다른 예제들이 있지만, 이 글이 이미 충분히 길다고 생각하므로 모두 다루지는 않겠습니다. 앱을 복제하고 Xcode에서 연 다음, PresentedController.swift에서 다양한 누수 시나리오을 확인하는 것을 권장합니다. (각 시나리오를 설명하는 주석을 추가했습니다)

누수 시나리오를 사용하여 앱을 실행하면, 컨트롤러가 나타나고 사라질 때마다 어떻게 앱 메모리 사용량이 지속적으로 증가하는지 알 수 있습니다.

The staircase is not a good sign!

Alternatives to [weak self]

결론을 내리기 전에, [weak self]를 사용하고 싶지 않다면 사용할 수 있는 몇 가지 트릭을 언급하려 합니다. (objc.ioswiftbysundell의 훌륭한 글에서 이것에 대해 배웠습니다.)

첫째, self를 직접 전달하고 [weak self]를 처리하는 대신, self에서 접근하고자하는 프로퍼티에 대한 참조를 생성하고, 클로저에 해당 참조를 전달할 수 있습니다.

애니메이션 클로저 내부에서 selfview 프로퍼티에 접근하기를 원한다면, 다음과 같은 코드를 사용할 수 있습니다:

func setupAnimator() {
    let view = self.view // create a reference to self's view property
    let anim = UIViewPropertyAnimator(duration: 2.0, curve: .linear) { 
      view?.backgroundColor = .red // no reference to self inside the closure
    }
    anim.addCompletion { _ in 
      view?.backgroundColor = .white 
    }
    self.animationStorage = anim
}

2번째 라인에서 view 프로퍼티에 대한 참조를 생성하고, 클로저 내부의 4번째와 7번째 라인에서 self를 사용하는 대신 이를 사용했습니다. 애니메이션은 9번째 라인에서 self의 프로퍼티로 저장되었지만, view 객체는 애니메이션에 대해 강한 참조를 가지지 않기 때문에 순환 참조가 발생하지 않습니다.

클로저에서 self의 여러 프로퍼티를 참조하기 위해, 튜플(컨텍스트라고 지칭)로 모두 그룹화한 다음 해당 컨텍스트를 클로저에 전달할 수 있습니다:

func setupAnimator() {

    let context = (view: self.view,
                   navigationItem: self.navigationItem,
                   parent: self.parent
                   )

    let anim = UIViewPropertyAnimator(duration: 2.0, curve: .linear) {
        context.view?.backgroundColor = .red
        context.navigationItem.rightBarButtonItems?.removeAll()
        context.parent?.view.backgroundColor = .blue
    }
    self.animationStorage = anim
}

결론

여기까지 온 걸 축하합니다! 글이 생각한 것보다 훨씬 길어 졌네요😅

주요 내용은 다음과 같습니다:

  • [unowned self]는 거의 항상 나쁜 아이디어입니다.
  • Non-escaping 클로저는 지연된 할당 해제를 걱정하지 않는 한 [weak self]가 필요하지 않습니다.
  • Escaping 클로저는 어딘가에 저장되거나 다른 클로저로 전달되고 나서, 클로저 내부의 객체가 클로저에 대한 참조를 유지하는 경우에 [weak self]가 필요합니다.
  • guard let self = self가 경우에 따라 지연된 할당 해제로 이어질 수 있고, 이는 의도에 따라 좋거나 나쁠 수 있습니다.
  • GCD와 애니메이션 호출은 나중에 사용하기 위해 프로퍼티에 저장하지 않는 한, 일반적으로 [weak self]가 필요하지 않습니다.
  • 타이머를 사용할 때는 항상 주의하세요!
  • 확신이 없을 때에는 deinitializer와 Instruments을 사용하세요.

앞서 소개한 순서도[weak self]를 사용할 때 도움이 되는 요약을 제공한다고 생각합니다.

댓글남기기