의존성 주입(Dependency Injection)은 테스트 가능하고 유지보수가 쉬운 코드를 작성하는 핵심 패턴입니다. 이번 포스트에서는 DiContainer를 개발하면서 적용한 Swift의 현대적인 기능들을 활용한 DI 패턴에 대해 공유하고자 합니다.
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())
""")
}
}
}
DiContainer는 타입 시스템을 활용해 의존성의 필수 여부를 자동으로 판단합니다:
class UserViewModel {
// 필수 의존성 - 없으면 크래시
@Inject(\.logger) var logger: LoggerProtocol
// 옵셔널 의존성 - 없어도 안전
@Inject(\.analytics) var analytics: AnalyticsProtocol?
// 기본값과 함께 - 항상 안전
@Inject(\.cache, default: { InMemoryCache() })
var cache: CacheProtocol
}
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()
}
}
많은 의존성들이 비동기 초기화를 필요로 합니다:
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)
}
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
}
KeyPath를 사용하면 오타나 잘못된 타입 사용을 컴파일 타임에 잡을 수 있습니다:
// ✅ 컴파일 타임에 검증됨
@Inject(\.userRepository) var repo: UserRepositoryProtocol
// ❌ 컴파일 에러 - KeyPath 타입 불일치
@Inject(\.userRepository) var repo: NetworkServiceProtocol
@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)
}
}
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)
}
}
class ExpensiveService {
// 실제 사용 시점까지 해결 지연
private lazy var repository = UnifiedDI.resolve(RepositoryProtocol.self)
func performAction() {
repository?.doSomething()
}
}
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의 강력한 타입 시스템과 현대적인 동시성 모델을 활용하면, 더 안전하고 효율적인 코드를 작성할 수 있습니다.