2020년 8월 1일 토요일

[안드로이드] Dagger & Hilt 기본개념 설명

Dagger 기본

gradle 설정

모듈레벨의 build.gradle에 다음을 추가합니다.
// 코틀린인 경우에 추가
//apply plugin: 'kotlin-kapt'
...
dependencies {
    implementation "com.google.dagger:dagger:2.28.2"
    // 자바
    annotationProcessor "com.google.dagger:dagger-compiler:$dagger2_version"
    // 코틀린
    //kapt "com.google.dagger:dagger-compiler:$dagger2_version"
}

Module

프로그램에서 사용하는 객체를 실제로 생성/제공해주는 역할을 담당합니다. Dagger에서는 특정 객체가 필요할 때, 그 객체를 제공하는 모듈이 있는지 확인하여, 모듈로부터 객체를 제공받아서 의존성주입을 수행합니다. 모듈 클래스의 기본구조는 다음과 같습니다.
class TestData {...}

@Module
class AppModule {
    @Provides
    fun provideTestData(): TestData = TestData()
}
모듈 역할을 수행할 클래스에 @Module 어노테이션을 추가해서 Dagger에게 모듈의 존재를 알려줍니다. 모듈이 어떤 객체를 제공할 수 있는지는 반환타입이 있는 메서드에 @Provides 어노테이션을 붙여줌으로써 지정할 수 있습니다.
Note : 클래스명과 메서드명은 아무렇게나 해도 상관없지만 모듈 클래스명은XXXModule로 하고, 객체를 제공하는 메서드명은 provideXXX() 하도록 권장하고 있습니다.

Component

외부에서 객체를 사용할 때에는 모듈로부터 직접 받는것이 아니라, 컴포넌트라는 인터페이스를 통하여 객체를 제공받아야 합니다. 즉, 컴포넌트는 외부에서 객체를 제공받기 위해 사용하는 인터페이스입니다. 컴포넌트의 기본구조는 다음과 같습니다.
@Component(modules = [AppModule::class])    //여러 모듈을 넣을 수 있음
interface AppComponent {
    fun getTestData(): TestData
}
컴포넌트 역할을 수행할 인터페이스에 @Component어노테이션을 추가해서 Dagger에게 컴포넌트의 존재를 알려줍니다. 어노테이션의 modules 필드에는 이 컴포넌트가 객체를 외부에 제공하기 위해서 필요한 모듈들을 의미합니다. Dagger라이브러리는 앱을 컴파일할 때, 이 어노테이션이 붙은 인터페이스들의 실제구현체 클래스를 자동생성해 줍니다.
Note : 인터페이스명과 메서드명은 아무렇게나 해도 상관없지만, 모듈과 비슷하게 컴포넌트 인터페이스명은 XXXComponent로 권장하고 있습니다. 또한, 컴파일후에 자동생성되는 구현체 클래스의 이름은 인터페이스명 앞에 Dagger가 prefix로 추가됩니다. (ex DaggerAppComponent)

컴포넌트에 요청하여 객체 직접 얻기

Dagger를 사용하는 목적은 객체간 의존성의 자동주입이지만, 필요하다면 객체를 직접 얻어서 사용할 수도 있습니다. 다음의 코드는 컴포넌트를 생성하고 객체를 얻는 방법을 보여줍니다.
//Factory or Builder 패턴
val appComponent: AppComponent = DaggerAppComponent.create()
//val appComponent: AppComponent = DaggerAppComponent.builder().build()
val testData: TestData = appComponent.getTestData()
자동 생성되는 컴포넌트 클래스에는 기본 Factory와 Builder가 자동적으로 포함되어 있어서, 이걸 이용하여 컴포넌트 객체를 생성할 수 있습니다.

Inject

Dagger가 객체들을 자동으로 생성하고 의존성을 관리하려면, 객체들이 의존성을 어떻게 주입받을지를 Dagger에게 명시해주어야 합니다. Dagger는 객체가 의존성을 주입받는 방법으로 다음 2가지를 지원합니다.
  • 생성자를 통한 주입
  • 멤버필드에 직접 주입

생성자를 통한 주입

