Bibbidi Bobbidi Boo
article thumbnail

* TIL/개념: 최대한 공식 문서 & 책을 기반으로 배운 내용을 정리

* 현재 취준생으로 풋내기 개발자가 쓰는 글입니다.

* 그러니 조언과 지적 및 훈수는 언제나 환영입니다! 댓글로 많이 달아주세요!


 

Hilt

What is Hilt?

이전 의존성 주입 포스팅에서 잠깐 언급하고 지나갔는데,

Koin과 Dagger와 마찬가지로 Android에서 지원하는 DI Framework 중 하나다.

 

다른 DI Framework가 그렇듯, 수동으로 의존성을 관리할 때보다 컨테이너를 관리하기가 수월해지고, 보일러 플레이트 코드가 줄어든다는 장점이 있다.

 

게다가 Hilt는 기존에 인기있던 Dagger를 기반으로 빌드되었기 때문에,

Dagger가 가지고 있던 장점인 컴파일 시간 정확성 + 런타임 성능 + 확장성 + Android Studio 지원을 그대로 가지고 간다.

또한 Dagger와 관련해서 Hilt의 목표가 따로 공식 문서에 다음처럼 명시되어있다.

바로 확 와닿는 것은 아무래도 Dagger 관련 인프라 간소화 + 상용구 코드 줄이기 일 것이다.

Dagger를 직접 사용해본 적은 없기 때문에 상용구 코드 줄이기도 사실 와닿지는 않는다.

 

다만 Hilt의 원리를 이해하려고 Dagger를 공부해볼까 싶어서 한 번 요 기본 가이드를 천천히 읽어봤는데... 

난이도가 Hilt에 비해서 좀 더 있기 때문에 한 페이지를 이해하는데 시간이 훨씬 오래 걸렸다.

 

Hilt는 이해까지는 아니더라도 당장 사용하기는 무리가 없었다.

그래서 배우는 시간이 단축된다(== 러닝커브가 낮다)는 게

신입 입장에서는 아무래도 가장 메리트가 크다. 

 

사용 방법

build.gradle 설정

Hilt를 사용하기로 했을 때는 가장 먼저 build.gradle.kt에 아래를 추가해야 한다.

// 루트 build.gradle

plugins {
  ...
  id("com.google.dagger.hilt.android") version "2.44" apply false
}

// app/build.gradle

plugins {
  kotlin("kapt")
  id("com.google.dagger.hilt.android")
}

android {
  ...
}

dependencies {
  implementation("com.google.dagger:hilt-android:2.44")
  kapt("com.google.dagger:hilt-android-compiler:2.44")
}

// Allow references to generated code
kapt {
  correctErrorTypes = true
}

 

@HiltAndroidApp, @AndroidEntryPoint, @HiltViewModel

그리고 나서 @HiltAndroidApp으로 주석을 지정한 Application 클래스를 포함해야 한다.(필수)

해당 Application 클래스는 Manifest에도 따로 설정해주어야 한다.

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

 

그 다음 아래 항목에 대해서 @HiltAndroidApp처럼 클래스 이름 위에 주석을 추가해서 사용한다.

ViewModel을 제외하고는 모두 @AndroidEntryPoint를 붙인다.

  • ViewModel(@HiltViewModel)
  • Activity
  • Fragment
  • View
  • Service
  • BroadcastReceiver

 

@Inject

그리고 @Inject를 사용해서 해당 컴포넌트가 필요한 객체를 주입한다.

예를 들어서 AnalyticsAdapter를 ExampleActivity가 필요로 할 때 아래처럼 해주어야 한다.

@AndroidEntryPoint
class ExampleActivity : AppCompatActivity() {

  @Inject lateinit var analytics: AnalyticsAdapter
  ...
}

또한 AnalyticsAdapter에서도 @Inject를 사용해서 인스턴스를 만드는 방법을 알려주어야 한다.

class AnalyticsAdapter @Inject constructor(
) { ... }

 

@Module

이렇게 되면 기본적으로 사용 방법은 끝!인데..

