Develup

[SwiftUI] EnvironmentValues vs EnvironmentObject: 완벽한 데이터 공유 가이드 본문

Swift/SwiftUI

[SwiftUI] EnvironmentValues vs EnvironmentObject: 완벽한 데이터 공유 가이드

Develup 2025. 3. 7. 14:21
반응형

SwiftUI 앱을 개발하다 보면 여러 뷰 간에 데이터를 공유하고 전달해야 하는 상황이 자주 발생합니다. 특히 뷰 계층 구조가 복잡해질수록 단순히 프로퍼티를 통해 데이터를 전달하는 방식은 번거롭고 유지보수가 어려워집니다. 이러한 문제를 해결하기 위해 SwiftUI는 환경 변수(EnvironmentValues)와 환경 객체(EnvironmentObject)라는 강력한 메커니즘을 제공합니다.

이 글에서는 SwiftUI의 환경 변수와 환경 객체의 개념, 차이점, 그리고 실제 활용 방법을 자세히 살펴보겠습니다. 이를 통해 복잡한 뷰 계층 구조에서도 효율적으로 데이터를 관리하고 상태를 공유하는 방법을 알아보겠습니다.

환경 변수(EnvironmentValues)란 무엇인가?

환경 변수는 SwiftUI에서 뷰 계층 구조 전체에 걸쳐 데이터를 암시적으로 전달하는 시스템입니다. 색상 구성표, 텍스트 크기, 로케일 등과

같은 앱 전체 설정이나 뷰 계층 구조의 특정 부분에 적용되는 설정을 관리하는 데 이상적입니다.

환경 변수의 핵심 특징

  • 시스템에서 제공하는 기본 환경 값들이 있음 (예: colorScheme, locale)
  • 사용자가 직접 커스텀 환경 값을 정의할 수 있음
  • 부모 뷰에서 설정한 값이 모든 자식 뷰에 자동으로 전파됨
  • 읽기 전용으로 사용하거나 수정할 수 있음

기본 환경 값 사용하기

SwiftUI는 다양한 기본 환경 값을 제공합니다. 이 값들은 @Environment 프로퍼티 래퍼를 통해 접근할 수 있습니다.

struct ContentView: View {
    @Environment(\.colorScheme) private var colorScheme
    @Environment(\.locale) private var locale
    
    var body: some View {
        Text("Current color scheme: \(colorScheme == .dark ? "Dark" : "Light")")
        Text("Current locale: \(locale.identifier)")
    }
}

위 예제에서는 현재 색상 구성표와 로케일 정보를 환경 값에서 가져와 표시합니다.

환경 값 수정하기

특정 뷰 계층에 대한 환경 값을 수정하려면 .environment() 수정자를 사용합니다.

struct ContentView: View {
    var body: some View {
        VStack {
            StandardText()
            
            // 이 자식 뷰와 그 하위 뷰들에만 다른 텍스트 크기 적용
            StandardText()
                .environment(\.font, .title)
        }
    }
}

struct StandardText: View {
    @Environment(\.font) private var font
    
    var body: some View {
        Text("Hello, World!")
            .font(font)
    }
}

커스텀 환경 값 생성하기

시스템에서 제공하는 환경 값 외에도 직접 커스텀 환경 값을 만들 수 있습니다. 이를 통해 앱에 특화된 설정을 뷰 계층 구조 전체에 전파할 수 있습니다.

커스텀 환경 값을 만들기 위한 3단계 과정을 살펴보겠습니다:

1. 환경 키 정의하기

먼저, EnvironmentKey 프로토콜을 준수하는 구조체를 만들어 환경 키를 정의합니다. 이 프로토콜은 기본값을 제공하는 defaultValue가 필요합니다.

struct ThemeKey: EnvironmentKey {
    static let defaultValue: Theme = .light
}

// 테마 정의
enum Theme {
    case light, dark, system
}

2. EnvironmentValues 확장하기

다음으로, EnvironmentValues를 확장하여 새로운 환경 값에 대한 접근자를 제공합니다.

extension EnvironmentValues {
    var theme: Theme {
        get { self[ThemeKey.self] }
        set { self[ThemeKey.self] = newValue }
    }
}

3. 환경 값 사용하기

이제 정의한 커스텀 환경 값을 사용할 수 있습니다.

struct ThemedView: View {
    @Environment(\.theme) private var theme
    
