SwiftUI Study – 메모리 누수(Leak)와 Retain Cycle을 예방하는 SwiftUI 비동기·클로저 설계 패턴
Dev Study/SwiftUI 2025. 12. 9. 10:52반응형
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 앱의 안정성과 직결된다.
반응형