클래스에서 필요한 의존 객체를 생성자로부터 주입받는 방법입니다. @Inject 어노테이션을 생성자의 앞에 붙여주어 Dagger에게 이 클래스를 생성할 때 객체를 주입해야 한다는 사실을 알려줍니다.
class TestData2 @Inject constructor (
    private val testData: TestData
) {...}
이 클래스의 생성을 모듈에서 하도록 추가해두면 추후에 객체가 생성될 때, 생성자에 넣을 객체를 모듈의 @Providers 메서드들로부터 제공받게 됩니다.
@Module
class AppModule {
    @Provides
    fun provideTestData(): TestData = TestData()
    
    @Provides
    fun provideTestData2(testData: TestData) = TestData2(testData)
}

멤버변수에 직접 주입

클래스에서 필요한 의존 객체를 멤버변수에 직접 지정하는 방법입니다. 멤버변수 앞에 @Inject 어노테이션을 붙여주어 Dagger에게 이 클래스에는 어떤 의존성이 주입되어야 하는지 알려줍니다. 이 방법은 모듈에서 객체를 직접 생성할 수 없는 경우에 사용할 수 있습니다. (ex 안드로이드의 컴포넌트들)
class TestData2 {
    @Inject
    lateinit var testData: TestData
}
외부에서 객체를 생성한 다음에 컴포넌트의 인자로 객체를 넘겨주면, Dagger가 @Inject어노테이션이 붙은 멤버변수들에게 객체를 주입해 줍니다.
// TestData2객체를 반환하는 메서드는 없어도 됨
@Module
class AppModule {
    @Provides
    fun provideTestData(): TestData = TestData()
}

// 외부에서 생성된 TestData2 객체를 받는 메서드만 존재
@Component(modules = [AppModule::class])
interface AppComponent {
    fun inject(testData2: TestData2)
}

// 실제 사용
val testData2: TestData2()
DaggerAppComponent.create().inject(testData2)

Binding Instance

객체를 생성할 때, 모듈에서 제공(@Provides)하는 것만으로는 인자를 주입하기 어려운 경우가 종종 있습니다. 가장 쉬운 예로는 안드로이드에서 객체 생성에 Context 객체가 필요한 경우입니다. Context는 모듈에서 생성할 수가 없기때문에, 이미 생성된 객체를 밖에서 받아야 합니다. 다음 예시와 같은 경우를 가정해 봅시다.
class TestData @Inject constructor (
    private val context: Context
) {...}

@Module
class AppModule {
    @Provides
    fun getTextData(context: Context) = TestData(context)
}
getTextData() 메서드에서 context 객체를 필요로 하지만, 모듈에서는 Context 객체를 제공해 줄 수가 없는 상황입니다. 이런 경우에는 필요한 context 객체를 컴포넌트를 이용하여 외부로부터 받아야 합니다.
@Component(modules = [AppModule::class])
interface AppComponent {
    fun getTestData(): TestData
    
    @Component.Builder
    interface Builder {
        fun setContext(@BindsInstance context: Context): Builder
        fun build(): AppComponent
    }
}

// 실제 사용
class TestApplication: Application() {
    ...
    override fun onCreate() {
        super.onCreate()
        val appComponent = DaggerAppComponent.Builder().setContext(this).build()
        val testData: TestData = appComponent.getTestData()
    }
}
Dagger로부터 자동 생성되는 구현체 클래스는 빌더 클래스도 자동으로 생성해 주지만, 별도의 인터페이스에@Component.Builder 어노테이션을 붙여서 빌더에 인자를 추가할 수도 있습니다. 추가하는 인자에 @BindsInstance 어노테이션을 붙여주어 외부로부터 객체를 받았음을 Dagger에 알려줍니다. Dagger는 모듈에서 이 인자를 필요로하는 경우에 자동으로 바인딩을 수행합니다.
Note : 빌더 인터페이스에는 컴포넌트를 반환하는 build() 메서드를 반드시 추가해 주어야 합니다.

Scope

컴포넌트는 외부로부터 객체를 요청받거나 inject 시점에 모듈에게 객체를 요청하는데, 이 때 모듈은 기본적으로 매번 새로운 객체를 생성하여 제공합니다. 그러나, 때로는 객체를 매번 재생성하는게 좋지 않을 수도 있습니다. Dagger에서는 이전에 생성했던 객체를 반환하도록 할 수 있습니다.

