SwiftUI + WeaveDI 완전 정복 - EnvironmentObject vs @Inject

Ios_Roy·2025년 9월 17일

라이브러리 개발

목록 보기
7/25
post-thumbnail

한 줄 요약: SwiftUI의 선언적 특성과 DiContainer의 타입 안전성이 만나면, 더 깔끔하고 테스트 가능한 앱이 완성됩니다.

안녕하세요! iOS 개발자 Roy입니다. SwiftUI와 의존성 주입을 함께 사용할 때 많은 개발자들이 고민하는 지점이 있습니다: "EnvironmentObject를 쓸까, @Inject를 쓸까?" 오늘은 이 질문에 대한 명확한 답과 함께 실전 활용법을 공유해드리겠습니다.

🤔 EnvironmentObject vs @Inject - 언제 무엇을 써야 할까?

EnvironmentObject의 특징과 한계

// ✅ EnvironmentObject - SwiftUI 네이티브 방식
class UserViewModel: ObservableObject {
    @Published var user: User?
    @Published var isLoading = false
}

struct ContentView: View {
    @EnvironmentObject var userViewModel: UserViewModel
    
    var body: some View {
        if userViewModel.isLoading {
            ProgressView()
        } else {
            UserProfileView()
        }
    }
}

// App.swift에서 주입
WindowGroup {
    ContentView()
        .environmentObject(UserViewModel()) // 🤔 여기서 의존성은?
}

EnvironmentObject의 한계:

  • 의존성 주입이 View 레벨에서 수동으로 이뤄짐
  • 중첩된 의존성 관리가 복잡함
  • 테스트에서 Mock 설정이 번거로움
  • 런타임에 환경 객체가 없으면 크래시

@Inject의 장점과 활용

// ✅ @Inject - DiContainer 방식
class UserViewModel: ObservableObject {
    @Inject private var userService: UserServiceProtocol
    @Inject private var analytics: AnalyticsProtocol
    
    @Published var user: User?
    @Published var isLoading = false
    
    func loadUser() async {
        isLoading = true
        defer { isLoading = false }
        
        do {
            user = try await userService.getCurrentUser()
            await analytics.track("user_loaded")
        } catch {
            // 에러 처리
        }
    }
}

struct ContentView: View {
    @StateObject private var viewModel = UserViewModel()
    
    var body: some View {
        if viewModel.isLoading {
            ProgressView()
        } else {
            UserProfileView()
        }
    }
}

🎯 언제 무엇을 사용할까? - 실전 가이드

1. EnvironmentObject를 사용해야 하는 경우

// ✅ 사용 권장: UI 상태나 테마처럼 View 계층 전반에 걸쳐 공유되는 경우
class ThemeManager: ObservableObject {
    @Published var isDarkMode = false
    @Published var primaryColor = Color.blue
}

class NavigationState: ObservableObject {
    @Published var selectedTab = 0
    @Published var navigationPath = NavigationPath()
}

struct MyApp: App {
    @StateObject private var theme = ThemeManager()
    @StateObject private var navigation = NavigationState()
    
    var body: some Scene {
        WindowGroup {
            MainTabView()
                .environmentObject(theme)
                .environmentObject(navigation)
        }
    }
}

2. @Inject를 사용해야 하는 경우

// ✅ 사용 권장: 비즈니스 로직, 서비스, 데이터 액세스
class ProductListViewModel: ObservableObject {
    @Inject private var productService: ProductServiceProtocol
    @Inject private var cartManager: CartManagerProtocol
    @Inject private var analytics: AnalyticsProtocol
    
    @Published var products: [Product] = []
    @Published var isLoading = false
    
    func loadProducts() async {
        await analytics.track("product_list_viewed")
        // 로직...
    }
}

🏗️ DiContainer + SwiftUI 통합 패턴

1. PropertyWrapper 기반 SwiftUI 통합

@propertyWrapper
struct InjectedStateObject<T: ObservableObject>: DynamicProperty {
    @StateObject private var object: T
    
    var wrappedValue: T {
        object
    }
    
    var projectedValue: ObservedObject<T>.Wrapper {
        $object
    }
    
    init() {
        let resolved = UnifiedDI.resolve(T.self) ?? fatalError("Cannot resolve \(T.self)")
        _object = StateObject(wrappedValue: resolved)
    }
}

// 사용법
struct ProductListView: View {
    @InjectedStateObject var viewModel: ProductListViewModel
    
