Swift에서 최신 DI 패턴 구현하기 - Property Wrapper와 Swift Concurrency 활용

Ios_Roy·2025년 9월 15일

라이브러리 개발

목록 보기
3/25
post-thumbnail

서론

의존성 주입(Dependency Injection)은 테스트 가능하고 유지보수가 쉬운 코드를 작성하는 핵심 패턴입니다. 이번 포스트에서는 DiContainer를 개발하면서 적용한 Swift의 현대적인 기능들을 활용한 DI 패턴에 대해 공유하고자 합니다.

Property Wrapper의 마법

1. @Inject의 구현

DiContainer의 핵심은 @Inject Property Wrapper입니다:

@propertyWrapper
public struct Inject<Value> {
    private let keyPath: KeyPath<DependencyContainer, Value?>
    private var defaultValue: (() -> Value)?
    
    public var wrappedValue: Value {
        get {
            // 타입 추론을 통한 자동 해결
            if let resolved = DependencyContainer.live[keyPath: keyPath] {
                return resolved
            }
            
            // 기본값 제공
            if let defaultValue = defaultValue {
                return defaultValue()
            }
            
            // 친화적인 에러 메시지
            fatalError("""
                💥 의존성 해결 실패!
                타입: \(String(describing: Value.self))
                해결책: 
                1. DependencyContainer.bootstrap에서 등록했는지 확인
                2. 옵셔널 타입으로 변경: \(Value.self)?
                3. 기본값 제공: @Inject(\\.\(keyPath), default: MockImplementation())
                """)
        }
    }
}

2. 옵셔널 vs 필수 의존성

DiContainer는 타입 시스템을 활용해 의존성의 필수 여부를 자동으로 판단합니다:

class UserViewModel {
    // 필수 의존성 - 없으면 크래시
    @Inject(\.logger) var logger: LoggerProtocol
    
    // 옵셔널 의존성 - 없어도 안전
    @Inject(\.analytics) var analytics: AnalyticsProtocol?
    
    // 기본값과 함께 - 항상 안전
    @Inject(\.cache, default: { InMemoryCache() }) 
    var cache: CacheProtocol
}

Swift Concurrency와의 완벽한 통합

1. Actor 기반 Thread Safety

public actor DependencyContainer {
    private var dependencies: [ObjectIdentifier: Any] = [:]
    
    public func register<T>(_ type: T.Type, factory: @escaping () -> T) {
        dependencies[ObjectIdentifier(type)] = factory
    }
    
    public func resolve<T>(_ type: T.Type) -> T? {
        guard let factory = dependencies[ObjectIdentifier(type)] as? () -> T else {
            return nil
        }
        return factory()
    }
}

2. 비동기 초기화 지원

많은 의존성들이 비동기 초기화를 필요로 합니다:

extension DependencyContainer {
    static func bootstrapAsync(_ configure: @escaping (DependencyContainer) async -> Void) async {
        let container = DependencyContainer()
        
        // 비동기 설정 실행
        await configure(container)
        
        // 원자적 교체
        await MainActor.run {
            Self._live = container
        }
    }
}

// 사용 예
await DependencyContainer.bootstrapAsync { container in
    // 데이터베이스 연결
    let database = await Database.connect()
    container.register(Database.self, instance: database)
    
    // 원격 설정 로드
    let config = await RemoteConfig.fetch()
    container.register(RemoteConfig.self, instance: config)
}

KeyPath 기반 타입 안전 API

1. Container Extension 패턴

extension DependencyContainer {
    var userRepository: UserRepositoryProtocol? {
        resolve(UserRepositoryProtocol.self)
    }
    
    var networkService: NetworkServiceProtocol? {
        resolve(NetworkServiceProtocol.self)
    }
}

// 사용
class UserService {
    @Inject(\.userRepository) var repository: UserRepositoryProtocol
    @Inject(\.networkService) var network: NetworkServiceProtocol
}

2. 컴파일 타임 검증

KeyPath를 사용하면 오타나 잘못된 타입 사용을 컴파일 타임에 잡을 수 있습니다:

// ✅ 컴파일 타임에 검증됨
@Inject(\.userRepository) var repo: UserRepositoryProtocol

// ❌ 컴파일 에러 - KeyPath 타입 불일치
@Inject(\.userRepository) var repo: NetworkServiceProtocol

실전 패턴들

1. Factory 패턴과의 결합

@propertyWrapper
public struct Factory<Value> {
    private let keyPath: KeyPath<FactoryValues, Value>
    
    public var wrappedValue: Value {
        FactoryValues.current[keyPath: keyPath]
    }
}

// 사용
class ViewModelFactory {
    @Factory(\.userViewModelFactory) 
    var makeUserViewModel: (User) -> UserViewModel
    
    func createViewModel(for user: User) -> UserViewModel {
        makeUserViewModel(user)
    }
}

2. 테스트 격리

class UserServiceTests: XCTestCase {
    override func setUp() async throws {
        // 테스트용 격리된 컨테이너
        await DependencyContainer.resetForTesting()
        
        await DependencyContainer.bootstrap { container in
            container.register(UserRepositoryProtocol.self) {
                MockUserRepository()
            }
        }
    }
    
    func testUserCreation() async throws {
        let service = UserService()
        // Mock이 자동으로 주입됨
        let user = try await service.createUser(...)
        XCTAssertNotNil(user)
    }
}

성능 최적화 팁

1. Lazy Resolution

class ExpensiveService {
    // 실제 사용 시점까지 해결 지연
    private lazy var repository = UnifiedDI.resolve(RepositoryProtocol.self)
    
    func performAction() {
        repository?.doSomething()
    }
}

2. Scope 관리

extension DependencyContainer {
    func register<T>(_ type: T.Type, 
                     scope: Scope = .transient,
                     factory: @escaping () -> T) {
        switch scope {
        case .singleton:
            let instance = factory()
            register(type, instance: instance)
        case .transient:
            register(type, factory: factory)
        }
    }
}

결론

DiContainer는 Swift의 현대적인 기능들을 최대한 활용하여 타입 안전하고 성능이 뛰어난 DI 솔루션을 제공합니다. Property Wrapper, Swift Concurrency, KeyPath 등을 조합하여 개발자 경험을 극대화했습니다.

이러한 패턴들은 DiContainer뿐만 아니라 여러분의 프로젝트에서도 활용할 수 있는 아이디어들입니다. Swift의 강력한 타입 시스템과 현대적인 동시성 모델을 활용하면, 더 안전하고 효율적인 코드를 작성할 수 있습니다.


profile
iOS 개발자 공부하는 Roy

0개의 댓글