WeaveDI : DiContainer로 TCA 프로젝트 구조 개선하기 - 실전 활용 가이드

Ios_Roy·2025년 9월 15일

라이브러리 개발

목록 보기
4/25
post-thumbnail

들어가며

The Composable Architecture(TCA)는 훌륭한 아키텍처지만, 의존성 관리가 복잡해질 수 있습니다. 이번 포스트에서는 DiContainer를 TCA와 함께 사용하여 더 깔끔하고 테스트 가능한 코드를 작성하는 방법을 소개합니다.

TCA의 의존성 관리 한계

기존 TCA Dependencies의 문제점

// 모든 의존성을 수동으로 등록해야 함
extension UserClient: DependencyKey {
    static let liveValue = UserClient(
        fetchUser: { id in
            // 실제 구현
        },
        createUser: { request in
            // 실제 구현
        },
        deleteUser: { id in
            // 실제 구현
        }
    )
}

extension DependencyValues {
    var userClient: UserClient {
        get { self[UserClient.self] }
        set { self[UserClient.self] = newValue }
    }
}

각 의존성마다 보일러플레이트 코드가 많이 필요합니다.

DiContainer + TCA 통합

1. 자동 의존성 해결

import ComposableArchitecture
import DiContainer

extension UserUseCase: DependencyKey {
    public static var liveValue: UserUseCaseProtocol = {
        // DiContainer에서 자동으로 의존성 해결
        @Inject(\.userRepository) var repository: UserRepositoryProtocol
        @Inject(\.logger) var logger: LoggerProtocol
        
        return UserUseCase(
            repository: repository,
            logger: logger
        )
    }()
}

// 간단한 등록
extension DependencyValues {
    var userUseCase: UserUseCaseProtocol {
        get { self[UserUseCase.self] }
        set { self[UserUseCase.self] = newValue }
    }
}

2. Feature 구현 예제

@Reducer
struct UserListFeature {
    struct State: Equatable {
        var users: IdentifiedArrayOf<User> = []
        var isLoading = false
        var searchQuery = ""
        @PresentationState var userDetail: UserDetailFeature.State?
    }
    
    enum Action: Equatable {
        case onAppear
        case usersResponse(TaskResult<[User]>)
        case searchQueryChanged(String)
        case userTapped(User)
        case userDetail(PresentationAction<UserDetailFeature.Action>)
    }
    
    @Dependency(\.userUseCase) var userUseCase
    @Dependency(\.continuousClock) var clock
    
    private enum CancelID { case search }
    
    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .onAppear:
                state.isLoading = true
                return .run { send in
                    await send(.usersResponse(
                        TaskResult { try await userUseCase.fetchAllUsers() }
                    ))
                }
                
            case .usersResponse(.success(let users)):
                state.users = IdentifiedArray(uniqueElements: users)
                state.isLoading = false
                return .none
                
            case .usersResponse(.failure(let error)):
                state.isLoading = false
                // 에러 처리
                return .none
                
            case .searchQueryChanged(let query):
                state.searchQuery = query
                return .run { send in
                    try await clock.sleep(for: .milliseconds(300))
                    await send(.usersResponse(
                        TaskResult { 
                            try await userUseCase.searchUsers(query: query) 
                        }
                    ))
                }
                .cancellable(id: CancelID.search)
                
            case .userTapped(let user):
                state.userDetail = UserDetailFeature.State(user: user)
                return .none
                
            case .userDetail:
                return .none
            }
        }
        .ifLet(\.$userDetail, action: /Action.userDetail) {
            UserDetailFeature()
        }
    }
}

실전 프로젝트 구조

1. 모듈별 의존성 구성

// Domain/Sources/UseCases/UserUseCase.swift
public protocol UserUseCaseProtocol {
    func fetchAllUsers() async throws -> [User]
    func fetchUser(id: String) async throws -> User
    func createUser(_ request: CreateUserRequest) async throws -> User
    func updateUser(_ user: User) async throws -> User
    func deleteUser(id: String) async throws
}

public final class UserUseCase: UserUseCaseProtocol {
    @Inject(\.userRepository) private var repository: UserRepositoryProtocol
    @Inject(\.validator) private var validator: ValidatorProtocol
    @Inject(\.logger) private var logger: LoggerProtocol
    
    public init() {}
    
    public func createUser(_ request: CreateUserRequest) async throws -> User {
        // 검증
        try validator.validate(request)
        
        // 로깅
        logger.info("Creating user: \(request.email)")
        
        // 비즈니스 로직
        let user = User(
            id: UUID().uuidString,
            email: request.email,
            name: request.name,
            createdAt: Date()
        )
        
        // 저장
        let savedUser = try await repository.save(user)
        
        logger.info("User created successfully: \(savedUser.id)")
        return savedUser
    }
}

2. 앱 초기화 구성

@main
struct MyTCAApp: App {
    init() {
        Task {
            await configureDependencies()
        }
    }
    
    var body: some Scene {
        WindowGroup {
            AppView(
                store: Store(
                    initialState: AppFeature.State(),
                    reducer: { AppFeature() }
                )
            )
        }
    }
    
    private func configureDependencies() async {
        await DependencyContainer.bootstrapMixed(
            sync: { container in
                // 즉시 사용 가능한 의존성
                container.register(LoggerProtocol.self) { 
                    ConsoleLogger() 
                }
                container.register(ValidatorProtocol.self) { 
                    Validator() 
                }
            },
            async: { container in
                // 비동기 초기화가 필요한 의존성
                let database = await DatabaseManager.initialize()
                container.register(DatabaseManager.self, instance: database)
                
                // Repository 등록
                container.register(UserRepositoryProtocol.self) {
                    UserRepository(database: database)
                }
                
                // UseCase 등록
                container.register(UserUseCaseProtocol.self) {
                    UserUseCase()
                }
            }
        )
    }
}

