[CaloLink] - DIContainer

sunghun kim·2025년 8월 13일

[캡디 - CaloLink]

목록 보기
8/9

1. DI Container의 필요성

클린 아키텍처에서 각 계층은 서로의 구체적인 구현을 알지 못하고 오직 프로토콜에만 의존해야 합니다.
이때 "누가 약속을 지키는 실제 객체를 만들어서 필요한 곳에 전달해 줄 것인가?"라는 문제를 해결하기 위해 의존성 주입 컨테이너(DI Container)가 필요합니다.

앱에 필요한 모든 객체(Repository, UseCase, ViewModel 등)를 생성하고 관리하는 중앙 관리소로서 DI Container를 도입하기로 했습니다.
이를 구현하기 위해 주로 사용되는 두 가지 패턴을 비교 분석하고 우리 프로젝트에 가장 적합한 방식을 선택하고자합니다.

2. 후보 패턴 비교 분석

서비스 로케이터 (Service Locator) 패턴

동작 방식: 앱이 시작될 때(AppDelegate) 필요한 모든 객체의 인스턴스를 미리 생성하여 DIContainer.shared와 같은 전역 싱글톤 컨테이너에 "등록(Register)"해둡니다.
이후 객체가 필요한 곳에서는 이 컨테이너에 직접 접근하여 필요한 의존성을 "꺼내오는(Resolve)" 방식입니다.

장점

  • 높은 유연성: DIContainer.shared를 통해 프로젝트 어디서든 필요한 의존성을 쉽게 가져올 수 있다.
  • 간단한 사용법: resolve() 메서드 하나로 모든 의존성을 해결할 수 있어 사용이 간편하다.

단점

  • 런타임 에러 발생 가능성: 만약 특정 의존성을 등록하는 것을 까먹었다면 컴파일 시점에는 아무 문제가 없다가 앱이 실행되고 해당 코드가 호출되는 순간 fatalError와 함께 크래시가 발생한다.
  • 숨겨진 의존성 (Hidden Dependency): 어떤 클래스가 어떤 의존성을 필요로 하는지 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)
    }
}

팩토리 (Factory) 패턴

동작 방식: DIContainer가 각 객체를 만드는 구체적인 "제조법(Factory Method)"make...() 형태의 함수로 가지고 있는 방식입니다.
객체가 필요한 시점에 이 제조법을 호출하여 의존성을 주입받은 새로운 인스턴스를 생성하여 반환합니다.

장점

  • 컴파일 타임 안전성 (Compile-time Safety): ViewModel을 만드는데 필요한 UseCaseDIContainer에 정의되어 있지 않다면 코드를 실행하기도 전에 Xcode가 "필요한 부품이 없습니다!"라고 컴파일 에러를 통해 알려준다. 런타임 크래시를 원천적으로 방지할 수 있다.
  • 명시적인 의존성 (Explicit Dependency): init(useCase: ...)처럼 초기화 메서드를 통해 어떤 의존성이 필요한지 명확하게 드러나므로 클래스의 역할과 책임이 코드상에 명확하게 보인다.

단점

  • 보일러플레이트 코드: 새로운 Scene을 추가할 때마다 DIContainer에 해당 ViewModelViewController를 위한 팩토리 메서드를 추가해야 하므로 파일이 다소 길어질 수 있다.

사용 예시

// 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") }
}

3. 최종 결정: 팩토리 패턴

두 패턴 모두 좋은 방식이지만 팩토리 패턴을 사용하기로 결정했습니다.

가장 큰 이유는 안정성으로, 서비스 로케이터 패턴의 런타임 에러 가능성은 프로젝트가 복잡해질수록 발견하기 어려운 버그를 만들어낼 수 있는 위험 요소라고 판단했습니다.
반면, 팩토리 패턴은 컴파일러의 도움을 받아 의존성 누락과 같은 실수를 사전에 방지할 수 있다고 판단했습니다.

또한, 각 객체가 필요로 하는 의존성이 init을 통해 명확하게 드러나는 점은 코드의 가독성과 유지보수성을 높이는 데 큰 장점이 될 것이라 생각합니다.
약간의 보일러플레이트 코드를 감수하더라도 장기적으로 더 안정적이고 예측 가능한 코드를 작성하기 위해 팩토리 패턴을 최종적으로 선택했습니다.

profile
기죽지않기

0개의 댓글