이것만으로는 부족해서 추가가 필요할 때가 있다. 이럴 때에는 @Module을 사용하게 된다.

인스턴스를 제공하는 방법을 캡슐화한 건데 다음처럼 사용한다.

@Module
@InstallIn(ActivityComponent::class)
object AnalyticsModule {
	// ....
}

@Module로 캡슐화했다는 것을 알려주고

@InstallIn으로 어디 컴포넌트에 속하는지 알려준다.

 

여기서 컴포넌트?! 라고 한다면

Hilt에서는 미리 Component를 정의하고 수명주기를 정의해두었는데 아래 표에서 볼 수 있다.

https://developer.android.com/training/dependency-injection/hilt-android#component-lifetimes

이게 처음 배웠을 때 나는 가장 띠용스러운 부분이었는데 차근차근 보면

위 상단에는 ActivityComponent에 있으므로

해당 모듈에 정의된 인스턴스는 Activity의 수명주기를 따르며, Activity에서 호출할 수 있다는 뜻이다.

 

뿐만 아니라 아래 상속 관계를 보면 ActivityComponent 아래에 있는 ViewComponent, FragmentComponent에 속한다면 

해당 Component를 호출할 수 있다.

위에서 Activity, Fragment 등의 클래스 이름 위에 붙여야 했던

@HiltAndroidApp, @HiltViewModel, @AndroidEntryPoint는

일종의 진입점 역할로, 위 컴포넌트에 접근할 수 있도록 돕는다.

 

이것이 Hilt의 장점이라고 하는 수명주기 관리인 듯하다.

 

그렇다면 다시 돌아와서....

@Module을 사용해서 주입해야 하는 경우를 살펴보자.

 

  • @Binds로 인터페이스 인스턴스 주입

만약 아래처럼 주입해야 하는 인스턴스의 타입이 인터페이스인 경우에는 어떻게 해야할까?

class AnalyticsAdapter @Inject constructor(
  private val service: AnalyticsService // -> 인터페이스
) { ... }

구현체가 하나더라도, 찾을 수 없기 때문에

 다음처럼 추상 클래스 + @Binds를 사용해서 주입한다.

interface AnalyticsService {
  fun analyticsMethods()
}

// Constructor-injected, because Hilt needs to know how to
// provide instances of AnalyticsServiceImpl, too.
class AnalyticsServiceImpl @Inject constructor(
  ...
) : AnalyticsService { ... }

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

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

 

  • @Provides

인터페이스 외에도, 외부 라이브러리에서 사용해서 생성자로 주입할 수 없는 경우가 존재한다.

(예를 들면 Retrofit2, OkHttpClient, Room DB 등)

이럴 때에는 @Provides를 사용해서 생성방법을 알려주어야 한다.

@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를 사용하는 걸 선호하시는 분도 있고,

@Binds를 사용할 수 있을 땐 사용하는 걸 선호하시는 분도 있다..

 

  • 동일한 유형에 대해 여러 결합 제공

그 외에도 주입해야하는 인스턴스에 대해서

인터페이스는 하나인데, 구현체가 둘 이상일 경우가 있다.

 

예를 들면 다음과 같은 경우.

interface DataSource

class LocalDataSource @Inject constructor(
   // ...
) : DataSource

class RemoteDataSource @Inject constructor(
   // ...
) : DataSource

 

이럴 때에는 다음처럼 annotation class를 정의한 후에 @Provides로 정의해준다.

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

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class RemoteDataSource
@Module
@InstallIn(SingletonComponent::class)
object DataSourceModule {
    @LocalDataSource
    @Provides
    fun provideLocalDataSource(
    	// ...
    ): DataSource {
    	// ...
    }
    
    @RemoteDataSource
    @Provides
    fun provideRemoteDataSource(
    	// ...
    ): DataSource {
    	// ...
    }
}

 

Dagger

개인적으로는 Hilt를 보고나서 Dagger를 보면 천천히 이해가 되는 것 같다.

