iOS) Needle 로 의존성 주입하기

Kanghoon
16 min readOct 18, 2022

--

https://github.com/uber/needle

iOS 에서 모듈화를 진행하다 보면 의존성을 주입하는 방식에 대해 많이 고민하게 됩니다. 오늘은 Uber 의 Needle 을 사용해 의존성을 주입하는 방법에 대해 알아보겠습니다.

Needle 을 소개하기 앞서 의존성 주입이 무엇인지부터 가볍게 알아보겠습니다. (본 글에서는 의존성 역전 원리(DIP) 에 대해서는 설명하지 않습니다.)

의존성 주입 간단히 알아보기

의존성을 주입하기 위한 방법은 여러가지 있습니다.

1. Constructor Injection

생성자로 객체가 필요로 하는 의존성을 모두 전달합니다.

protocol Networking {
func request(with request: URLRequest) async throws -> Response
}

final class RepositoryImpl: Repository {
private let networking: Networking

init(networking: Networking) {
self.networking = networking
}
}

2. Property Injection / Method Injection

의존성을 속성으로 표시하고, 직접 혹은 메서드를 이용해 전달받습니다.

// Property Injection
final class RepositoryImpl: Repository {
var networking: Networking?
}

// Method Injection
final class RepositoryImpl: Repository {
private var networking: Networking?

func setNetworking(_ networking: Networking) {
self.networking = networking
}
}

3. Interface Injection

의존성 주입에 대한 역할을 프로토콜로 추상화합니다.

protocol NetworkingDependent {
func registerNetworking(_ networking: Networking)
}

final class RepositoryImpl: Repository, NetworkingDependent {
private var networking: Networking?

func registerNetworking(_ networking: Networking) {
self.networking = networking
}
}

IoC Container

DIP 가 적용된 모듈의 조립을 도와주는 도구입니다. 하지만 IoC Container 가 반드시 필요하지는 않습니다. iOS 진영에서 주로 사용하는 프레임워크로 Swinject 가 있습니다.

// Constructor Injection
let networking = NetworkingImpl()

let repository = RepositoryImpl(networking: networking)

// IoC Container
let container = Container()

container.register(Networking.self) { _ in NetworkingImpl() }
container.register(Repository.self) { r in
RepositoryImpl(networking: r.resolve())
}

let repository = container.resolve(Repository.self)!

의존성 그래프가 구성되는 것을 컴파일 시점에 확인하기 위해 생성자 주입을 주로 사용합니다.

다만, 의존성 그래프가 복잡해지면 생성자 주입만으로 모듈을 조립하기가 쉽지 않습니다. 이 때 IoC Container 를 활용하는데, 대표적인 Swinject 를 사용하면 컴파일 시점에 안전성을 확인하기 어렵습니다. (resolve 시점에 값이 없을 수 있음)

멀티 모듈 환경이 되면 의존성의 수는 점점 많아집니다. Swinject 를 활용해서 구성하기에는 수동으로 연결해야하는 수많은 객체가 생기고, 더더욱 안전성을 보장하기 어렵습니다.

Needle 알아보기

Needle is a dependency injection (DI) system for Swift. Unlike other DI frameworks, such as Cleanse, Swinject, Needle encourages hierarchical DI structure and utilizes code generation to ensure compile-time safety. This allows us to develop our apps and make code changes with confidence. If it compiles, it works. In this aspect, Needle is more similar to Dagger for the JVM.

위를 통해 알 수 있듯이 Needle 은 기존의 Swinject 같은 프레임워크와 다릅니다. Needle은 계층적 DI 구조 (트리 구조)를 권장하고 코드를 자동생성해 컴파일 시점에 안전성을 보장합니다.

위에서 이야기했던 iOS 개발에서 IoC Container 를 활용할 때 가장 문제되는 컴파일 시점의 안전성을 보장할 수 있습니다.

Needle 이 어떻게 동작하는지 실제로 사용해보며 알아봅시다.

Needle 사용해보기

Needle 에서 각 의존성의 범위는 Component로 정의하고, 그에 대한 의존성은 protocol로 캡슐화됩니다. 그리고 이 둘을 제네릭을 사용하여 연결합니다.

말이 어렵기 때문에 모두에게 친숙한 코드로 살펴보겠습니다.

먼저 의존성을 프로토콜로 정의합니다.

// 이 프로토콜은 상위 Scope에서 얻은 의존성을 캡슐화합니다.
protocol MyDependency: Dependency {
// 이 객체들은 상위 Scope에서 얻는 객체이므로 현재 Scope에는 없습니다.
var chocolate: Food { get }
var milk: Food { get }
}

다음은 컴포넌트입니다. 정의했던 의존성 프로토콜을 활용해 컴포넌트를 정의합니다. 이 컴포넌트는 상위 Scope 에서 의존성을 획득합니다.

