30 July 2022

서비스 로케이터는 객체를 로케이터에 등록하고, 해당 객체가 필요한 곳에서는 로케이터에 접근하여 객체를 제공받는 방식입니다.

서비스 로케이터는 객체를 컨테이너에 저장하고, 필요한 시점에 컨테이너에서 해당 객체를 꺼내어 사용하는 방식입니다. 이를 위해, 이전에는 직접 컨테이너에서 객체를 꺼내는 코드를 작성하거나, 이를 위한 클래스를 만들어 사용했었습니다.

Swift 5.1에서 도입된 PropertyWrapper를 사용하면, 직접 코드를 작성하지 않고도 컨테이너에서 객체를 꺼내와 사용할 수 있습니다. 이를 이용하여, 스프링이나 안드로이드의 Koin, Hilt와 비슷한 방식으로 코드를 작성하고 동작시킬 수 있습니다.

Swift 5.1에서 도입된 PropertyWrapper를 사용하면,직접 코드를 작성하지 않고도 컨테이너에서 객체를 꺼내와 사용할 수 있습니다.

이를 이용하여, 스프링이나 안드로이드의 Koin, Hilt와 비슷한 방식으로 코드를 작성하고 동작시킬 수 있습니다.

특정 프로토콜만 등록할 수 있는 서비스 로케이터

먼저, Injectable 프로토콜을 따르는 타입의 객체만 서비스 로케이터에 등록할 수 있다고 가정해봅시다.

/// Module : Container
/// FileName : Module.swift
public protocol Injectable {}

public struct Module {
    let name: String
    let resolve: () -> Injectable

    public init(_ name: Any.Type, _ resolve: @escaping () -> Injectable) {
        self.name = String(describing: name)
        self.resolve = resolve
    }
}

이전에 만든 모듈을 관리하는 컨테이너를 만들어봅시다.

/// Module : Container
/// FileName : Container.swift

/// A dependency collection that provides resolutions for object instances.
public class Container {
    /// Composition root container.
    static var root = Container()

    /// Stored object instance factories.
    private var modules: [String: Module] = [:]
    
    public init() {}
    deinit { modules.removeAll() }
}

extension Container {
    /// Registers a specific type and its instantiating factory.
    func add(module: Module) {
        modules[module.name] = module
    }
}

public extension Container {
    /// Resolves through inference and returns an instance of the given type from the current default container.
    ///
    /// If the dependency is not found, an exception will occur.
    static func resolve<T>(for name: String? = nil) -> T {
        let name = name ?? String(describing: T.self)
        
        guard let component: T = root.modules[name]?.resolve() as? T else {
            fatalError("Container '\(T.self)' not resolved!")
        }
        
        return component
    }
    
    /// Construct dependency resolutions.
    convenience init(@ModuleBuilder _ modules: () -> [Module]) {
        self.init()
        modules().forEach { add(module: $0) }
    }
    
    /// Construct dependency resolution.
    convenience init(@ModuleBuilder _ module: () -> Module) {
        self.init()
        add(module: module())
    }
    
    /// Assigns the current container to the composition root.
    func build() {
        // Used later in property wrapper
        Self.root = self
    }
    
    /// DSL for declaring modules within the container dependency initializer.
    @resultBuilder  struct ModuleBuilder {
        public static func buildBlock(_ modules: Module...) -> [Module] { modules }
        public static func buildBlock(_ module: Module) -> Module { module }
        public static func buildEither(first component: Module) -> Module { component }
    }
}

위 컨테이너를 사용하면, 쉽게 객체를 등록할 수 있습니다.

protocol Service {
    func doSomething()
}

struct ServiceImpl: Service, Injectable {
    func doSomething() {
        print("Doing something...")
    }
}

let container = Container {
    Module(Service.self) { ServiceImpl() }
}
container.build()

let service: Service = Container.resolve()
service.doSomething()
// Output: Doing something...

여기에 PropertyWrapper를 활용하면, Container.resolve()를 직접 호출하지 않아도 객체를 얻을 수 있습니다.

/// Module : Container
/// FileName : Inject.swift

@propertyWrapper
public class Inject<Value> {
    private var storage: Value?

    public var wrappedValue: Value {
        storage ?? {
            let value: Value = Container.resolve()
            storage = value // Reuse instance for later
            return value
        }()
    }

    public init() {}
}

Inject라는 PropertyWrapper를 사용하면, 객체를 얻을 수 있습니다.

@Inject 
var service: Service
service.doSomething()
// Output: Doing something...

하지만 Inject는 제약이 없기 때문에 어떤 타입이라도 사용할 수 있습니다. 따라서 컨테이너에 등록되어 있지 않은 타입을 Inject하면 에러가 발생합니다.

