Dependency Injection (with Swinject)

Dophi·2023년 7월 30일

개발 기술

목록 보기
3/12

소개글

요즘 소마에서 프로젝트를 해보며 개발 기술들에 대해 배우고 적용해보고 있습니다. 이러한 기술들에 대해 개념을 자세히 설명하기보다는 실제로 어떻게 썼는지, 어떤 점이 좋았는지 정리해보고자 합니다!

Dependency Injection (DI) 이란

개발을 하다 보면 DI, IoC, DIP 같은 기술들을 활용하곤 합니다. 세가지 개념이 서로 같은 것은 아니지만, 다 함께 쓰이는 경우가 많고 이럴 때 시너지가 강력하다고 생각합니다.

이번 글에서는 이 중에서도 DI에 대해 설명을 드리겠습니다. 우선 DI를 제 스스로 간단하게 정리를 하자면 아래와 같습니다. 특징 같은 경우는 일반적인 특징과 함께 제가 구현한 DI의 특징을 써놨습니다.

개념

  • 필요로 하는 (의존하는) 객체를 직접 생성하거나 찾지 않고, 외부에서 넣어주는 방식
  • 보통은 프로토콜을 활용하여 DIP 원칙을 지키는 것까지 말함

쓰는 이유

  • 의존하는 객체가 변하더라도, 기존 코드는 수정할 필요가 없어짐 (DIP, OCP 만족)
  • 단위 테스트를 수행할 때, Mock 객체를 주입해서 쉽게 테스트 가능

특징

  • 생성자 방식, Setter 방식이 있음
  • Swinject를 활용하여 생성자 방식의 DI 구현 가능

코드

코드와 함께 어떻게 구현했는지 설명드리겠습니다. 참고로 개발환경은 아래와 같습니다.

클린아키텍쳐, MVVM, Swinject, SwiftUI, Combine

ViewModel

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는 ViewModel에 의존
  • ViewModel은 UseCase와 Coordinator에 의존
  • UseCase는 Repository에 의존
    ...

그렇다면 다음 화면으로 넘어가기 위해 View 객체를 정의할 때 아래와 같이 해야합니다.

let nextView = View(ViewModel(UseCase(Repository(...)), Coordinator))

딱 보기에도 너무 복잡해보이지 않나요?
그래서 Swift에서는 이에 도움을 주기 위해 Swinject라는 것을 지원해줍니다.

Dependency Injector

우선 아래는 Swinject를 약간 커스텀한 코드입니다. 간단하게 말하자면 DependencyInjector는 아래의 가능들을 가지고 있는 것입니다.

  • Register: 특정 객체를 등록하기
  • Resolve: 등록한 객체를 꺼내 쓰기
  • Assemeble: 서로 연관된 여러개의 Register를 한번에 다하기
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)!
    }
}

Assembly

위에서 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)
        }
        ...
    }
}

App

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)
                          ])
        ...
    }
}

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를 코드와 함께 설명드렸습니다. 사실 저도 현재 배우고 있는 입장이기 때문에 틀린 개념, 부족한 개념들이 있을 수 있습니다. 혹시라도 그런 부분이 있다면 언제든지 지적해주셔도 됩니다. 긴 글 읽어주셔서 감사합니다. 😊

profile
개발을 하며 경험한 것들을 이것저것 작성해보고 있습니다!

0개의 댓글