그리고 결론적으로는 Dagger 또한 알아야 온전히 원리를 이해할 수 있는 듯.

 

다음은 Dagger에 대해 아주 조금...! 정말 아주 조금..! 공부한 것으로 

좀 더 자세한 내용을 원한다면 공식 문서를 참고하자.

 

@Inject는 인스턴스를 만드는 방법을 알려주는 역할을 한다.

예를 들면 아래 코드에서는 이렇게 알려주는 것이다.

  1. UserRepository 인스턴스를 만드는 방법
  2. 종속 항목은 UserLocalDataSource, UserRemoteDataSource
// @Inject lets Dagger know how to create instances of this object
class UserRepository @Inject constructor(
    private val localDataSource: UserLocalDataSource,
    private val remoteDataSource: UserRemoteDataSource
) { ... }

 

그럼 이렇게 @Inject로 방법을 알려주는 것만으로도 의존성 주입이 가능한가?

아니다. 어떻게 할 수 있을 지만이 아닌, 어디에 있는가도 알아야 한다.

Dagger 공식 문서에는 다음과 같은 설명이 있다.

Dagger can create a graph of the dependencies in your project that it can use to find out where it should get those dependencies when they are needed. To make Dagger do this, you need to create an interface and annotate it with @Component. Dagger creates a container as you would have done with manual dependency injection.

Dagger는 위치를 찾기 위해서 Application 그래프라는 것을 생성한다.

https://developer.android.com/training/dependency-injection/dagger-android#dagger-modules

이 Application에서 화살표는 인스턴스간 의존성을 의미한다.

 

위 그림에서 LoginActivity는 ApplicationComponent에 inject 하고,

ApplicationComponent는 NetworkModule을 include 하고 있는 것을 볼 수 있는데

실제로 Dagger에서는 다음과 같은 방식으로 관계를 만들어서 그래프를 생성하고 있다.

// The "modules" attribute in the @Component annotation tells Dagger what Modules
// to include when building the graph
@Singleton
@Component(modules = [NetworkModule::class])
interface ApplicationComponent {
    fun inject(activity: LoginActivity)
    // ... 
}
@Singleton
class UserRepository @Inject constructor(
    private val localDataSource: UserLocalDataSource,
    private val remoteDataSource: UserRemoteDataSource
) { ... }
@Module
class NetworkModule {
    // Hypothetical dependency on LoginRetrofitService
    @Provides
    fun provideLoginRetrofitService(
        okHttpClient: OkHttpClient
    ): LoginRetrofitService { ... }
}
class LoginActivity: Activity() {
    // Reference to the Login graph
    lateinit var loginComponent: LoginComponent

    // Fields that need to be injected by the login graph
    @Inject lateinit var loginViewModel: LoginViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        // Creation of the login graph using the application graph
        loginComponent = (applicationContext as MyDaggerApplication)
                            .appComponent.loginComponent().create()

        // Make Dagger instantiate @Inject fields in LoginActivity
        loginComponent.inject(this)

        // Now loginViewModel is available

        super.onCreate(savedInstanceState)
    }
}

 

이렇게 직접 Component를 만들고,

해당 Component에 대해서 Module과 수명주기를 직접 정의했는데,

 

Hilt에서는 기본으로 제공되는 Component가 있고 @InstallIn으로 지원해준다.

이렇게 보면 Hilt에서 지원하는

@HiltAndroidApp, @HiltViewModel, @AndroidEntryPoint이 진입점의 역할을 한다는 것을

좀 더 이해할 수 있을 것 같다.

 

어노테이션으로 선언해서 그래프의 진입점을 만들어주고,

이를 바탕으로 그래프를 탐색해서 의존성을 주입하고 있었다.


이슈

다음은 Hilt를 프로젝트에 적용하면서 있었던 이슈나 팁?을 정리했다.

멀티 모듈에서의 Hilt 사용

우선 공식 문서에서 나와있듯 기능 모듈을 사용하는 구조라면 모듈 의존 방식이 반전되므로 Dagger를 대신 사용하라고 언급된다.(...)

 

