iOS 개발/Swift

[Swift] weak self, 어떻게 사용해야할까? (1/2)

꽁치대디 2022. 10. 23. 19:27

[Swift] weak self, 어떻게 사용해야할까? (1/2)

클로저를 매개변수로 받아 메소드를 작성하는 경우, 혹은 URLSession 이나 DispatchQueue 를 이용할 때, 클로저 내부에서 클로저 외부의 프로퍼티나 메소드를 사용하는 경우 self 를 사용해야 한다는 경고창을 한 번쯤은 보신적이 있으실겁니다.

 

저는 위와 같은 상황을 겪을때마다 별 생각 없이 self 를 앞에 붙여주었습니다.

 

시간이 지나 self 는 해당 클로저를 품고있는 객체를 강하게 참조한다는 것을 알게 되었고, [weak self] 를 이용하다는 것이 안전하다고 하기에 그렇게 사용하고 있었습니다.

 

하지만 이런 경우 self 가 약하게 참조되기 때문에 옵셔널 체이닝을 해주거나, guard let 구문을 이용하여 바인딩 해줘야하는 경우가 발생했습니다.

 

그래서 어떤 경우에 옵셔널 체이닝을 해주고, 어떤 경우에 바인딩을 해주는지 알아보던 중 아래 글을 발견했습니다.

 

https://medium.com/@almalehdev/you-dont-always-need-weak-self-a778bec505ef

 

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

We will talk about weak self inside of Swift closures to avoid retain cycles & explore cases where it may not be necessary to capture self weakly.

medium.com

 

위에서 궁금해하던 내용들이 모두 들어가있기도 하고, 그 누구도 알려주지 않았던(?) 좋은 내용들도 많이 포함되어있는 것 같아 이를 공유해보고자 합니다.

 

제 나름대로 번역했기 때문에 잘못된 내용이 포함되어 있을수도 있습니다. 잘못된 부분이 있다면 언제든 댓글로 달아주세요!

ARC

본론에 들어가기 앞서 ARC (Automatic Reference Counting) 에 대한 이해가 필요합니다.

 

  • Swift 의 메모리는 ARC 에 의해서 관리됨
  • 더 이상 필요하지 않은 클래스 인스턴스가 (Heap 메모리) 사용하는 메모리를 해제하기 위해 작동됨
  • 대부분 자체적으로 작동하지만, 때로는 객체 간 관계를 명확히 하기 위해 추가 정보를 제공해야함
    • 예를 들어 부모 객체에 대한 레퍼런스를 가지고 있는 자식 컨트롤러의 경우..
    • 순환참조 문제를 방지하기 위해 weak 키워드를 이용해 약한 참조를 사용함

ARC 에 대해 더 자세한 내용이 필요하시다면 아래 포스트를 참고하시면 됩니다.

 

 

[Swift] ARC (1/2)

[Swift] ARC (Automatic Reference Counting) 앱의 메모리 사용량을 추적하고 관리 어떠한 클래스 인스턴스가 더 이상 필요하지 않을 때 인스턴스에 할당된 메모리 자동해제 Reference Type 인 클래스 인스턴스

trumanfromkorea.tistory.com

 

만약 어떤 경우에 메모리 누수가 의심된다면, 다음과 같은 작업들을 수행해볼 수 있습니다.

  • deinit 콜백 관찰
  • Optional 객체가 해제 후 nil 이 되었는지 확인
  • 앱 메모리 사용량 관찰
  • Leaks, Allocation Instruments 사용

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

let changeColorToRed = DispatchWorkItem { [weak self] in
    self?.view.backgroundColor = .red
}
  • 위 코드에서는 self[weak self] 를 통해 약하게 참조되었기 때문에 클로저 내부에서는 self 가 Optional 이 되어버림
  • 하지만 만약 여기서 [weak self] 를 이용하지 않았더라면 메모리 누수가 일어났을까?

Unowned, Weak

클로저는 어떤 값을 캡쳐할 때 강한 기본적으로 강한 참조를 이용해 값을 캡쳐합니다. 그렇기 때문에 클로저 내부에서 self 를 사용하는 경우, 해당 클로저의 생명주기 동안 self 를 강하게 참조할 것이고, 이 참조가 계속 유지된다면 강한 참조 사이클이 발생할 가능성이 있습니다.

 