    var body: some View {
        List(viewModel.products, id: \.id) { product in
            ProductRow(product: product)
        }
        .task { await viewModel.loadProducts() }
    }
}

2. Preview에서의 Mock 데이터 처리

// Mock 서비스 정의
class MockProductService: ProductServiceProtocol {
    func getProducts() async throws -> [Product] {
        [
            Product(id: "1", name: "iPhone 15", price: 999),
            Product(id: "2", name: "MacBook Pro", price: 1999)
        ]
    }
}

class MockCartManager: CartManagerProtocol {
    private(set) var items: [CartItem] = []
    
    func addToCart(_ product: Product) {
        items.append(CartItem(product: product))
    }
}

// Preview용 컨테이너 설정
extension DependencyContainer {
    static var preview: DependencyContainer {
        let container = DependencyContainer()
        
        // Mock 서비스 등록
        Task {
            await container.register(ProductServiceProtocol.self) {
                MockProductService()
            }
            await container.register(CartManagerProtocol.self) {
                MockCartManager()
            }
        }
        
        return container
    }
}

// Preview에서 사용
struct ProductListView_Previews: PreviewProvider {
    static var previews: some View {
        ProductListView()
            .environment(\.diContainer, .preview) // 커스텀 환경값
            .previewDisplayName("Products")
    }
}

3. 환경값(Environment Values)과의 통합

// 커스텀 환경 키 정의
private struct DIContainerKey: EnvironmentKey {
    static let defaultValue = DependencyContainer.live
}

extension EnvironmentValues {
    var diContainer: DependencyContainer {
        get { self[DIContainerKey.self] }
        set { self[DIContainerKey.self] = newValue }
    }
}

// 환경 인식 Property Wrapper
@propertyWrapper
struct EnvironmentInject<T>: DynamicProperty {
    @Environment(\.diContainer) private var container
    @State private var value: T?
    
    var wrappedValue: T {
        if let value = value {
            return value
        }
        
        let resolved = container.resolve(T.self)
        DispatchQueue.main.async {
            self.value = resolved
        }
        
        return resolved ?? fatalError("Cannot resolve \(T.self)")
    }
    
    init() {}
}

🧪 SwiftUI 생명주기와 DI 통합

1. View의 생명주기에 맞춘 의존성 관리

class ViewLifecycleAwareViewModel: ObservableObject {
    @Inject private var locationService: LocationServiceProtocol
    @Inject private var analytics: AnalyticsProtocol
    
    @Published var currentLocation: CLLocation?
    
    func onAppear() async {
        await analytics.track("view_appeared")
        await startLocationUpdates()
    }
    
    func onDisappear() async {
        await analytics.track("view_disappeared") 
        await stopLocationUpdates()
    }
    
    private func startLocationUpdates() async {
        // 위치 서비스 시작
        for await location in locationService.locationUpdates {
            await MainActor.run {
                currentLocation = location
            }
        }
    }
    
    private func stopLocationUpdates() async {
        await locationService.stopUpdates()
    }
}

struct LocationView: View {
    @InjectedStateObject var viewModel: ViewLifecycleAwareViewModel
    
    var body: some View {
        VStack {
            if let location = viewModel.currentLocation {
                Text("위치: \(location.coordinate.latitude), \(location.coordinate.longitude)")
            } else {
                Text("위치 로딩 중...")
            }
        }
        .task { await viewModel.onAppear() }
        .onDisappear { 
            Task { await viewModel.onDisappear() }
        }
    }
}

2. NavigationStack과 의존성 스코프

// Navigation 스코프 관리
class NavigationScopeManager: ObservableObject {
    @Published var navigationPath = NavigationPath()
    private var scopeStack: [String] = []
    
    func push<T: View>(_ view: T, scopeId: String? = nil) {
        if let scopeId = scopeId {
            // 새로운 스코프 생성
            Task {
                await DependencyContainer.live.beginScope(scopeId)
                scopeStack.append(scopeId)
            }
        }
        navigationPath.append(view)
    }
    
    func pop() {
        navigationPath.removeLast()
        
        // 스코프가 있다면 정리
        if !scopeStack.isEmpty {
            let lastScope = scopeStack.removeLast()
            Task {
                await DependencyContainer.live.endScope(lastScope)
            }
        }
    }
}

struct NavigationRootView: View {
    @StateObject private var navigationManager = NavigationScopeManager()
    
    var body: some View {
        NavigationStack(path: $navigationManager.navigationPath) {
            HomeView()
                .navigationDestination(for: String.self) { destination in
                    switch destination {
                    case "profile":
                        ProfileView()
                    case "settings":
                        SettingsView()
                    default:
                        EmptyView()
                    }
                }
        }
        .environmentObject(navigationManager)
    }
}

