반응형

SwiftUI Study – 메모리 누수(Leak)와 Retain Cycle을 예방하는 SwiftUI 비동기·클로저 설계 패턴

 

1. 왜 중요한가 (문제 배경)

SwiftUI 자체는 “값 타입(View struct)” 기반이라 얼핏 보면 메모리 누수와는 거리가 멀어 보입니다.
하지만 실제 앱에서는 다음과 같은 요소들이 반드시 섞이게 됩니다.

  • ObservableObject / ViewModel (class)
  • Task, Timer, NotificationCenter
  • URLSession, WebSocket 등 비동기 API
  • 클로저를 인자로 받는 서비스/유즈케이스 레이어
  • Combine의 AnyCancellable

이때 참조 타입(class)와 클로저, 비동기 작업을 잘못 설계하면 강한 참조 사이클(retain cycle) 이 발생하고,
View가 사라졌는데도 메모리가 해제되지 않는 상황이 생깁니다.

SwiftUI 화면이 “닫힌 것처럼 보이지만”
실제로는 ViewModel·Task·타이머가 계속 살아 있는 경우가 매우 많습니다.

이를 방치하면:

  • 화면 이동을 반복할수록 메모리 사용량이 누적
  • 백그라운드에서 불필요한 네트워크/타이머가 계속 동작
  • 예측 불가한 버그와 크래시가 장기적으로 발생

따라서 SwiftUI에서도 전통적인 iOS 메모리 관리 규칙 + 비동기 취소 규칙을 반드시 의식해야 합니다.

 

2. 잘못된 패턴 예시

❌ 예시 1: ViewModel 안에서 self를 강하게 캡처한 Task

@MainActor
final class ProfileViewModel: ObservableObject {
    @Published var user: User?

    func load() {
        Task {                    // ❌ self를 강하게 캡처
            let result = try await api.fetchUser()
            self.user = result
        }
    }
}

문제점

  • Task 클로저가 self를 강하게 잡고 있음
  • Task가 오래 살아 있으면 ViewModel도 함께 유지
  • View에서 ViewModel 참조, ViewModel에서 Task → 순환 구조로 이어질 수 있음

❌ 예시 2: Timer를 만들고 해제하지 않는 패턴

final class TimerViewModel: ObservableObject {
    @Published var count = 0
    private var timer: Timer?

    func start() {
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
            self.count += 1       // ❌ self 캡처 + timer 반복
        }
    }
}

문제점

  • Timer는 기본적으로 RunLoop에 강하게 붙어 있음
  • invalidate() 하지 않으면 View가 사라져도 계속 동작
  • 클로저에서 self를 강하게 캡처 → ViewModel 해제 안 됨

❌ 예시 3: NotificationCenter 관찰자 제거 누락

final class NotificationVM: ObservableObject {
    init() {
        NotificationCenter.default.addObserver(
            forName: .someEvent,
            object: nil,
            queue: .main
        ) { [weak self] note in
            self?.handle(note)
        }
    }
}

문제점

  • addObserver(forName:object:queue:using:) 는 토큰을 반환하지 않으면 제거 불가
  • deinit에서 제거하지 않으면 VM이 해제되지 않거나,
    이미 사라진 객체에 메시지가 전달될 수 있음

❌ 예시 4: Combine AnyCancellable을 정리하지 않는 패턴

final class CombineVM: ObservableObject {
    var cancellables: Set<AnyCancellable> = []

    func bind() {
        NotificationCenter.default.publisher(for: .someEvent)
            .sink { [weak self] _ in
                self?.reload()
            }
            .store(in: &cancellables)    // ❌ 괜찮아 보이지만, 해제 타이밍을 의식해야 함
    }
}

문제점

  • cancellables 가 살아 있는 한 스트림도 계속 유지
  • ViewModel이 해제되지 않으면 이벤트 스트림도 종료되지 않음
  • 특히 “전역 Singleton에서 ViewModel을 강하게 잡고 있는” 구조와 결합 시 누수 위험

 

3. 올바른 패턴 예시

✅ 예시 1: Task 핸들을 프로퍼티로 잡고, deinit이나 취소 시점에 cancel

@MainActor
final class ProfileViewModel: ObservableObject {
    @Published var user: User?
    private var loadTask: Task<Void, Never>?

    func load() {
        loadTask?.cancel()   // 이전 작업 취소
        loadTask = Task { [weak self] in
            guard let self else { return }

            do {
                let result = try await api.fetchUser()
                self.user = result
            } catch {
                // 에러 처리
            }
        }
    }

    deinit {
        loadTask?.cancel()   // 뷰모델 해제 시 비동기 작업도 종료
    }
}

