WeaveDI : DiContainer 모듈화 전략 - 깔끔한 프로젝트 구조 만들기

Ios_Roy·2025년 9월 17일

라이브러리 개발

목록 보기
9/25
post-thumbnail

한 줄 요약: 개인 프로젝트부터 팀 프로젝트까지, DiContainer로 확장 가능한 깔끔한 구조를 만들어보세요.

안녕하세요! iOS 개발자 Roy입니다. 프로젝트가 커질수록 코드가 복잡해지고 관리하기 어려워지죠. 오늘은 DiContainer를 활용해서 처음부터 깔끔하게 구조화된 프로젝트를 만드는 방법을 공유해드리겠습니다. 개인 프로젝트든 팀 프로젝트든 상관없이 바로 적용할 수 있는 실용적인 가이드입니다!

🏗️ 왜 모듈화가 필요한가?

일반적인 iOS 프로젝트의 문제점

// ❌ 전형적인 문제: 모든 것이 하나에 섞여있음
class ViewController: UIViewController {
    // 네트워킹
    private func fetchUserData() {
        URLSession.shared.dataTask(with: URL(string: "https://api.com/user")!) { data, response, error in
            // JSON 파싱
            let user = try? JSONDecoder().decode(User.self, from: data!)
            
            // 데이터베이스 저장
            CoreDataManager.shared.save(user)
            
            // 분석 이벤트 전송
            Analytics.track("user_loaded")
            
            // UI 업데이트
            DispatchQueue.main.async {
                self.nameLabel.text = user?.name
            }
        }.resume()
    }
}

이런 코드의 문제점:

  • 테스트하기 어려움 (Mock 만들기 복잡)
  • 재사용 불가능
  • 코드 변경 시 여러 곳에 영향
  • 새로운 기능 추가할 때마다 복잡도 증가

🎯 DiContainer 기반 모듈화 전략

1. 기본 모듈 구조 설계

// 프로젝트 구조 예시
MyApp/
├── App/
│   ├── AppDelegate.swift
│   ├── SceneDelegate.swift
│   └── DIContainer+Bootstrap.swift
├── Core/
│   ├── Network/
│   ├── Database/
│   └── Analytics/
├── Features/
│   ├── UserProfile/
│   ├── ProductList/
│   └── Cart/
└── Shared/
    ├── Models/
    ├── Extensions/
    └── Utils/

2. 의존성 계층 설계

// ✅ 계층별 의존성 분리
protocol NetworkServiceProtocol {
    func fetchUser(id: String) async throws -> User
}

protocol DatabaseServiceProtocol {
    func save<T: Codable>(_ object: T) async throws
    func load<T: Codable>(_ type: T.Type, id: String) async throws -> T?
}

protocol AnalyticsServiceProtocol {
    func track(_ event: String, properties: [String: Any]?)
}

// Core 모듈: 구체적인 구현
class NetworkService: NetworkServiceProtocol {
    func fetchUser(id: String) async throws -> User {
        // 실제 네트워크 구현
    }
}

class DatabaseService: DatabaseServiceProtocol {
    func save<T: Codable>(_ object: T) async throws {
        // Core Data 또는 다른 DB 구현
    }
    
    func load<T: Codable>(_ type: T.Type, id: String) async throws -> T? {
        // 데이터 로드 구현
    }
}

class AnalyticsService: AnalyticsServiceProtocol {
    func track(_ event: String, properties: [String: Any]? = nil) {
        // Firebase, Mixpanel 등 구현
    }
}

3. Feature 모듈 분리

// ✅ UserProfile 모듈
class UserProfileViewModel: ObservableObject {
    @Inject private var networkService: NetworkServiceProtocol
    @Inject private var databaseService: DatabaseServiceProtocol
    @Inject private var analytics: AnalyticsServiceProtocol
    
    @Published var user: User?
    @Published var isLoading = false
    
