SwiftUI Study – SwiftUI 메모리 모델 심층 이해: Retain Cycle · State Lifetime · Task Lifecycle
Dev Study/SwiftUI 2025. 12. 15. 09:48SwiftUI Study – SwiftUI 메모리 모델 심층 이해: Retain Cycle · State Lifetime · Task Lifecycle
1. 왜 이 주제가 중요한가 (문제 배경)
SwiftUI는 “선언형 UI”를 표방하지만, 내부적으로는 다음 세 가지 축이
메모리와 생명주기를 결정합니다.
- View struct – 값 타입, 매번 새로 만들어지는 선언
- State/ObservableObject – 실제로 메모리를 오래 점유하는 상태 객체
- Task/비동기 작업 – 화면 생명주기와 연결되기도, 분리되기도 하는 실행 단위
이 세 가지를 제대로 이해하지 못하면 다음과 같은 문제가 자주 발생합니다.
- 화면이 사라져도 네트워크 Task가 계속 돌면서 불필요한 리소스 소비
- View와 ViewModel, Task 사이에 강한 참조(Strong Reference) 순환이 생겨 메모리 누수
- @ObservedObject / @StateObject / @EnvironmentObject를 섞어 쓰다가
객체 생명주기를 잘못 설계해 예상치 못한 재생성·초기화 발생 - Task 안에서 self 캡처를 잘못해서 뷰가 이미 없어졌는데 상태를 건드리는 버그
이 문서는 SwiftUI에서의 메모리 모델과 생명주기를 깊이 있게 정리해,
실전에서 메모리 누수와 예측 불가능한 동작을 줄이는 고급 가이드입니다.
2. 큰 그림: SwiftUI에서 “실제로 오래 사는 것”과 “금방 사라지는 것”
SwiftUI 코드를 볼 때 항상 다음처럼 나누어 생각하면 이해가 쉽습니다.
- 금방 사라지는 것 (Ephemeral)
- View struct (body 결과 포함)
- 임시 계산 값
- 일회성 클로저 캡처 값 등
- 오래 사는 것 (Long‑Lived)
- @State 가 가진 Storage
- @StateObject / @ObservedObject / @EnvironmentObject 가 참조하는 클래스 인스턴스
- Task (특히 Task.detached, 수동으로 관리하는 Task)
- 싱글톤, Static, DI 컨테이너 등
핵심:
View struct는 “설명서”일 뿐,
메모리를 먹고 있는 것은 대부분 State / ObservableObject / Task 이다.
3. View struct vs State/ObservableObject – 생명주기 비교
3-1. View struct
struct MyView: View {
var body: some View {
Text("Hello")
}
}
- 값 타입(struct)이므로 body가 호출될 때마다 새로운 인스턴스가 생성.
- “언제 사라지나?”를 고민할 필요가 거의 없다.
→ 그냥 스택/힙에서 자연스럽게 해제된다. - 메모리 누수의 주범이 되는 경우는 매우 드물다.
실질적인 메모리·라이프타임 문제는 대부분 참조 타입(클래스) 에서 발생한다.
3-2. @State / @StateObject / @ObservedObject / @EnvironmentObject 요약
SwiftUI 입장에서 중요한 것은 “Storage가 어디에 있고, 언제까지 유지되는가?” 이다.
간단 정리:
- @State
- View에 붙어 있는 값 타입용 저장소
- View struct는 사라져도, 해당 위치(identity)의 State 스토리지는
그 View가 화면에서 완전히 제거될 때까지 유지됨.
- @StateObject
- 참조 타입(클래스)을 View의 생명주기에 맞춰 한 번만 생성하기 위한 래퍼.
- View가 다시 그려져도 같은 객체 유지.
- 해당 View가 NavigationStack / TabView 등에서 완전히 제거되면 해제.
- @ObservedObject
- 소유권을 가지지 않고 외부에서 주입된 객체를 관찰만.
- 실제 생명주기는 외부에서 관리.
- 잘못 사용하면, “어디서 살아 있는지 모르는 객체”가 여기저기 떠돌 수 있다.
- @EnvironmentObject
- 상위에서 주입된 전역/공용 Store.
- App 루트에 가까운 곳에서 소유.
- 대부분 앱 전체 라이프타임과 같이 감 (사실상 싱글톤과 비슷).
이 구분을 명확히 해야 어디서 메모리 누수가 가능한지 감이 잡힌다.
4. Retain Cycle(강한 참조 순환) – SwiftUI에서 자주 나오는 패턴
SwiftUI + Combine + async/await 조합에서는 다음에서 순환 참조가 잘 생깁니다.
- ViewModel(ObservableObject) ↔ 서비스/클로저
- Task ↔ self(또는 ViewModel)
- Timer / NotificationCenter / Combine Publisher 구독 클로저 ↔ self
4-1. ViewModel 내부에서 클로저로 self를 강하게 캡처
final class MyViewModel: ObservableObject {
@Published var text: String = ""
private let service: SomeService
init(service: SomeService) {
self.service = service
service.onUpdate = {
// ❌ self를 캡처하지만, self가 서비스도 쥐고 있으면 순환
self.text = "updated"
}
}
}
문제:
- MyViewModel이 SomeService를 강하게 참조
- SomeService의 onUpdate 클로저가 MyViewModel(self)을 강하게 참조
→ 강한 참조 순환 → 두 객체 모두 해제되지 않음
해결:
service.onUpdate = { [weak self] in
self?.text = "updated"
}
4-2. Task 안에서 self 캡처
final class MyViewModel: ObservableObject {
@Published var items: [Item] = []
func load() {
Task {
let result = try await api.fetch()
// ❌ self 캡처 – ViewModel이 사라져도 Task가 오래 살면 메모리 유지 + 잘못된 업데이트 가능
self.items = result
}
}
}
보다 안전한 패턴:
func load() {
Task { [weak self] in
guard let self else { return }
let result = try await api.fetch()
await MainActor.run {
self.items = result
}
}
}
혹은 ViewModel 자체를 @MainActor로 선언하고:
@MainActor
final class MyViewModel: ObservableObject {
// ...
func load() {
Task {
let result = try await api.fetch()
self.items = result // MainActor 보장
}
}
}
여기서도 Task가 얼마나 오래 살아야 하는지는 별도 고민해야 한다.
5. Task Lifecycle – SwiftUI에서 Task는 언제까지 살아 있는가?
Swift Concurrency Task는 크게 두 가지로 나눌 수 있다.
- View에 붙는 Task
- .task {}, .task(id:), .onAppear { Task { ... } }
- View의 생명주기에 따라 자동 취소되기도, 안 되기도 한다.
- 독립(Task.detached) / 수동 관리 Task
- 명시적으로 캡처하고, 필요 시 cancel을 호출해야 한다.
5-1. .task { } 의 생명주기
struct MyView: View {
var body: some View {
Text("Hello")
.task {
await load()
}
}
}
- 이 Task는 해당 View가 등장할 때 시작.
- View가 사라지면 SwiftUI가 Task에 cancel 신호를 보낸다.
- 단, cancel 신호를 받았을 때 내부 코드가 try await, Task.checkCancellation() 등을
적절히 사용하지 않으면 “취소된 줄 모르고 계속 실행”할 수 있다.
즉, .task {}는 “구조화된 동시성”에 가까우며,
View 생명주기와 비교적 잘 정렬되어 있다.
5-2. Task { } (전역/독립 Task)의 생명주기
Task {
await load()
}
- 호출한 곳(View)이 사라져도 해당 Task는 계속 살아 있을 수 있다.
- 명시적 참조(예: storedTask = Task { ... })를 보관했다면,
소유자가 사라지지 않는 한 Task도 이어진다.
따라서 View 내부에서 무분별하게:
.onAppear {
Task {
await load()
}
}
를 사용하면 View가 사라진 뒤에도 Task가 계속 돌아갈 위험이 있다.
가능하면 .task modifier를 사용하거나,
ViewModel에서 수명과 취소를 명시적으로 관리하는 편이 좋다.
5-3. Task.detached 는 정말 주의
Task.detached {
await doSomething()
}
- 부모 Task와 완전히 분리된 독립적인 Task.
- 구조화된 동시성 트리 밖에서 동작하므로,
View 생명주기와 무관하게 계속 실행될 수 있다.
필요한 경우가 아니라면
SwiftUI + 앱 수준에서는 Task.detached 사용은 최소화하는 것이 좋다.
6. State Lifetime – 언제까지 살아있고 언제 해제되는가?
6-1. @State
struct CounterView: View {
@State private var count = 0
// ...
}
- CounterView의 위치(identity)가 유지되는 동안 count는 유지된다.
- NavigationStack에서 pop되거나, 조건부 렌더링(if 문에서 완전히 사라지는 경우)
해당 위치가 사라지면 @State 스토리지가 해제된다.
즉,
- 같은 View 타입이라도 어디에 렌더링되는지가 State Lifetime에 영향을 준다.
- .id() modifier로 identity를 바꾸면 이전 State 스토리지가 날아가고 새로 만들어진다.
6-2. @StateObject / @ObservedObject
struct Screen: View {
@StateObject private var viewModel = MyViewModel()
// ...
}
- @StateObject는 해당 View의 생명주기에 귀속된다.
- NavigationStack에서 이 View를 pop하면 ViewModel도 해제된다 (참조가 없다면).
반면:
struct Screen: View {
@ObservedObject var viewModel: MyViewModel
}
- 생명주기는 외부에서 결정.
- 만약 상위 @StateObject가 이 인스턴스를 가지고 있다면,
상위 뷰가 사라질 때까지 계속 유지될 수 있다.
정리:
- “해당 화면에 종속된 상태”라면 @StateObject
- “상위에서 소유하는 글로벌/공용 상태”라면 상위에서 @StateObject + 하위에서는 @ObservedObject
7. 메모리/라이프타임 관련 실전 팁
✔ 팁 1 – View struct 자체를 걱정하지 말고, 클래스와 Task를 먼저 의심
- 메모리 누수 추적 시
- View가 아니라 ObservableObject / Service / Task / Timer 중심으로 보자.
✔ 팁 2 – ViewModel에서 클로저/Task에 self 캡처 시 항상 “weak/강한 캡처”를 의식
- 서비스 콜백, Notification, Timer, Task, Combine sink 등에서
- 기본은 [weak self] 패턴을 사용하고
- 정말로 ViewModel이 Task보다 오래 살아야 할 때만 strong self 허용.
✔ 팁 3 – .task {} 와 Task {} 의 생명주기를 항상 구분
- 화면과 함께 시작/종료되어야 하는 작업 → .task {} / .task(id:)
- 화면과 무관한 백그라운드 작업 → 상위 객체(예: AppEnvironment, Manager)에서 관리.
✔ 팁 4 – @State / @StateObject는 “View의 Identity”에 종속됨
- .id()를 함부로 바꾸지 말 것.
- 조건부 렌더링(if/else)에 따라 State 저장소가 분리되므로,
복잡한 조건문 안에 State가 많으면 Lifetime이 예측하기 어렵다.
✔ 팁 5 – NavigationStack에서 pop 시 ViewModel 해제가 안 되면 Retain Cycle 의심
- 로그로 deinit이 호출되는지 자주 확인.
- 안 된다면:
- 서비스 ↔ ViewModel 간 강한 순환
- Task 안에서의 self strong 캡처
- 상위에서 ViewModel을 강하게 잡고 있는 구조 등을 점검.
8. 정리
- SwiftUI의 메모리 모델은 “View는 가볍고, State/ObservableObject/Task가 무겁다”는 전제를 가져야 한다.
- 진짜 문제는 대부분 참조 타입(클래스)와 Task에서 발생한다.
- @State / @StateObject / @ObservedObject / EnvironmentObject 의 생명주기를 정확히 이해하면,
“왜 이 ViewModel이 안 죽지?” 같은 문제를 상당 부분 예방할 수 있다. - Task의 생명주기(View 기반 .task vs 독립 Task {} vs Task.detached)를 구분하면,
취소 누락/백그라운드 과다 실행 문제를 크게 줄일 수 있다.
이 내용을 기반으로, Instruments나 로그를 통해 deinit 호출 여부를 확인하면
SwiftUI 앱의 메모리/생명주기 문제를 훨씬 체계적으로 다룰 수 있다.


