Develup

[SwiftUI] @StateObject vs @ObservedObject 완벽 가이드: 올바른 선택 방법 본문

Swift/SwiftUI

[SwiftUI] @StateObject vs @ObservedObject 완벽 가이드: 올바른 선택 방법

Develup 2025. 3. 7. 22:27
반응형

소개

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를 사용해야 하는 경우

  1. 객체를 처음 생성하는 뷰에서: 객체의 생명주기가 뷰와 직접적으로 연결된 경우
struct ShoppingCartView: View {
    // 이 뷰가 CartManager를 처음 생성하므로 @StateObject 사용
    @StateObject private var cartManager = CartManager()
    
    var body: some View {
        // 뷰 내용
    }
}
  1. 싱글톤이 아닌 객체의 소유권이 필요한 경우: 객체가 독립적인 생명주기를 가져야 하는 경우
struct ChatView: View {
    // 채팅 세션이 이 뷰에 종속되므로 @StateObject 사용
    @StateObject private var chatSession = ChatSession()
    
    var body: some View {
        // 뷰 내용
    }
}

@ObservedObject를 사용해야 하는 경우

  1. 외부에서 생성된 객체를 전달받는 경우: 부모 뷰나 환경에서 객체를 전달받는 경우
struct ProductDetailView: View {
    // 이 객체는 외부에서 생성되어 전달되므로 @ObservedObject 사용
    @ObservedObject var product: ProductViewModel
    
    var body: some View {
        // 뷰 내용
    }
}
  1. 의존성 주입 패턴을 사용하는 경우: 테스트나 재사용을 위해 객체를 주입하는 경우
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는 뷰의 생명주기와 연결되어 있어 뷰가 제거되면 함께 제거됩니다. 그러나 객체 내부에서 강한 순환 참조가 있다면 메모리 누수가 발생할 수 있으므로 주의해야 합니다.

반응형