상위 Scope란 컴포넌트 생성에 사용하는 parent 파라미터를 의미합니다.

// Dependency 프로토콜을 통해 상위 Scope에서 의존성을 획득하고, 프로퍼티들을 선언하여 DI 그래프에 새 객체를 제공하며 하위 Scope를 인스턴스화할 수 있는 새로운 의존성 범위를 정의합니다.
class MyComponent: Component<MyDependency> {

// 새로운 객체인 hotChocolate을 의존성 그래프에 추가합니다.
// 하위 Scope들에서 Dependency 프로토콜을 통해 이를 획득할 수 있습니다.
var hotChocolate: Drink {
return HotChocolate(dependency.chocolate, dependency.milk)
}

// 자식 Scope는 항상 부모 Scope에 의해 인스턴스화됩니다.
var myChildComponent: MyChildComponent {
return MyChildComponent(parent: self)
}
}

MyComponent 는 상위 Scope 에서 획득한 chocolate 과 milk 의존성을 가지고 hotChocolate 을 생성했습니다.

이는 자식 Scope 인 MyChildComponent 에서 의존성을 정의해 접근할 수 있습니다. 이를 코드로 작성하면 다음과 같습니다.

protocol MyChildDependency {
var hotChocolate: Drink { get }
}

class MyChildComponent: Component<MyChildDependency> {
var veryHotChocolate: Drink {
// 상위 Scope 에서 hotChocolate 을 획득
return VeryHotChocolate(dependency.hotChocolate)
}
}

Needle 의 의존성 구성에 대해 가볍게 살펴봤으니 이제 실제 앱에 적용해보겠습니다.

먼저 최상위 컴포넌트를 정의합니다. 상위 Scope 이 없는 BootstrapComponent 를 활용합니다.

final class RootComponent: BootstrapComponent {}

이에 RootViewController 와 필요한 의존성들을 정의합니다. 로그인 화면과 로그아웃된 화면이 필요하기에 각각을 컴포넌트로 정의합니다.

RootComponent 예시

// RootComponent.swift

final class RootComponent: BootstrapComponent {
var playersStream: PlayersStream {
return mutablePlayersStream
}

// 해당 스코프에 객체가 하나로 유지되어야 하면 shared 를 활용해요
// RootComponent 에서 활용하면 싱글톤 패턴으로 활용 가능해요
var mutablePlayersStream: MutablePlayersStream {
return shared { PlayersStreamImpl() }
}

var rootViewController: UIViewController {
return RootViewController(
loggedOutBuilder: loggedOutComponent,
loggedInBuilder: loggedInComponent
)
}

var loggedOutComponent: LoggedOutComponent {
return LoggedOutComponent(parent: self)
}

var loggedInComponent: LoggedInComponent {
return LoggedInComponent(parent: self)
}
}

각 서브 컴포넌트 예시

protocol LoggedOutDependency: Dependency {
var mutablePlayersStream: MutablePlayersStream { get }
}

final class LoggedOutComponent: Component<LoggedOutDependency>, LoggedOutBuilder {

var loggedOutViewController: UIViewController {
return LoggedOutViewController(
mutablePlayersStream: mutablePlayersStream
)
}
}

// ViewController 를 지연 생성하기 위해 프로토콜과 computed property 활용
protocol LoggedOutBuilder {
var loggedOutViewController: UIViewController { get }
}

해당 컴포넌트들을 이용해 의존성을 그려줘야 합니다. Build Phase 로 이동해 빌드 이전에 Needle 을 실행해 NeedleGeneratd.swift (의존성 구성) 파일을 생성해주시면 됩니다. 간단하게 아래처럼 작성할 수 있습니다.

자세한 옵션들은 Github Needle 을 확인해주세요

brew install needle 를 사용해 needle 을 설치해주세요

이제 의존성을 연결하기 위해 메서드를 호출합니다. main.swift 혹은 AppDelegate 에서 registerProviderFactories 를 호출하면 의존성이 연결됩니다.

@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?

func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
registerProviderFactories()
}

Needle 동작방식

구현을 하다보니 Build Phase 에서 호출해주는 needle cli 와 registerProviderFactories 메서드가 어떤 동작을 하는지 궁금해졌습니다.

Build Phase 에서 사용하는 needle code generator 에 대해 알아보겠습니다.

needle code generator

needle code generator 는 개발자가 작성한 코드를 구문 분석해 Swift 소스 코드를 생성하는 커맨드라인 유틸리티입니다. 생성된 코드는 개발자가 작성하는 다양한 Component 서브클래스를 연결합니다. DI 그래프 구조에 따라 generator 는 각 Component 를 연결합니다. 생성된 코드는 앱에서 컴파일되며 완전한 DI 그래프를 제공합니다.

컴파일 안전성