@Singleton

Dagger에서 이미 생성된 객체를 재사용하는 가장 쉬운 방법은 @Singleton 어노테이션을 사용하는 것입니다. 이 어노테이션을 다음과 같이 모듈과 컴포넌트에 붙여줍니다.
// provide 메서드에 @Singleton 어노테이션 붙여줌
@Module
class AppModule {
    @Singleton
    @Provides
    fun provideTestData(): TestData = TestData()
}

// 컴포넌트 클래스에 @Singleton 어노테이션 붙여줌
@Singleton
@Component(modules = [AppModule::class])
interface AppComponent {
    fun getTestData(): TestData
}

// 실제 사용 - testData1, testData2는 동일객체
val appComponent = DaggerAppComponent.create()
val testData1 = appComponent.getTestData()
val testData2 = appComponent.getTestData()
@Singleton어노테이션은 객체의 수명(Scope)을 지정하고 Dagger에 알려주는 역할을 합니다. provide 메서드에 이 어노테이션을 사용하여 @Singleton이라는 수명을 가진 객체를 제공할 것을 명시했으며, 컴포넌트 클래스에 이 어노테이션을 사용하여 @Singleton이라는 수명은 컴포넌트 객체의 수명과 동일하도록 지정했습니다. 즉, 해당 컴포넌트의 객체가 살아있는 동안에는 계속 같은 객체를 사용하게 됩니다.
// 실제 사용 - testData1, testData2는 다른객체
val appComponent1 = DaggerAppComponent.create()
val appComponent2 = DaggerAppComponent.create()
val testData1 = appComponent1.getTestData()
val testData2 = appComponent2.getTestData()
위의 사용 예시에서는 appComponent1과 appComponent2가 각각 생성된 다른 객체입니다. 그렇기 때문에 testData1과 testData2도 다른 수명의 다른 객체입니다.
Note : @Singleton은 javax.inject 패키지 하위에 있는 어노테이션으로 Dagger의 것은 아닙니다.

커스텀 Scope

스코프를 지정하기 위해 직접 어노테이션을 만들어 사용할 수도 있습니다. @Scope어노테이션을 사용하여 우리가 사용할 어노테이션을 먼저 만들어야 합니다.
@Scope
@kotlin.annotation.Retention(AnnotationRetention.RUNTIME)
annotation class CustomScope
만든 어노테이션은 @Singletone을 대체하여 사용할 수 있습니다.
// provide 메서드에 @Singleton 어노테이션 붙여줌
@Module
class AppModule {
    @CustomScope
    @Provides
    fun provideTestData(): TestData = TestData()
}

// 컴포넌트 클래스에 @Singleton 어노테이션 붙여줌
@CustomScope
@Component(modules = [AppModule::class])
interface AppComponent {
    fun getTestData(): TestData
}

// 실제 사용 - testData1, testData2는 동일객체
val appComponent = DaggerAppComponent.create()
val testData1 = appComponent.getTestData()
val testData2 = appComponent.getTestData()
얼핏 보기에 스코프를 직접 만들어 사용하는 것은 불필요해 보일 수 있지만, 여러 컴포넌트가 있거나 서브컴포넌트가 있는 경우에 객체의 수명을 관리하기 위해 필요합니다.
Note : @Scope은 javax.inject 패키지 하위에 있는 어노테이션으로 Dagger의 것은 아닙니다.

SubComponent

Dagger에서는 복잡한 의존성 그래프를 효율적으로 관리하기 위해 서브컴포넌트를 지원합니다. 즉, 컴포넌트간에 상하관계를 지정할 수 있습니다. 서브컴포넌트는 기본적으로 컴포넌트를 구성하는 방법과 동일하되, @SubComponent 어노테이션을 사용하는 점과 @SubComponent.Builder어노테이션으로 반드시 빌더를 구성해주어야 한다는 점만 다릅니다.
class TestData {...}
class TestData2 {
    @Inject
    lateinit var testData: TestData
}

// 서브컴포넌트에서 사용할 새로운 모듈
@Module
class TestModule {
    @Provides
    fun provideTestData(): TestData = TestData()
}

// 서브컴포넌트
@Subcomponent(modules = [TestModule::class])
interface AppSubComponent {
    fun getTestData(): TestData
    fun inject(testData2: TestData2)
    
