Combine과 ViewModel, [weak self]를 깜빡하면 메모리 누수로 앱터짐❗️

꾸Jun·2025년 7월 8일
0

🍎 iOS

목록 보기
21/25

Swift와 SwiftUI, 그리고 Combine을 사용해 MVVM 아키텍처로 앱을 개발하다 보면 비동기 처리를 위해 .sink를 습관적으로 사용하게 된다. 이때 클로저의 캡처 리스트에 [weak self]를 추가하는 것 또한 습관처럼 따라온다.

그런데 "정말 [weak self]를 안 쓰면 큰일이 날까?" 하는 의문이 들 수 있다.

결론부터 말하자면, 정말 큰일 난다. 이 포스트에서는 ViewModel에서 [weak self]를 생략했을 때 어떤 치명적인 문제가 발생하고, 왜 그것이 선택이 아닌 필수인지 구체적인 시나리오를 통해 알아보겠다.



모든 문제의 원인: 강한 순환 참조 (Strong Reference Cycle)

[weak self]를 사용하지 않았을 때 발생하는 문제는 강한 순환 참조다. ARC(Automatic Reference Counting)로 메모리를 관리하는 Swift에서 두 개 이상의 객체가 서로를 강하게 참조해 레퍼런스 카운트가 0이 되지 않아 메모리에서 해제되지 않는 현상을 말한다.

ViewModelCombine.sink 클로저 사이에서는 이 강한 순환 참조가 매우 쉽게 만들어진다.

  1. ViewModel이 구독 정보를 소유한다.

    • ViewModel 인스턴스(self)는 구독 정보를 담기 위한 cancellables 프로퍼티를 가진다.
    • selfcancellables강하게 참조한다.
  2. 구독 정보가 클로저를 소유한다.

    • 네트워크 요청 후 .store(in: &cancellables)를 호출하면, 구독을 관리하는 AnyCancellable 객체가 cancellables에 저장된다.
    • 이 구독 객체는 .sink에 전달된 클로저를 강하게 참조한다.
  3. 클로저가 ViewModel을 소유한다.

    • [weak self] 없이 클로저 내부에서 self.isLoading = false 처럼 self의 프로퍼티나 메서드에 접근하면, 클로저는 self강하게 참조하게 된다.

이 세 단계가 합쳐져 selfcancellables클로저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)
    }
}

이제 사용자의 행동에 따라 어떤 일이 벌어지는지 따라가 보자.

  1. 페이지 진입: 사용자가 관리자 페이지에 진입한다. AdminViewAdminViewModel 인스턴스를 생성하고 강하게 참조한다.

  2. 데이터 요청: fetchPresident()가 호출되어 서버에 데이터를 요청한다. 이 작업은 비동기로 일어나므로 약간의 시간이 걸린다.

  3. 페이지 이탈 (문제의 순간): 참을성 없는 사용자가 로딩이 끝나기도 전에 뒤로 가기 버튼을 눌러 이전 화면으로 돌아간다.

  4. 메모리 누수 발생:

    • 화면에서 사라진 AdminViewAdminViewModel에 대한 참조를 놓아준다.
    • 정상이라면 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의 생명주기에 영향을 주지 않는다. 사용자가 페이지를 이탈해 AdminViewAdminViewModel을 놓아주면, 레퍼런스 카운트가 정상적으로 0이 되어 메모리에서 깨끗하게 해제된다. 강한 순환 참조의 고리가 끊어진 것이다.



결론

클래스(Class) 환경에서 비동기 작업을 처리하는 이스케이핑 클로저(Escaping Closure)가 self를 캡처할 때, [weak self]는 선택이 아닌 필수다.

이 원칙은 Combine의 .sink 뿐만 아니라 Timer의 클로저나 다른 콜백 기반의 비동기 API를 사용할 때도 동일하게 적용된다. 메모리 누수는 발견하기 어려운 버그 중 하나이므로, 처음부터 [weak self]를 습관화하여 잠재적인 위험을 원천 차단하는 것이 중요하다.



참고 문서 및 출처

profile
꾸준🐢

0개의 댓글