The Composable Architecture(TCA)는 훌륭한 아키텍처지만, 의존성 관리가 복잡해질 수 있습니다. 이번 포스트에서는 DiContainer를 TCA와 함께 사용하여 더 깔끔하고 테스트 가능한 코드를 작성하는 방법을 소개합니다.
// 모든 의존성을 수동으로 등록해야 함
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 }
}
}
각 의존성마다 보일러플레이트 코드가 많이 필요합니다.
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 }
}
}
@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()
}
}
}
// 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
}
}
@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()
}
}
)
}
}
@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")
])))
}
}
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()
}
}
}
}
}
@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)
}
}
}
}
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()
)
}
}
@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
}
}
}
}
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를 함께 사용하면 다음과 같은 이점을 얻을 수 있습니다:
실제 프로덕션 앱에서 DiContainer + TCA 조합을 사용한 결과:
더 자세한 예제와 문서는 GitHub 저장소에서 확인하실 수 있습니다.