이동식 저장소

[Hilt] 2. Dependency Injection with Hilt 본문

Primary/Android

[Hilt] 2. Dependency Injection with Hilt

해스끼 2021. 4. 29. 19:42

Hilt는 Android에서 사용할 수 있는 DI 라이브러리이다. Hilt는 모든 Android 클래스의 container를 제공하고, container의 생명주기를 자동으로 관리한다. Container란 의존성을 관리하는 객체를 말한다. 즉 Hilt는 Activity, Fragment 등 Android의 주요 컴포넌트에서 사용할 의존성 객체를 관리해 준다.

Dependency 추가

DI의 그 dependency가 아니고, build.gradle의 dependency를 말한다. 우선 root level 파일을 열어서 다음을 추가하자.

buildscript {
    ...
    ext.hilt_version = '2.35'
    dependencies {
        ...
        classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
    }
}

이제 app level의 build.gradle에 다음을 추가하자. Hilt에서 Java 8의 기능을 사용하기 때문에 프로젝트에서도 Java 8을 사용하도록 설정해야 한다.

apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'

android {
  ...
  compileOptions {
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
  }
}

dependencies {
    implementation "com.google.dagger:hilt-android:$hilt_version"
    kapt "com.google.dagger:hilt-compiler:$hilt_version"
}

Hilt Application 클래스

Hilt를 사용하는 모든 앱은 @HiltAndroidApp으로 어노테이션된 Application 클래스를 정의해야 한다. @HiltAndroidApp이 있어야 Hilt 코드가 생성된다.

@HiltAndroidApp
class MyVocaApplication : Application() { ... }

이 컴포넌트는 Application 객체의 생명주기를 참고하여 의존성 객체를 제공한다.

의존성 주입하기

Application 객체에 Hilt를 설치했다면 이제 Hilt를 사용할 수 있다. 의존성 객체를 주입받을 클래스에 @AndroidEntryPoint 어노테이션을 추가하자.

@AndroidEntryPoint
class MainActivity : AppCompatActivity() { ... }

이제 MainActivity는 Hilt로부터 의존성 객체를 제공받을 수 있다. Hilt는 다음의 클래스를 지원한다.

  • Application (@HiltAndroidApp)
  • ViewModel (@HiltViewModel)
  • Activity (ComponentActivity를 상속하는 activity만 지원)
  • Fragment (androidx.Fragment를 상속하는 fragment만 지원, retained fragment는 미지원)
  • View
  • Service
  • BroadcastReceiver

클래스 A에 @AndroidEntryPoint 어노테이션을 추가했다면, A에 의존하는 클래스에도 @AndroidEntryPoint를 추가해야 한다. 예를 들어 Fragment에 어노테이션을 추가했다면 그 Fragment를 사용하는 Activity에도 어노테이션을 추가해야 한다.

Hilt 컴포넌트로부터 의존성 객체를 얻고 싶다면 @Inject 어노테이션을 사용해야 한다. 객체 타입을 private로 지정하면 안 된다.

@AndroidEntryPoint
class ExampleActivity : AppCompatActivity() {

  @Inject lateinit var analytics: AnalyticsAdapter
  ...
}

analytics 변수는 activity의 onCreate()에서 할당되고, onDestroy()에서 할당 해제된다. 다른 클래스의 경우에는 여기를 참조하자.

Hilt는 궁금하다

물론 @Inject만 써 주면 객체가 할당되는 마법같은 일은 일어나지 않는다. Hilt에게 객체를 제공하는 방법(binding)을 가르쳐 줘야 한다. Binding은 의존성 객체를 제공하는 방법을 Hilt에게 알려준다.

바인딩 정보를 알려주는 하나의 방법으로 생성자 주입이 있다. 생성자를 통해 객체를 만들 수 있다고 알려주는 것이다.

class AnalyticsAdapter @Inject constructor(
  private val service: AnalyticsService
) { ... }

이 코드를 통해 Hilt는 AnalyticsAdapter 객체를 만들려면 AnalyticsService가 필요하구나라는 정보를 알게 된다. 이제 Hilt에게 AnalyticsService 객체를 만드는 방법을 알려주면 된다.

생성자 주입으로는 충분하지 않다

생성자 주입을 사용할 수 없는 경우가 있다. 예를 들어 interface는 생성자가 없으므로 생성자 주입을 사용할 수 없다. 내가 작성하지 않은 클래스(Room 등)를 사용할 때에도 생성자 주입을 사용할 수 없다. 이런 경우에는 Hilt module을 이용하여 바인딩 정보를 제공해야 한다.