🎨 State 관리와 DI의 조화

1. 복합 상태 관리 패턴

// 전역 상태 + 로컬 상태 + 의존성 주입
class AppState: ObservableObject {
    @Published var user: User?
    @Published var isAuthenticated = false
}

class UserProfileViewModel: ObservableObject {
    @Inject private var userService: UserServiceProtocol
    @Inject private var imageService: ImageServiceProtocol
    
    // 전역 상태 참조
    @ObservedObject private var appState = AppState.shared
    
    // 로컬 상태
    @Published var isEditing = false
    @Published var profileImage: UIImage?
    
    var user: User? { appState.user }
    var isAuthenticated: Bool { appState.isAuthenticated }
    
    func updateProfile(_ updates: UserProfileUpdate) async {
        guard let currentUser = user else { return }
        
        do {
            let updatedUser = try await userService.updateProfile(
                for: currentUser.id,
                updates: updates
            )
            
            await MainActor.run {
                appState.user = updatedUser
                isEditing = false
            }
        } catch {
            // 에러 처리
        }
    }
}

2. 조건부 의존성 주입

@propertyWrapper
struct ConditionalInject<T> {
    private let condition: () -> Bool
    private let fallback: () -> T
    
    var wrappedValue: T {
        if condition() {
            return UnifiedDI.resolve(T.self) ?? fallback()
        } else {
            return fallback()
        }
    }
    
    init(
        condition: @escaping () -> Bool,
        fallback: @escaping () -> T
    ) {
        self.condition = condition
        self.fallback = fallback
    }
}

class FeatureFlagViewModel: ObservableObject {
    @ConditionalInject(
        condition: { FeatureFlag.isNewCheckoutEnabled },
        fallback: { LegacyCheckoutService() }
    )
    var checkoutService: CheckoutServiceProtocol
    
    @Published var isNewCheckoutFlow = false
    
    func initiateCheckout() async {
        isNewCheckoutFlow = FeatureFlag.isNewCheckoutEnabled
        await checkoutService.startCheckout()
    }
}

🧪 테스트 전략

1. SwiftUI View 테스트

// ViewInspector를 활용한 통합 테스트
import ViewInspector

class ProductListViewTests: XCTestCase {
    
    override func setUp() async throws {
        await DependencyContainer.resetForTesting()
        
        await DependencyContainer.bootstrap { container in
            container.register(ProductServiceProtocol.self) {
                MockProductService(products: Self.mockProducts)
            }
        }
    }
    
    func testProductListLoadsSuccessfully() async throws {
        let view = ProductListView()
        
        // 초기 로딩 상태 확인
        let loadingView = try view.inspect().find(ProgressView.self)
        XCTAssertNotNil(loadingView)
        
        // 데이터 로딩 대기
        try await Task.sleep(nanoseconds: 100_000_000)
        
        // 제품 리스트 확인
        let list = try view.inspect().find(ViewType.List.self)
        XCTAssertEqual(try list.count(), 2)
    }
    
    static let mockProducts = [
        Product(id: "1", name: "Test Product 1", price: 10.0),
        Product(id: "2", name: "Test Product 2", price: 20.0)
    ]
}

2. ViewModel 단위 테스트

class ProductListViewModelTests: XCTestCase {
    var viewModel: ProductListViewModel!
    var mockProductService: MockProductService!
    
    @MainActor
    override func setUp() async throws {
        await DependencyContainer.resetForTesting()
        
        mockProductService = MockProductService()
        
        await DependencyContainer.bootstrap { container in
            container.register(ProductServiceProtocol.self) {
                self.mockProductService
            }
        }
        
        viewModel = ProductListViewModel()
    }
    
    @MainActor
    func testLoadProductsSuccess() async throws {
        // Given
        let expectedProducts = [
            Product(id: "1", name: "Product 1", price: 100)
        ]
        mockProductService.mockProducts = expectedProducts
        
        // When
        await viewModel.loadProducts()
        
        // Then
        XCTAssertEqual(viewModel.products.count, 1)
        XCTAssertEqual(viewModel.products.first?.name, "Product 1")
        XCTAssertFalse(viewModel.isLoading)
    }
}

🎯 성능 최적화

1. View 업데이트 최적화

class OptimizedViewModel: ObservableObject {
    @Inject private var dataService: DataServiceProtocol
    