    func loadUser(id: String) async {
        await MainActor.run { isLoading = true }
        defer { Task { await MainActor.run { isLoading = false } } }
        
        do {
            // 1. 캐시에서 먼저 확인
            if let cachedUser = try? await databaseService.load(User.self, id: id) {
                await MainActor.run { user = cachedUser }
            }
            
            // 2. 네트워크에서 최신 데이터 가져오기
            let freshUser = try await networkService.fetchUser(id: id)
            
            // 3. 캐시 업데이트
            try await databaseService.save(freshUser)
            
            // 4. UI 업데이트
            await MainActor.run { user = freshUser }
            
            // 5. 분석 이벤트
            await analytics.track("user_profile_loaded", properties: ["user_id": id])
            
        } catch {
            // 에러 처리
            await analytics.track("user_profile_load_error", properties: ["error": error.localizedDescription])
        }
    }
}

struct UserProfileView: View {
    @StateObject private var viewModel = UserProfileViewModel()
    let userId: String
    
    var body: some View {
        VStack {
            if viewModel.isLoading {
                ProgressView("사용자 정보 로딩 중...")
            } else if let user = viewModel.user {
                VStack(alignment: .leading, spacing: 16) {
                    AsyncImage(url: user.profileImageURL) { image in
                        image
                            .resizable()
                            .aspectRatio(contentMode: .fill)
                    } placeholder: {
                        Circle()
                            .fill(Color.gray.opacity(0.3))
                    }
                    .frame(width: 100, height: 100)
                    .clipShape(Circle())
                    
                    Text(user.name)
                        .font(.title)
                        .fontWeight(.bold)
                    
                    Text(user.email)
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                }
            } else {
                Text("사용자 정보를 불러올 수 없습니다")
                    .foregroundColor(.red)
            }
        }
        .task {
            await viewModel.loadUser(id: userId)
        }
    }
}

🔧 DI 컨테이너 설정 및 부트스트랩

1. 환경별 설정 관리

enum Environment {
    case development
    case staging  
    case production
    
    static var current: Environment {
        #if DEBUG
        return .development
        #elseif STAGING
        return .staging
        #else
        return .production
        #endif
    }
    
    var baseURL: String {
        switch self {
        case .development: return "https://dev-api.myapp.com"
        case .staging: return "https://staging-api.myapp.com"
        case .production: return "https://api.myapp.com"
        }
    }
    
    var enableAnalytics: Bool {
        switch self {
        case .development: return false
        case .staging: return false
        case .production: return true
        }
    }
}

// 환경별 서비스 구현
class DevelopmentAnalytics: AnalyticsServiceProtocol {
    func track(_ event: String, properties: [String: Any]? = nil) {
        print("🔍 [Analytics] \(event): \(properties ?? [:])")
    }
}

class ProductionAnalytics: AnalyticsServiceProtocol {
    func track(_ event: String, properties: [String: Any]? = nil) {
        // Firebase Analytics 등 실제 구현
        Analytics.logEvent(event, parameters: properties)
    }
}

2. 모듈별 DI 설정

// DI 컨테이너 부트스트랩
extension DependencyContainer {
    static func bootstrapApp() async {
        await DependencyContainer.bootstrap { container in
            // Core Services
            await setupCoreServices(container)
            
            // Feature Services  
            await setupFeatureServices(container)
            
            // Development Tools (디버그 빌드에만)
            #if DEBUG
            await setupDevelopmentTools(container)
            #endif
        }
    }
    
    private static func setupCoreServices(_ container: DependencyContainer) async {
        // 네트워크 서비스
        container.register(NetworkServiceProtocol.self, scope: .singleton) {
            NetworkService(baseURL: Environment.current.baseURL)
        }
        
        // 데이터베이스 서비스
        container.register(DatabaseServiceProtocol.self, scope: .singleton) {
            DatabaseService()
        }
        
        // 분석 서비스 (환경별 다른 구현)
        container.register(AnalyticsServiceProtocol.self, scope: .singleton) {
            if Environment.current.enableAnalytics {
                return ProductionAnalytics()
            } else {
                return DevelopmentAnalytics()
            }
        }
    }
    
    private static func setupFeatureServices(_ container: DependencyContainer) async {
        // UserProfile 관련 서비스
        container.register(UserServiceProtocol.self) {
            UserService()
        }
        
        // Product 관련 서비스
        container.register(ProductServiceProtocol.self) {
            ProductService()
        }
        
        // Cart 관련 서비스
        container.register(CartServiceProtocol.self, scope: .scoped("user_session")) {
            CartService()
        }
    }
    