Hilt module은 Hilt에게 특정 타입의 객체를 어떻게 만들 수 있는지 알려주는 클래스로, @Module로 어노테이션된다. Android에서는 해당 module이 어느 범위에서 사용될 것인지를 @InstallIn으로 지정해 줘야 한다. 예를 들어 앱 전체에서 사용할 모듈이라면 application 범위에, 특정 fragment에서만 사용한다면 fragment 범위에 모듈을 설치해야 한다.

참고: 모듈 위계(hierarchy)

특정 컴포넌트에 모듈을 설치하면, 해당 컴포넌트의 하위 컴포넌트에서도 모듈을 사용할 수 있다. 예를 들어 ActivityComponent에 설치한 모듈은 FragmentComponent에서도 접근할 수 있다. 컴포넌트 간의 상하관계는 여기를 참고하자.

@Binds로 인터페이스 주입하기

위의 AnalyticsAdapter가 인터페이스라면 당연히 생성자 주입을 사용할 수는 없다. 대신 @Binds로 어노테이션된 abstract 메소드를 통해 바인딩 정보를 제공할 수 있다. @Binds 어노테이션은 인터페이스 인스턴스를 만들 때 어떤 구현체(인터페이스를 구현한 클래스)를 사용해야 하는지 알려준다.

abstract 메소드를 구현하면 Hilt에게 다음의 정보를 제공할 수 있다.

  • 메소드의 반환 타입은 메소드가 제공할 인스턴스의 타입이다.
  • 메소드의 매개변수는 메소드가 제공할 구현체를 의미한다.
interface AnalyticsService {
  fun analyticsMethods()
}

// Hilt에게 구현체 인스턴스를 만드는 방법을 알려줘야 한다.
class AnalyticsServiceImpl @Inject constructor(
  ...
) : AnalyticsService { ... }

@Module
@InstallIn(ActivityComponent::class)
abstract class AnalyticsModule {

  @Binds
  abstract fun bindAnalyticsService(
    analyticsServiceImpl: AnalyticsServiceImpl
  ): AnalyticsService
}

이제 Hilt는 AnalyticsModule을 통해 AnalyticsService 인터페이스의 구현체인 AnalyticsServiceImpl을 제공할 수 있다.

그런데 인터페이스의 구현이 여러 개이면 어떻게 될까? 구현체가 여러 개이므로 @Binds 메소드 역시 여러 개 존재할 것이다. 따라서 메소드를 서로 구분할 수 있는 방법이 필요하다. 이 내용은 아래에서 자세히 살펴보도록 하겠다.

@Provides로 인스턴스 주입하기

생성자 주입을 사용할 수 없는 또 다른 경우가 있다. 외부 라이브러리에 속하는 클래스(Room, OkHttpClient 등) 또는 builder pattern으로 객체를 만들어야 하는 경우 생성자 주입을 사용할 수 없다. 따라서 Hilt에게 해당 객체를 만드는 방법을 알려줘야 하는데, 이 경우에는 @Provides 어노테이션을 사용해야 한다.

@Provides 메소드는 Hilt에게 다음의 정보를 알려준다.

  • 메소드의 반환 타입은 메소드가 제공하는 인스턴스의 타입이다.
  • 메소드의 매개변수는 해당 인스턴스의 의존성을 말한다.
  • 메소드의 내부에는 해당 인스턴스를 만드는 코드가 존재한다. Hilt는 이 타입의 인스턴스가 필요할 때마다 메소드를 실행한다.
@Module
@InstallIn(ActivityComponent::class)
object AnalyticsModule {

  @Provides
  fun provideAnalyticsService(
    // Potential dependencies of this type
  ): AnalyticsService {
      return Retrofit.Builder()
               .baseUrl("https://example.com")
               .build()
               .create(AnalyticsService::class.java)
  }
}

@Binds와 @Provides의 차이

사실 생성자 주입이 가능한 경우에도 @Provides를 사용할 수 있다. 다만 @Provides 메소드 안에서 객체를 만들기만 하기 때문에 굳이 @Provides를 사용할 이유가 없는 것이다.

@Module
@InstallIn(ActivityComponent::class)
abstract class AnalyticsModule {  
	@Provides  
    abstract fun provideAnalyticsService(      
    	// Potential dependencies of this type  
    ): AnalyticsService {      
    	return AnalyticsServiceImpl(...)  
    }
}

생성자 주입이 가능한 경우 @Binds를, 객체를 만들 때 복잡한 코드가 필요한 경우 @Provides를 사용한다고 기억해 두자.

인터페이스의 구현이 여러 개라면

Hilt에서는 qualifier를 이용하여 같은 타입에 대해 여러 개의 바인딩을 정의할 수 있다. Qualifier는 바인딩의 이름이라고 생각하면 된다. 반환 타입이 같은 바인딩이 여러 개 있을 경우, qualifier를 이용하여 바인딩을 구분할 수 있다. 참고로 꼭 인터페이스에만 적용되는 내용은 아니며, 하나의 클래스를 여러 방법으로 만들 수 있는 경우에도 사용할 수 있다.