    // 불필요한 뷰 업데이트 방지
    private var _data: [DataModel] = []
    @Published private(set) var displayData: [DataModel] = []
    
    private var lastUpdateTime: Date = .distantPast
    private let updateThreshold: TimeInterval = 0.5 // 500ms
    
    func updateData(_ newData: [DataModel]) {
        _data = newData
        
        let now = Date()
        if now.timeIntervalSince(lastUpdateTime) >= updateThreshold {
            displayData = _data
            lastUpdateTime = now
        } else {
            // 디바운스를 통한 업데이트 지연
            DispatchQueue.main.asyncAfter(deadline: .now() + updateThreshold) {
                if now == self.lastUpdateTime { // 가장 최신 요청만 처리
                    self.displayData = self._data
                }
            }
        }
    }
}

2. 메모리 효율적인 큰 리스트 처리

class LazyLoadingViewModel: ObservableObject {
    @Inject private var dataService: DataServiceProtocol
    
    @Published var items: [ListItem] = []
    @Published var isLoadingMore = false
    
    private var currentPage = 0
    private let pageSize = 20
    
    func loadNextPageIfNeeded(currentItem: ListItem) async {
        guard let index = items.firstIndex(where: { $0.id == currentItem.id }),
              index == items.count - 5, // 끝에서 5개 전에 로드 시작
              !isLoadingMore else { return }
        
        await loadMoreItems()
    }
    
    private func loadMoreItems() async {
        await MainActor.run { isLoadingMore = true }
        
        do {
            let newItems = try await dataService.getItems(
                page: currentPage + 1,
                size: pageSize
            )
            
            await MainActor.run {
                items.append(contentsOf: newItems)
                currentPage += 1
                isLoadingMore = false
            }
        } catch {
            await MainActor.run { isLoadingMore = false }
        }
    }
}

🛠️ 실전 활용 예제

완전한 앱 구조 예제

// 1. 메인 앱 구조
@main
struct ShoppingApp: App {
    init() {
        Task {
            await setupDependencies()
        }
    }
    
    var body: some Scene {
        WindowGroup {
            RootView()
        }
    }
    
    private func setupDependencies() async {
        await DependencyContainer.bootstrap { container in
            // 서비스 레이어
            container.register(ProductServiceProtocol.self) {
                ProductService()
            }
            
            container.register(CartServiceProtocol.self, scope: .singleton) {
                CartService()
            }
            
            // 뷰모델 팩토리
            container.register(ProductListViewModelFactory.self) {
                ProductListViewModelFactory()
            }
        }
    }
}

// 2. 루트 뷰
struct RootView: View {
    @StateObject private var appState = AppState()
    
    var body: some View {
        Group {
            if appState.isAuthenticated {
                MainTabView()
            } else {
                LoginView()
            }
        }
        .environmentObject(appState)
    }
}

// 3. 메인 탭 뷰
struct MainTabView: View {
    var body: some View {
        TabView {
            ProductListView()
                .tabItem {
                    Label("Products", systemImage: "list.bullet")
                }
            
            CartView()
                .tabItem {
                    Label("Cart", systemImage: "cart")
                }
            
            ProfileView()
                .tabItem {
                    Label("Profile", systemImage: "person")
                }
        }
    }
}

🎉 결론 및 베스트 프랙티스

언제 어떤 것을 사용할지 결정하는 체크리스트

EnvironmentObject 사용 시기:

  • ✅ UI 상태 (테마, 네비게이션)
  • ✅ 앱 전역 상태
  • ✅ SwiftUI 생명주기와 밀접한 관련

@Inject 사용 시기:

  • ✅ 비즈니스 로직 서비스
  • ✅ 외부 API 통신
  • ✅ 데이터베이스 접근
  • ✅ 복잡한 의존성 그래프

핵심 원칙

  1. 관심사의 분리: UI는 EnvironmentObject, 비즈니스 로직은 @Inject
  2. 테스트 용이성: Mock 처리가 쉬운 @Inject 활용
  3. 성능 고려: 불필요한 재렌더링 방지
  4. 타입 안전성: 컴파일 타임 검증 우선

SwiftUI와 DiContainer의 조합으로 더 깔끔하고 유지보수하기 쉬운 앱을 만들어보세요! 🚀


다음 포스트 예고: DiContainer vs Swinject vs Factory - 성능 벤치마크와 마이그레이션 가이드

질문이나 피드백은 언제든 GitHub에 남겨주세요!

profile
iOS 개발자 공부하는 Roy

0개의 댓글