    // 기존 컴포넌트와는 다르게 명시적으로 선언해 주어야 외부에서 사용가능함
    @Subcomponent.Builder
    interface Builder {
        fun build(): AppSubComponent
    }
}
위와 같이 서브컴포넌트를 구성한 후에, 이를 상위컴포넌트와 연결해주어야 합니다.
// 모듈에서 서브컴포넌트를 지정함
@Module(subcomponents = [AppSubComponent::class])
class AppModule {
    @Provides
    fun provideTestData2(): TestData2 = TestData2()
}

// 서브컴포넌트의 빌더를 얻어오는 메서드 추가함
@Component(modules = [AppModule::class])
interface AppComponent {
    fun getTestData2(): TestData2 = TestData2()
    fun getSubComponent(): AppSubComponent.Builder
}

// 실제 사용
val appComponent = DaggerAppComponent.create()
val testData2 = appComponent.getTestData2()

val subComponent = appComponent.getSubComponent().build()
subComponent.inject(testData2)

Dagger Hilt

Dagger Hilt 는 Dagger를 안드로이드에서 좀 더 쉽게 사용할 수 있도록, Jetpack에 추가된 DI 라이브러리입니다. Hilt는 Dagger2를 기반으로 하여 Dagger의 개념을 기본적으로 차용하고 있기 때문에, 기존에 Dagger를 썼다면 쉽게 마이그레이션 할 수 있습니다.

Gradle 설정

프로젝트 레벨의 build.gradle에 다음을 추가합니다.
buildscript {
    ...
    dependencies {
        ...
        classpath 'com.google.dagger:hilt-android-gradle-plugin:2.28-alpha'
    }
}
모듈 레벨의 build.gradle에 다음을 추가합니다.
// 코틀린인 경우에 추가
//apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'
...
dependencies {
    implementation "com.google.dagger:hilt-android:2.28.1-alpha"
    // 자바
    annotationProcessor "com.google.dagger:hilt-android-compiler:2.28.1-alpha"
    // 코틀린
    //kapt "com.google.dagger:hilt-android-compiler:2.28.1-alpha"
}
Note : Dagger의 의존성이 이미 설정되어 있었다면, Hilt의 의존성으로 대체하고 다시 빌드해줍니다.

Hilt Components

기본적인 Dagger에는 앱 코드에서 컴포넌트를 직접 초기화하고 객체를 얻거나 전달하는 등의 동작이 필요했지만, Hilt는 컴포넌트를 직접 초기화하지 않도록 하고 있습니다. Hilt에는 안드로이드 앱의 라이프사이클을 고려한 컴포넌트들과 스코프들을 미리 생성하고 제공해 주고 있습니다.




위의 그림은 Hilt에서 제공해주고 있는 컴포넌트와 관련 스코프의 관계를 보여주고 있습니다. 화살표의 시작에 있는 컴포넌트는 상위 컴포넌트이고, 화살표의 끝에 있는 컴포넌트는 하위 컴포넌트입니다. Dagger와 마찬가지로 하위 컴포넌트에서는 상위 컴포넌트의 의존성을 모두 사용할 수 있습니다. 아래 표는 각 컴포넌트의 특성들을 나열한 것입니다.
ComponentScopeCreate atDestroyed atinjector forDefault Binding
ApplicationComponent@SingletonApplication#onCreate()Application#onDestroy()ApplicationApplication
ActivityRetainedComponent@ActivityRetainedScopeActivity#onCreate()Activity#onDestroy()ViewModelApplication
ActivityComponent@ActivityScopedActivity#onCreate()Activity#onDestroy()ActivityApplication, Activity
FragmentComponent@FragmentScopedFragment#onAttach()Fragment#onDestroy()FragmentApplication, Activity, Fragment
ViewComponent@ViewScopedView#super()View destroyedViewApplication Activity, View
ViewWithFragmentComponent@ViewScopedView#super()View destroyed@WithFragmentBindings 있는 ViewApplication, Activity, Fragment, View
ServiceComponent@ServiceScopedService#onCreate()Service#onDestroy()ServiceApplication, Service
Injector for 컬럼은 각 컴포넌트가 멤버필드 주입을 수행하는 대상을 나타냅니다. @AndroidEntryPoint 어노테이션을 Injector for에 해당하는 클래스에 붙여주면 컴포넌트들이 해당 클래스에 멤버필드 주입을 수행하게 됩니다. Default Binding 컬럼은 컴포넌트에 기본적으로 바인딩되는 클래스를 의미합니다. Dagger에서 @BindsInstance 어노테이션으로 컴포넌트에 바인드하는 것과 동일합니다.

