SwiftUI+DI

SwiftUI에서의 추상화

SwiftUI로 뷰를 구현하면서 추상화된 객체가 필요한 시점이 있다.
UIKit과 같이 MVVM을 구현한다면 사실 ViewModel까지는 구체타입으로 두고(예전에는 이 부분이 안티패턴이라고 생각했는데, 경험상 1:1을 벗어난 적이 거의 없어서 고민하던 참이다.) ViewModel을 다른 추상타입에 의존시키는 방법을 사용할 수 있겠지만, ViewModel을 걷어내는 시점에서 결국 View에서 추상 타입을 바라봐야 하는 시점이 오게 된다.

이 추상화된 객체가 순수함수로 값을 돌려준다면 값을 반환받아 상태머신을 View에 두고 사용하면 되므로 크게 이슈가 없다.
그런데 만약 이 추상화 객체가 Repository와 같이 뷰의 상태값을 가지는 객체라면 View의 갱신을 위해 SwiftUI 특성상 ObservableObject를 구현하게 되는데,
사용하려고 보면 안타깝게도 @ObservedObject, @StateObject와 같은 SwiftUI property wrapper에서 추상 타입을 지원하지 않는다.

struct DependencyInjectedView: View {
    @ObservedObject var dependency: Dependency // error - Type any Dependency cannot conform to ObservableObject
    
    @ViewBuilder
    var body: some View {
        EmptyView()
            .onAppear {
                self.dependency.service()
            }
    }
}

제네릭 사용

한 가지 해결할 수 있는 방법은, 제네릭을 이용하는 것이다.
이 View가 메모리에 적재되는 동안 dependency가 Dependnecy를 채택하는 구체타입이라는 걸 확정시켜 주면 되기 때문에 제네릭 제약을 걸면 추상 타입이더라도 SwiftUI의 property wrapper를 사용할 수 있게 된다.

struct GenericDependencyInjectedView<D: Dependency>: View {
    @ObservedObject var dependency: D
    
    @ViewBuilder
    var body: some View {
        EmptyView()
            .onAppear {
                self.dependency.service()
            }
    }
}

제네릭 사용의 단점

그러나 이 방법은 단점이 존재하는데, Dependency의 개수가 늘어갈수록 제네릭 제약 코드가 늘어나게 된다.
예를 들어 protocol 내부에서 다시 protocol이 사용되는 경우, associatedType을 참조하는 경우 등 제네릭 특성상 컴파일 타임에 모든 게 결정되어야 하기 때문에 끝없이 where절을 사용해 가며 제약조건을 걸어서 런타임에 다형성을 가지지 않는다는 것이 보장되어야 한다.
사실 처음에는 별로 소요가 없다고 생각했는데, 막상 실무에 적용하다 보니 보일러플레이트가 너무 많고 일일이 지정하기가 힘들었다.

Type-Erasing

결국 Type Erasing 패턴으로 구현하게 되었다. AnyDependency를 구현하고(보통 Swiftt에서 이렇게 네이밍이 되어 있다. AnyHashable, AnyView...) 동일한 동작을 수행하도록 작성한다.
이 때 어차피 View에서 구체타입을 보기 때문에 함수 시그니처는 사실 상관이 없지만 Dependency를 채택하면 구현사항이 명확해진다.

final class AnyDependency: Dependency {
    private let dependency: any Dependency
    
    init(dependency: any Dependency) {
        self.dependency = dependency
    }
    
    func service() {
        /// Type erasing 외 부가적인 동작을 실행하면 유지보수가 힘들어진다.
        self.dependency.service()
    }
}

struct TypeErasedDependencyInjectedView: View {
    @ObservedObject var dependency: AnyDependency
    
    @ViewBuilder
    var body: some View {
        EmptyView()
            .onAppear {
                self.dependency.service()
            }
    }
}

func makeView() {
    let mockDependency = AnyDependency(dependency: MockDependency())
    let mockView = TypeErasedDependencyInjectedView(dependency: mockDependency)
    
    let defaultDependency = AnyDependency(dependency: DefaultDependency())
    let defaultView = TypeErasedDependencyInjectedView(dependency: defaultDependency)
}

간결한 코드로 property wrapper를 사용하면서 다형성을 유지할 수 있게 되었다.
다만 Type eraser에서 service()함수를 그대로 실행시켜야 하는데 그렇지 않고 부가적인 동작을 넣을 수 있는 요지가 있어 객체의 순수함이 보장되지는 않는다.