안드로이드 프로젝트에 Dagger2를 활용한 의존성 주입 기법을 도입하면서 몇가지 애로사항이 발생했다. 그중 가장 신경쓰어야 했던 부분은 Context를 주입하여야 할 경우였다. Dagger는 서드 파티 라이브러리에서 제공하는 객체, 혹은 안드로이드 시스템에서 생성하는 객체의 의존성을 암시적으로 처리할 수 없다. 이 말인 즉슨, 이런 경우에 해당하는 객체의 의존성을 의존성 그래프에 포함시킬 경우에는 명시적으로 그 생성 방식을 Dagger에게 알려주거나 의존성이 필요한 객체를 직접 Dagger에게 넘겨주는 방식을 사용하여 의존성 그래프에 해당 객체에 대한 의존성을 포함시켜야 한다.
서드파티 라이브러리에서 제공하는 객체의 의존성의 경우에는 Module 내부에서 @Provides annotation을 가진 함수를 이용하여 해당 객체의 생성 방법을 직접 Dagger에게 알려주어 Dagger가 해당 의존성이 필요한 곳에 지정된 생성 방식으로 객체를 생성하여 주입해 줄 수 있지만, 안드로이드 시스템에서 생성하는 객체의 경우에는 의존성 그래프를 생성할 때 외부에서 직접 객체를 넘겨주어 의존성 그래프에 포함시키는 방법을 사용해야만 한다. 따라서 이 글에서는 어떠한 방식으로 외부에서 직접 객체를 의존성 그래프로 넘겨줄 수 있는지에 때해 서술하고자 한다.
Dagger에서 미리 필요한 의존성을 정의하는 Module은 생성자를 가질 수 있는데, 이때 생성자를 통하여 외부에서 필요한 객체를 직접 의존성 그래프에 넘겨줄 수 있다.
@Module
class AppModule(val applicationContext: Context) {
@Provides
fun providesApplicationContext() = applicationContext
@Provides
fun providesAlarmManager(): AlarmManager {
return applicationContext.getSystemServie(
Context.ALARM_SERVICE) as AlarmManager
}
}
위 코드 스니펫에서 보여주는 바와 같이, 안드로이드 시스템이 제공하는 객체인 ApplicationContext를 의존성 그래프에 포함시켜 주입하기 위해서 Module의 생성자에서 val applicationContext: Context를 통해 외부에서 생성된 Context 객체를 받아 의존성 그래프에 포함시키는 방식을 사용하는 것을 볼 수 있다. 또한 @Provides annotation을 가진 함수를 사용하여 의존성 그래프 내에 필요한 곳에 넘겨 받은 Context 객체를 주입해 주는 것 또한 가능하다.
생성자를 통해 외부에서 의존이 필요한 객체를 넘겨 받는 구조를 가진 Module은 의존성 그래프 생성 전, 객체를 넘겨받아 초기화 하는 과정이 필요한데, 이는 연관된 Component를 생성할 때 처리할 수 있다. Dagger는 Component를 생성하는 방식을 명시적으로 정의하는 것이 가능하도록 Builder 와 Factory 라는 인터페이스를 제공한다. 물론 이러한 인터페이스도 Optional 이나, 따로 명시적으로 정의하지 않을 경우, Dagger는 내부적으로 Builder 인터페이스를 사용한다.
위와 같은 경우에는 Builder 패턴을 따르는 생성 방식을 제공하는 @Component.Builder 인터페이스를 사용할 수 있다. Builder 패턴을 따르는 생성 방식을 통해 생성 과정에서 메소드 체이닝 을 사용하여 생성과정에서 필요한 초기화 과정들을 수행할 수 있다.
@Component(
modules = [AppModule::class]
)
interface AppComponent {
@Component.Builder
interface Builder {
fun appModule(context: Context): AppModule
// ...
fun build(): AppComponent
}
}
class MainApplication(): Application() {
...
val appComponent = DaggerAppComponent.builder()
.appModule(this)
.build()
}
위 코드 스니펫과 같이 Dagger가 구현한 DaggerAppComponent는 미리 정의된 @Component.Builder또한 구현하며 이를 통해 생성 과정에서 AppModule을 필요한 파라미터와 함께 초기화할 수 있다.
@BindsInstance Annotation물론 필요한 외부 의존성을 Module의 생성자를 통해 직접 의존성 그래프에 포함시킬 수 있으나, 여러 Module에서 범용적으로 필요한 의존성을 포함하여 할 경우에는 적절한 방식이 아닐 수 있다. 매 Module의 생성자에 필요한 객체를 파라미터로 받도록 정의한다면 Component의 생성과정에서 과도한 메소드 체이닝이 필요한 상황이 생길 수 있기 때문이다. 따라서, Dagger는 이렇게 범용적으로 필요한 외부 의존성을 의존성 그래프에 포함시킬 수 있도록 @BindsInstance라는 annotation을 제공한다.
@BindsInstance annotation은 @Provides annotation을 가진 함수를 통한 의존성 주입과는 다르게, Component를 통한 의존성 그래프가 생성되기 이전에 수신한 객체를 Component에 포함시키는 방식을 사용한다. 이렇게 Component에 포함된 객체는 Component가 의존성 그래프를 생성하는 과정에서 필요한 위치에 주입되어 사용된다. 예를 들어, ModuleA와 ModuleB 모두가 ApplicationContext에 대한 의존성이 필요한 경우, Module의 생성자를 통해 의존성 그래프에 포함시킨다면 아래와 같이 구현할 수 있다.
@Module
class ModuleA(val applicationContext: Context) {
...
}
@Module
class ModuleB(val applicationContext: Context) {
...
}
@Component(
modules = [ModuleA::class, ModuleB::class]
)
interface AppComponent {
@Component.Builder
interface Builder {
fun moduleA(context: Context): Builder
fun moduleB(context: Context): Builder
fun build(): AppComponent
}
}
val appComponent = DaggerAppComponent.builder()
.moduleA(this)
.moduleB(this)
.build()
이러한 구조는 해당 외부 의존성을 사용하는 Module의 갯수가 많아질수록 더 많은 메소드 체이닝과 더 많은 boilderplate code들을 생성한다. 하지만, @BindsInstance annotation을 사용할 경우, 구현은 다음과 같아진다.
@Module
class ModuleA {
@Provides
fun providesAlarmManager() = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
}
@Module
class ModuleB {
@Provides
fun providesNotificationManager() = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
@Component(
modules = [ModuleA::class, ModuleB::class]
)
interface AppComponent {
@Component.Builder
interface Builder {
@BindsInstance
fun applicationContext(context: Context): Builder
fun build(): AppComponent
}
}
val appComponent = DaggerAppComponent.builder()
.applicationContext(this)
.build()
위 코드 스니펫처럼 범용적 외부 의존성인 ApplicationContext를 Component에 포함시키는 방식을 사용함으로서 해당 Component가 생성하는 의존성 그래프 내에서 ApplicationContext가 필요로 하는 위치에 해당 객체가 주입되어 사용될 수 있도록 한다. 이를 통해 이전 방식이 가졌던 문제점인 과도한 메소드 체이닝 및 boilerplate code 증가를 효과적으로 해소할 수 있으며, 나아가 확장성까지 보장한다. 하지만, 위와 같은 방법 또한 문제를 완전히 해소하는 것은 아니다. 만약 범용적으로 필요한 외부 의존성이 늘어날 경우, 각 의존성별로 @BindsInstance annotation을 사용한 메소드를 Builder 내부에 정의해주어야 하며 이는 마찬가지로 boilerpate code의 증가 및 과도한 메소드 체이닝을 야기할 수 있다. 이것을 해결하기 위하여 Dagger가 제공하는 또다른 생성방식인 @Component.Factory 인터페이스를 사용할 수 있다.
@Component.Factory + @BindsInstanceFactory 패턴을 따르는 생성방식은 Builder 패턴을 따르는 방식과는 다르게 보다 추상적인면이 강하다. @Component.Factory는 하나의 추상 메서드 (create())를 통해 필요한 모든 외부 의존성을 한번에 전달 받아 Component를 생성한다. 하지만 @Component.Builder의 경우, 필요한 외부 의존성들은 순차적으로 전달받아 (메소드 체이닝) Component를 설정한 후 생성한다. 따라서 @Component.Factory를 사용한다면, 이전에 식별된 문제인 과도한 메소드 체이닝 및 boilerplate code의 증가 문제를 효과적으로 해결할 수 있다.
@Module
class ModuleA {
@Provides
fun providesAlarmManager() = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
}
@Module
class ModuleB {
@Provides
fun providesNotificationManager() = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
@Component(
modules = [ModuleA::class, ModuleB::class]
)
interface AppComponent {
@Component.Factory
interface Factory {
fun create(@BindsInstance context: Context): AppComponent
}
}
val appComponent = DaggerAppComponent.factory().create(this)
@Component.Builder를 사용한 구현 방식과 비교해 보았을 때, 코드가 더욱 간결해진 것을 확인할 수 있다. 물론 @Component.Factory를 사용할 경우 장점만 있는 것은 아니다. @Component.Builder와 비교했을때 생성 과정에서 유연성이 떨어지는 점이 있다. 만약 상황에 따른 선택적 설정이 필요한 경우, @Component.Factory는 적합하지 않을 수 있다.
이번 글에서는 Dagger를 통한 의존성 주입 기법에서 외부 의존성이 필요한 경우, 해당 의존성을 의존성 그래프에 포함시킬 수 있는 방법에 대해 살펴보았다. 살펴본 세가지 방법 모두 장단이 존재하며 이러한 장점과 단점들 사이에서 상황에 맞는 가장 이상적인 방법을 선택하여 사용하는 것이 바람직할 것이다.