[Needle] Uber의 Needle로 컴파일 타임에 안전한 의존성 주입 환경을 구현하기
의존성 주입(dependency injection)의 필요성과 일반적인 문제점
DI, 의존성 주입은 객체 지향 프로그래밍에서 책임을 분리하고, 확장에 유연하게 만들어주는 핵심적인 패턴 중의 하나입니다.
의존성 주입의 장점은 다음과 같습니다.
1. Class와 Structure의 요구사항과 책임이 더욱 분명하고 명확해진다.
2. 의존성을 Mock으로 교체하여 Unit Test를 수행하기 편리해진다.
3. 의존성 주입을 받는 객체는 해당 의존성에 대한 책임을 지지 않으면서도 그 의존성의 기능을 사용할 수 있다.
4. Protocol을 사용하면 프로젝트에 존재하는 Coupling을 제거할 수 있다.
5. 여러 개발자가 분리된 객체에 대한 동시 작업이 가능해진다.
물론 장점만 있는 것은 아닙니다. 프로그래머의 노력과 시간 투자가 많이 들며(유지 보수 비용을 생각하면 반대인 것 같기는 합니다), 각 객체의 흐름을 파악하기 위해 계층적으로 클래스를 타고 들어가야 하기 때문에 파악이 어려워집니다. 그러나 이러한 단점들을 감수하고도 사용할 가치는 다분하기에 아직까지도 많은 프로젝트에서 채택되는 방식인 것 같습니다.
클린 아키텍쳐와 같이 레이어가 많이 분리되어 있는 아키텍쳐에서 의존성 주입을 하다보면, 아래와 같이 아주 복잡한 객체의 조합이 필요해 질 수 있습니다. 또는 Runtime에 되어서야 객체의 존재를 알 수 있는 경우도 있습니다. 만약 의존성이 nil이라면 crash가 날 수 있는 중대한 문제이며, 개발자의 입장에서는 이를 확인하는 작업에 많은 비용이 소모됩니다.
만약 컴파일 타임에 의존성 주입이 잘못 수행되고 있다는 것을 알아체고, 경고를 받을 수 있다면 훨씬 효율적, 안정적으로 작업을 할 수 있을 것입니다. 이를 가능하게 해주는 것이 Uber의 Needle입니다. 아래는 Uber의 Needle을 이용하여 의존성 주입 환경을 조성하고, needle의 code generator를 통해 의존성 주입을 완성하려 하는 순간 잘못된 정의에 대한 경고를 받는 사진입니다.
Needle은 swift의 protocol을 이용하여 손쉽게 의존성 구조를 작성하게 하고, 위와 같이 잘못된 부분에 대한 오류를 컴파일 시간에 확인할 수 있습니다. 다음 문단부터 Needle에 대해 본격적으로 알아보겠습니다.
Needle의 목표
Needle을 Swinject와 같은 다른 의존성 주입 도구와 비교했을 때, 두드러지는 차이점이 있습니다. 부모-자식 Component 관계를 통한 계층적 DI 구조를 장려하고, 컴파일 시간 안정성을 보장한다는 것입니다. ReadMe에서 밝히는 Needle의 목표는 3가지입니다.
1. DI가 컴파일 시간에 안전함을 보장하고자 한다.
2. 수백만 줄의 규모가 큰 코드베이스에서도 효율적으로 코드를 생성한다.
3. 모든 iOS 아키텍쳐에서 호환되게 한다.
NeedleFoundation과 Code Generator
Needle은 실행 가능한 Code Generator와 NeedleFoundation 프레임워크로 이루어 집니다.
NeedleFoundation: Xcode 내에서 swift 언어를 통해 Needle DI를 작성할 수 있도록 합니다. SPM, Carthage 등으로 Xcode Project에 추가할 수 있습니다.
Code Generator: 작성된 코드를 기반으로 컴파일 시간 의존성 주입 검사를 위한 추가적인 Code를 생성합니다. brew를 통해 install 가능합니다.
NeedleFoundation의 구성
NeedleFoundation은 아래와 같은 요소로 구성되어 있습니다.
Component : 의존성의 Container 역할을 합니다. 하나의 Component는 하나의 Dependency Protocol과 매치되고, Scope로 간주됩니다.
Dependency : 해당 Component에서 사용할 상위 Scope의 의존성들을 정의합니다.
Needle에서 의존성 주입을 구현하기 위해서는, 위의 두 요소만 적절히 구성하고 각 Scope(Component) 간의 관계를 명시하면 됩니다. 나머지 필요한 일들은 Code Generator가 해줄 것입니다. Component와 Dependency에 대해서는 Sample Project를 작성해보며 자세히 언급하겠습니다.
Sample Project를 구성하며 이해하기
빠른 이해를 위해 Sample Project를 작성해 보았습니다. 3개의 뷰로 이루어진 간단한 예제입니다.
스펙은 다음과 같습니다.
1. MainVC : FirstVC와 SecondVC로 이동하는 버튼, SharedClass를 통해 공유하는 값을 라벨로 확인 가능
2. FirstVC : button을 통해 SharedClass의 value를 상승시킬 수 있음
3. SecondVC : SharedClass의 sharedValue를 라벨로 확인 가능
Main Component
먼저 MainVC를 가지고 있는 MainComponent를 보겠습니다.
import UIKit
import NeedleFoundation
class MainComponent: BootstrapComponent {
var rootViewController: UIViewController {
return MainVC(
firstBuilder: firstComponent,
secondBuilder: secondComponent,
sharedClass: sharedClass
)
}
var firstComponent: FirstComponent {
return FirstComponent(parent: self)
}
var secondComponent: SecondComponent {
return SecondComponent(parent: self)
}
var sharedClass: SharedClass {
shared { SharedClass() }
}
}
class SharedClass {
var sharedValue = 1 {
willSet {
listener?(newValue)
}
}
var listener: ((Int)->Void)?
}
BootStrapComponent
- Needle에서 모든 Component는 부모-자식 관계를 이루어야 합니다. Main Component는 Parent가 없는 Root Component이기 때문에, BootStrapComponent를 상속하여 이를 명시할 수 있습니다.
- 또한 Component는 상위 Scope에서 가져올 의존성을 Dependency Protocol로 명시해야 합니다. 최상위 Component는 상위 Component가 없기 때문에, Dependency 프로토콜과 매칭되지 않습니다. 실제로 아래와 같이 EmptyDependency로 정의되어 있습니다.
open class BootstrapComponent: Component<EmptyDependency> {
- 최상위 Component이기 때문에 MainComponent()와 같은 형태로 선언이 가능합니다.
연산 프로퍼티
Component 내부에는 각각의 의존성들을 연산 프로퍼티로 정의합니다. UIViewController, Components, sharedClass를 연산 프로퍼티의 형태로 가지고 있습니다.
부모-자식 관계
Needle에서는 Component 들을 부모-자식 관계로 매치해야 합니다. 이는 FirstComponent(parent: self) 생성자를 이용하는 것으로 간단한게 해결됩니다. MainComponent는 FirstComponent와 SecondComponent를 자식으로 가지게 됩니다. 다르게 말하면, FirstComponent와 SecondComponent의 상위 Scope가 MainComponent가 되는 것입니다.
Shared Contructor
Shared 생성자를 이용하여 싱글턴과 같은 기능을 수행하도록 할 수 있습니다. shared 내부의 인스턴스를 참조할 때 항상 같은 인스턴스를 반환합니다. SharedClass를 FirstComponent와 SecondComponent에 공유하겠습니다.
FirstComponent
import UIKit
import NeedleFoundation
protocol FirstDependency: Dependency {
var sharedClass: SharedClass { get }
}
class FirstComponent: Component<FirstDependency>, FirstBuilder {
var firstVC: UIViewController {
return FirstVC(sharedClass: dependency.sharedClass)
}
}
protocol FirstBuilder {
var firstVC: UIViewController { get }
}
FirstComponent가 MainComponent와 다른 점은, Dependency를 가진다는 것입니다.
Dependency 내부에 프로퍼티를 선언하면, Needle은 변수명과 type을 체크하여 매치되는 가장 가까운(트리 구조에서) 의존성을 찾습니다. MainComponent에서 Shared로 생성했기 때문에, 동일한 인스턴스를 공유하게 될 것입니다.
만약 의존성을 사용하고 싶다면, Generic을 통해 전달된 dependency에 접근하여 가져올 수 있습니다.
MainVC / FirstVC
final class MainVC: UIViewController {
// MARK: - Properties
private let firstBuilder: FirstBuilder
private let secondBuilder: SecondBuilder
private let sharedClass: SharedClass
// MARK: - UI Components
private lazy var firstButton: UIButton = {
let bt = UIButton()
bt.setTitle("첫 번째 페이지", for: .normal)
bt.setTitleColor(.black, for: .normal)
bt.addAction(
UIAction { [weak self] _ in
guard let self else { return }
self.present(self.firstBuilder.firstVC, animated: true)
},
for: .touchUpInside
)
return bt
}()
init(
firstBuilder: FirstBuilder,
secondBuilder: SecondBuilder,
sharedClass: SharedClass
) {
self.firstBuilder = firstBuilder
self.secondBuilder = secondBuilder
self.sharedClass = sharedClass
super.init(nibName: nil, bundle: nil)
}
...
private func bind() {
self.sharedClass.listener = { [weak self] sharedValue in
guard let self = self else { return }
self.valueLabel.text = "공유 값 : \(sharedValue)"
}
}
}
final class FirstVC: UIViewController {
var sharedClass: SharedClass
private lazy var upButton: UIButton = {
let bt = UIButton()
bt.setTitle("FirstVC : UP Button", for: .normal)
bt.setTitleColor(.black, for: .normal)
bt.addAction(
UIAction { [weak self] _ in
guard let self else { return }
self.sharedClass.sharedValue += 1
},
for: .touchUpInside
)
return bt
}()
init(sharedClass: SharedClass) {
self.sharedClass = sharedClass
super.init(nibName: nil, bundle: nil)
}
...
}
그리고 각가의 VC는 위와 같이 구현되어 있습니다. FirstVC에서 버튼을 누른다면 SharedClass의 값이 증가하고, bind를 통해 값 증가의 순간 FirstVC에서도 UI가 업데이트 됩니다.
NeedleGenerated
지금까지와 같이 의존성을 정의한 다음, needle generated 명령어를 이용하여 의존성을 register 할 수 있습니다.
제 경우에는 프로젝트 경로에 바로 파일을 생성하도록 했습니다.
needle generate NeedlePractice/NeedleGenerated.swift NeedlePractice/
또는 Run Script에 사용 가능합니다.
export PATH="$PATH:/opt/homebrew/bin"
if which needle; then
SOURCEKIT_LOGGING=0 && needle generate NeedlePractice/NeedleGenerated.swift NeedlePractice/
else
echo "warning: Needle not installed, download from https://github.com/uber/needle using Homebrew"
fi
생성되면 아래와 같은 코드들이 만들어져 있습니다.
// MARK: - Traversal Helpers
private func parent1(_ component: NeedleFoundation.Scope) -> NeedleFoundation.Scope {
return component.parent
}
// MARK: - Providers
#if !NEEDLE_DYNAMIC
private class FirstDependency7da73e266af7d243503bProvider: FirstDependency {
var sharedClass: SharedClass {
return mainComponent.sharedClass
}
private let mainComponent: MainComponent
init(mainComponent: MainComponent) {
self.mainComponent = mainComponent
}
}
트리 순회를 위한 메서드, dependency 프로토콜의 구현체를 찾아볼 수 있고
#else
extension FirstComponent: Registration {
public func registerItems() {
keyPathToName[\FirstDependency.sharedClass] = "sharedClass-SharedClass"
}
}
extension SecondComponent: Registration {
public func registerItems() {
keyPathToName[\FirstDependency.sharedClass] = "sharedClass-SharedClass"
}
}
extension MainComponent: Registration {
public func registerItems() {
}
}
#endif
private func factoryEmptyDependencyProvider(_ component: NeedleFoundation.Scope) -> AnyObject {
return EmptyDependencyProvider(component: component)
}
// MARK: - Registration
private func registerProviderFactory(_ componentPath: String, _ factory: @escaping (NeedleFoundation.Scope) -> AnyObject) {
__DependencyProviderRegistry.instance.registerDependencyProviderFactory(for: componentPath, factory)
}
#if !NEEDLE_DYNAMIC
@inline(never) private func register1() {
registerProviderFactory("^->MainComponent->FirstComponent", factory18174c17369cf47342b70ae93e637f014511a119)
registerProviderFactory("^->MainComponent->SecondComponent", factorybf3761afe12802163eca0ae93e637f014511a119)
registerProviderFactory("^->MainComponent", factoryEmptyDependencyProvider)
}
#endif
public func registerProviderFactories() {
#if !NEEDLE_DYNAMIC
register1()
#endif
}
keyPath에 대한 name을 등록하고, factory 인스턴스를 등록하는 과정을 통해 최종적으로 registerProviderFactories()를 만들어 냅니다.
SceneDelegate에서 아래와 같이 팩토리 인스턴스들을 등록하고, MainComponent를 생성하여 사용 가능합니다.
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
registerProviderFactories()
guard let windowScene = (scene as? UIWindowScene) else { return }
let window = UIWindow(windowScene: windowScene)
let root = MainComponent()
let rootVC = root.rootViewController
window.rootViewController = rootVC
window.makeKeyAndVisible()
self.window = window
}
마무리
Needle을 통해 의존성 주입 환경을 조성해 보았습니다. 손쉬운 인터페이스로 의존성을 간결하게 정의할 수 있고, 사이즈가 큰 프로젝트의 경우 각각의 의존성을 관리하기에도 쉬워질 것입니다. 무엇보다 Needle의 목표인 '컴파일 시간 안전성'이 개발 과정을 효율적으로 만들어 줄 가장 큰 장점이 될 것 같습니다. 추후 복잡한 프로젝트에도 Needle을 적용해보고, 후기를 작성해 보겠습니다.
아래는 프로젝트 링크입니다.