커스텀 컴포넌트 정의하기

기본 제공되는 컴포넌트 외에도 DefineComponent어노테이션을 이용하여 컴포넌트를 추가로 정의할 수도 있습니다. (Dagger의 @Component와 비슷합니다.)
// 별도 스코프 지정
@Scope
@MustBeDocumented
@Retention(value = AnnotationRetention.RUNTIME)
annotation class TestScope

// Hilt 방식의 Component 추가
@TestScope
@DefineComponent(parent = ApplicationComponent::class)
interface AppComponent {
    // Builder 추가 - 방식은 기존 Dagger와 동일
    @DefineComponent.Builder
    interface Builder {
        fun setTestData(@BindsInstance testData: TestData): AppComponent.Builder
        fun build(): AppComponent
    }
}
기존 Dagger의 컴포넌트와 비슷한 선언방식이지만, 한가지 다른 점은 @DefineComponent의 parent 필드를 지정한 것입니다. parent는 해당 컴포넌트의 상위 컴포넌트를 지정해주는 필드로, 반드시 Hilt에서 기본 제공하는 컴포넌트중 하나여야 합니다.

Hilt Module

기존 Dagger에서 사용하는 모듈을 Hilt에서도 그대로 사용할 수 있습니다. 단, @InstallIn어노테이션을 모듈클래스에 추가로 명시해 주어야 합니다. 이 어노테이션은 모듈이 어떤 컴포넌트에 연결(인스톨)되어야 할 지를 지정해 줍니다. (기존 Dagger에서는 @Component / @Subcomponent에서 수행)
@Module
@InstallIn(ApplicationComponent::class)
class AppModule {
    @Provides
    fun provideTestData(application: Application): TestData = TestData()
}
모듈에서는 @InstallIn로 연결된 컴포넌트에서 바인딩된 안드로이드 클래스의 객체를 인자로 받을 수도 있습니다.

모듈에 여러 컴포넌트를 연결하기

하나의 모듈에는 여러 Hilt 컴포넌트를 연결할 수도 있습니다. @InstallIn에 Array로 값을 주면 됩니다.
@InstallIn({ViewComponent::class, ViewWithFragmentComponent::class})
단, 이 경우에는 세가지 규칙이 있습니다.
  • @Provides는 연결한 컴포넌트들이 모두 동일한 스코프에 속해있을 경우에만 스코프를 지정할 수 있습니다.
  • @Provides는 연결한 컴포넌트가 서로 간 요소에게 접근이 가능한 경우에만 주입이 가능합니다. 예를들어, ViewComponent와 ViewWithFragmentComponent는 서로 간 요소에 접근이 가능하기 때문에 View에 주입이 가능하지만, FragmentComponent와 ServiceComponent는 한쪽에게 주입이 불가능합니다.
  • 상위 컴포넌트와 하위 컴포넌트에 동시에 연결될 수 없습니다. 대신, 하위 컴포넌트는 상위 컴포넌트의 모듈에 접근할 수 있습니다.

Hilt Application

Hilt를 사용하려면 반드시 @HiltAndroidApp어노테이션을 붙인 Application 클래스가 있어야 합니다. 이 어노테이션은 Hilt의 컴포넌트들을 생성하는 역할을 담당합니다. 또한 @AndroidEntryPoint어노테이션을 붙인 것처럼, Application에 멤버필드 주입을 수행합니다.
@HiltAndroidApp
class TestApplication: MultiDexApplication {
    @Inject
    lateinit var testData: TestData
    
    override fun onCreate() {
        // super.onCreate()가 수행될 때 멤버필드 주입이 완료됨
        super.onCreate()
        // testData 사용 가능
        ...
    }
}

Android Entry Point

