-
SwiftUI의 View 생명주기와 상태 관리: iOS와 macOS의 차이점 상세 분석Apple🍎/SwiftUI 2025. 4. 10. 21:05
문제 상황
SwiftUI로 멀티 플렛폼 앱을 개발하면서 다음과 같은 이상한 현상을 경험했습니다.
- ActivityDetailView에서 활동 데이터를 편집했습니다.
- 편집한 데이터는 데이터베이스에 성공적으로 저장되었습니다.
- 편집 모드에서 보기 모드로 돌아간 후
- iOS에서는 편집된 최신 데이터가 화면에 표시됩니다.
- macOS에서는 편집 전의 원래 데이터가 화면에 표시됩니다.
같은 코드를 실행했는데도 플랫폼에 따라 다른 결과가 나왔습니다. 데이터베이스에는 제대로 저장되었으나, macOS에서는 UI에 반영되지 않았습니다.
플렛폼 별 차이가 생긴 이유
NavigationStack(path: $path) { HomeView(viewModel: viewModel, path: $path) .navigationTitle("활동 관리") .navigationDestination(for: Activity.self) { activity in // 활동 상세 화면으로 이동할 때마다 새로운 viewModel 생성 let detailViewModel = container.makeActivityDetailViewModel(activity: activity) ActivityDetailView(viewModel: detailViewModel) } } struct ActivityDetailView: View { @ObservedObject var viewModel: ActivityDetailViewModel @Environment(\.dismiss) private var dismiss @State private var isEditMode = false // 나머지 코드... }
이 코드에서 문제가 발생하는 구체적인 시나리오를 단계별로 살펴보겠습니다.
iOS에서의 시나리오 (정상 동작)
- 초기 상태: 사용자가 HomeView에서 Activity를 선택하면 navigationDestination이 실행되어 ActivityDetailView가 표시됩니다.
- 편집 모드 진입: 사용자가 편집 모드로 들어가면 isEditMode = true가 되고 UI가 편집 모드로 전환됩니다.
- 데이터 편집: 사용자가 Activity 정보를 변경하고 "완료" 버튼을 누릅니다.
- DB 업데이트: viewModel.updateActivity가 호출되어 DB에 변경 사항이 저장됩니다.
- 보기 모드 복귀: isEditMode = false가 되고 UI가 보기 모드로 돌아갑니다.
- 결과: iOS에서는 ActivityDetailView가 재생성되지 않고 동일한 뷰 인스턴스가 유지됩니다. 따라서 기존 viewModel이 계속 사용되어 업데이트된 데이터가 올바르게 표시됩니다.
iOS에서 시트가 표시되거나 닫히는 과정에서, 시트 뒤에 있는 DetailView는 화면 계층 구조에서 제거되지 않고 그대로 유지됩니다. 시트는 기존 뷰 위에 새로운 레이어로 표시되며, 시트가 닫히면 기존 뷰가 그대로 드러납니다.
macOS에서의 시나리오 (문제 발생)
- 초기 상태: 사용자가 HomeView에서 Activity를 선택하면 navigationDestination이 실행되어 ActivityDetailView가 표시됩니다.
- 편집 모드 진입: 사용자가 편집 모드로 들어가면 isEditMode = true가 되고 UI가 편집 모드로 전환됩니다.
- 데이터 편집: 사용자가 Activity 정보를 변경하고 "완료" 버튼을 누릅니다.
- DB 업데이트: viewModel.updateActivity가 호출되어 DB에 변경 사항이 저장됩니다.
- 보기 모드 복귀: isEditMode = false가 되고 UI가 보기 모드로 돌아갑니다.
- 문제 발생: macOS에서는 이 시점에 DetailView가 재생성됩니다. 이때 navigationDestination 블록이 다시 실행되어 새로운 viewModel이 생성됩니다. 이 새 viewModel은 HomeView에서 전달받은 원래의 activity 객체를 기반으로 생성되므로, DB에 저장된 최신 상태가 아닌 이전 상태를 보여줍니다.
macOS에서는 시트(또는 윈도우)가 닫히면 시스템이 더 적극적으로 뷰 계층 구조를 재구성합니다. 이는 macOS의 윈도우 기반 인터페이스 모델과 관련이 있으며, 시트가 닫힌 후 뒤의 뷰가 업데이트되어 NavigationStack이 다시 렌더링됩니다.
macOS와 iOS의 뷰 재생성 차이
macOS와 iOS는 다음과 같은 이유로 다르게 동작합니다:
- 윈도우 vs 전체 화면 모델
- macOS는 전통적으로 다중 윈도우 환경으로, 각 윈도우가 독립적인 UI 상태를 가집니다.
- iOS는 기본적으로 전체 화면 앱 모델로, 화면 전환이 다르게 처리됩니다.
- 이벤트 루프와 업데이트 주기
- macOS는 이벤트 처리 후 더 적극적으로 뷰 계층 구조를 재구성합니다.
- iOS는 성능과 배터리 최적화를 위해 불필요한 뷰 재생성을 최소화합니다.
- 메모리 관리 전략
- macOS는 일반적으로 더 많은 시스템 리소스를 사용할 수 있어, 뷰 객체를 더 자주 재생성합니다.
- iOS는 제한된 리소스를 고려해 뷰 객체를 가능한 한 재사용합니다.
SwiftUI의 상태 관리 메커니즘
- @ObservedObject
- 참조 타입(클래스)의 객체를 관찰할 때 사용
- 외부에서 주입된 객체에 적합
- 중요: 뷰가 재생성되면 새로운 객체로 대체될 수 있음
- @StateObject
- ObservableObject 프로토콜을 준수하는 객체의 수명 주기를 뷰에 바인딩
- 뷰가 재생성되어도 객체 인스턴스가 유지됨
- 뷰가 자체적으로 소유하는 객체에 적합
초기 코드에서는 detailViewModel을 @ObservedObject로 선언하고 있었습니다.
@ObservedObject var viewModel: ActivityDetailViewModel
그리고 새 뷰를 생성할 때마다 새로운 viewModel을 생성했습니다.
let detailViewModel = container.makeActivityDetailViewModel(activity: activity) ActivityDetailView(viewModel: detailViewModel)
이 때문에 macOS에서 뷰가 재생성될 때 새로운 viewModel은 HomeView에서 전달받은 원래의 activity 객체를 기반으로 생성되므로, DB에 저장된 최신 상태가 아닌 이전 상태를 보여줍니다.
해결책
문제를 해결하기 위해 컨테이너 패턴을 적용했습니다. 이 패턴의 핵심은 다음과 같습니다.
struct ActivityDetailContainer: View { @EnvironmentObject private var container: DependencyContainer let activity: Activity // @StateObject 사용으로 뷰가 재생성되어도 viewModel 유지 @StateObject private var detailViewModel: ActivityDetailViewModel init(container: DependencyContainer, activity: Activity) { _detailViewModel = StateObject( // 초기화 시점에 한 번만 viewModel 생성 wrappedValue: container.makeActivityDetailViewModel(activity: activity) ) self.activity = activity } var body: some View { ActivityDetailView(viewModel: detailViewModel) } }
이 컨테이너 뷰의 역할을 구체적으로 분석해보겠습니다:
- @StateObject의 핵심 기능
- @StateObject는 뷰가 메모리에서 완전히 제거되기 전까지 객체 인스턴스를 유지합니다.
- 초기화는 뷰가 처음 생성될 때만 수행되며, 이후 뷰가 재렌더링되어도 같은 객체 인스턴스가 사용됩니다.
- _detailViewModel = StateObject(wrappedValue: ...) 구문은 초기화 시점에만 실행되어 메모리 할당이 한 번만 이루어집니다.
- 컨테이너의 역할
- ActivityDetailContainer는 상태(viewModel)를 소유하고 관리합니다.
- 실제 UI는 ActivityDetailView에 위임하여 관심사를 분리합니다.
- 이 분리를 통해 상태 관리와 UI 로직이 명확히 구분됩니다.
- NavigationDestination에서의 사용
- 이제 macOS에서 뷰가 재생성되더라도, ActivityDetailContainer 내부의 viewModel은 @StateObject에 의해 유지됩니다.
- 따라서 편집된, 현재 상태의 데이터를 계속 표시합니다.
.navigationDestination(for: Activity.self) { activity in ActivityDetailContainer(container: container, activity: activity) }
SwiftUI 뷰 라이프사이클의 이해
SwiftUI의 뷰 라이프사이클을 심층적으로 이해하면 이러한 문제를 예방할 수 있습니다:
- 뷰 생성 및 렌더링
- SwiftUI는 선언적 UI 프레임워크로, 뷰의 상태가 변경될 때마다 body 프로퍼티를 재평가합니다.
- 이는 뷰가 "재생성"되는 것처럼 보이지만, 실제로는 뷰 설명(view description)만 다시 계산하는 것입니다.
- 그러나 NavigationStack과 같은 일부 컨테이너는 내부 뷰를 실제로 재생성할 수 있습니다.
- 메모리 관리와 수명 주기
- @State와 @StateObject는 SwiftUI의 내부 저장소에 값을 저장하여 뷰 재생성 시에도 유지됩니다.
- @ObservedObject는 외부에서 주입된 객체에 대한 참조만 유지하므로, 뷰가 재생성되면 새 객체로 대체될 수 있습니다.
- init()은 뷰가 생성될 때마다 호출되지만, @StateObject의 초기화는 첫 번째 생성 시에만 수행됩니다.
- 플랫폼별 고려사항
- iOS와 macOS는 같은 SwiftUI 코드를 사용하지만, 기본 UI 패러다임의 차이로 인해 다르게 동작할 수 있습니다.
- macOS에서는 윈도우 기반 인터페이스 모델로 인해 뷰 계층 구조가 더 자주 재구성됩니다.
- iOS에서는 전체 화면 앱 모델로 인해 뷰가 더 오래 유지됩니다.
컨테이너 패턴을 적용함으로써, 우리는 이러한 플랫폼 차이에도 불구하고 일관된 상태 관리를 보장할 수 있게 되었습니다. 특히 @StateObject를 사용해 view model의 수명 주기를 명시적으로 관리함으로써, macOS와 iOS 모두에서 일관된 사용자 경험을 제공할 수 있게 되었습니다.
정리
DetailView 내부에서 상태 관리(편집)를 하고 있는데,
밖에 있는 NavigationStack과 navigationDestination이 macOS에서 DetailView를 재생성하면서 문제가 발생
문제의 흐름을 명확하게 이해하기 위해 정리해보면
- NavigationStack 내에서 navigationDestination으로 DetailView를 표시합니다.
- 사용자가 detailView에서 데이터를 편집하고 저장합니다.
- 편집 모드에서 보기 모드로 돌아갈 때, macOS에서만 NavigationStack이 내부적으로 DetailView를 재생성합니다.
- 이때 navigationDestination 블록이 다시 실행되어 새로운 viewModel을 생성하고 이전 activity 데이터를 사용합니다.
- 그 결과 DB에는 새 데이터가 저장되었지만, UI에는 이전 데이터가 표시됩니다.
Container 패턴이 이 문제를 해결하는 이유는 NavigationStack의 재생성 흐름에 중간 레이어(Container)를 추가해서 상태 관리의 책임을 DetailView 바깥으로 옮기기 때문입니다.
ActivityDetailContainer는 activity의 ID에 따라 고유하게 인식되고, 그 안의 @StateObject는 Container가 재생성되더라도 동일한 ID를 가진 컨테이너에 대해 상태를 유지합니다. 따라서 같은 activity를 보고 있는 한, macOS에서 Container(DetailView)가 재생성되더라도 이전과 동일한 viewModel 인스턴스가 사용됩니다.
쉽게 말해서, Container 패턴은 "데이터 관리"와 "UI 표시"의 책임을 분리하여, NavigationStack의 재생성 문제가 데이터 관리에 영향을 주지 않도록 만드는 방식입니다.
'Apple🍎 > SwiftUI' 카테고리의 다른 글
SwiftUI : List 업데이트가 안됨 ( Swift 랜더링 최적화 과정 ) (0) 2025.04.09 SwiftUI 멀티 플렛폼 Navigation 아키텍쳐 설계 (Coordinator 야 저리 가라) (0) 2025.04.08 [Issue] SwiftUI: Canvas 컴포넌트가 onAppear로 인한 @State 값 변경을 업데이트 하지 않음 (0) 2025.03.24 List와 ScrollView+LazyVStack 비교하기 (0) 2025.03.07 SwiftUI 데이터 모델에 actor를 사용하면 안되는 이유 (0) 2025.02.19