SwiftUI 메모리 관리 및 순환 참조 함정 (프로덕션 가이드)
Source: Dev.to
Introduction
SwiftUI는 많은 메모리 복잡성을 숨깁니다—하지만 어느 순간에는 드러납니다. 대규모에서는 팀이 다음과 같은 문제를 겪습니다:
- 절대로 해제되지 않는 ViewModel
- 영원히 실행되는 Task
- 메모리를 누수하는 Navigation 스택
EnvironmentObject가 전체 그래프를 유지함- 클로저에 의해 발생하는 미묘한 순환 참조
- 오랜 사용 후 성능 저하
이 가이드는 SwiftUI에서 메모리가 실제로 어떻게 동작하는지, 누수가 발생하는 원인, 그리고 누수 없이 프로덕션에 안전한 아키텍처를 설계하는 방법을 설명합니다.
뷰 vs. 객체
- 뷰는 값 타입입니다.
- 객체(
class인스턴스)는 참조 타입입니다. - SwiftUI는 뷰를 지속적으로 재생성하므로, 메모리 문제는 거의 항상 뷰 자체가 아니라 여러분이 소유한 객체에서 발생합니다.
struct Screen: View {
@StateObject var vm = ViewModel()
}
Screen이 이제 ViewModel을 소유합니다. 화면이 계층 구조를 떠나지 않으면 ViewModel은 해제되지 않습니다.
@StateObject private var vm = ViewModel()
소유권이 명시적이며 범위가 한정됩니다.
네비게이션 스택
네비게이션 스택은 다음을 유지합니다:
- 뷰
- 해당
ViewModel - 캡처된 모든 의존성
ViewModel이 네비게이션 상태, 전역 서비스, 혹은 self를 캡처하는 클로저를 참조한다면, 절대 해제되지 않을 수 있습니다.
규칙: 네비게이션 경로에 남아 있으면, 계속 살아 있습니다.
클래식 순환 참조
class ViewModel {
var onUpdate: (() -> Void)?
func bind() {
onUpdate = {
self.doSomething()
}
}
}
클로저가 self를 강하게 캡처 → 순환 참조 발생.
수정
onUpdate = { [weak self] in
self?.doSomething()
}
SwiftUI는 이 패턴으로부터 여러분을 보호하지 않습니다.
Tasks
뷰에 연결된 장기 실행 작업이 누수의 일반적인 원인입니다:
.task {
await loadData()
}
loadData()가 반복하거나, 오래 지속되는 작업을 await 하거나, 절대 완료되지 않으면 해당 작업이 뷰 모델을 유지하게 됩니다.
Proper Cancellation
.task {
await loadData()
}
.onDisappear {
cancelTasks()
}
또는
Task { [weak self] in
await self?.loadData()
}
EnvironmentObjects
EnvironmentObject는 전역 강한 참조입니다:
.environmentObject(AppState())
AppState가 서비스, 캐시, 클로저 또는 옵저버를 보유하고 있다면, 하위에 있는 모든 것이 계속 살아 있게 됩니다.
잘못된 패턴
class AppState {
let featureA = FeatureAViewModel()
let featureB = FeatureBViewModel()
}
아무것도 해제되지 않습니다.
더 나은 패턴
AppState는 식별자와 플래그만 보유합니다.- 각 기능이 자체 뷰 모델을 소유합니다.
- 네비게이션이 기능 상태를 생성하고 파괴합니다.
Subscriptions
Long‑lived Combine pipelines can leak:
publisher
.sink { value in
self.handle(value)
}
.store(in: &cancellables)
If self never deallocates, the pipeline never cancels.
Rules
- Cancel on
onDisappear. - Scope subscriptions tightly to the view model’s lifecycle.
- Prefer short‑lived pipelines.
디이니셜라이제이션 디버깅
모든 뷰 모델에 deinit 로그를 추가하세요:
deinit {
print("Deinit:", Self.self)
}
로그가 보이지 않으면, 아직 객체를 소유하고 있는 것이 있습니다 (네비게이션 경로, 작업, 환경, 클로저).
Windows 및 멀티‑윈도우 앱
각 창:
- 각 창은 자체 뷰 트리와 탐색 상태를 소유합니다.
- 뷰 모델을 중복할 수 있습니다.
의도하지 않는 한 창 간에 뷰 모델을 공유하지 마세요; 이렇게 하면 메모리 누수가 빠르게 증가할 수 있습니다.
Common Leak Sources to Avoid
- 뷰 모델에 대한 전역 싱글톤
- 객체 안에 뷰를 저장하는 것
- 취소 없이 장시간 실행되는 작업
- 무거운
EnvironmentObjects self를 강하게 캡처하는 클로저- 네비게이션과 상태 소유권을 혼합하는 것
안전한 소유권 체크리스트
- 이 객체의 소유자는 누구인가?
- 언제 해제되어야 하는가?
- 무엇이 이를 캡처하는가?
- 무엇이 이를 유지하는가?
- 네비게이션이 이를 살아 있게 하는가?
- 작업이 이를 살아 있게 하는가?
이 질문들에 답할 수 없다면, 메모리 누수가 있을 가능성이 높습니다.
결론
SwiftUI가 제공하는 것:
- 예측 가능한 라이프사이클
- 명확한 소유권 경계
- 강력한 추상화
하지만 여전히 소유권을 올바르게 설계해야 합니다. 메모리가 깨끗할 때:
- 성능이 안정화됩니다
- 버그가 사라집니다
- 네비게이션이 기대대로 동작합니다
- 긴 세션이 원활하게 유지됩니다