    var body: some View {
        Text("Current theme: \(themeString)")
            .foregroundColor(themeTextColor)
            .padding()
            .background(themeBackgroundColor)
    }
    
    private var themeString: String {
        switch theme {
        case .light: return "Light"
        case .dark: return "Dark"
        case .system: return "System"
        }
    }
    
    private var themeTextColor: Color {
        switch theme {
        case .light: return .black
        case .dark: return .white
        case .system: return .primary
        }
    }
    
    private var themeBackgroundColor: Color {
        switch theme {
        case .light: return .white
        case .dark: return .black
        case .system: return .background
        }
    }
}

// 사용 예시
struct ContentView: View {
    var body: some View {
        VStack {
            ThemedView()
                .environment(\.theme, .light)
            
            ThemedView()
                .environment(\.theme, .dark)
            
            ThemedView()
                .environment(\.theme, .system)
        }
    }
}
반응형

환경 객체(EnvironmentObject)란 무엇인가?

환경 객체는 관찰 가능한 객체(ObservableObject)를 뷰 계층 구조 전체에 걸쳐 공유하는 방법입니다. 복잡한 애플리케이션 상태나 비즈니스 로직을 여러 뷰에서 접근하고 수정해야 할 때 특히 유용합니다.

환경 객체의 핵심 특징

  • ObservableObject 프로토콜을 준수하는 클래스로 정의됨
  • 변경 사항이 발생하면 의존하는 뷰를 자동으로 업데이트함
  • 뷰 계층 구조 내에서 객체 인스턴스를 명시적으로 전달할 필요가 없음
  • 주로 앱의 전역 상태나 공유 데이터 모델에 사용됨

환경 객체 정의하기

환경 객체는 ObservableObject 프로토콜을 준수하는 클래스로 정의합니다. 이 프로토콜은 변경 사항을 발행하는 objectWillChange 퍼블리셔를 제공합니다.

class UserSettings: ObservableObject {
    @Published var username: String = ""
    @Published var isLoggedIn: Bool = false
    @Published var prefersDarkMode: Bool = false
    
    func login(username: String) {
        self.username = username
        self.isLoggedIn = true
    }
    
    func logout() {
        self.username = ""
        self.isLoggedIn = false
    }
}

여기서 @Published 프로퍼티 래퍼는 프로퍼티가 변경될 때마다 알림을 발행하도록 합니다.

환경 객체 주입하기

환경 객체를 뷰 계층 구조에 주입하려면 .environmentObject() 수정자를 사용합니다.

@main
struct MyApp: App {
    let userSettings = UserSettings()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(userSettings)
        }
    }
}

환경 객체 사용하기

주입된 환경 객체는 @EnvironmentObject 프로퍼티 래퍼를 통해 액세스할 수 있습니다.

struct ContentView: View {
    @EnvironmentObject var userSettings: UserSettings
    
    var body: some View {
        VStack {
            if userSettings.isLoggedIn {
                Text("Welcome, \(userSettings.username)!")
                Button("Logout") {
                    userSettings.logout()
                }
            } else {
                LoginView()
            }
            
            Toggle("Dark Mode", isOn: $userSettings.prefersDarkMode)
                .padding()
        }
        .padding()
    }
}

struct LoginView: View {
    @EnvironmentObject var userSettings: UserSettings
    @State private var usernameInput: String = ""
    
    var body: some View {
        VStack {
            TextField("Username", text: $usernameInput)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding()
            
            Button("Login") {
                userSettings.login(username: usernameInput)
            }
            .disabled(usernameInput.isEmpty)
        }
    }
}

위 예제에서 ContentView와 LoginView는 모두 동일한 UserSettings 인스턴스에 접근하여 상태를 공유합니다. LoginView에서 로그인을 수행하면 ContentView가 자동으로 업데이트됩니다.

환경 변수 vs. 환경 객체: 언제 무엇을 사용해야 할까?

환경 변수와 환경 객체는 모두 데이터를 뷰 계층 구조 전체에 전파하는 메커니즘을 제공하지만 사용 사례가 다릅니다.

환경 변수를 사용해야 할 때

  • 정적이거나 자주 변경되지 않는 설정이나 환경설정을 전달할 때
  • 테마나 색상 구성표와 같은 UI 관련 정보를 제공할 때
  • 뷰 계층 구조의 특정 부분에만 특정 설정을 적용하고 싶을 때
  • 뷰의 동작을 구성하거나 사용자 정의하기 위한 간단한 값이 필요할 때