protocol AAAA {
    func doSomething()
}

@Inject 
var service: AAAA
service.doSomething()
// Error : Fatal error: Container 'AAAA' not resolved!

Inject를 사용할 때는 제약을 주어 의도한 대로 동작하도록 만들 수 있습니다. Inject의 제네릭 타입 ValueInjectable 프로토콜을 준수하도록 제한하면, Injectable을 준수하지 않는 타입은 사용할 수 없습니다.

/// Module : Container
/// FileName : Inject.swift

@propertyWrapper
public class Inject<Value: Injectable> {
    private var storage: Value?

    public var wrappedValue: Value {
        storage ?? {
            let value: Value = Container.resolve()
            storage = value // Reuse instance for later
            return value
        }()
    }

    public init() {}
}

하지만 Injectable을 준수하는 구현 타입이 필요하기 때문에, 추상화를 할 수는 없습니다.

@Inject 
var service: Service
service.doSomething()
// Error : Type 'any Service' cannot conform to 'Injectable'

따라서 Service 대신 ServiceImpl을 사용해야 합니다.

let container = Container {
    Module(ServiceImpl.self) { ServiceImpl() }
}
container.build()

@Inject 
var service: ServiceImpl
service.doSomething()
// Output: Doing something...

혹은 Adapter를 만들어 사용할 수 있습니다.

struct ServiceAdapter: Injectable {
    let service: Service
    init(service: Service) {
        self.service = service
    }
    func doSomething() {
        service.doSomething()
    }
}

let container = Container {
    Module(ServiceAdapter.self) { ServiceAdapter(service: ServiceImpl()) }
}
container.build()

@Inject 
var service: ServiceAdapter
service.doSomething()
// Output: Doing something...

프로토콜, 키를 한 쌍으로 사용하는 서비스 로케이터

앞에서는 컨테이너에 등록된 객체를 얻어오는 방법을 설명했습니다. 하지만, 제약을 두면 구현 타입을 사용할 수밖에 없는 한계가 있습니다. 이를 해결하는 방법에 대해서 설명하려고 합니다.

/// Module : Container
/// FileName : InjectionKey.swift

public protocol InjectionKey {
    associatedtype Value
    static var currentValue: Self.Value { get }
}

키에 사용할 프로토콜을 정의했습니다. InjectionKey에서 정의된 associatedtype은 키에 사용할 타입을 정의합니다. 그리고 currentValue는 자기 자신을 키로 컨테이너에서 객체를 꺼내도록 할 것입니다.

그리고 Module 코드는 InjectionKey를 이름으로 하는 코드로 변경됩니다.

/// Module : Container
/// FileName : Module.swift
public protocol Injectable {}

public struct Module {
    let name: String
    let resolve: () -> Injectable

    public init<T: InjectionKey>(_ name: T.Type, _ resolve: @escaping () -> Injectable) {
        self.name = String(describing: name)
        self.resolve = resolve
    }
}

다음으로, Property Wrapper인 Inject는 initialize에서 키를 인자로 받도록 합니다.

다음으로, Property Wrapper인 Injectinitialize에서 키를 인자로 받도록 합니다.

@propertyWrapper
public class Inject<Value> {
    private let lazyValue: (() -> Value)
    private var storage: Value?

    public var wrappedValue: Value {
        storage ?? {
            let value: Value = lazyValue()
            storage = value // Reuse instance for later
            return value
        }()
    }

    public init<K>(_ key: K.Type) where K : InjectionKey, Value == K.Value {
        lazyValue = {
            key.currentValue
        }
    }
}

Inject를 사용할 때는, 해당 프로퍼티 래퍼를 선언하는 타입이 InjectionKey 프로토콜을 준수하는 타입으로 지정되어야 하며, 해당 키 타입에 정의된 Value와 동일한 타입을 지정해야 합니다. 앞에서 정의한 Service에서 ServiceKey를 추가하고, 해당 키 타입에서 Value 프로퍼티에 해당하는 값이 해당 타입과 일치하도록 합니다.

struct ServiceKey: InjectionKey {
    typealias Value = Service
    static var currentValue: Value { Container.resolve(for: Self.self) }
}

protocol Service {
    func doSomething()
}

struct ServiceImpl: Service, Injectable {
    func doSomething() {
        print("Doing something...")
    }
}

이제 ServiceKeyServiceImpl을 컨테이너에 등록하고, 사용하는 것을 확인해봅시다.

let container = Container {
    Module(ServiceKey.self) { ServiceImpl() }
}
container.build()