우선 qualifier를 정의하자.

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class AuthInterceptorOkHttpClient

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class OtherInterceptorOkHttpClient

이제 바인딩을 정의하고, 바인딩에 qualifier를 부여하자.

@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()
  }
}

각 구현에 대한 바인딩을 정의했으니, 이제 객체를 주입해 보자. 필드 또는 매개변수 앞에 해당 qualifier를 붙여주면 된다.

// As a dependency of another class.
@Module
@InstallIn(ActivityComponent::class)
object AnalyticsModule {

  @Provides
  fun provideAnalyticsService(
    @AuthInterceptorOkHttpClient okHttpClient: OkHttpClient // Parameter Injection
  ): AnalyticsService {
      return Retrofit.Builder()
               .baseUrl("https://example.com")
               .client(okHttpClient)
               .build()
               .create(AnalyticsService::class.java)
  }
}

// As a dependency of a constructor-injected class.
class ExampleServiceImpl @Inject constructor(
  @AuthInterceptorOkHttpClient private val okHttpClient: OkHttpClient // Parameter Injection
) : ...


// Field injection.
@AndroidEntryPoint
class ExampleActivity: AppCompatActivity() {

  @AuthInterceptorOkHttpClient
  @Inject lateinit var okHttpClient: OkHttpClient
}

인터페이스의 구현체가 여러 개 존재한다면, 모든 구현체에 qualifier를 부여하자. 암시적인 코드보다는 명시적인 코드가 항상 더 좋다(고 생각한다).

미리 정의된 qualifier

Hilt에는 자주 사용되는 타입의 qualifier가 이미 정의되어 있다. 예를 들어 Context 객체는 application 또는 activity로부터 얻을 수 있는데, 이 경우 Hilt의 @ApplicationContext@ActivityContext qualifier를 사용하면 편리하다.

class AnalyticsAdapter @Inject constructor(
    @ActivityContext private val context: Context,
    private val service: AnalyticsService
) { ... }

그 밖에도 다음의 qualifier가 미리 정의되어 있다.

Android component Default bindings
SingletonComponent Application
ActivityRetainedComponent Application
ViewModelComponent SavedStateHandle
ActivityComponent Application, Activity
FragmentComponent Application, Activity, Fragment
ViewComponent Application, Activity, View
ViewWithFragmentComponent Application, Activity, Fragment, View
ServiceComponent Application, Service

예를 들어 ActivityComponent에서는 @Application qualifier를 사용할 수 있다.

Component?

Hilt component는 바인딩을 제공해 주는 역할을 맡는다.

Hilt component Hilt component를 사용할 수 있는 클래스
SingletonComponent Application
ActivityRetainedComponent N/A
ViewModelComponent ViewModel
ActivityComponent Activity
FragmentComponent Fragment
ViewComponent View
ViewWithFragmentComponent View annotated with @WithFragmentBindings
ServiceComponent Service

예를 들어 ViewModelViewModelComponent로부터 바인딩을 제공받는다. 또, 위에서 봤듯이 컴포넌트 간에는 포함 관계가 존재한다. 하위 컴포넌트에서는 상위 컴포넌트의 바인딩을 사용할 수 있지만, 그 반대는 불가능하다.

Component의 생명주기

Hilt는 Android 클래스의 생명주기에 맞추어 컴포넌트를 만들고 파괴한다.

Generated component 생성되는 곳 파괴되는 곳
SingletonComponent Application#onCreate() Application#onDestroy()
ActivityRetainedComponent Activity#onCreate() Activity#onDestroy()
ViewModelComponent ViewModel created ViewModel destroyed
ActivityComponent Activity#onCreate() Activity#onDestroy()
FragmentComponent Fragment#onAttach() Fragment#onDestroy()
ViewComponent View#super() View destroyed
ViewWithFragmentComponent View#super() View destroyed
ServiceComponent Service#onCreate() Service#onDestroy()

Component scope

기본적으로 모든 바인딩에는 범위(scope)가 없다. 범위 없는 바인딩은 호출될 때마다 매번 새 객체를 반환한다. 그런데 매번 같은 객체를 반환해야 하는 경우가 있다. 예를 들어 Activity에서 하위 fragment에 공통으로 사용할 객체가 존재할 수 있다.

이런 경우에는 Hilt의 scoped binding을 사용할 수 있다. 컴포넌트가 특정 바인딩에 대해 항상 같은 객체를 반환하도록 할 수 있다. 바인딩에 scope를 부여하려면 클래스 또는 메소드에 다음의 어노테이션을 추가해야 한다.