저희는 오늘 [weak self] 에 대해 이야기하고 있는데, 그렇다면 왜 Unowned 는 사용하지 않는걸까? 라는 의문이 생길수도 있습니다.

 

Unowned 를 사용하는 경우는 통상적으로 레퍼런스가 nil 이 되지 않을 것이라는 확신이 있을때 입니다. 이는 마치 Optional 변수 뒤에 ! 를 붙여 Forced Unwrapping 을 사용하는 것과 비슷하게 들립니다.

 

그렇기 때문에 만약 Unowned 를 이용해 어떤 값을 캡쳐했는데, 그 값이 nil 이 되어버린다면 앱이 죽어버리는 상황이 발생할수도 있는 것입니다. 이것이 저희가 Unowned 대신 Weak 을 주로 사용하는 이유입니다.

 

Unowned 의 잘못된 사용으로 앱이 죽어버리는 경우

 

근데 그렇다면, 모든 부분에서 [weak self] 를 사용하면 되는 것일까요?

Escaping / Non-Escaping 클로저

클로저에는 크게 2가지 종류가 존재합니다.

 

  • Non-Escaping
    • compactMap 같은 고차함수들 ...
    • 스코프 내에서 코드를 바로 실행함
    • 어딘가에 저장되거나 나중에 실행될 수 없음
  • Escaping
    • 어딘가에 저장되거나 나중에 필요한 시점에 실행시킬 수 있음
    • 다른 클로저에 전달될 수도 있음

 

Non-Escaping 클로저에서는 강한 참조 사이클이 발생할일이 거의 없기 때문에 Weak 이나 Unowned 를 사용할 필요가 없습니다.

 

Escaping 클로저를 사용할때는 Weak 이나 Unowned 를 사용하지 않으면서 아래 2가지 조건을 만족할 경우 순환참조가 일어날 수 있습니다.

 

  1. 클로저가 어딘가 저장되거나 다른곳으로 전달될 때
  2. 클로저 내부 객체가 강한 참조를 가지고 있을 때 (self 같이)

그래서 저희가 클로저를 사용할 때, 다음과 같은 플로우를 따라간다면 [weak self] 를 언제 사용하면 좋을지 판단할 수 있습니다.

 

[weak self] 사용 여부 판단해보기

Delayed Deallocation

Delayed Deallocation 을 한글로 직역한다면 지연된 할당 해제 쯤으로 해석할 수 있을 것입니다. 이는 Escaping, Non-Escaping 클로저 모두에서 생기는 부작용으로, 메모리 누수는 아니지만 이로 인해 예상치 못한 동작이 발생할 수 있습니다.

 

예를 들자면 Controller 는 Dismiss 되었지만 클로저는 살아있어서 해당 태스크가 끝날때까지 메모리 해제가 되지 않는다거나...

 

클로저는 기본적으로 내부에서 참조되는 객체를 강력하게 캡쳐하기 때문에, 해당 스코프가 살아있는 한 객체가 메모리에서 할당 해제되는것을 불가능하게 합니다. 이것이 Delayed Deallocation 이 발생하는 주된 원인입니다.

 

위에서 설명한것처럼, 어떤 스코프를 살아있는 상태로 유지하는 경우가 몇가지 있습니다.

 

  1. 클로저 내부에서 값비싼 작업을 연속적으로 수행할 때
  2. 클로저를 사용하면서 스레드를 block 하는 방식을 사용할 때 (Dispatch Semaphore 등...)
  3. Escaping 클로저에 딜레이를 주어 실행되도록 할 때 (asyncAfter, afterDelay 등...)
  4. Escaping 클로저가 긴 TimeoutInterval 을 가지고 있을때 (특정 시간동안 계속 수행한다거나...)

다음 코드는 위의 경우에서 4번, TimeoutInterval 을 가지고 있는 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()
}

위의 코드는 다음과 같은 특징을 가지고 있습니다.

 

  • 요청이 성공할 수 없는 (막혀있는) 81번 포트에 요청을 보내 Request Timeout 유발
  • 해당 요청은 999 초의 TimeoutInterval 을 가지고 있음
    • 999 초동안 요청이 성공할때까지 계속 보내는것
    • 999 초가 지났는데도 요청이 실패한다면 종료
  • Weak, Unowned 사용하지 않고 있음
  • 강한 참조 사이클을 발생시키고 있지는 않음
  • 클로저가 어딘가에 저장되지 않았고, 바로 수행되고 있음