@Inject(ServiceKey.self)
var service: Service
service.doSomething()
// Output: Doing something...

만약 ServiceKey에서 정의된 타입과 다른 타입을 사용하면 에러가 발생합니다.

@Inject(ServiceKey.self) // Error : Type of expression is ambiguous without more context
var service: AAAA
service.doSomething()

따라서 Inject를 사용할 때에는 키와 프로토콜은 쌍으로 사용하여, 실수할 가능성이 줄어들게 됩니다.

또한, InjectionKey 프로토콜에서 정의된 currentValue의 코드를 계속 구현하려면 꽤 많은 코드를 작성해야 합니다. 그러나 extension을 활용하여 currentValue를 구현하면 코드의 양을 줄일 수 있습니다.

public extension InjectionKey {
    static var currentValue: Value {
        return Container.resolve(for: Self.self)
    }
}

struct ServiceKey: InjectionKey {
    typealias Value = Service
}

정리

PropertyWrapper를 활용하면 컨테이너에서 객체를 쉽게 가져와 사용할 수 있습니다.

다음은 전체코드입니다.

/// Module : DIContainer
/// FileName : Module.swift
public protocol Injectable {}

public protocol InjectionKey {
    associatedtype Value
    static var currentValue: Self.Value { get }
}

public extension InjectionKey {
    static var currentValue: Value {
        return Container.resolve(for: Self.self)
    }
}

/// A type that contributes to the object graph.
public struct Module {
    let name: String
    let resolve: () -> Injectable

    public init<T: InjectionKey>(_ name: T.Type, _ resolve: @escaping () -> Injectable) {
        self.name = String(describing: name)
        self.resolve = resolve
    }
}
/// Module : DIContainer
/// FileName : Container.swift

/// A dependency collection that provides resolutions for object instances.
public class Container {
    /// Composition root container.
    static var root = Container()

    /// Stored object instance factories.
    private var modules: [String: Module] = [:]
    
    public init() {}
    deinit { modules.removeAll() }
}

extension Container {
    /// Registers a specific type and its instantiating factory.
    func add(module: Module) {
        modules[module.name] = module
    }

    /// Resolves through inference and returns an instance of the given type from the current default container.
    ///
    /// If the dependency is not found, an exception will occur.
    static func resolve<T>(for type: Any.Type?) -> T {
        let name = type.map { String(describing: $0) } ?? String(describing: T.self)
        
        guard let component: T = root.modules[name]?.resolve() as? T else {
            fatalError("Dependency '\(T.self)' not resolved!")
        }
        
        return component
    }
}

public extension Container {
    /// Construct dependency resolutions.
    convenience init(@ModuleBuilder _ modules: () -> [Module]) {
        self.init()
        modules().forEach { add(module: $0) }
    }
    
    /// Construct dependency resolution.
    convenience init(@ModuleBuilder _ module: () -> Module) {
        self.init()
        add(module: module())
    }
    
    /// Assigns the current container to the composition root.
    func build() {
        // Used later in property wrapper
        Self.root = self
    }
    
    /// DSL for declaring modules within the container dependency initializer.
    @resultBuilder  struct ModuleBuilder {
        public static func buildBlock(_ modules: Module...) -> [Module] { modules }
        public static func buildBlock(_ module: Module) -> Module { module }
        public static func buildEither(first component: Module) -> Module { component }
    }
}
/// Module : DIContainer
/// FileName : Inject.swift
@propertyWrapper
public class Inject<Value> {
    private let lazyValue: (() -> Value)
    private var storage: Value?

    public var wrappedValue: Value {
        storage ?? {
            let value: Value = lazyValue()
            storage = value // Reuse instance for later
            return value
        }()
    }

    public init<K>(_ key: K.Type) where K : InjectionKey, Value == K.Value {
        lazyValue = {
            key.currentValue
        }
    }
}
/// Module : Application
/// FileName : Service.swift

import DIContainer

struct ServiceKey: InjectionKey {
    typealias Value = Service
}

protocol Service {
    func doSomething()
}

struct ServiceImpl: Service, Injectable {
    func doSomething() {
        print("Doing something...")
    }
}
/// Module : Application
/// FileName : Application.swift

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil
    ) -> Bool {
        ...

        let container = Container {
            Component(ServiceKey.self) { ServiceImpl() }
        }
        container.build()

        ...

        @Inject(ServiceKey.self)
        var service: Service
        
        service.doSomething()
    }
}

참고자료

다음 편

다음 편에서는 서비스 로케이터를 사용했을 때 로케이터에 등록되어 있는지 어떻게 보장할 것인지 알아보도록 하겠습니다.