환경 객체를 사용해야 할 때

  • 복잡한 상태나 비즈니스 로직을 여러 뷰에서 공유해야 할 때
  • 자주 변경되는 데이터를 처리할 때
  • 앱의 전역 상태나 사용자 세션 정보를 관리할 때
  • 여러 뷰에 영향을 미치는 변경 가능한 데이터 모델이 필요할 때

실제 활용 예제: 장바구니 기능 구현하기

이제 환경 객체를 사용하여 간단한 쇼핑 앱의 장바구니 기능을 구현하는 예제를 살펴보겠습니다.

1. 데이터 모델 정의하기

struct Product: Identifiable {
    let id = UUID()
    let name: String
    let price: Double
}

class CartManager: ObservableObject {
    @Published var items: [Product] = []
    
    var totalPrice: Double {
        items.reduce(0) { $0 + $1.price }
    }
    
    func addToCart(product: Product) {
        items.append(product)
    }
    
    func removeFromCart(productId: UUID) {
        items.removeAll { $0.id == productId }
    }
    
    func clearCart() {
        items.removeAll()
    }
}

2. 환경 객체 주입하기

@main
struct ShoppingApp: App {
    let cartManager = CartManager()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(cartManager)
        }
    }
}

3. 상품 목록 뷰 구현하기

struct ProductListView: View {
    @EnvironmentObject var cart: CartManager
    
    let products = [
        Product(name: "Laptop", price: 1299.99),
        Product(name: "Smartphone", price: 799.99),
        Product(name: "Headphones", price: 199.99),
        Product(name: "Tablet", price: 499.99)
    ]
    
    var body: some View {
        List(products) { product in
            HStack {
                VStack(alignment: .leading) {
                    Text(product.name)
                        .font(.headline)
                    Text("$\(product.price, specifier: "%.2f")")
                        .foregroundColor(.secondary)
                }
                
                Spacer()
                
                Button("Add to Cart") {
                    cart.addToCart(product: product)
                }
                .buttonStyle(.bordered)
            }
            .padding(.vertical, 4)
        }
        .navigationTitle("Products")
    }
}

4. 장바구니 뷰 구현하기

struct CartView: View {
    @EnvironmentObject var cart: CartManager
    
    var body: some View {
        VStack {
            if cart.items.isEmpty {
                Spacer()
                Text("Your cart is empty")
                    .font(.headline)
                    .foregroundColor(.secondary)
                Spacer()
            } else {
                List {
                    ForEach(cart.items) { item in
                        HStack {
                            Text(item.name)
                            Spacer()
                            Text("$\(item.price, specifier: "%.2f")")
                            
                            Button(action: {
                                cart.removeFromCart(productId: item.id)
                            }) {
                                Image(systemName: "trash")
                                    .foregroundColor(.red)
                            }
                        }
                    }
                }
                
                VStack {
                    HStack {
                        Text("Total:")
                            .font(.headline)
                        Spacer()
                        Text("$\(cart.totalPrice, specifier: "%.2f")")
                            .font(.headline)
                    }
                    
                    Button("Checkout") {
                        // 결제 처리 로직
                        cart.clearCart()
                    }
                    .buttonStyle(.borderedProminent)
                    .padding(.top)
                }
                .padding()
            }
        }
        .navigationTitle("Shopping Cart")
        .toolbar {
            Button("Clear Cart") {
                cart.clearCart()
            }
            .disabled(cart.items.isEmpty)
        }
    }
}

5. 메인 뷰 구현하기

struct ContentView: View {
    @EnvironmentObject var cart: CartManager
    
    var body: some View {
        NavigationView {
            ProductListView()
                .toolbar {
                    NavigationLink(destination: CartView()) {
                        HStack {
                            Image(systemName: "cart")
                            Text("\(cart.items.count)")
                        }
                    }
                }
        }
    }
}

이 예제에서는 CartManager 환경 객체를 통해 상품 목록 뷰와 장바구니 뷰 간에 장바구니 상태를 공유합니다. 사용자가 상품을 장바구니에 추가하면 장바구니 아이콘의 카운터와 장바구니 뷰가 자동으로 업데이트됩니다.

 

환경 변수와 환경 객체 사용 시 주의 사항