Needle의 가장 큰 장점 중 하나는 컴파일 타임 안전성 보장입니다. Component에 필요한 의존성을 도달 가능한 상위 Component로 충족할 수 없는 경우, 생성된 Swift 코드는 컴파일에 실패합니다.

이 경우 Needle 은 의존성을 찾을 수 없다는 오류를 반환합니다.

Could not find a provider for (scoreStream: MyScoreStream) which was required by ScoreSheetDependency, along the DI branch of RootComponent->LoggedInComponent->ScoreSheetComponent.

개발자는 앱을 실행하지 않고 빌드만으로 DI 그래프가 올바른지 확인할 수 있습니다. 이런 보장을 통해 개발자는 확신있게 DI 코드를 작성하고 수정할 수 있습니다. Xcode 빌드가 성공하면 DI 코드에 대한 변경 사항이 올바른 것입니다.

Needle 알고리즘

needle code generator 는 대략 5단계로 실행됩니다.

첫번째

SourceKittenFramework를 통해 SourceKit을 사용하여 개발자가 작성한 모든 소스 Swift 파일을 분석합니다. generator 는 모든 Component 노드의 메모리 캐시와 DI 그래프의 정점을 나타내는 Dependency 프로토콜을 생성할 수 있습니다.

두번째

generator 는 모든 Component 의 부모-자식 관계를 서로 연결합니다. 이 작업은 어떤 Component가 다른 Component를 인스턴스화하는지 살펴봄에 따라 수행됩니다.

final class LoggedInComponent: Component<LoggedInDependency> {
var gameComponent: GameComponent {
return GameComponent(parent: self)
}
}

generator 는 위의 Swift 코드를 구문 분석하여 LoggedInComponentGameComponent의 부모임을 추론합니다.

세번째

Dependency 프로토콜에 선언된 각 Component의 의존성에 대해, generator 는 해당 Component에서 시작하여 위쪽으로 이동하며 의존성 객체를 찾기 위해 모든 상위 Component를 방문합니다.

의존성 객체는 속성의 변수 이름 및 유형이 모두 일치하는 경우에만 발견됩니다. generator 는 위쪽으로 이동하기 때문에 맨 위에 있는 DI 그래프의 루트에서 볼 때 가장 낮은 수준과 가장 가까운 의존성 객체가 항상 사용됩니다.

이 단계에서 의존성을 충족하는 객체를 찾을 수 없는 경우, generator 는 위에서 설명한 것과 같은 형태의 오류를 반환합니다. 의존성을 충족하는 객체가 발견되면 generator 는 다음 단계에서 사용할 경로를 메모리에 저장합니다.

네번째

generator 는 ComponentDependency 프로토콜을 준수하는 DependencyProvider 클래스를 생성하여 이전 단계에서 찾은 경로를 통해 의존성을 제공합니다. 이렇게 생성된 클래스는 두 번째 수준의 컴파일 시간 안전성도 제공합니다.

이전 단계에서 경로가 잘못 생성된 경우, 생성된 DependencyProvider클래스는 Dependency 프로토콜을 따르지 않기 때문에 컴파일되지 않습니다. 생성된 각 DependencyProvider에 대해 provider가 제공하는 Component로 연결되는 DI 그래프 경로에 대한 provider 등록 코드도 생성됩니다.

이것은 Needle의 API에서 이야기하는 registerProviderFactories 메소드의 출처입니다.

다섯번째

생성된 모든 DependencyProvider 클래스는 registration 코드와 함께 Swift 파일로 생성됩니다. 이 Swift 파일은 다른 소스 파일과 마찬가지로 Xcode 프로젝트에 포함되어야 합니다.

마무리

실제로 운영중인 앱의 의존성 구성은 더 복잡하기 때문에 저는 별도의 Builder 를 만들어 활용하고 있습니다. (RIBs 를 참고)

Needle 은 Uber 에서 만든 도구이지만, RIBs 를 포함한 여러 아키텍처에서 모두 사용할 수 있습니다. 스크립트에 의존하게 된다는 점이 단점으로 작용하기도 하지만, 컴파일 타임에서 안정성을 가져간다는 것만으로 사용할 가치가 있는 것 같습니다.

단순 트리 방식의 의존성을 구성하는 것 뿐만 아니라 PluginizedComponent 를 이용해 다양하게 구성할 수 있는데, 이는 이후에 별도의 글로 소개해보도록 하겠습니다.

의존성 주입 방식에 대해 고민하고 계신 분들께 Needle 을 한 번 사용해보시길 추천드리며 글을 마쳐봅니다.

당근마켓에서 함께할 iOS 엔지니어를 찾고 있어요. 당근마켓에 대해 궁금한 점이 있으시면 ray@daangn.com 으로 메일 남겨주세요. 가벼운 티타임도 가능해요!

--

--

Kanghoon
Kanghoon

Responses (1)