위의 예시는 강한 참조 사이클을 발생시키고 있지는 않지만, 만약 해당 코드가 포함된 Controller 를 Dismiss 한다면 메모리가 해제되지 않았다는 경고가 출력될 것입니다.

 

그 이유는 Escaping 클로저와 TimeoutInterval 때문인데, 클로저 내부에서 참조하고 있는 객체들에 대해 강한 참조를 가지고 있기 때문입니다. 이렇게 된다면 Request 가 성공하거나 Timeout 이 완료될때까지 (999 초가 지날때까지) 클로저는 객체들에 대한 참조를 유지할 것입니다.

 

결과적으로 참조되고 있는 self 가 계속 살아있을것이기 때문에 Controller 가 Dismiss 되더라도 self 의 할당 해제는 잠재적으로 지연될 것입니다.

 

이런 경우에 저희는 [weak self] 를 이용해 문제를 해결할 수 있습니다. 하지만 만약 Unowned 를 사용했다면 앱이 죽었을 것입니다.

guard let vs Optional Chaining

[weak self] 를 사용할 경우, self 는 Optional 값이기에 이를 사용할때 뒤에 ? 를 붙여 Optional Chaining 방식을 사용하거나, guard let 구문을 이용해 바인딩을 할 수 있습니다.

 

그럼 둘 중 어떤 방법을 사용하는것이 좋을까요?

 

먼저 guard let 구문을 사용할 시 나타날 수 있는 부작용에 대해 알아보겠습니다. Delayed Deallocation 의 1번 예시, 값비싼 작업을 연속적으로 수행하는 클로저가 있다고 가정해보겠습니다.

func process(image: UIImage, completion: @escaping (UIImage?) -> Void) {
    DispatchQueue.global(qos: .userInteractive).async { [weak self] in
        guard let self = self else { return }
        // 이미지를 활용한 값비싼 작업 시작
        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 구문을 사용한다는 것은, 스코프 내에서 임시적으로 self 에 대한 강한 참조를 생성한다는 뜻입니다.

 

코드에서 표시된 값비싼 작업들이 수행되는 동안, 즉 스코프가 살아있는 동안 self 는 할당 해제되는 일이 없을 것입니다. 이는 guard let 이 클로저 생명주기동안 self 가 살아있을것이라고 보장해주는 것과 같습니다.

 

그럼 만약 self?. 처럼 Optional Chaining 을 사용한 방식으로 위 코드를 수정해준다면 어떻게 될까요?

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

Optional Chaining 을 이용한다면 self 에 접근할때마다 nil 인지 아닌지 검사를 해야할 것입니다. 이 말은 즉 클로저의 어느 시점에서든 selfnil 이라면, 해당 라인을 건너뛰고 다음 라인을 실행할것이라는 뜻입니다.

 

그래서 위 코드를 Optional Chaining 을 활용한 방식으로 수정해준다면, self 가 할당 해제될 시 self?. 에 접근하는 모든 값비싼 작업은 건너뛰어지게 될 것입니다.

 

이처럼 guard let 구문을 이용할지, self?. 와 같은 방식으로 Optional Chaining 을 이용할지는 상황마다 다르게 판단해야 한다는 것을 알 수 있습니다.

 

위 내용을 요약하자면

 

  • Optional Chaining self?. - VC 가 할당 해제된 후 불필요한 작업을 피하려는 경우
  • guard let 구문 - VC 할당해제 이전에 모든 작업이 완료되었는지 확인해야하는 경우

정도로 정리해볼수 있습니다.


내용이 너무 길어지는 탓에 글을 2 부분으로 나눠서 작성하겠습니다..!

 

이를 실제로 활용하는 예시들과 특별히 조심해야하는 부분들에 대해서는 아래 글을 참고해주세요! 감사합니다.

 

 

[Swift] weak self, 어떻게 사용해야할까? (2/2)

[Swift] weak self, 어떻게 사용해야할까? (2/2) [Swift] weak self, 어떻게 사용해야할까? [Swift] weak self, 어떻게 사용해야할까? 클로저를 매개변수로 받아 메소드를 작성하는 경우, 혹은 URLSession 이나 Di..

trumanfromkorea.tistory.com