1. 성능 고려 사항

  • 환경 변수나 객체를 너무 많이 사용하면 앱의 성능에 영향을 줄 수 있습니다.
  • 특히 환경 객체를 사용할 때는 객체가 너무 많은 책임을 가지지 않도록 주의해야 합니다.
  • 모든 것을 환경을 통해 전달하기보다는 필요한 경우에만 사용하는 것이 좋습니다.

2. 디버깅 어려움

  • 환경을 통한 값 전달은 종종 "마법 같은" 느낌이 들 수 있으며, 디버깅이 어려울 수 있습니다.
  • 오류가 발생했을 때 어디서 데이터가 변경되었는지 추적하기 어려울 수 있습니다.

3. 의존성 주입 관리

  • 환경 객체를 사용할 때 항상 객체가 주입되었는지 확인해야 합니다. 그렇지 않으면 런타임 오류가 발생합니다.
  • 테스트 시 환경 객체의 모의(mock) 버전을 제공하는 것을 고려해야 합니다.
// 환경 객체가 주입되지 않은 경우 런타임 오류가 발생합니다.
struct ExampleView: View {
    @EnvironmentObject var settings: UserSettings // 주입 필요!
    
    var body: some View {
        Text("Username: \(settings.username)")
    }
}

4. 적절한 도구 선택하기

  • 항상 상황에 맞는 적절한 도구를 선택하세요.
  • 단순히 부모에서 자식으로 데이터를 전달하는 경우, 직접 프로퍼티를 전달하는 것이 더 명확할 수 있습니다.
  • 복잡한 상태 관리가 필요한 경우, SwiftUI의 내장 솔루션 외에도 Redux, Composable Architecture와 같은 상태 관리 라이브러리를 고려할 수 있습니다.

결론

SwiftUI의 환경 변수와 환경 객체는 뷰 간에 데이터를 효율적으로 공유하는 강력한 메커니즘을 제공합니다. 환경 변수는 정적이거나 UI 관련 구성에 적합하며, 환경 객체는 복잡하고 변경 가능한 상태 관리에 적합합니다. 두 도구 모두 적절하게 사용하면 코드의 가독성과 유지보수성을 크게 향상시킬 수 있습니다.

이 글에서 살펴본 예제와 패턴을 통해 SwiftUI 앱에서 복잡한 데이터 흐름을 효과적으로 관리하는 방법을 이해하셨기를 바랍니다. 상황에 맞는 적절한 도구를 선택하고, 앱의 아키텍처를 설계할 때 데이터 흐름을 신중하게 고려하면 더 나은 사용자 경험과 개발자 경험을 제공할 수 있습니다.

자주 묻는 질문 (FAQ)

환경 객체가 주입되지 않았을 때 "No observable object of type X was found" 오류를 방지하는 방법은?

개발 중에는 @EnvironmentObject 프로퍼티를 사용하는 모든 뷰에 해당 객체가 주입되도록 해야 합니다. 프리뷰에서 테스트할 때는 다음과 같이 .environmentObject() 수정자를 사용하세요:

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
            .environmentObject(UserSettings())  // 미리보기에 환경 객체 주입
    }
}

환경 변수와 @State 또는 @Binding의 차이점은 무엇인가요?

  • @State는 단일 뷰 내에서 로컬 상태를 관리하는 데 사용됩니다.
  • @Binding은 상위 뷰에서 관리하는 상태를 하위 뷰와 공유하는 데 사용됩니다.
  • 환경 변수는 뷰 계층 구조 전체에 걸쳐 값을 공유하는 데 사용되며, 명시적인 프로퍼티 전달 없이도 깊이 중첩된 뷰에서 접근할 수 있습니다.

환경 객체는 싱글톤 패턴과 어떻게 다른가요?

환경 객체와 싱글톤 패턴은 모두 공유 상태를 제공하지만 중요한 차이가 있습니다:

  • 싱글톤은 전역 접근이 가능하고 앱 전체에 하나의 인스턴스만 존재합니다.
  • 환경 객체는 뷰 계층 구조 내에서만 공유되고, 여러 인스턴스를 가질 수 있으며, 테스트하기 더 쉽습니다.
  • 환경 객체는 SwiftUI의 선언적 패러다임에 더 잘 맞으며, 변경 사항에 따라 UI를 자동으로 업데이트합니다.
반응형