Android class Generated component Scope annotation
Application SingletonComponent @Singleton
Activity ActivityRetainedComponent @ActivityRetainedScoped
ViewModel ViewModelComponent @ViewModelScoped
Activity ActivityComponent @ActivityScoped
Fragment FragmentComponent @FragmentScoped
View ViewComponent @ViewScoped
View annotated with @WithFragmentBindings ViewWithFragmentComponent @ViewScoped
Service ServiceComponent @ServiceScoped

예를 들어 AnalyticsAdapterActivityComponent 범위를 지정하고 싶다면 @ActivityScoped 어노테이션을 추가해야 한다.

@ActivityScoped
class AnalyticsAdapter @Inject constructor(
  private val service: AnalyticsService
) { ... }

생성자 주입을 사용할 수 없는 경우에는 바인딩 메소드에 어노테이션을 추가해야 한다.

// AnalyticsService가 인터페이스라면
@Module
@InstallIn(SingletonComponent::class)
abstract class AnalyticsModule {

  @Singleton
  @Binds
  abstract fun bindAnalyticsService(
    analyticsServiceImpl: AnalyticsServiceImpl
  ): AnalyticsService
}

// AnalyticsService를 직접 작성하지 않았다면
@Module
@InstallIn(SingletonComponent::class)
object AnalyticsModule {

  @Singleton
  @Provides
  fun provideAnalyticsService(): AnalyticsService {
      return Retrofit.Builder()
               .baseUrl("https://example.com")
               .build()
               .create(AnalyticsService::class.java)
  }
}

위의 코드에서는 @Singleton 어노테이션을 통해 AnalyticsService의 범위를 application으로 설정했다. 즉 항상 같은 AnalyticsService 객체가 반환된다. 말 그대로 singleton이다. Hilt component scope에 대해 자세히 알고 싶다면 Scoping in Android and Hilt를 참고하자.

주의: 모듈이 component에 설치됐다고 해서 모듈의 바인딩이 scope되는 건 아니다. 바인딩을 scope하고 싶다면 별도로 어노테이션을 붙여야 한다. 예를 들어 모듈을 @SingletonComponent에 설치하고 바인딩을 scope하지 않았다면, 앱 전체에서 모듈 인스턴스는 동일하지만 모듈 안의 바인딩은 객체를 매번 새로 만든다(=scope되지 않았다).

모듈의 component와 바인딩의 scope는 별개이다!

Hilt가 지원하지 않는 클래스에서도 사용하고 싶다면 (원문)

Hilt는 거의 대부분의 Android 클래스를 지원하지만, Hilt가 지원하지 않는 클래스에서 DI를 사용하고 싶을 수도 있다. 이 경우에는 @EntryPoint 어노테이션을 이용하여 entry point를 만들어야 한다.

Entry point는 Hilt에 의해 관리되는 코드를 구분짓는 역할을 한다. 간단히 말하자면 바인딩을 관리하는 Hilt 객체이다. 이 부분은 한국어로 옮기기가 조금 어려운데, 원문을 읽어보는 게 더 좋을 것이다.

어쨌든, Hilt는 content provider를 지원하지 않는다. 그런데 content provider 안에서 Hilt를 이용해 의존성을 얻고 싶을 수도 있다. 이 경우에는 먼저 @EntryPoint로 어노테이션된 인터페이스를 정의하고, @InstallIn을 통해 entry point를 설치할 컴포넌트를 지정해야 한다.

class ExampleContentProvider : ContentProvider() {

  @EntryPoint
  @InstallIn(SingletonComponent::class)
  interface ExampleContentProviderEntryPoint {
    fun analyticsService(): AnalyticsService
  }

  ...
}

Entry point에 접근하려면 EntryPointAccessors의 static 메소드를 사용하자. 메소드의 매개변수는 component 인스턴스 또는 component holder 역할을 하는 @AndroidEntryPoint 객체여야 한다. Entry point를 설치한 컴포넌트에 따라 실행해야 하는 메소드가 다르다.

class ExampleContentProvider: ContentProvider() {
    ...

  override fun query(...): Cursor {
    val appContext = context?.applicationContext ?: throw IllegalStateException()
    val hiltEntryPoint =
      EntryPointAccessors.fromApplication(appContext, ExampleContentProviderEntryPoint::class.java)

    val analyticsService = hiltEntryPoint.analyticsService()
    ...
  }
}

위의 경우에는 ExampleContentProviderEntryPointSingletonComponent에 설치했기 때문에 EntryPointAccessors.fromApplication() 메소드를 호출했다. ActivityComponent에 설치했다면 fromActivity() 메소드를 호출해야겠지?

읽을거리

Hilt를 적용한 샘플 코드

Codelabs (추천)

블로그


오타, 오역, 틀린 내용 지적 받습니다.

Comments