보시면 netowrkService는 싱글턴으로 사용되었고 myService는 특정 세션을 가지네요. 자세한 사항은 Factory-Scope 공식문서에서 확인하실 수 있습니다. 싱글턴 외에도 Cache로 설정할 수도 있고, Session은 특정한 트리거를 기점으로 새로 생성되는 기능입니다. 예를 들어서 사용자가 로그아웃했을때 외부에서 세션을 종료시키면 객체가 바뀌게 됩니다. 그 외에도 시간을 설정해서 1시간, 2시간 등으로 객체의 생명주기를 설정할 수 있습니다.
Swinject는 5.8K 스타로 제가 조사한 라이브러리 중에서 가장 많은 스타를 받은 라이브러리입니다. 주요 기능은 다음과 같습니다!
✅ 컨테이너 기반 + 계층 구성 가능 ✅ Storyboard를 이용한 DI 기능도 제공
Swinject도 마찬가지로 컨테이너 기반의 DI 매커니즘을 가지고 있는데요, 한번 사용법을 살펴보겠습니다.
컨테이너 사용법
// 컨테이너 세팅
let container = Container()
container.register(Animal.self) { _ in Cat(name: "Mimi") }
container.register(Person.self) { r in
PetOwner(pet: r.resolve(Animal.self)!)
}
// 사용
let person = container.resolve(Person.self)!
person.play() // prints "I'm playing with Mimi."
Factory와 마찬가지로 먼저 컨테이너를 생성하고 .register 메서드를 이용해 사용할 객체를 등록해줍니다.
그 다음 .resolve함수를 통해 객체를 꺼내올 수 있습니다.
계층구조 구현
let parentContainer = Container()
parentContainer.register(Animal.self) { _ in Cat() }
let childContainer = Container(parent: parentContainer)
let cat = childContainer.resolve(Animal.self)
print(cat != nil) // prints "true"
parent를 사용하면 Container를 겹쳐 계층구조를 구성할 수도 있습니다.
컨테이너 관리
extension Container {
static let shared: Container = {
let container = Container()
container.register(Networking.self) { _ in
NetworkManager()
}.inObjectScope(.container)
container.register(Database.self) { _ in
DatabaseManager()
}.inObjectScope(.container)
return container
}()
}
// 사용
Container.shared.resolve(Networking.self)
extension을 구현에 어디서든 접근이 용이하도록 설정하면 더 좋겠죠?
테스팅
class MyAppTests: XCTestCase {
var container: Container!
override func setUp() {
super.setUp()
Container.shared.register(Networking.self) { _ in
MockNetworkManager() // Mock 객체를 주입
}
// testing...
}
}
테스트시엔 원래 객체 대신 Mock객체를 재 register하는 것으로 mocking을 할 수 있습니다.
Needle은 앞서 본 라이브러리들과 달리 VC의존성을 주로 관리하는 라이브러리입니다. 코디네이터 패턴이나 Ribs를 써보신 분들은 비슷한 개념이라고 생각하면 되겠습니다.
✅ 계층적 DI 구조 ✅ 컴파일 타임에 안전하게 구성 ✅ 코드 제너레이터
계층적 DI
Needle에서는 계층적 DI를 제공한다고 하는데요, 이 의미는 부모의 의존성을 그대로 받은 자식 의존성을 만들 수 있다는 것으로 의존성을 재활용할 수 있다는 의미입니다!
한 곳에서 모든 컴포넌트의 스코프를 관리하는 Container DI와 다르게 선언에 따라 각각 다른 스코프를 갖게 됩니다.
인스턴스를 한번에 관리하려는 용도보단 VC -> ViewModel -> Usecase -> Repository ... 와 같은 깊은 의존성을 촘촘하게 관리하는데 유용합니다.
protocol MyDependency: Dependency {
var chocolate: Food { get }
var milk: Food { get }
}
class MyComponent: Component<MyDependency> {
var hotChocolate: Drink {
return HotChocolate(dependency.chocolate, dependency.milk)
}
var myChildComponent: MyChildComponent {
return MyChildComponent(parent: self)
}
}
우선 Needle은 컴포넌트라는 객체를 사용하고 이 객체가 하나의 Scope입니다.
위 코드에서 MyDependency는 부모 의존성에 대한 프로토콜인데요, 프로토콜내에 있는 프로퍼티는 부모 컴포넌트를 탐색해서 가져오겠다는 의미입니다. 그리고 실제 컴포넌트 (MyConponent)에서는 추가적인 프로퍼터를 선언할 수 있습니다. 따라서 MyComponent는 chocolate + milk + hotChocolate이라는 프로퍼티를 가지는데, 이중 chocolate + milk는 부모에게서 찾고, hotChocolate는 직접 생성합니다.
그럼 MyComponent의 부모가 어디있는지 어떻게 찾을 수 있을까요? 코드를 다시 보시면 MyChildComponent가 선언되어 있는데요, 이 선언을 이용해서 부모를 추론하게 됩니다. 그리고 이러한 작업을 코드제너레이터가 해줍니다.
코드 제너레이터 + 컴파일 타임 안전성
Needle을 사용하려면 코드 제너레이터를 함께 설치해서 사용해야합니다.
brew install needle
이 코드 제너레이터의 역할은 의존성 그래프를 그리는 것입니다.
class LoggedInComponent: Component<LoggedInDependency> {
var gameComponent: GameComponent {
return GameComponent(parent: self)
}
}
이런 컴포넌트가 있을 때, LoggedInComponent가 GameComponent의 부모 컴포넌트임을 추론해 resolve한다고합니다. 즉, gameComponent가 생성될 때 gameComponent를 자식으로 선언한 컴포넌트가 있는지 살펴보고 컴포넌트가 있다면 그 컴포넌트에서 프로퍼티를 가져옵니다. 만약 여러개의 부모 컴포넌트가 있다면 자식 컴포넌트가 찾는 프로퍼티가 있는 (깊이가) 가장 가까운 컴포넌트에서 프로퍼티를 가져온다고합니다.
그리고 이때 찾는 프로퍼티를 가진 부모가 없는 등의 의존성 문제가 있을 경우 코드가 제너레이트 되지 않고 오류를 발생시키므로 컴파일 타임에 의존성에 대한 오류를 확인할 수 있다고 합니다!
사용법
import NeedleFoundation
protocol LoggedInDependency: Dependency {
var imageCache: ImageCache { get }
var networkService: NetworkService { get }
}
class LoggedInComponent: Component<LoggedInDependency> {
var scoreStream: ScoreStream {
return mutableScoreStream
}
var mutableScoreStream: MutableScoreStream {
return shared { ScoreStreamImpl() }
}
var loginViewController: UIViewController {
return LoggedInViewController(
gameBuilder: gameComponent,
scoreStream: scoreStream,
scoreSheetBuilder: scoreSheetComponent,
imageCache: dependency.imageCache
)
}
// MARK: - Children
var gameComponent: GameComponent {
return GameComponent(parent: self)
}
}
먼저 컴포넌트를 생성해주어야겠죠? 부모에서 받을 프로퍼티와, 해당 컴포넌트를 부모로하는 자식 컴포넌트를 선언해줄 수 있습니다. 또한 위 코드에서는 의존성을 주입을 완료한 VC 프로퍼티도 가지고 있네요.
트리를 올라가면 결국 최상위 컴포넌트가 있을 텐데요, 부모 의존성이 없는 최상위 컴포넌트는 다음과 BootstrapComponent로 선언할 수 있다고 합니다.
let rootComponent = RootComponent()
class RootComponent: NeedleFoundation.BootstrapComponent {
/// Root component code...
}
루트 컴포넌트는 부모가 없기 때문에 바로 인스턴스화 할 수 있으므로 이를 이용해 App/SceneDelegate 설정을 해줄 수 있습니다.
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
window = UIWindow(frame: windowScene.coordinateSpace.bounds)
window?.windowScene = windowScene
registerProviderFactories() // generated Needle Function
let rootComponent = RootComponent() // our starting point
window?.rootViewController = rootComponent.rootViewController
window?.makeKeyAndVisible()
}
}
만약 Mocking을 하고 싶다면 Mocking 객체들을 대신 할당한 MockVC를 생성하거나, 프로퍼티 클로저에 Impl객체 대신 Mock객체를 넣어 테스트해볼 수 있을 것 같습니다!
⚠️ 디팬던시를 다 맞춰주었는데도 'Could not find a provider for'오류가 발생하는 경우가 있었는데요, 부모가 자식에게 넘겨주는 프로퍼티를 public으로 변경하니 해결되었습니다. Issue 발생한 에러
protocol SoundHelperDependency: Dependency {
var timerCompleteSoundHelper: SoundHelper { get }
}
final class SoundHelperComponent: Component<SoundHelperDependency> {
var soundHelper: SoundHelper {
dependency.timerCompleteSoundHelper
}
}
final class RootComponent: BootstrapComponent, SoundHelperDependency {
public var timerCompleteSoundHelper: SoundHelper { // public
TimerCompleteSoundHelper()
}
var soundHelperComponent: SoundHelperComponent {
SoundHelperComponent(parent: self)
}
}
이 라이브러리는 TCA를 만든 곳에서 만든 라이브러리이며 TCA에는 아예 내장되어 있습니다! 따라서 SwiftUI+TCA 조합에 아주 유용합니다. 하지만 꼭 TCA를 써야만 적용할 수 있는 것은 아니고, 좋은 기능들이 많아서 소개해보려고합니다.
✅ TCA 내장! (TCA + SwiftUI 👍) ✅ 프로퍼티 래퍼 사용 ✅ 간단히 preview mock과 test mock 생성 가능
사용법
final class FeatureModel: ObservableObject {
@Dependency(\.continuousClock) var clock // Controllable way to sleep a task
@Dependency(\.date.now) var now // Controllable way to ask for current date
@Dependency(\.mainQueue) var mainQueue // Controllable scheduling on main queue
@Dependency(\.uuid) var uuid // Controllable UUID creation
// ...
}
swift-dependencies는 @Dependency라는 프로퍼티래퍼를 이용해 사용할 수 있습니다. 위와 같이 선언해주면 정해진 객체가 자동으로 할당되는 것이죠. 그리고 이를 위해서 몇가지 설정을 해주어야합니다.
1. DependencyKey 정의
private enum APIClientKey: DependencyKey {
// TCA스타일에 맞춰 live 프로퍼티를 만들어도 되고 APIClient()처럼 일반적으로 인스턴스화해도 가능합니다. 🙆♀️
static let liveValue = APIClient.live
}
인스턴스화하고자 하는 객체가 APIClient라는 객체라면 이에 대한 DependencyKey를 정의해주어야합니다. 그리고 필수적으로 static let liveVlaue를 선언해주어야합니다.
2. 익스텐션에 프로퍼티 추가
extension DependencyValues {
var apiClient: APIClient {
get { self[APIClientKey.self] }
set { self[APIClientKey.self] = newValue }
}
}
@Dependency가 알 수 있도록 DependencyValue에 해당 객체(apiClient)에 대한 설정을 해주어야합니다.
3. @Dependency를 통해 사용
final class TodosModel: ObservableObject {
@Dependency(\.apiClient) var apiClient
// ...
}
세팅을 완료하면 이러한 형태로 사용할 수 있습니다.
Mocking
조사한 라이브러리중에 mocking 기능이 가장 강력한 것 같습니다.
우선 기본적으로 중간에 객체 내부 동작을 변경하고 싶다면 withDependencies라는 함수를 사용할 수 있습니다.
단점이 있다면, dependency를 등록할 때 기존에 등록된 dependency를 참조할 수 없는 것 같습니다. (혹시 가능하다면 댓글 남겨주세요..!)
따라서 의존성 계층구조를 구성하는데는 어려움이 있을 것 같습니다.
마치며
라이브러리들을 살펴 본 후 다음과 같은 기준을 세워볼 수 있었습니다.
Factory
Swinject
Needle
swift-dependencies
구분
컨테이너형DI
컨테이너형,계층형DI
계층형DI
컨테이너형DI
추천프레임워크
SwiftUI
UIKit , Storyboard
UIKit
SwiftUI
Mocking
O
O
X
O
Self-registration
O
O
O
X
Scope
O
O
O(일부)
X
간단하게 컨테이너 기반 DI를 하고싶다면 -> Factory
스토리보드 기반 프로젝트거나 자유도 높은 DI가 필요하다면 -> Swinject
VC계층 및 깊은 의존성 관리가 필요한데 Ribs는 좀 부담스럽다면 -> Needle
SwiftUI 프로젝트이고 Mocking을 쉽게 하고싶다면 -> swift-dependencies
아무래도 모든 라이브러리의 기능을 세세하게 살펴보기는 어려워서 대략적으로 한번 살펴보았습니다. 이 외에도 Pure, Cleanse 등 더 많은 라이브러리들이 있는데 이것들은 나중에 추가적으로 포스팅해보려고합니다. ㅎㅎ 그리고 소개해드린 것들도 추가적으로 제공하는 기능들도 정말 많고 다양하므로 공식문서를 참고해주시면 좋을 것 같습니다!