클린 아키텍처에서 각 계층은 서로의 구체적인 구현을 알지 못하고 오직 프로토콜에만 의존해야 합니다.
이때 "누가 약속을 지키는 실제 객체를 만들어서 필요한 곳에 전달해 줄 것인가?"라는 문제를 해결하기 위해 의존성 주입 컨테이너(DI Container)가 필요합니다.
앱에 필요한 모든 객체(Repository, UseCase, ViewModel 등)를 생성하고 관리하는 중앙 관리소로서 DI Container를 도입하기로 했습니다.
이를 구현하기 위해 주로 사용되는 두 가지 패턴을 비교 분석하고 우리 프로젝트에 가장 적합한 방식을 선택하고자합니다.
동작 방식: 앱이 시작될 때(AppDelegate) 필요한 모든 객체의 인스턴스를 미리 생성하여 DIContainer.shared와 같은 전역 싱글톤 컨테이너에 "등록(Register)"해둡니다.
이후 객체가 필요한 곳에서는 이 컨테이너에 직접 접근하여 필요한 의존성을 "꺼내오는(Resolve)" 방식입니다.
DIContainer.shared를 통해 프로젝트 어디서든 필요한 의존성을 쉽게 가져올 수 있다.resolve() 메서드 하나로 모든 의존성을 해결할 수 있어 사용이 간편하다.fatalError와 함께 크래시가 발생한다.init 초기화 메서드에 명확히 드러나지 않는다. 클래스 내부의 resolve() 코드를 직접 확인해야만 의존성을 파악할 수 있어 코드의 명확성이 떨어진다.// DIContainer.swift
final class DIContainer {
static let shared = DIContainer()
private var dependencies: [String: Any] = [:]
private init() {}
// MARK: - Register
func register<T>(_ type: T.Type, instance: T) {
let key = String(describing: type)
dependencies[key] = instance
}
// MARK: - Resolve
func resolve<T>(_ type: T.Type) -> T {
let key = String(describing: type)
guard let instance = dependencies[key] as? T else {
fatalError("\(key) 의존성이 등록되지 않았습니다.")
}
return instance
}
}
// AppDelegate.swift
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
registerDependencies()
return true
}
// MARK: - Register Dependencies
private func registerDependencies() {
let diContainer = DIContainer.shared
// MARK: - NetworkManager
diContainer.register(
NetworkManagerProtocol.self,
instance: NetworkManager()
)
// MARK: - Repository
diContainer.register(
SearchKeywordRepositoryProtocol.self,
instance: SearchKeywordRepository()
)
...
// ListViewController.swift
class ListViewController: UIViewController {
private var viewModel: ListViewModelProtocol!
override func viewDidLoad() {
super.viewDidLoad()
// 뷰 컨트롤러가 스스로 컨테이너에 접근해 의존성을 해결
self.viewModel = DIContainer.shared.resolve(ListViewModelProtocol.self)
}
}
동작 방식: DIContainer가 각 객체를 만드는 구체적인 "제조법(Factory Method)"을 make...() 형태의 함수로 가지고 있는 방식입니다.
객체가 필요한 시점에 이 제조법을 호출하여 의존성을 주입받은 새로운 인스턴스를 생성하여 반환합니다.
ViewModel을 만드는데 필요한 UseCase가 DIContainer에 정의되어 있지 않다면 코드를 실행하기도 전에 Xcode가 "필요한 부품이 없습니다!"라고 컴파일 에러를 통해 알려준다. 런타임 크래시를 원천적으로 방지할 수 있다.init(useCase: ...)처럼 초기화 메서드를 통해 어떤 의존성이 필요한지 명확하게 드러나므로 클래스의 역할과 책임이 코드상에 명확하게 보인다.Scene을 추가할 때마다 DIContainer에 해당 ViewModel과 ViewController를 위한 팩토리 메서드를 추가해야 하므로 파일이 다소 길어질 수 있다.// DIContainer.swift
final class DIContainer {
// ... UseCase, Repository 등 생성 로직 ...
func makeListViewModel() -> ListViewModel {
return ListViewModel(searchProductsUseCase: searchProductsUseCase)
}
func makeListViewController() -> ListViewController {
return ListViewController(viewModel: makeListViewModel())
}
}
// ListViewController.swift
class ListViewController: UIViewController {
private let viewModel: ListViewModel
// 초기화(init) 시점에 어떤 의존성이 필요한지 명확히 드러남
init(viewModel: ListViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
}
두 패턴 모두 좋은 방식이지만 팩토리 패턴을 사용하기로 결정했습니다.
가장 큰 이유는 안정성으로, 서비스 로케이터 패턴의 런타임 에러 가능성은 프로젝트가 복잡해질수록 발견하기 어려운 버그를 만들어낼 수 있는 위험 요소라고 판단했습니다.
반면, 팩토리 패턴은 컴파일러의 도움을 받아 의존성 누락과 같은 실수를 사전에 방지할 수 있다고 판단했습니다.
또한, 각 객체가 필요로 하는 의존성이 init을 통해 명확하게 드러나는 점은 코드의 가독성과 유지보수성을 높이는 데 큰 장점이 될 것이라 생각합니다.
약간의 보일러플레이트 코드를 감수하더라도 장기적으로 더 안정적이고 예측 가능한 코드를 작성하기 위해 팩토리 패턴을 최종적으로 선택했습니다.