요즘 소마에서 프로젝트를 해보며 개발 기술들에 대해 배우고 적용해보고 있습니다. 이러한 기술들에 대해 개념을 자세히 설명하기보다는 실제로 어떻게 썼는지, 어떤 점이 좋았는지 정리해보고자 합니다!
개발을 하다 보면 DI, IoC, DIP 같은 기술들을 활용하곤 합니다. 세가지 개념이 서로 같은 것은 아니지만, 다 함께 쓰이는 경우가 많고 이럴 때 시너지가 강력하다고 생각합니다.
이번 글에서는 이 중에서도 DI에 대해 설명을 드리겠습니다. 우선 DI를 제 스스로 간단하게 정리를 하자면 아래와 같습니다. 특징 같은 경우는 일반적인 특징과 함께 제가 구현한 DI의 특징을 써놨습니다.
코드와 함께 어떻게 구현했는지 설명드리겠습니다. 참고로 개발환경은 아래와 같습니다.
클린아키텍쳐, MVVM, Swinject, SwiftUI, Combine
DI는 여러 곳에서 사용하고 있지만 그 중에서 ViewModel을 예시로 들어보겠습니다.
ViewModel의 생성자를 보면 useCase와 coordinator를 받아옵니다. 또한, ProblemListUseCase와 CoordinatorProtocol은 둘 다 프로토콜인데 이렇게 함으로써 DIP도 만족시켰습니다.
final public class ProblemListViewModel: BaseViewModel {
private let useCase: ProblemListUseCase
public init(useCase: ProblemListUseCase, coordinator: CoordinatorProtocol) {
self.useCase = useCase
super.init(coordinator: coordinator)
}
}
단순히 말하자면 이게 DI의 전부입니다.
만약 단위 테스트를 하고 싶다면 ProblemListUseCase 프로토콜을 따르는 MockProblemListUseCase 이라는 객체를 만들어서 하면 되는 것이고, 만약 이렇게 사용하는 객체가 변하더라도 ProblemListViewModel의 코드를 수정할 필요도 없다는게 DI를 쓰는 이유입니다.
다만 이렇게 끝난다면 문제가 될 수 있는 점을 알려드리겠습니다.
우선 현재 상황은 이렇습니다.
그렇다면 다음 화면으로 넘어가기 위해 View 객체를 정의할 때 아래와 같이 해야합니다.
let nextView = View(ViewModel(UseCase(Repository(...)), Coordinator))
딱 보기에도 너무 복잡해보이지 않나요?
그래서 Swift에서는 이에 도움을 주기 위해 Swinject라는 것을 지원해줍니다.
우선 아래는 Swinject를 약간 커스텀한 코드입니다. 간단하게 말하자면 DependencyInjector는 아래의 가능들을 가지고 있는 것입니다.
import Swinject
// 등록 관련 프로토콜
public protocol DependencyAssemblable {
func assemble(_ assemblyList: [Assembly])
func register<T>(_ serviceType: T.Type, _ object: T)
}
// 사용 관련 프로토콜
public protocol DependencyResolvable {
func resolve<T>(_ serviceType: T.Type) -> T
func resolve<T, Arg>(_ serviceType: T.Type, argument: Arg) -> T
}
// Injector 타입은 DependencyAssemblable, DependencyResolvable 프로토콜을 따름
public typealias Injector = DependencyAssemblable & DependencyResolvable
// Injector 프로토콜에 따라 메소드 구현
public final class DependencyInjector: Injector {
private let container: Container
public init(container: Container) {
self.container = container
}
public func assemble(_ assemblyList: [Assembly]) {
assemblyList.forEach {
$0.assemble(container: container)
}
}
public func register<T>(_ serviceType: T.Type, _ object: T) {
container.register(serviceType) { _ in object }
}
public func resolve<T>(_ serviceType: T.Type) -> T {
container.resolve(serviceType)!
}
public func resolve<T, Arg>(_ serviceType: T.Type, argument: Arg) -> T {
container.resolve(serviceType, argument: argument)!
}
}
위에서 Assemble 함수는 서로 연관된 여러개의 Register를 한번에 다하는 것이라고 했는데, Register들을 바로 이 Assembly에서 정의를 합니다.
참고로 저희 프로젝트는 클린 아키텍쳐를 따랐기 때문에 Assembly를 Presentation, Domain, Data 3개로 구분을 해놨습니다.
실제로 사용하는 모습을 보면 ProblemListViewModel을 등록하기 위해 앞서 등록해둔 ProblemListUseCase를 꺼내서 주입해주는 것을 확인할 수 있습니다.
import Swinject
import Domain
import Presentation
public struct PresentationAssembly: Assembly {
let coordinator: Coordinator
public func assemble(container: Container) {
// ProblemList
container.register(ProblemListViewModel.self) { resolver in
let useCase = resolver.resolve(ProblemListUseCase.self)!
return ProblemListViewModel(useCase: useCase, coordinator: coordinator)
}
container.register(ProblemListView.self) { resolver in
let viewModel = resolver.resolve(ProblemListViewModel.self)!
return ProblemListView(viewModel: viewModel)
}
...
}
}
Assembly를 활성화하는 곳, 즉 assemble 함수를 실질적으로 실행하는 것은 App이 실행되자마자 되도록 해놨습니다.
import SwiftUI
import Data
import Presentation
import Domain
import Swinject
@main
struct LitoApp: App {
private let injector: Injector
@ObservedObject private var coordinator: Coordinator
init() {
...
injector = DependencyInjector(container: Container())
coordinator = Coordinator(.loginScene)
injector.assemble([DomainAssembly(),
DataAssembly(),
PresentationAssembly(coordinator: coordinator)
])
...
}
}
저번 포스팅에서 코디네이터에 대해 말씀드렸었는데, 이렇게 Swinject에 미리 객체들을 등록해둔 덕분에 단순히 resolve를 통해 꺼내 쓸 수 있습니다.
import SwiftUI
import Presentation
import Domain
public class Coordinator: ObservableObject, CoordinatorProtocol {
@Published public var path: NavigationPath // 앱 전반에 걸쳐 공유되야 하는 변수
private let initialScene: AppScene
var injector: Injector?
...
// 이동할 화면을 생성함
@ViewBuilder
public func buildScene(scene: AppScene) -> some View {
switch scene {
case .learningHomeScene:
injector?.resolve(LearningHomeView.self)
case .problemDetailScene(let id):
injector?.resolve(ProblemDetailView.self, argument: id)
...
}
}
}
이상으로 제가 사용해본 DI 및 Swinject를 코드와 함께 설명드렸습니다. 사실 저도 현재 배우고 있는 입장이기 때문에 틀린 개념, 부족한 개념들이 있을 수 있습니다. 혹시라도 그런 부분이 있다면 언제든지 지적해주셔도 됩니다. 긴 글 읽어주셔서 감사합니다. 😊