한 줄 요약: SwiftUI의 선언적 특성과 DiContainer의 타입 안전성이 만나면, 더 깔끔하고 테스트 가능한 앱이 완성됩니다.
안녕하세요! iOS 개발자 Roy입니다. SwiftUI와 의존성 주입을 함께 사용할 때 많은 개발자들이 고민하는 지점이 있습니다: "EnvironmentObject를 쓸까, @Inject를 쓸까?" 오늘은 이 질문에 대한 명확한 답과 함께 실전 활용법을 공유해드리겠습니다.
// ✅ 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의 한계:
// ✅ @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()
}
}
}
// ✅ 사용 권장: 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)
}
}
}
// ✅ 사용 권장: 비즈니스 로직, 서비스, 데이터 액세스
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")
// 로직...
}
}
@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() }
}
}
// 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")
}
}
// 커스텀 환경 키 정의
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() {}
}
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() }
}
}
}
// 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)
}
}
// 전역 상태 + 로컬 상태 + 의존성 주입
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 {
// 에러 처리
}
}
}
@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()
}
}
// 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)
]
}
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)
}
}
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
}
}
}
}
}
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 사용 시기:
@Inject 사용 시기:
SwiftUI와 DiContainer의 조합으로 더 깔끔하고 유지보수하기 쉬운 앱을 만들어보세요! 🚀
다음 포스트 예고: DiContainer vs Swinject vs Factory - 성능 벤치마크와 마이그레이션 가이드
질문이나 피드백은 언제든 GitHub에 남겨주세요!