Swift와 SwiftUI, 그리고 Combine을 사용해 MVVM 아키텍처로 앱을 개발하다 보면 비동기 처리를 위해 .sink
를 습관적으로 사용하게 된다. 이때 클로저의 캡처 리스트에 [weak self]
를 추가하는 것 또한 습관처럼 따라온다.
그런데 "정말 [weak self]
를 안 쓰면 큰일이 날까?" 하는 의문이 들 수 있다.
결론부터 말하자면, 정말 큰일 난다. 이 포스트에서는 ViewModel에서 [weak self]
를 생략했을 때 어떤 치명적인 문제가 발생하고, 왜 그것이 선택이 아닌 필수인지 구체적인 시나리오를 통해 알아보겠다.
[weak self]
를 사용하지 않았을 때 발생하는 문제는 강한 순환 참조다. ARC(Automatic Reference Counting)로 메모리를 관리하는 Swift에서 두 개 이상의 객체가 서로를 강하게 참조해 레퍼런스 카운트가 0이 되지 않아 메모리에서 해제되지 않는 현상을 말한다.
ViewModel
과 Combine
의 .sink
클로저 사이에서는 이 강한 순환 참조가 매우 쉽게 만들어진다.
ViewModel
이 구독 정보를 소유한다.
ViewModel
인스턴스(self
)는 구독 정보를 담기 위한 cancellables
프로퍼티를 가진다.self
는 cancellables
를 강하게 참조한다.구독 정보가 클로저를 소유한다.
.store(in: &cancellables)
를 호출하면, 구독을 관리하는 AnyCancellable
객체가 cancellables
에 저장된다..sink
에 전달된 클로저를 강하게 참조한다.클로저가 ViewModel
을 소유한다.
[weak self]
없이 클로저 내부에서 self.isLoading = false
처럼 self
의 프로퍼티나 메서드에 접근하면, 클로저는 self
를 강하게 참조하게 된다.이 세 단계가 합쳐져 self
→ cancellables
→ 클로저
→ self
라는 절대 끊어지지 않는 강한 참조의 고리가 완성된다.
코드를 통해 실제 어떤 문제가 발생하는지 살펴보자. 다음은 관리자 페이지에서 소속 회장 정보를 불러오는 AdminViewModel
의 일부다.
// AdminViewModel.swift
@Observable
class AdminViewModel {
var isLoading: Bool = false
private var cancellables = Set<AnyCancellable>()
// ...
func fetchPresident() {
self.isLoading = true
networkingManager.run(...)
.sink { /* [weak self]가 없다고 가정 */ completion in
// 1. sink 클로저가 self를 강하게 참조
self.isLoading = false
// ...
} receiveValue: { /* [weak self]가 없다고 가정 */ response in
// 2. receiveValue 클로저도 self를 강하게 참조
self.currentPresidentName = response.data.name
// ...
}
.store(in: &cancellables)
}
}
이제 사용자의 행동에 따라 어떤 일이 벌어지는지 따라가 보자.
페이지 진입: 사용자가 관리자 페이지에 진입한다. AdminView
가 AdminViewModel
인스턴스를 생성하고 강하게 참조한다.
데이터 요청: fetchPresident()
가 호출되어 서버에 데이터를 요청한다. 이 작업은 비동기로 일어나므로 약간의 시간이 걸린다.
페이지 이탈 (문제의 순간): 참을성 없는 사용자가 로딩이 끝나기도 전에 뒤로 가기 버튼을 눌러 이전 화면으로 돌아간다.
메모리 누수 발생:
AdminView
는 AdminViewModel
에 대한 참조를 놓아준다.AdminViewModel
의 레퍼런스 카운트가 0이 되어 메모리에서 해제되어야 한다..sink
클로저가 AdminViewModel
을 강하게 붙잡고 있으므로 레퍼런스 카운트는 0이 되지 않는다.AdminViewModel
은 메모리에서 해제되지 못하고 '좀비' 객체로 남게 된다.사용자가 관리자 페이지를 들락날락할 때마다 해제되지 않은 AdminViewModel
이 메모리에 계속 쌓이고, 앱은 점점 느려지다가 결국 메모리 부족으로 강제 종료될 수 있다. 이것이 바로 [weak self]
를 생략했을 때 마주하는 '큰일'이다.
[weak self]
로 참조의 고리 끊기해결책은 간단하다. 클로저가 self
를 강하게 참조하지 않도록 [weak self]
를 추가해 약한 참조(Weak Reference)로 바꿔주면 된다.
func fetchPresident() {
self.isLoading = true
networkingManager.run(...)
.sink { [weak self] completion in // 약한 참조로 변경
guard let self = self else { return } // self가 해제되었다면 이후 코드 실행 안 함
self.isLoading = false
// ...
} receiveValue: { [weak self] response in // 약한 참조로 변경
guard let self = self else { return }
self.currentPresidentName = response.data.name
// ...
}
.store(in: &cancellables)
}
이렇게 하면 클로저는 더 이상 self
의 생명주기에 영향을 주지 않는다. 사용자가 페이지를 이탈해 AdminView
가 AdminViewModel
을 놓아주면, 레퍼런스 카운트가 정상적으로 0이 되어 메모리에서 깨끗하게 해제된다. 강한 순환 참조의 고리가 끊어진 것이다.
클래스(Class) 환경에서 비동기 작업을 처리하는 이스케이핑 클로저(Escaping Closure)가 self
를 캡처할 때, [weak self]
는 선택이 아닌 필수다.
이 원칙은 Combine의 .sink
뿐만 아니라 Timer
의 클로저나 다른 콜백 기반의 비동기 API를 사용할 때도 동일하게 적용된다. 메모리 누수는 발견하기 어려운 버그 중 하나이므로, 처음부터 [weak self]
를 습관화하여 잠재적인 위험을 원천 차단하는 것이 중요하다.