그러나 기능 모듈을 사용하는 구조는 아니었고, 아키텍처에 따라 아래처럼 분리했었다.

보다시피 domain은 어느 곳에도 의존하지 않고 있다.

이럴 경우 Hilt를 사용하게 되었을 때, 

필연적으로 DI 관리 역할의 모듈이 필요하게 되어 app 모듈을 presentation에서 분리하게 된다.

 

presentation과 domain, data를 모두 알고 있는 app 모듈을 만들어서 

@HiltAndroidApp이 포함된 Application 클래스와 DI Module 정보를 담는 형태가 된다.

 

Hilt_Fragment.context != Activity.context

java.lang.ClassCastException: dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper cannot be cast to android.app.Activity

Fragment에서 T Map API에서 지원하는 MapView를 사용하다가 발생한 에러다.

보면 ViewComponentManager&FragmentContextWrapper를 Activity로 cast할 수 없다고 한다.

 

결론부터 말하면 이는 MapView 코드 내에서 context를 강제로 ActivityContext로 캐스팅했기 때문에 발생하는 이슈였다.

 

기존에 사용하던 Fragment는 Activity와 달리 context를 상속받지 않기 때문에,

Fragment에서 context가 필요하면 Activity의 context를 사용한다.

 

그러나 Hilt의 Fragment는 Activity의 context가 아니다.

다음은 debug된 Hilt_MainFragment.java의 내부 코드이다.

getContext를 할 때 componentContext를 반환하고 있다.

이 componentContext를 파고 들어가보면, Activity의 Context가 아니라

Hilt에서 직접 만든 FragmentContextWrapper 타입이다.

 

때문에 Hilt의 Fragment에서 ActivityContext가 필요하다면,

다음처럼 사용할 수 있다.

fun getActivityContext(context: Context): Context {  
  return if (context is ViewComponentManager.FragmentContextWrapper) {  
  context.baseContext  
  } else context  
}

 

이슈와 관련해 좀 더 자세한 사항은 여기를 참고.

 

Assited Inject - 런타임 주입하기

프로젝트에서 ViewModel의 생성자로 데이터 클래스를 넘겨받아서 

ViewModel이 가진 데이터를 초기화 시키는 과정이 필요했다.

 

찾아보니 대부분 saveInstanceState를 추천하는데,

이는 navArgs()를 사용해야했어서 불가능했다.(ㅠㅠ)

 

-> SavedStateHandle을 이용해서 주입이 가능하다.

 

그러다 이전 Dagger에서 사용하던 방식에서 런타임 중에 주입하는 방식이 존재했다.

바로 @AssistedInject ! 

원래는 Hilt에서는 안되다가, 2.31버전 이후로 새롭게 추가되었다고 한다.

 

그래서 다음처럼 Activity에서 데이터 클래스를 넘겨서

ViewModel에 생성자로 넘겨 초기화시킬 수 있었다!

data class DataModel (
    // ...
)

class MainViewModel @AssistedInject constructor(
    @Assisted private val data: DataModel
) : ViewModel() {
	
    @AssistedFactory
    interface DataModelAssistedFactory {
    	fun create(data: DataModel): MainViewModel
    }
    
    companion object {
    	fun provideFactory(
            assistedFactory: DataModelAssistedFactory,
            data: DataModel
        ): ViewModelProvider.Factory = object : ViewModelProvider.Factory {
        	override fun <T : ViewModel?> create(modelClass: Class<T>): T {
            	return assistedFactory.create(data) as T
            }
        }
    }
}

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    @Inject
    lateinit var mainViewModelFactory: MainViewModel.DataModelAssistedFactory
  
    private val viewModel by viewModels<MainViewModel> {
        MainViewModel.provideFactory(mainViewModelFactory, data)
    }
    
    //...
    
}

다만 이 내용은 이 블로그를 보고 참고해서 작성해서,

주의사항 등은 해당 링크를 참고.


 

profile

Bibbidi Bobbidi Boo

@비비디

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!