@AndroidEntryPoint어노테이션을 이용하여 Application을 제외한 안드로이드 프레임워크의 클래스들에 멤버필드 주입을 수행할 수 있습니다. (@HiltAndroidApp어노테이션의 동작이 선행되어야 합니다.) 이 어노테이션을 붙일 수 있는 클래스는 다음과 같습니다.
  • Activity
  • Fragment
  • View
  • Service
  • BroadcastReceiver
Note : ContentProvider의 경우는 Application보다도 먼저 onCreate가 불리기 때문에 이 어노테이션을 이용한 주입을 지원하지 않습니다. ViewModel의 경우는 JetPack의 추가 라이브러리를 사용해야 합니다.
다음은 Activity의 멤버필드 주입을 사용하는 예시입니다. 다른 클래스들도 동일한 방법으로 사용할 수 있습니다.
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    // Bindings in ApplicationComponent or ActivityComponent
    @Inject lateinit var testData: TestData

    override fun onCreate(savedInstanceState: Bundle?) {
        // super.onCreate()가 수행될 때 멤버필드 주입이 완료됨
        super.onCreate()
        // testData 사용 가능
        ...
    }
}
Note : 현재 Hilt는 AndroidX의 Activity / Fragment만 지원하고 있습니다.

Retained Fragment

Fragment는 setRetainInstance(true) 메서드를 이용하여 Activity의 ConfigurationChange 시점에도 인스턴스를 보존하는 API를 지원하고 있습니다. (이 메서드는 JetPack의 Fragment에서 Deprecate 되었습니다.) Hilt에서는 컴포넌트가 멤버필드 주입 등의 이유로 Activity의 레퍼런스를 가지고 있기 때문에, 프레그먼트의 인스턴스가 보존된다면 메모리 릭이 발생하게 됩니다. 그래서 Configuration Change중에 setRetainInstance(true) 중인 Hilt Fragment가 발견되면 런타임 크래시를 발생시킵니다.

뷰에서의 Fragment binding

기본적으로는 ApplicationComponent와 ActivityComponent바인딩만 뷰에 주입이 됩니다. 뷰에서 Fragment 바인딩을 활성화하려면, @WithFragmentBindings어노테이션을 뷰 클래스에 추가해줍니다.
@AndroidEntryPoint
@WithFragmentBindings
class MyView : View {
  // Bindings in ApplicationComponent, ActivityComponent,
  // FragmentComponent, and ViewComponent
  @Inject lateinit var bar: Bar

  constructor(context: Context) : super(context)
  constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)

  init {
    // Do something with bar ...
  }

  override fun onFinishInflate() {
    super.onFinishInflate();

    // Find & assign child views from the inflated hierarchy.
  }
}

Entry Point

Hilt에서는 EntryPoint라는 개념을 이용하여 Hilt 내에서 관리하는 객체들을 얻어오는 방법을 제공합니다. Dagger의 컴포넌트에 정의된 메서드를 통해 외부에서 객체를 얻는 방법과 동일합니다. 이 방법은 생성자 주입, 멤버필드 주입이 어려워서 직접 객체를 가져와야 하는 경우에 사용될 수 있습니다.
// EntryPoint 선언
@EntryPoint
@InstallIn(ActivityComponent::class)
interface TestEntryPoint {
    fun getTestData(): TestData
}

// 실제 사용
class MainActivity: AppCompatActivity {
    override fun onCreate(savedInstanceState: Bundle?) {
        val testEntryPoint = EntryPoints.get(this, TestEntryPoint::class.java)
        val testData = testEntryPoint.getTestData()
    }
}
Dagger의 컴포넌트처럼 외부에서 객체를 얻기 위해서 사용할 인터페이스를 정의하고, @EntryPoint@InstallIn어노테이션을 붙여줍니다. Hilt가 자동적으로 구현체를 생성하여 주는데, 그 구현체는 EntryPoints.get()메서드를 이용하여 얻어올 수 있습니다. get() 메서드의 첫번째 인자로는 @InstallIn어노테이션에서 명시했던 표준컴포넌트의 바인딩 된 안드로이드 클래스를 넘겨주면 되는데, 이 메서드는 타입 unsafe하게 동작하므로 타입 safe하게 EntryPoint의 구현체를 얻어오려면 EntryPointAccessors 클래스의 메서드들을 이용하면 됩니다.

참고자료