순환 참조 방지를 위해 다음과 같이 클로저에서 [waek self]를 사용하여 약한 참조( ARC의 retain을 증가시키지 않는 )를 사용하곤 합니다.
습관적으로 모든 클로저에서 [weak self]를 사용하곤 하였는데 이번 아티클을 계기로 [weak self]를 사용하는 이유와 어느 순간에 사용해야 하는지 다시 한번 제대로 알아봅시다!
순환참조란?
서로 다른 객체가 강한 참조로 서로를 참조하면서 메모리에서 해제되지 못하는 상태로 간단한 예시를 살펴봅시다
class Human {
var name: String
var pet: Pet?
init(name: String) {
self.name = name
}
deinit {
print("\(name) 메모리 해제")
}
}
class Pet {
var name: String
var owner: Human?
init(name: String) {
self.name = name
}
deinit {
print("\(name) 메모리 해제")
}
}
인스턴스가 메모리에서 해제될 때 deinit을 통해 print구문을 동작시키게 구현한 후
순환참조가 발생하지 않는 코드
main() 메서드를 실행시키면 당연히 main() 메서드 종료 이후 다음과 같이 메모리에서 해제되었다는 print 구문이 동작하게 됩니다.
순환참조가 발생하는 코드
하지만 위와 같이 생성된 인스턴스들이 서로를 참조하고 있다면 main2() 메서드가 종료된 이후에도 인스턴스들이 메모리에서 해제되지 못하는 Memory Leak이 발생하게 됩니다.
순환참조가 발생하던 코드를 weak를 사용해 통해 순환참조를 방지
위 main2() 메서드에서 순환참조 방지를 위해 Pet Class에 owner 프로퍼티를 weak 타입으로 변경하고 나서
class Pet {
var name: String
weak var owner: Human? // weak로 변경
init(name: String) {
self.name = name
}
deinit {
print("\(name) 메모리 해제")
}
}
다시 main2() 메서드를 실행시키면
main2() 메서드가 종료되고 나서 terry인스턴스와 dog인스턴스가 정상적으로 메모리에서 해제됨을 확인할 수 있습니다.
위 코드를 정리해보면 다음과 같이 동작합니다.
- main2() 메서드 호출
- terry 변수에 Human 인스턴스를 할당하여 힙 영역의 Human 인스턴스의 retain 1 증가
- dog 변수에 Pet 인스턴스를 할당하여 힙 영역의 Pet 인스턴스의 retain 1 증가
- terry 인스턴스의 pet 프로퍼티에 Pet 인스턴스를 할당하여 힙 영역의 Pet 인스턴스의 retain 1 증가
- dog 인스턴스의 owner 프로퍼티에 terry 인스턴스를 약한 참조로 할당하여 힙 영역의 Human 인스턴스의 retain 변화 없음
- main2() 메서드가 종료되면서 스택 프레임 제거되어 terry 변수와 dog 변수 없어짐과 동시에 힙 영역의 Human 인스턴스의 retain 1 감소(release), Pet 인스턴스 retain 1 감소(release)
- 힙 영역의 Human 인스턴스의 최종 retain이 0이 되면서 메모리에서 해제됨 이와 동시에 Human 인스턴스의 프로퍼티로 가지고 있는 Pet 프로퍼티도 제거되면서 Pet 인스턴스 retain 1 감소(release)
- 힙 영역의 Pet 인스턴스의 최종 retain이 0이 되면서 메모리에서 해제됨
[weak self]를 사용하여 순환참조를 방지해야 하는 상황?
순환참조가 발생하는 이유와 weak를 통해 순환참조를 해결하는 코드를 알아보았으니 이제는 [weak self]를 통해 순환참조를 방지할 수 있는 상황을 알아봅시다.
우리는 언제 [weak self]를 통해 순환참조를 방지해야 할까요?
@escaping 키워드가 붙어 있는 탈출 클로저에서 사용해야 한다!
탈출 클로저가 뭔가요?
메서드의 인자로 넣어준 함수를 해당 메서드가 종료된 이후에도 동작할 수 있다는 특징을 가진 함수입니다.
또한 Swift에서 클로저는 일급객체이기 때문에 변수에 저장될 수도 있죠.
마찬가지로 간단한 예시를 확인해 봅시다.
func fetchData(_ closure: @escaping () -> Void) {
DispatchQueue.global().async {
sleep(2)
closure()
}
print("fetchData 종료")
}
fetchData {
print("closure 실행")
}
fetchData() 메서드를 호출하면 GCD를 사용하여 비동기 코드를 동작시켰기에 fetchData() 메서드가 먼저 종료되고 closure()가 실행됨을 알 수 있습니다.
일반적으로 네트워크 로직을 구현하며 위와 같은 방식이 많이 사용되는데 네트워크 로직은 오래 걸리고 무겁기 때문에 비동기로 동작시키게 됩니다. 동기로 동작시키면 네트워크 로직이 동작할 동안 앱이 멈춰버리겠죠!
비동기랑 탈출 클로저랑 무슨 상관이냐?
이는 GCD를 사용한 비동기 로직의 특징을 살펴보면 이해할 수 있는데
- 메인 스레드의 작업 흐름이 비동기 task를 queue에 보내자마자 다시 돌아옴
- 작업 queue로 보내진 비동기 task를 운영체제가 적절한 시점에 실행시킴 즉 언제 실행되고 끝남을 보장할 수 없음
위 특징을 바탕으로 fetchData() 메서드의 동작을 보면 GCD를 사용해 task를 queue에 보내자마자 다시 실행 흐름이 돌아와 print("fetchData 종료") 실행 이후 메서드가 종료되고, task 즉 탈출 클로저는 fetchData() 메서드가 종료되고 나서 운영체제가 판단하에 적절한 시점에 실행되게 됨을 알 수 있습니다.
물론 운영체제의 빠른 판단으로 fetchData() 메서드가 종료되기 전에 클로저를 동작시킬 수도 있으나 개발자인 우리는 이 시점을 예상할 수 없겠죠!
그래서 결국 메서드가 종료되고 나서 동작할 수 있는 함수인 탈출 클로저가 비동기 로직에 필요하게 된 겁니다!!!
우리가 API호출을 위해 흔히 사용하는 dataTask도 다음과 같이 completionHandler에 @escaping 키워드가 붙어있음을 알 수 있죠
그럼 이제 드디어 오늘의 메인 주제인 [weak self]에 대해 알아봅시다..!
순환참조가 발생하는 예시
아래와 같은 3가지 기능이 있는 프로젝트를 구성해 줄 거예요
- Navigation을 사용해 move버튼을 누르면 FirstViewController에서 SecondViewController로 이동할 수 있다
- SecondViewController에서는 Timer를 사용해 UILabel에 경과 시간을 표시해 준다
- SceondViewController에서 다시 FirstViewController로 돌아올 때 SceondViewController의 deinit을 동작시킨다
자세한 코드는 다음과 같아요
class FirstViewController: UIViewController {
let button: UIButton = {
let button = UIButton()
button.setTitle("move", for: .normal)
button.setTitleColor(.black, for: .normal)
button.backgroundColor = .green
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()
override func viewDidLoad() {
super.viewDidLoad()
}
@objc private func moveToSecondVC() {
var secondVC = SecondViewController()
self.navigationController?.pushViewController(secondVC, animated: true)
}
}
단순히 버튼을 누르면 SecondViewController로 이동하는 기능을 가진 FirstViewController
class SecondViewController: UIViewController {
lazy var timerLabel: UILabel = {
let label = UILabel()
label.text = "0"
label.textColor = .systemRed
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
var timer: Timer?
var second = 0
override func viewDidLoad() {
super.viewDidLoad()
setTimerWithWeakCapture()
}
// 강한 참조
private func setTimerWithStrongCapture() {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
self.second += 1
self.timerLabel.text = String(self.second)
print(self.second)
}
}
// 약한 참조
private func setTimerWithWeakCapture() {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
guard let self else { return }
self.second += 1
self.timerLabel.text = String(self.second)
print(self.second)
}
}
deinit {
print("SecondViewController 메모리 제거")
timer?.invalidate()
}
}
Timer기능을 통해 1초마다 UILabel에 경과 시간을 나타내는 기능
deinit을 통해 timer의 invalidate() 메서드를 호출하는 기능
Timer를 설정하는 코드를 자세히 비교해 봅시다
private func setTimerWithStrongCapture() {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
self.second += 1
self.timerLabel.text = String(self.second)
print(self.second)
}
}
Timer를 scheduledTimer() 메서드로 동작시키는 로직이에요 이때 trailing closure는 탈출 클로저로 구성되어 있어요.
혹시 모르니 공식문서에서 확인해 보면 다음과 같이 @escaping 키워드가 붙어있는 걸 알 수 있어요.
그리고 탈출 클로저에서 self를 강한 참조를 통해 참조하고 있음을 알 수 있죠.
Swift 클로저는 명시적으로 weak 또는 unowned 키워드를 사용하지 않는다면 강한 참조를 통해 참조해요.
해당 클로저가 참조하고 있는 self는 SecondViewController를 가리키고 이는 힙영역의 SecondViewController의 인스턴스의 retain 1 증가함을 의미해요.
그리고 아래 코드와 같이 FirsViewController에서도 navigaionController를 사용해 SecondViewController에 대한 참조를 갖고 있으니 힙영역의 SecondViewController의 인스턴스의 retain 1 증가해서 힙영역의 SecondViewController는 총 2의 참조 횟수를 갖게 되죠.
@objc private func moveToSecondVC() {
var secondVC = SecondViewController()
self.navigationController?.pushViewController(secondVC, animated: true)
}
그럼 위 코드의 문제점을 확인해 보기 위해 코드를 실행시켜 볼게요.
실행 결과를 보면 SecondViewController가 pop 되어도 Timer가 계속 동작함을 알 수 있어요.
심지어 다시 SecondViewController에 들어가면 기존 타이머와 새로운 타이머가 같이 동작하게 되고요.
SecondViewController의 deinit { }이 동작하지 않았기에 해당 문제가 발생했겠네요.
deinit {
print("SecondViewController 메모리 제거")
timer?.invalidate()
}
위 문제의 순서를 정리해 봅시다
- FirstViewController에서 SecondViewController를 navigation을 통해 참조하고 있음
- Timer의 탈출 클로저에서 SecondViewController를 참조하고 있음
- SecondViewController가 pop 되면서 SecondViewController의 참조 횟수가 1 감소함
SecondViewController를 참조하고 있는 곳은 2곳인데 참조가 release 되는 로직은 한 번뿐이니 계속 메모리에 남아 있겠군요.
그렇다면 SecondViewController에 들어갔다가 나올 때마다 매번 새로운 SecondViewController가 순환 참조로 인해 힙영역에 남아있겠네요.
이것도 한번 확인해 봅시다!
매번 SecondViewController에 들어갔다 나올때마다 SecondViewController의 Persistent가 1씩 증가하는 게 보이네요!
유저가 우리 앱을 사용할 때 매번 특정 뷰에 들어갔다 나올 때마다 순환참조로 인해 다음과 같은 상황이 발생하면 우리의 앱은 점점 느려지고 무거워지겠죠.
그래서 개발자는 위와 같은 상황을 조심하고 방지해야 해요.
그래서 위 상황을 어떻게 해결할 수 있을까요?
Timer의 trailing 클로저인 탈출 클로저가 강하게 참조하고 있던 부분을 약한 참조로 바꿔주면 해결되겠네요!
[weak self]를 사용해서 순환 참조 방지
아래와 같이 기존에 강한 참조를 약한 참조로 바꿔줬어요.
private func setTimerWithWeakCapture() {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
guard let self else { return }
self.second += 1
self.timerLabel.text = String(self.second)
print(self.second)
}
}
이렇게 하면 SecondViewController가 pop 될 때 참조 횟수가 0이 되면서 더 이상 힙 영역에 남아있지 않을 것이고,
SecondViewController가 메모리에서 사라지기 전 아래의 deinit { } 이 동작하면서 타이머도 런루프에서 사라져 버리겠군요.
deinit {
print("SecondViewController 메모리 제거")
timer?.invalidate()
}
실행시켜 보면 SecondViewController가 pop 될 때마다 정상적으로 deinit { }이 동작함을 알 수 있네요.
이것도 자세히 확인해 보면 SecondViewController가 pop 될 때마다 메모리에서 사라지는 것을 볼 수 있어요!
순환 참조를 발생시킬 때와 달리 SecondViewController가 남아있지 않음을 확인할 수 있네요.
'iOS > Swift' 카테고리의 다른 글
강한 참조가 사라지면 진짜 ARC가 해제시켜줄까? 에 대한 메모리 실험 (0) | 2023.11.11 |
---|---|
[Swift] Subscript 알아보기 :: String에서는 subscript를 사용할 수 없는 이유 (0) | 2023.03.02 |
[Swift] AnyObject란? (런타임 시점에 결정된다) (0) | 2023.02.23 |
[Swift] String과 Substring을 메모리에서 효율적으로 관리하는 방법 (0) | 2023.02.18 |
[Swift] 문자열은 무엇으로 구성되어 있을까? - Extended Grapheme Clusters (0) | 2023.02.18 |