[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
위에서 궁금해하던 내용들이 모두 들어가있기도 하고, 그 누구도 알려주지 않았던(?) 좋은 내용들도 많이 포함되어있는 것 같아 이를 공유해보고자 합니다.
제 나름대로 번역했기 때문에 잘못된 내용이 포함되어 있을수도 있습니다. 잘못된 부분이 있다면 언제든 댓글로 달아주세요!
ARC
본론에 들어가기 앞서 ARC (Automatic Reference Counting) 에 대한 이해가 필요합니다.
- Swift 의 메모리는 ARC 에 의해서 관리됨
- 더 이상 필요하지 않은 클래스 인스턴스가 (Heap 메모리) 사용하는 메모리를 해제하기 위해 작동됨
- 대부분 자체적으로 작동하지만, 때로는 객체 간 관계를 명확히 하기 위해 추가 정보를 제공해야함
- 예를 들어 부모 객체에 대한 레퍼런스를 가지고 있는 자식 컨트롤러의 경우..
- 순환참조 문제를 방지하기 위해
weak
키워드를 이용해 약한 참조를 사용함
ARC 에 대해 더 자세한 내용이 필요하시다면 아래 포스트를 참고하시면 됩니다.
만약 어떤 경우에 메모리 누수가 의심된다면, 다음과 같은 작업들을 수행해볼 수 있습니다.
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 을 주로 사용하는 이유입니다.
근데 그렇다면, 모든 부분에서 [weak self]
를 사용하면 되는 것일까요?
Escaping / Non-Escaping 클로저
클로저에는 크게 2가지 종류가 존재합니다.
- Non-Escaping
compactMap
같은 고차함수들 ...- 스코프 내에서 코드를 바로 실행함
- 어딘가에 저장되거나 나중에 실행될 수 없음
- Escaping
- 어딘가에 저장되거나 나중에 필요한 시점에 실행시킬 수 있음
- 다른 클로저에 전달될 수도 있음
Non-Escaping 클로저에서는 강한 참조 사이클이 발생할일이 거의 없기 때문에 Weak 이나 Unowned 를 사용할 필요가 없습니다.
Escaping 클로저를 사용할때는 Weak 이나 Unowned 를 사용하지 않으면서 아래 2가지 조건을 만족할 경우 순환참조가 일어날 수 있습니다.
- 클로저가 어딘가 저장되거나 다른곳으로 전달될 때
- 클로저 내부 객체가 강한 참조를 가지고 있을 때 (
self
같이)
그래서 저희가 클로저를 사용할 때, 다음과 같은 플로우를 따라간다면 [weak self]
를 언제 사용하면 좋을지 판단할 수 있습니다.
Delayed Deallocation
Delayed Deallocation 을 한글로 직역한다면 지연된 할당 해제 쯤으로 해석할 수 있을 것입니다. 이는 Escaping, Non-Escaping 클로저 모두에서 생기는 부작용으로, 메모리 누수는 아니지만 이로 인해 예상치 못한 동작이 발생할 수 있습니다.
예를 들자면 Controller 는 Dismiss 되었지만 클로저는 살아있어서 해당 태스크가 끝날때까지 메모리 해제가 되지 않는다거나...
클로저는 기본적으로 내부에서 참조되는 객체를 강력하게 캡쳐하기 때문에, 해당 스코프가 살아있는 한 객체가 메모리에서 할당 해제되는것을 불가능하게 합니다. 이것이 Delayed Deallocation 이 발생하는 주된 원인입니다.
위에서 설명한것처럼, 어떤 스코프를 살아있는 상태로 유지하는 경우가 몇가지 있습니다.
- 클로저 내부에서 값비싼 작업을 연속적으로 수행할 때
- 클로저를 사용하면서 스레드를 block 하는 방식을 사용할 때 (Dispatch Semaphore 등...)
- Escaping 클로저에 딜레이를 주어 실행되도록 할 때 (asyncAfter, afterDelay 등...)
- 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
인지 아닌지 검사를 해야할 것입니다. 이 말은 즉 클로저의 어느 시점에서든 self
가 nil
이라면, 해당 라인을 건너뛰고 다음 라인을 실행할것이라는 뜻입니다.
그래서 위 코드를 Optional Chaining 을 활용한 방식으로 수정해준다면, self
가 할당 해제될 시 self?.
에 접근하는 모든 값비싼 작업은 건너뛰어지게 될 것입니다.
이처럼 guard let
구문을 이용할지, self?.
와 같은 방식으로 Optional Chaining 을 이용할지는 상황마다 다르게 판단해야 한다는 것을 알 수 있습니다.
위 내용을 요약하자면
- Optional Chaining
self?.
- VC 가 할당 해제된 후 불필요한 작업을 피하려는 경우 guard let
구문 - VC 할당해제 이전에 모든 작업이 완료되었는지 확인해야하는 경우
정도로 정리해볼수 있습니다.
내용이 너무 길어지는 탓에 글을 2 부분으로 나눠서 작성하겠습니다..!
이를 실제로 활용하는 예시들과 특별히 조심해야하는 부분들에 대해서는 아래 글을 참고해주세요! 감사합니다.
'iOS 개발 > Swift' 카테고리의 다른 글
[Swift] weak self, 어떻게 사용해야할까? (2/2) (0) | 2022.10.23 |
---|---|
[Swift] Swift 언어가 가진 특징 (0) | 2022.08.17 |
[TIL] Swift 에서 Array 를 탐색하는 방법들의 차이 (for in) (0) | 2022.07.17 |
[Swift] Optional (0) | 2022.07.04 |
[Swift] GCD & Dispatch Queue (2) | 2022.05.27 |