    #if DEBUG
    private static func setupDevelopmentTools(_ container: DependencyContainer) async {
        // 개발용 도구들
        container.register(NetworkLoggerProtocol.self) {
            ConsoleNetworkLogger()
        }
        
        container.register(MemoryMonitorProtocol.self) {
            MemoryMonitor()
        }
    }
    #endif
}

3. 앱 시작 시 초기화

// AppDelegate.swift
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        
        // DI 컨테이너 초기화 (비동기)
        Task {
            await DependencyContainer.bootstrapApp()
            print("✅ DI Container 초기화 완료")
        }
        
        return true
    }
}

// SwiftUI App
@main
struct MyApp: App {
    init() {
        // DI 컨테이너 초기화
        Task {
            await DependencyContainer.bootstrapApp()
        }
    }
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

🧪 테스트 친화적 구조

1. Mock 서비스 생성

// Mock 구현들
class MockNetworkService: NetworkServiceProtocol {
    var mockUsers: [String: User] = [:]
    var shouldFailWithError: Error?
    
    func fetchUser(id: String) async throws -> User {
        if let error = shouldFailWithError {
            throw error
        }
        
        return mockUsers[id] ?? User(id: id, name: "Mock User", email: "mock@test.com")
    }
}

class MockDatabaseService: DatabaseServiceProtocol {
    private var storage: [String: Any] = [:]
    
    func save<T: Codable>(_ object: T) async throws {
        let key = String(describing: T.self)
        storage[key] = object
    }
    
    func load<T: Codable>(_ type: T.Type, id: String) async throws -> T? {
        let key = String(describing: type)
        return storage[key] as? T
    }
}

class MockAnalyticsService: AnalyticsServiceProtocol {
    var trackedEvents: [(event: String, properties: [String: Any]?)] = []
    
    func track(_ event: String, properties: [String: Any]? = nil) {
        trackedEvents.append((event: event, properties: properties))
    }
}

2. 테스트용 컨테이너 설정

extension DependencyContainer {
    static func setupForTesting() async {
        await DependencyContainer.resetForTesting()
        
        await DependencyContainer.bootstrap { container in
            // Mock 서비스들 등록
            container.register(NetworkServiceProtocol.self) {
                MockNetworkService()
            }
            
            container.register(DatabaseServiceProtocol.self) {
                MockDatabaseService()
            }
            
            container.register(AnalyticsServiceProtocol.self) {
                MockAnalyticsService()
            }
        }
    }
}

// 테스트 예시
class UserProfileViewModelTests: XCTestCase {
    var viewModel: UserProfileViewModel!
    var mockNetworkService: MockNetworkService!
    var mockAnalytics: MockAnalyticsService!
    
    @MainActor
    override func setUp() async throws {
        await DependencyContainer.setupForTesting()
        
        // Mock 서비스 참조 획득
        mockNetworkService = UnifiedDI.resolve(NetworkServiceProtocol.self) as? MockNetworkService
        mockAnalytics = UnifiedDI.resolve(AnalyticsServiceProtocol.self) as? MockAnalyticsService
        
        viewModel = UserProfileViewModel()
    }
    
    @MainActor
    func testLoadUserSuccess() async throws {
        // Given
        let expectedUser = User(id: "123", name: "Test User", email: "test@example.com")
        mockNetworkService.mockUsers["123"] = expectedUser
        
        // When
        await viewModel.loadUser(id: "123")
        
        // Then
        XCTAssertEqual(viewModel.user?.name, "Test User")
        XCTAssertFalse(viewModel.isLoading)
        
        // Analytics 호출 확인
        let trackedEvents = mockAnalytics.trackedEvents
        XCTAssertEqual(trackedEvents.count, 1)
        XCTAssertEqual(trackedEvents.first?.event, "user_profile_loaded")
    }
    