장점

  • Task 생명주기를 ViewModel과 함께 관리
  • deinit에서 명시적 cancel → 비동기 로직이 뒤에서 계속 돌지 않음
  • [weak self] 로 캡처해 Task → ViewModel 강한 사이클 방지

✅ 예시 2: Timer는 반드시 invalidate + [weak self] 조합

final class TimerViewModel: ObservableObject {
    @Published var count = 0
    private var timer: Timer?

    func start() {
        timer?.invalidate()

        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
            self?.count += 1
        }
    }

    func stop() {
        timer?.invalidate()
        timer = nil
    }

    deinit {
        timer?.invalidate()
    }
}

장점

  • 타이머는 명시적으로 중지
  • 클로저는 [weak self]로 캡처 → ViewModel 해제 가능
  • 화면 전환 시 불필요한 타이머 동작 제거

✅ 예시 3: NotificationCenter 토큰을 저장하고 deinit에서 제거

final class NotificationVM: ObservableObject {
    private var observer: NSObjectProtocol?

    init() {
        observer = NotificationCenter.default.addObserver(
            forName: .someEvent,
            object: nil,
            queue: .main
        ) { [weak self] note in
            self?.handle(note)
        }
    }

    deinit {
        if let observer {
            NotificationCenter.default.removeObserver(observer)
        }
    }

    private func handle(_ note: Notification) {
        // 처리
    }
}

장점

  • 토큰을 유지하고 해제 시점에 removeObserver
  • 더 이상 사용되지 않는 ViewModel에 이벤트가 오는 일 방지

✅ 예시 4: Combine 스트림은 ViewModel 생명주기에 맞게 해제

final class CombineVM: ObservableObject {
    private var cancellables: Set<AnyCancellable> = []

    func bind() {
        NotificationCenter.default.publisher(for: .someEvent)
            .sink { [weak self] _ in
                self?.reload()
            }
            .store(in: &cancellables)
    }

    deinit {
        // Set<AnyCancellable> 는 deinit 시 자동 cancel 되지만,
        // ViewModel이 불필요하게 오래 살아 있지 않도록 구조를 설계하는 것이 핵심.
        print("CombineVM deinit")
    }
}

장점

  • ViewModel이 해제될 때 함께 cancel
  • ViewModel을 오래 붙잡는 전역 참조 구조만 피하면 안전

 

4. 실전 적용 팁

✔ 팁 1 – 비동기 작업(Task)은 “반드시” 취소 지점을 가져야 한다

  • ViewModel 프로퍼티로 Task 핸들을 잡고,
    • 새로운 작업 시작 전에 cancel()
    • deinit 에서도 cancel()
  • View/화면 레벨에서는 .onDisappear 에서 cancel하는 패턴도 유효

✔ 팁 2 – 클로저에서 self 캡처 시 기본은 [weak self]

  • 특히 Task, Timer, NotificationCenter, URLSession, Combine sink 에서 필수
  • unowned self 는 crash 위험이 있으므로, 정말 확신할 때만 사용

✔ 팁 3 – “계속 반복되는 것”은 반드시 정리 코드가 있어야 한다

  • Timer, Notification, Stream, WebSocket, observe 계열
  • “시작 코드”를 만들었다면 “정리 코드(stop/deinit)”도 같이 작성

✔ 팁 4 – View와 ViewModel 간 참조 그래프를 종이에 그려보기

  • View는 보통 @StateObject 로 ViewModel을 강하게 잡고
  • ViewModel은 View를 참조하지 않는 방향이 이상적
  • 반대로 ViewModel → View 로 강한 참조가 있다면 구조를 재설계해야 한다

✔ 팁 5 – Instruments(Leaks, Allocations)로 실제 누수 여부 확인

  • 의심 지점: 반복 화면 이동, 탭 간 전환, 리스트 푸시/팝
  • 화면을 여러 번 열었다 닫았을 때 ViewModel 인스턴스 수가 증가하는지 체크

 

5. 정리

  • SwiftUI라고 해서 메모리 누수에서 자유로운 것은 아니다.
    View는 struct지만, ViewModel·서비스·비동기 작업·타이머·스트림은 모두 class와 클로저로 이루어져 있다.
  • Task, Timer, NotificationCenter, Combine 등을 사용할 때는
    취소(cancel)·해제(remove)·deinit 정리 를 한 세트로 항상 의식해야 한다.
  • 클로저에서 self를 캡처할 때는 [weak self] 를 기본값으로 두고,
    구조적으로 ViewModel이 오래 붙잡히지 않도록 설계하는 것이 SwiftUI 앱의 안정성과 직결된다.
반응형
Posted by 까칠코더
,