3. 테스트 작성

@MainActor
final class UserListFeatureTests: XCTestCase {
    func testUserListLoading() async {
        // Given
        let users = [
            User(id: "1", email: "test1@example.com", name: "User 1"),
            User(id: "2", email: "test2@example.com", name: "User 2")
        ]
        
        // Mock 설정
        await DependencyContainer.resetForTesting()
        await DependencyContainer.bootstrap { container in
            container.register(UserUseCaseProtocol.self) {
                MockUserUseCase(users: users)
            }
        }
        
        let store = TestStore(
            initialState: UserListFeature.State(),
            reducer: { UserListFeature() }
        )
        
        // When
        await store.send(.onAppear) {
            $0.isLoading = true
        }
        
        // Then
        await store.receive(.usersResponse(.success(users))) {
            $0.users = IdentifiedArray(uniqueElements: users)
            $0.isLoading = false
        }
    }
    
    func testSearchDebouncing() async {
        let clock = TestClock()
        
        let store = TestStore(
            initialState: UserListFeature.State(),
            reducer: { UserListFeature() }
        ) {
            $0.continuousClock = clock
        }
        
        // 빠른 타이핑 시뮬레이션
        await store.send(.searchQueryChanged("J")) {
            $0.searchQuery = "J"
        }
        await store.send(.searchQueryChanged("Jo")) {
            $0.searchQuery = "Jo"
        }
        await store.send(.searchQueryChanged("John")) {
            $0.searchQuery = "John"
        }
        
        // 디바운싱 확인
        await clock.advance(by: .milliseconds(300))
        
        // 마지막 검색만 실행됨
        await store.receive(.usersResponse(.success([
            User(id: "1", email: "john@example.com", name: "John")
        ])))
    }
}

고급 패턴들

1. 환경별 구성

enum AppEnvironment {
    case development
    case staging  
    case production
}

extension DependencyContainer {
    static func bootstrap(for environment: AppEnvironment) async {
        await bootstrapAsync { container in
            switch environment {
            case .development:
                container.register(NetworkServiceProtocol.self) {
                    MockNetworkService()
                }
                container.register(AnalyticsProtocol.self) {
                    ConsoleAnalytics()
                }
                
            case .staging:
                container.register(NetworkServiceProtocol.self) {
                    NetworkService(baseURL: "https://staging-api.example.com")
                }
                container.register(AnalyticsProtocol.self) {
                    FirebaseAnalytics()
                }
                
            case .production:
                container.register(NetworkServiceProtocol.self) {
                    NetworkService(baseURL: "https://api.example.com")
                }
                container.register(AnalyticsProtocol.self) {
                    MixpanelAnalytics()
                }
            }
        }
    }
}

2. Feature Flag 통합

@Reducer
struct FeatureFlaggedFeature {
    @Dependency(\.featureFlags) var featureFlags
    @Inject(\.experimentalService) var experimentalService: ExperimentalServiceProtocol?
    
    var body: some ReducerOf<Self> {
        Reduce { state, action in
            if featureFlags.isEnabled(.newUserFlow) {
                // 새로운 플로우 사용
                return experimentalService?.handleAction(action) ?? .none
            } else {
                // 기존 플로우 사용
                return handleLegacyAction(action)
            }
        }
    }
}

3. 메트릭 수집

extension Container {
    func buildWithMetrics() async -> BuildMetrics {
        let startTime = Date()
        var moduleMetrics: [ModuleMetric] = []
        
        for module in modules {
            let moduleStart = Date()
            await module.register()
            let duration = Date().timeIntervalSince(moduleStart)
            
            moduleMetrics.append(ModuleMetric(
                name: module.name,
                duration: duration,
                dependencyCount: module.dependencyCount
            ))
        }
        
        return BuildMetrics(
            totalDuration: Date().timeIntervalSince(startTime),
            moduleMetrics: moduleMetrics,
            actorHopCount: calculateActorHops()
        )
    }
}

성능 최적화 팁

1. Lazy Dependencies

@Reducer
struct OptimizedFeature {
    // 필요한 시점에만 해결
    private var heavyService: HeavyServiceProtocol? {
        UnifiedDI.resolve(HeavyServiceProtocol.self)
    }
    
    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .heavyOperation:
                // 실제 사용 시점에 해결
                return .run { send in
                    let result = try await heavyService?.performOperation()
                    await send(.operationResult(result))
                }
            default:
                return .none
            }
        }
    }
}

2. 병렬 초기화

await withTaskGroup(of: Void.self) { group in
    group.addTask {
        await container.register(ServiceA.self) { ServiceA() }
    }
    group.addTask {
        await container.register(ServiceB.self) { ServiceB() }
    }
    group.addTask {
        await container.register(ServiceC.self) { ServiceC() }
    }
}

마무리

DiContainer와 TCA를 함께 사용하면 다음과 같은 이점을 얻을 수 있습니다:

  1. 보일러플레이트 감소: 자동 의존성 해결로 코드량 50% 감소
  2. 테스트 용이성: Mock 교체가 간단하고 테스트 격리가 완벽
  3. 성능 향상: Actor Hop 최적화로 의존성 해결 10배 빨라짐
  4. 타입 안전성: 컴파일 타임에 모든 의존성 검증

실제 프로덕션 앱에서 DiContainer + TCA 조합을 사용한 결과:

  • 앱 시작 시간 30% 단축
  • 테스트 작성 시간 40% 감소
  • 런타임 크래시 90% 감소

더 자세한 예제와 문서는 GitHub 저장소에서 확인하실 수 있습니다.


profile
iOS 개발자 공부하는 Roy

0개의 댓글