A 라는 클래스에서 a,b,c 클래스의 기능을 이용할때 이를 A 는 a, b, c 에 의존하고 있다고 한다.
이때 a, b, c 가 구현체일 경우 구현 방식이 변경될때 굉장히 버거운 작업이 되지 않을까? a 를 주입받은 모든 클래스에서 오류가 발생할 수 있다. 하지만 A 와 a 를 매개하는 인터페이스를 중간에 두고, a 라는 구현체를 주입해준다고? 이러한 문제에서 자유로울 수 있다. 이러한 것을 가능케 하는것이 DI 이다.
또한 DI 는 이러한 구현체의 주입을 집중해서 관리할 수 있도록 해주어 관리가 한층 수월해지며, 중간에 인터페이스를 중계하도록 하기 때문에 테스트에서도 이점을 얻을 수 있다.
검증되어있고, 가장 최신의 DI 기술은 Hilt 이다. Hilt 라이브러리는 구글에서 제공하는 것으로, Dagger 라이브러리를 기반으로 작성되었다.
Hilt는 클래스 생성시 필요한 의존성 주입 코드를 컴파일 타임에 자동으로 작성해줌으로써 반복되는 코드 작성량을 줄일 수 있고, 의존성 변경을 손쉽게 할 수 있도록 도와주어 테스트 코드 작성을 용이하게 해준다.
Hilt 를 이용하기 위해서는 당연히 의존성을 설정해 줘야하고
// project 수준
buildscript {
ext.hilt_version = "2.38.1"
repositories {
google()
mavenCentral()
}
dependencies {
classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
}
}
// app수준
plugins {
id 'dagger.hilt.android.plugin'
}
dependencies {
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
implementation "androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha03"
kapt "androidx.hilt:hilt-compiler:1.0.0"
}
Hilt를 이용해서 종속성을 주입하는 방법을 알아보자.
// Application() 클래스를 확장한 클래스에 @HiltAndroidApp 에노테이션을 붙여줘야한다.
@HiltAndroidApp
class GlobalApp: Application()
// 그리고 의존성을 주입받은 activity 등은 @AndroidEntryPoint 에노테이션을 붙여줘야한다.
@AndroidEntryPoint
class MainActivity: AppCompatActivity() {
// @Inject를 통해 주입된 의존성은 super.onCreate() 가 호출될때 초기화 되며 따라서 그 이후에 사용가능해진다.
@Inject lateinit var player: MusicPlayer
override onCreate(savedInstanceState: Bundle) {
super.onCreate(bundle)
player.play("...")
}
}
// MusicPlayer 라는 클래스에 별도의 설정을 해줌으로써 가능해진다.
// 이때 constructor 에 들어가는 변수들은 생성자 주입이 된다고 하여 hilt 에서 관리한다.
// 이와는 달리 activity 에서는 필드 주입 방식이 사용되었다.
class MusicPlayer @Inject constuctor() {
fun play(id: String) {...}
}
위와 같은 방식으로 종속성을 주입하지 못하는 경우가 있는데, Interface 타입에 대한 주입, 그리고 외부 라이브러리의 클래스는 주입할 수 없다. 또한 빌더 패턴으로 생성해야하는 인스턴스는 생성자 삽입이 불가능하다. 클래스가Concrete 클래스가 아닌 Interface 에 의존하도록 하는 것이 테스트를 쉽게 만드는 핵심인데, 이러한 방법이 제한되는 것은 치명적이다.
이러한 경우에 Module 이라는 것을 이용한다. @Module 로 주석처리된 클래스는 Hilt에게 특정 타입의 인스턴스를 제공하는 방법을 Hilt 에게 알려준다.
즉, Interface 타입을 요청받았을때 어떤 인스턴스를 제공해야하는지 설정을 해두는 파일이라고 보면 될 것이다.
이 모듈 클래스에 @InstallIn 에노테이션을 지정하여 해당 의존성의 라이프 싸이클을 결정한다. 이렇게 생성된 모듈 클래스는 @Binds 과 @provides를 이용하여 해당 인터페이스에 어떤 인스턴스를 주입할지 지정한다.
@Binds 주석은이 달린 함수를 추상 함수여야하며, 인터페이스의 인스턴스를 제공해야할 때 사용할 구현체를 Hilt에게 알려준다.
함수의 반환 타입은 어떤 인터페이스에 대한 인스턴스를 제공하는지 알려주어야 하며, 매개변수는 제공할 구현체에 대한 정보를 담고 있어야 한다.
@Module
@InstallIn(ServiceComponent::class)
abstract class ServiceModule {
@Binds
abstract fun bindLoginService(
kakaologin: KaKaoLoginService): KakaoLoginInterface
}
외부 라이브러리에서 제공하는 클래스인 경우, 또는 빌더 패턴으로 인스턴스를 생성해야하는 경우(@Binds 는 추상 함수이므로 body를 갖지 못한다)
@Module
@InstallIn(SingletonComponent::class) {
@Singleton
@Provides
fun injectNonAuthRetrofitApi(): NonAuthAPI {
return Retrofit.Builder()
.baseUrl(liveServer)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(NonAuthAPI::class.java)
}
@Singleton
@Provides
fun injectNonAuthRepo(retrofit: NonAuthAPI): NonAuthRepoInterface = NonAuthRepo(retrofit)
}
OkHttpClient 를 제공하는데, Interceptor 를 이용하여 token 을 검사하고 refresh 해야하는 client 와 다른 기능이 필요한 Interceptor 가 있다고 하자, 위의 경우처럼 아예 retrofit api를 주입할때 서로 다른 client 를 삽입하는 방식으로 접근할 수도 있겠지만 한정자(@Qualifier)를 이용한 방법도 있다.
한정자 설정
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class AuthInterceptorOkHttpClient
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class OtherInterceptorOkHttpClient
각 한정자에 맞는 구현체 정보를 제공
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@AuthInterceptorOkHttpClient
@Provides
fun provideAuthInterceptorOkHttpClient(
authInterceptor: AuthInterceptor
): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.build()
}
@OtherInterceptorOkHttpClient
@Provides
fun provideOtherInterceptorOkHttpClient(
otherInterceptor: OtherInterceptor
): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(otherInterceptor)
.build()
}
}
@Inejct와 함께 한정자 지정된 한정자 에너테이션을 이용하여 생성자 주입과 필드 주입에서 모두 활용이 가능하다.
class ExampleServiceImpl @Inject constructor(
@AuthInterceptorOkHttpClient private val okHttpClient: OkHttpClient
) : ...
// At field injection.
@AndroidEntryPoint
class ExampleActivity: AppCompatActivity() {
@AuthInterceptorOkHttpClient
@Inject lateinit var okHttpClient: OkHttpClient
}
Module 클래스는 InstallIn 에노테이션을 통해 종속성 주입 스코프를 결정할 수 있다. 주입된 종속성의 라이프 싸이클이 이 스코프에 의해 결정된다.
이들을 Component 라고 부른다.
만약 SingltonComponent 에 모든 의존성을 설정해 놓으면 당장은 편하겠지만, 어플리케이션의 모든 활동에서 메모리에 공간을 차지하고 있기 때문에 앱 성능에 부정적인 영향을 끼칠 것이다. 적절한 범위를 설정하는 것이 필요한 이유다.
이러한 compnent들은 계층 구조를 가지고 있어서, 하위 compnent는 상위 compnent에 설정된 종속성을 주입받을 수 있다. 이러한 계층 구조를 이용하여 효율적으로 의존성 주입 스코프를 이용할 수 있을 것이다.
일반적인 Class 에도 Hilt의 종속성 주입을 이용할 수 있다. 하지만 ContentProvider 를 확장한 클래스에 대해서는 종속성 주입을 제공하지 않는데, 이때는 @EntryPoint 주석을 사용하여 진입점을 만들면 종속성 주입을 이용할 수 있다. 이러한 설정은 원하는 종속성 주입 요소마다 되어야 한다.
class ExampleContentProvider : ContentProvider() {
@EntryPoint
@InstallIn(ApplicationComponent::class)
interface ExampleContentProviderEntryPoint {
fun analyticsService(): AnalyticsService
}
...
}