    @MainActor
    func testLoadUserFailure() async throws {
        // Given
        mockNetworkService.shouldFailWithError = URLError(.notConnectedToInternet)
        
        // When
        await viewModel.loadUser(id: "123")
        
        // Then
        XCTAssertNil(viewModel.user)
        XCTAssertFalse(viewModel.isLoading)
        
        // 에러 분석 이벤트 확인
        let errorEvents = mockAnalytics.trackedEvents.filter { $0.event == "user_profile_load_error" }
        XCTAssertEqual(errorEvents.count, 1)
    }
}

🔄 CI/CD 파이프라인 통합

1. GitHub Actions 설정

# .github/workflows/ios-ci.yml
name: iOS CI

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    name: Test
    runs-on: macos-latest
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Set up Xcode
      uses: maxim-lobanov/setup-xcode@v1
      with:
        xcode-version: '15.0'
        
    - name: Cache Swift Package Manager
      uses: actions/cache@v3
      with:
        path: |
          ~/Library/Caches/org.swift.swiftpm/
          .build
        key: ${{ runner.os }}-spm-${{ hashFiles('Package.swift') }}
        
    - name: Build and Test
      run: |
        # 프로젝트 빌드
        xcodebuild -scheme MyApp -configuration Debug build-for-testing
        
        # 테스트 실행 (DI 컨테이너 포함)
        xcodebuild -scheme MyApp -configuration Debug test-without-building \
          -destination 'platform=iOS Simulator,name=iPhone 15,OS=17.0' \
          -resultBundlePath TestResults
          
    - name: Upload Test Results
      uses: actions/upload-artifact@v3
      if: always()
      with:
        name: test-results
        path: TestResults

2. 빌드 설정 분리

// Build Configuration별 설정
#if DEBUG
let DIContainer_LogLevel = "debug"
let DIContainer_EnableMetrics = true
#elseif STAGING
let DIContainer_LogLevel = "info"
let DIContainer_EnableMetrics = true
#else
let DIContainer_LogLevel = "error"
let DIContainer_EnableMetrics = false
#endif

// 빌드 타임에 DI 검증
#if DEBUG
extension DependencyContainer {
    static func validateConfiguration() {
        // 모든 등록된 서비스의 의존성 그래프 검증
        Task {
            let validator = DependencyValidator()
            let results = await validator.validateAll()
            
            for result in results {
                if case .failure(let error) = result {
                    print("⚠️ DI Validation Error: \(error)")
                }
            }
        }
    }
}
#endif

📋 팀 컨벤션 및 코드 리뷰 가이드

1. DI 사용 규칙

// ✅ 좋은 예시
class GoodViewModel: ObservableObject {
    // 1. Protocol 타입으로 의존성 주입
    @Inject private var userService: UserServiceProtocol
    @Inject private var analytics: AnalyticsServiceProtocol
    
    // 2. Published 프로퍼티로 상태 관리
    @Published var users: [User] = []
    @Published var isLoading = false
    
    // 3. async 메서드로 비즈니스 로직 구현
    func loadUsers() async {
        await MainActor.run { isLoading = true }
        defer { Task { await MainActor.run { isLoading = false } } }
        
        do {
            let loadedUsers = try await userService.getAllUsers()
            await MainActor.run { users = loadedUsers }
            await analytics.track("users_loaded", properties: ["count": loadedUsers.count])
        } catch {
            await analytics.track("users_load_failed", properties: ["error": error.localizedDescription])
        }
    }
}

// ❌ 나쁜 예시
class BadViewModel: ObservableObject {
    // 1. 구체 클래스에 직접 의존 (테스트 어려움)
    private let networkManager = NetworkManager()
    private let coreDataManager = CoreDataManager()
    
