[SwiftUI] @StateObject vs @ObservedObject 완벽 가이드: 올바른 선택 방법
소개
SwiftUI에서 상태 관리는 선언적 UI 프레임워크를 효과적으로 사용하기 위한 핵심 요소입니다. 특히 @StateObject와 @ObservedObject 프로퍼티 래퍼는 매우 유사해 보이지만 중요한 차이점을 가지고 있어 많은 개발자들이 혼란을 겪습니다. 이 두 도구는 Observable 객체의 생명주기와 소유권에 영향을 미치며, 잘못 사용할 경우 메모리 누수나 예상치 못한 UI 동작을 초래할 수 있습니다.
이 글에서는 @StateObject와 @ObservedObject의 핵심 차이점을 파헤치고, 각각 언제 사용해야 하는지 명확한 가이드라인을 제시하겠습니다. 실제 코드 예제를 통해 두 프로퍼티 래퍼가 앱의 성능과 예측 가능성에 어떤 영향을 미치는지 알아보겠습니다.
@StateObject와 @ObservedObject의 기본 개념
@StateObject란 무엇인가?
@StateObject는 SwiftUI 뷰가 ObservableObject 프로토콜을 준수하는 객체의 소유권을 가지고 해당 객체의 수명 주기를 관리하는 프로퍼티 래퍼입니다.
class UserViewModel: ObservableObject {
@Published var name: String = "Steve"
@Published var age: Int = 30
}
struct ProfileView: View {
@StateObject private var viewModel = UserViewModel()
var body: some View {
VStack {
Text("Name: \(viewModel.name)")
Text("Age: \(viewModel.age)")
Button("Update") {
viewModel.name = "Tim"
viewModel.age = 40
}
}
}
}
@StateObject의 핵심 특징:
- 뷰의 생명주기 동안 한 번만 초기화됩니다
- 뷰가 재생성되어도 객체는 유지됩니다
- SwiftUI 2.0 이상에서 사용 가능합니다
- 객체를 소유하는 뷰에서 사용합니다
@ObservedObject란 무엇인가?
@ObservedObject는 다른 곳에서 생성되고 소유된 ObservableObject를 관찰하기 위한 프로퍼티 래퍼입니다.
struct DetailView: View {
@ObservedObject var viewModel: UserViewModel
var body: some View {
VStack {
Text("Name: \(viewModel.name)")
Text("Age: \(viewModel.age)")
Button("Update in Detail") {
viewModel.name = "Craig"
viewModel.age = 45
}
}
}
}
@ObservedObject의 핵심 특징:
- 뷰가 재생성될 때마다 다시 평가될 수 있습니다
- 객체의 소유권이 없으며 단지 관찰만 합니다
- SwiftUI 1.0부터 사용 가능합니다
- 외부에서 생성된 객체를 관찰하는 데 사용합니다
두 프로퍼티 래퍼의 중요한 차이점
생명주기와 소유권
가장 중요한 차이점은 객체의 생명주기와 소유권에 있습니다:
struct ParentView: View {
@StateObject private var viewModel = UserViewModel() // 소유권을 가짐
var body: some View {
VStack {
Text("Parent: \(viewModel.name)")
// DetailView는 viewModel을 소유하지 않고 관찰만 함
DetailView(viewModel: viewModel)
}
}
}
@StateObject:
- 뷰의 생명주기 동안 한 번만 초기화되고 유지됩니다
- 뷰가 재렌더링되어도 객체는 유지됩니다
- 객체를 소유하고 생성하는 뷰에서 사용합니다
@ObservedObject:
- 뷰가 재생성될 때마다 재평가될 수 있습니다
- 뷰가 재렌더링될 때 객체가 재생성될 위험이 있습니다
- 다른 곳에서 생성된 객체를 참조할 때 사용합니다
메모리 관리 측면
두 프로퍼티 래퍼의 메모리 관리 방식 차이를 확인해 보겠습니다:
struct ContentView: View {
@State private var showDetail = false
@StateObject private var viewModel = UserViewModel()
var body: some View {
VStack {
Button("Toggle Detail") {
showDetail.toggle()
}
if showDetail {
// DetailView는 viewModel을 관찰만 하므로 @ObservedObject 사용
DetailView(viewModel: viewModel)
}
}
}
}
위 예제에서:
- ContentView는 viewModel을 @StateObject로 소유합니다
- DetailView가 표시되거나 숨겨져도 viewModel은 유지됩니다
- DetailView는 viewModel을 @ObservedObject로 참조하여 변경 사항을 관찰합니다
잘못된 사용 시 발생할 수 있는 문제
@StateObject 대신 @ObservedObject 사용 시 문제
부모 뷰에서 @StateObject 대신 @ObservedObject를 사용하면 다음과 같은 문제가 발생할 수 있습니다:
// 잘못된 사용 예
struct ParentView: View {
// 주의: @StateObject를 사용해야 할 곳에 @ObservedObject 사용
@ObservedObject private var viewModel = UserViewModel()
var body: some View {
VStack {
Text("Name: \(viewModel.name)")
Button("Update") {
viewModel.name = "Tim"
}
}
}
}
위 코드의 문제점:
- 뷰가 재생성될 때마다 viewModel이 재생성될 수 있음
- 상태가 예상치 못하게 초기화될 수 있음
- UI 업데이트가 일관되지 않게 작동할 수 있음
@ObservedObject 대신 @StateObject 사용 시 문제
자식 뷰에서 @ObservedObject 대신 @StateObject를 사용하면:
// 잘못된 사용 예
struct DetailView: View {
// 주의: @ObservedObject를 사용해야 할 곳에 @StateObject 사용
@StateObject var viewModel: UserViewModel
var body: some View {
// 뷰 내용
}
}
위 코드의 문제점:
- 컴파일 오류 발생: @StateObject 프로퍼티는 초기화가 필요함
- 수정하더라도 동일한 객체에 대해 여러 인스턴스가 생성됨
- 서로 다른 뷰가 동일한 데이터에 대해 다른 상태를 가지게 됨
언제 어떤 프로퍼티 래퍼를 사용해야 할까?
다음은 각 프로퍼티 래퍼를 사용해야 하는 상황에 대한 명확한 가이드라인입니다:
@StateObject를 사용해야 하는 경우
- 객체를 처음 생성하는 뷰에서: 객체의 생명주기가 뷰와 직접적으로 연결된 경우
struct ShoppingCartView: View {
// 이 뷰가 CartManager를 처음 생성하므로 @StateObject 사용
@StateObject private var cartManager = CartManager()
var body: some View {
// 뷰 내용
}
}
- 싱글톤이 아닌 객체의 소유권이 필요한 경우: 객체가 독립적인 생명주기를 가져야 하는 경우
struct ChatView: View {
// 채팅 세션이 이 뷰에 종속되므로 @StateObject 사용
@StateObject private var chatSession = ChatSession()
var body: some View {
// 뷰 내용
}
}
@ObservedObject를 사용해야 하는 경우
- 외부에서 생성된 객체를 전달받는 경우: 부모 뷰나 환경에서 객체를 전달받는 경우
struct ProductDetailView: View {
// 이 객체는 외부에서 생성되어 전달되므로 @ObservedObject 사용
@ObservedObject var product: ProductViewModel
var body: some View {
// 뷰 내용
}
}
- 의존성 주입 패턴을 사용하는 경우: 테스트나 재사용을 위해 객체를 주입하는 경우
struct SettingsView: View {
// 테스트나 다른 환경에서 주입될 수 있으므로 @ObservedObject 사용
@ObservedObject var settingsManager: SettingsManager
var body: some View {
// 뷰 내용
}
}
실제 시나리오: 표준 MVVM 패턴에서의 사용
MVVM(Model-View-ViewModel) 패턴에서 두 프로퍼티 래퍼의 적절한 사용법을 살펴보겠습니다:
// Model
struct User {
var id: UUID
var name: String
var email: String
}
// ViewModel
class UserListViewModel: ObservableObject {
@Published var users: [User] = []
@Published var isLoading = false
func fetchUsers() {
isLoading = true
// 네트워크 요청 시뮬레이션
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.users = [
User(id: UUID(), name: "John", email: "john@example.com"),
User(id: UUID(), name: "Mary", email: "mary@example.com")
]
self.isLoading = false
}
}
}
// 부모 View (객체 소유)
struct UserListView: View {
@StateObject private var viewModel = UserListViewModel()
var body: some View {
NavigationView {
List {
if viewModel.isLoading {
ProgressView()
} else {
ForEach(viewModel.users, id: \.id) { user in
NavigationLink(destination: UserDetailView(viewModel: viewModel, userId: user.id)) {
Text(user.name)
}
}
}
}
.navigationTitle("Users")
.onAppear {
viewModel.fetchUsers()
}
}
}
}
// 자식 View (객체 관찰)
struct UserDetailView: View {
@ObservedObject var viewModel: UserListViewModel
let userId: UUID
private var user: User? {
viewModel.users.first(where: { $0.id == userId })
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
if let user = user {
Text("Name: \(user.name)")
.font(.headline)
Text("Email: \(user.email)")
.font(.subheadline)
} else {
Text("User not found")
}
}
.padding()
.navigationTitle("User Details")
}
}
이 예제에서:
- UserListView는 UserListViewModel을 소유하므로 @StateObject 사용
- UserDetailView는 동일한 UserListViewModel을 참조하므로 @ObservedObject 사용
- 두 뷰는 동일한 ViewModel 인스턴스를 공유하므로 데이터 일관성 유지
성능 최적화 고려사항
@StateObject와 @ObservedObject의 성능 영향
두 프로퍼티 래퍼는 성능에도 영향을 미칩니다:
@StateObject:
- 객체 생성 및 초기화 비용이 한 번만 발생
- 뷰가 자주 재생성되는 경우 더 효율적
- 메모리 사용량이 약간 더 높을 수 있음(객체 수명이 길어짐)
@ObservedObject:
- 뷰가 재생성될 때마다 객체 참조를 재평가
- 잘못 사용 시 불필요한 재생성 가능성
- 메모리 사용량이 잠재적으로 더 적을 수 있음(객체 수명이 더 짧을 수 있음)
최적화 팁
성능 최적화를 위한 팁:
// 성능 최적화 예제
struct OptimizedListView: View {
// 리스트 전체에 필요한 데이터를 관리하는 ViewModel
@StateObject private var listViewModel = ListViewModel()
var body: some View {
List {
ForEach(listViewModel.items) { item in
// 각 행은 동일한 ViewModel을 관찰만 함
RowView(viewModel: listViewModel, itemId: item.id)
}
}
}
}
struct RowView: View {
// 부모로부터 전달받은 ViewModel 참조
@ObservedObject var viewModel: ListViewModel
let itemId: UUID
// 이 계산 프로퍼티로 필요한 항목만 필터링
private var item: Item? {
viewModel.items.first(where: { $0.id == itemId })
}
var body: some View {
if let item = item {
Text(item.name)
}
}
}
위 예제의 최적화 포인트:
- 모든 행이 동일한 ListViewModel 인스턴스를 공유
- 각 행은 필요한 데이터만 필터링하여 사용
- 불필요한 객체 생성 방지
흔한 오해와 실수
오해 1: "@ObservedObject는 항상 재생성된다"
많은 개발자들이 @ObservedObject가 항상 뷰가 재렌더링될 때마다 재생성된다고 오해합니다.
사실:
- @ObservedObject는 객체 자체를 재생성하지 않습니다
- 하지만 선언 시 초기화하면 뷰가 재생성될 때마다 객체가 새로 생성될 수 있습니다
- 외부에서 전달된 객체는 유지됩니다
// 잘못된 사용 (재생성 위험)
struct BadExample: View {
@ObservedObject var viewModel = UserViewModel() // 주의: 재생성 위험
var body: some View {
// 뷰 내용
}
}
// 올바른 사용
struct GoodExample: View {
@ObservedObject var viewModel: UserViewModel // 외부에서 전달받음
var body: some View {
// 뷰 내용
}
}
오해 2: "항상 @StateObject가 @ObservedObject보다 낫다"
사실:
- 두 프로퍼티 래퍼는 서로 다른 용도로 설계됨
- 객체 소유 관계와 생명주기에 따라 적절한 것을 선택해야 함
- 잘못된 선택은 메모리 누수나 예상치 못한 동작을 초래할 수 있음
실수: 두 프로퍼티 래퍼를 동시에 사용
// 잘못된 사용 예
struct ConfusingView: View {
@StateObject private var viewModel1 = UserViewModel()
@ObservedObject private var viewModel2 = UserViewModel() // 문제: 동일한 타입의 다른 인스턴스
var body: some View {
// 혼란스러운 결과: viewModel1과 viewModel2는 서로 다른 인스턴스
VStack {
Text("Name 1: \(viewModel1.name)")
Text("Name 2: \(viewModel2.name)")
Button("Update 1") { viewModel1.name = "Updated" }
Button("Update 2") { viewModel2.name = "Updated" }
}
}
}
위 코드의 문제점:
- 동일한 목적을 가진 두 개의 서로 다른 뷰모델 인스턴스 생성
- 사용자는 하나가 업데이트되면 다른 하나도 업데이트될 것으로 기대할 수 있음
- 실제로는 서로 독립적으로 작동하여 혼란 초래
고급 시나리오: 환경 객체와의 조합
SwiftUI의 @EnvironmentObject와 함께 사용하는 경우:
// 앱 전체에서 공유되는 환경 객체
class AppSettings: ObservableObject {
@Published var isDarkMode: Bool = false
@Published var fontSize: Int = 14
}
// 환경 객체를 사용하는 부모 뷰
struct AppSettingsView: View {
@StateObject private var settings = AppSettings()
var body: some View {
NavigationView {
SettingsDetailView()
}
.environmentObject(settings) // 환경에 객체 주입
}
}
// 환경 객체를 관찰하는 자식 뷰
struct SettingsDetailView: View {
@EnvironmentObject var settings: AppSettings
var body: some View {
Form {
Toggle("Dark Mode", isOn: $settings.isDarkMode)
Stepper("Font Size: \(settings.fontSize)", value: $settings.fontSize, in: 10...30)
}
}
}
이 예제에서:
- AppSettingsView는 AppSettings를 @StateObject로 소유
- SettingsDetailView는 환경을 통해 동일한 객체에 접근
- 이 패턴은 깊은 뷰 계층에서 객체를 공유할 때 유용
결론
@StateObject와 @ObservedObject는 유사해 보이지만 중요한 차이점을 가지고 있습니다:
- @StateObject는 객체를 소유하고 뷰의 생명주기 동안 유지하며, 객체를 처음 생성하는 뷰에서 사용해야 합니다.
- @ObservedObject는 객체를 관찰만 하며, 외부에서 생성된 객체를 참조할 때 사용해야 합니다.
올바른 프로퍼티 래퍼 선택은 앱의 성능, 예측 가능성, 유지 관리성에 직접적인 영향을 미칩니다. 이 가이드에서 설명한 원칙을 따르면 SwiftUI 앱에서 데이터 흐름을 더 효과적으로 관리할 수 있습니다.
자주 묻는 질문 (FAQ)
Q: iOS 13에서는 @StateObject가 없는데 어떻게 해야 하나요?
A: iOS 13에서는 @ObservedObject를 사용하고, 객체가 재생성되지 않도록 뷰 외부에서 객체를 생성하여 주입하는 방식으로 관리해야 합니다.
Q: @StateObject와 @State의 차이점은 무엇인가요?
A: @State는 단순한 값 타입(Int, String 등)을 위한 것이고, @StateObject는 참조 타입(ObservableObject 프로토콜을 준수하는 클래스)을 위한 것입니다.
Q: 하나의 ObservableObject를 여러 뷰에서 공유하는 가장 좋은 방법은 무엇인가요?
A: 객체를 소유할 부모 뷰에서 @StateObject로 선언하고, 자식 뷰에는 @ObservedObject로 전달하거나 @EnvironmentObject를 사용하여 전체 뷰 계층에 주입하는 것이 좋습니다.
Q: @Published 프로퍼티가 변경될 때 @ObservedObject와 @StateObject 모두 뷰를 업데이트하나요?
A: 네, 두 프로퍼티 래퍼 모두 @Published 프로퍼티가 변경될 때 뷰를 업데이트합니다. 차이점은 객체의 생명주기와 소유권에 있습니다.
Q: @StateObject를 사용하면 메모리 누수가 발생할 수 있나요?
A: @StateObject는 뷰의 생명주기와 연결되어 있어 뷰가 제거되면 함께 제거됩니다. 그러나 객체 내부에서 강한 순환 참조가 있다면 메모리 누수가 발생할 수 있으므로 주의해야 합니다.