    // 2. 동기 메서드로 비동기 작업 (블로킹)
    func loadUsers() {
        // 메인 스레드 블로킹...
        let semaphore = DispatchSemaphore(value: 0)
        var result: [User] = []
        
        networkManager.fetchUsers { users in
            result = users
            semaphore.signal()
        }
        semaphore.wait()
        
        self.users = result
    }
}

2. 코드 리뷰 체크리스트

# DI 관련 코드 리뷰 체크리스트

## 의존성 주입
- [ ] Protocol 타입으로 의존성 주입했는가?
- [ ] @Inject 어노테이션을 적절히 사용했는가?
- [ ] 순환 의존성은 없는가?

## 비동기 처리
- [ ] async/await를 적절히 사용했는가?
- [ ] MainActor.run으로 UI 업데이트를 보호했는가?
- [ ] Task나 TaskGroup을 적절히 활용했는가?

## 테스트 가능성
- [ ] Mock 객체를 만들기 쉬운 구조인가?
- [ ] 비즈니스 로직이 UI로부터 분리되어 있는가?

## 성능
- [ ] 불필요한 싱글톤 패턴 사용은 없는가?
- [ ] 메모리 누수 가능성은 없는가?
- [ ] 적절한 스코프를 사용했는가?

## 컨벤션
- [ ] 네이밍 컨벤션을 따랐는가?
- [ ] 에러 처리가 적절한가?
- [ ] 로깅/분석 이벤트가 적절한가?

3. Xcode Template 생성

// File Template: ViewModel.xctemplate
import SwiftUI
import DiContainer

class ___FILEBASENAME___ViewModel: ObservableObject {
    @Inject private var <#service#>: <#ServiceProtocol#>
    @Inject private var analytics: AnalyticsServiceProtocol
    
    @Published var <#data#>: [<#Model#>] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    func <#loadData#>() async {
        await MainActor.run { 
            isLoading = true 
            errorMessage = nil
        }
        defer { Task { await MainActor.run { isLoading = false } } }
        
        do {
            let result = try await <#service#>.<#fetchData#>()
            await MainActor.run { <#data#> = result }
            await analytics.track("<#data_loaded#>", properties: ["count": result.count])
        } catch {
            let message = error.localizedDescription
            await MainActor.run { errorMessage = message }
            await analytics.track("<#data_load_failed#>", properties: ["error": message])
        }
    }
}

🎯 실전 적용 팁

1. 점진적 도입 전략

// Step 1: 새로운 기능부터 적용
class NewFeatureViewModel: ObservableObject {
    @Inject private var service: NewFeatureServiceProtocol
    // 새로운 기능은 처음부터 DI 적용
}

// Step 2: 기존 코드 리팩토링
class LegacyViewController: UIViewController {
    // 기존 코드는 점진적으로 변경
    @Inject private var userService: UserServiceProtocol
    
    // 기존 메서드들을 async로 변환하면서 DI 적용
    func loadUserProfile() async {
        let user = try? await userService.getCurrentUser()
        // ...
    }
}

2. 성능 모니터링

#if DEBUG
extension DependencyContainer {
    static func enablePerformanceMonitoring() {
        // DI 해결 시간 측정
        let monitor = DIPerformanceMonitor()
        monitor.startMonitoring()
    }
}

class DIPerformanceMonitor {
    func startMonitoring() {
        // 의존성 해결 성능 추적
        NotificationCenter.default.addObserver(
            forName: .DIResolutionCompleted,
            object: nil,
            queue: .main
        ) { notification in
            if let duration = notification.userInfo?["duration"] as? TimeInterval {
                print("📊 DI Resolution took \(duration)ms")
            }
        }
    }
}
#endif

🎉 마무리

DiContainer를 활용한 모듈화 전략으로 얻을 수 있는 것들:

  • 🧪 테스트 용이성: Mock 객체 쉽게 생성
  • 🔧 유지보수성: 코드 변경이 다른 부분에 미치는 영향 최소화
  • ⚡ 성능: 필요한 시점에만 객체 생성
  • 🚀 확장성: 새로운 기능 추가가 쉬워짐
  • 👥 협업: 명확한 인터페이스로 팀 작업 효율성 증대

작은 프로젝트부터 시작해서 점진적으로 적용해보세요. DiContainer와 함께 더 깔끔하고 유지보수하기 쉬운 앱을 만들어나가실 수 있을 거예요! 🚀


다음 포스트 예고: DiContainer로 구현하는 Clean Architecture - 계층별 의존성 관리

궁금한 점이나 실제 적용 과정에서 문제가 발생하면 언제든 GitHub에 남겨주세요!

profile
iOS 개발자 공부하는 Roy

0개의 댓글