본문 바로가기

안드로이드 기술 공유

Dagger Hilt로 안드로이드 의존성 주입하기

Dependency Injection

의존성 주입(Dependency Injection)은 여러 컴포넌트간의 의존성이 강한 안드로이드에서 클래스 간 의존성을 낮춰줍니다.

객체 생성 시 클래스 간 의존성이 생기게 되는데 이때, 객체의 생성을 클래스 내부에서 하는 게 아니라 클래스 외부에서 객체를 생성하여 주입시켜주는 디자인 패턴을 말합니다.

 

그렇다면 의존성 주입이라는 용어는 어떤 의미일까요?

 

의존성?

클래스 간의 의존 관계를 가지는 상황으로 A클래스 내부에서 B클래스가 생성, 사용되는 경우라고 볼 수 있습니다.

 

class B {
    val id = "newtopia"
}

class A {
    val data = B() //의존관계 생성
}

>>> println(A().data.id) //A클래스는 B클래스에 의존적이 된다.

주입?

클래스 내부에서 객체 생성이 아니라, 외부에서 객체를 생성해서 넣어주는 경우입니다.

 

class B {
    val id = "newtopia"
}

class A(bClass: B) {
    val data = bClass
}

>>> val bClass = B() //외부에서 객체 생성
>>> println(A(bClass).data.id) //외부에서 생성된 객체를 주입

 

의존성 주입은 클래스 외부에서 객체를 생성하여 주입하는 것!

 

개념 이해를 돕기 위한 실생활 예제

오늘은 점심으로 서브웨이 샌드위치를 먹기위해 서브웨이 매장에 왔습니다.

이제 샌드위치(Sandwich 객체)를 만들어보도록 하겠습니다!

 

1.

우리는 먼저 빵(Bread 객체)을 선택하게 됩니다.

저는 허니오트 빵을 선택하겠습니다.

 

Bread 객체 생성 및 주입

 

2.

다음으로 빵안에 들어갈 토핑(Topping 객체)을 선택하게 됩니다.

저는 이탈리안 비엠티 토핑을 넣겠습니다. 

 

Topping 객체 생성 및 주입

 

3.

위에서 선택한

허니오트 빵(Bread 객체)과

이탈리안 비엠티 토핑(Topping 객체)으로

샌드위치(Sandwich 객체)가 완성되었습니다!

Sandwich 객체 생성

 

위와 같은 예제를 의존성 주입 방식의 코드로 작성해보면 아래와 같은 형태로 프로그래밍 하게 됩니다.

 

/* 의존성 주입의 일반적인 큰 틀 */

//빵 Bread 주입, 토핑 Topping 주입
class Sandwich(private val bread: Bread, private val topping: Topping) {
    init {
        bread.add()
        topping.add()
    }
}

 

여기서 만약 빵(Bread)를 바꾸거나 토핑(Topping)을 바꾼다면 어떻게 될까요?

에그마요 샌드위치를 만들기 위해선 어떻게 해야할까요?

 

답은 그저 에그마요 토핑(Topping) 객체를 주입해주기만하면 에그마요 샌드위치가 완성이 됩니다! 

 

즉, Sandwich 객체는 주입되는 Bread, Topping 객체의 구현을 알 필요가 없기 때문에 클래스 간의 의존성이 느슨해져서 프로그램 설계가 훨씬 유연해지는 장점이 있습니다.

 

장점

- 외부에서 객체를 생성해서 주입하기 때문에 코드의 재사용성이 높습니다.

- 테스트에 용이합니다.

- 의존성이 낮아지기 때문에 코드 변경에 유연하고 자유롭습니다.

- 앱 생명주기에 따라 관리되어 적절한 시점에 필요한 객체들이 자동으로 주입됩니다.

- 보일러 플레이트가 대폭 줄어듭니다.

 

Dagger Hilt

인스턴스를 클래스 외부에서 주입하기 위해서는 인스턴스에 대한 생명주기(생성~소멸되기까지)의 관리가 필요합니다.

이를 자동으로 관리해주기 위해 Google에서는 2020년 6월에 안드로이드 전용 DI(Dependency Injection) 라이브러리인 "Dagger Hilt" 를 발표하였습니다.

 

Hilt는 기존의 Dagger를 기반으로 하며 Dagger보다 러닝커브가 훨씬 낮고, 초기 DI 환경 구축 비용을 크게 절감할 수 있으며, 안드로이드 앱에 최적화 된 DI 라이브러리 입니다.

또한, 최근 Google에서 적극적으로 지원하고 있는 Jetpack과 AAC 라이브러리를 통해서 Hilt 의존성 주입을 정말 간편하게 구현할 수 있도록 지원해주고 있기 때문에 안드로이드 DI 환경 구축에 굉장히 유용한 라이브러리 입니다.

 

Adding Dependencies

Hilt를 프로젝트에 적용하기 위한 gradle 셋업 입니다. (Android Developers Guides 참고)

 

먼저 hilt-android-gradle-plugin을 project-level의 build.gradle 파일에 추가합니다.

 

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

 

그런 다음, app-level의 build.gradle 파일에 아래 코드를 추가합니다.

 

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

android {
    ...
}

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

 

이렇게 기본적인 gradle 셋업을 마쳤고, 본격적으로 Hilt를 안드로이드 프로젝트에서 활용하는 방법에 대해 알아보도록 하겠습니다!

 

Hilt Application Class

Hilt를 사용하는 모든 앱은 @HiltAndroidApp 어노테이션을 Application Class에 반드시 추가해야 합니다.

이 어노테이션으로 의존성 주입의 시작점을 지정합니다. 

Hilt는 Application 생명 주기를 따르며 컴파일 단계 시 DI에 필요한 구성요소들을 초기화 하기 위함 입니다.

 

@HiltAndroidApp
class MainApplication : Application()

 

AndroidEntryPoint

Hilt에서는 객체를 주입할 Android 클래스에 @AndroidEntryPoint 어노테이션을 추가하는 것만으로도 자동으로 생명주기에 따라 적절한 시점에 Hilt 요소로 인스턴스화 되어 처리됩니다.  

이 어노테이션으로 의존성 주입의 시작점을 나타냅니다.

Hilt가 지원하는 Android 클래스는 다음과 같습니다.

 

- Activity

- Fragment

- View

- Service

- BroadcastReceiver

 

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    @Inject lateinit var viewModel: MainViewModel
}

 

@Inject 어노테이션을 사용하여 의존성을 주입 받으려는 변수에 객체를 주입 할 수 있습니다.

@Inject 어노테이션이 붙은 변수는 의존성을 주입받는 포인트를 선언한다 는 의미입니다.

 

의존성 생성 방법

- @Inject constructor

- Hilt 모듈 (@Module, @Provides, @Binds)

 

@Inject constructor

클래스의 생성자에서 @Inject 어노테이션을 사용하여 의존성을 생성하는 방법 입니다.

 

class MainViewModel @Inject constructor(private val repository: MainRepository) : ViewModel() {
   ...
}

 

생성자 constructor()에 @Inject 어노테이션으로 의존성 인스턴스를 생성하고, 생성자의 파라미터로 의존성을 주입받을 수도 있습니다.

 

Hilt 모듈

외부 라이브러리를 사용하는 경우(Retrofit, OkHttpClient, Room databases...)와 같이 개발자가 생성자를 만들고 삽입할 수 없는 경우에는 Hilt 모듈을 사용하여 의존성을 생성 할 수 있습니다.

Hilt 모듈은 @Module 어노테이션이 지정된 클래스 입니다.

이 모듈은 의존성 인스턴스를 제공하는 방법을 Hilt에 알려주는 역할을 합니다.

이러한 module에 @InstallIn(component) 어노테이션을 지정하여 어떤 컴포넌트에 install 할지를 반드시 정해주어야 합니다.

 

@InstallIn 에 사용되는 Hilt 컴포넌트들은 각자의 생명주기를 갖고 있으며 해당 모듈들이 이 컴포넌트의 생명주기에 맞춰 그대로 따라가게 됩니다.

 

@Module과 @InstallIn 선언은 아래와 같은 형태로 선언되며 @InstallIn 안에 들어가는 Hilt 컴포넌트들과 자세한 내용은 아래에서 설명을 이어가겠습니다.

 

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
   ...
}

 

Component hierarchy

현재 @InstallIn(component) 에 사용되는 컴포넌트들은 다음과 같이 제공하고 있습니다.

Hilt 내부적으로 컴포넌트들의 생명주기를 자동으로 관리해주기 때문에 개발자가 DI 환경을 구축하는데 수고를 최소화 해주고 있습니다!

다음은 Hilt에서 제공하는 Component hierarchy 입니다.

 

 

Hilt에서 제공하는 Component, 생명주기, Scope는 아래와 같습니다.

 

Hilt component Injector for Created at Destroyed at Scope
SingletonComponent Application Application#onCreate() Application#onDestroy() @Singleton
ActivityRetainedComponent 해당 없음 Activity#onCreate() Activity#onDestroy() @ActivityRetainedScoped
ViewModelComponent ViewModel ViewModel created ViewModel destroyed @ViewModelScoped
ActivityComponent Activity Activity#onCreate() Activity#onDestroy() @ActivityScoped
FragmentComponent Fragment Fragment#onAttach() Fragment#onDestroy() @FragmentScoped
ViewComponent View View#super() View destroyed @ViewScoped
ViewWithFragmentComponent @WithFragmentBindings 가 붙은 View View#super() View destroyed @ViewScoped
ServiceComponent Service Service#onCreate() Service#onDestroy() @ServiceScoped

 

각 컴포넌트들은 생성 시점부터 ~ 파괴되기 전까지 Injection이 가능하고, 각 컴포넌트마다 자신만의 생명주기를 갖습니다.

 

- SingletonComponent : Application의 생명주기를 갖습니다. Application이 생성되는 시점에 함께 생성되고, 파괴되는 시점에 함께 파괴됩니다.

- ActivityRetainedComponent : Activity의 생명주기를 갖습니다. 다만, Activity의 Configuration Change(디바이스 화면전환 등...) 시에는 파괴되지 않고 유지됩니다.

- ViewModelComponent : Jetpack ViewModel의 생명주기를 갖습니다.

- ActivitComponent : Activity의 생명주기를 갖습니다. Activity가 생성되는 시점에 함께 생성되고, 파괴되는 시점에 함께 파괴됩니다.

- FragmentComponent : Fragment의 생명주기를 갖습니다. Fragment가 Activity에 붙는 시점에 함께 생성되고, 파괴되는 시점에 함께 파괴됩니다.

- ViewComponent : View의 생명주기를 갖습니다. View가 생성되는 시점에 함께 생성되고, 파괴되는 시점에 함께 파괴됩니다.

- ViewWithFragmentComponent : Fragment의 View 생명주기를 갖습니다. View가 생성되는 시점에 함께 생성되고, 파괴되는 시점에 함께 파괴됩니다.

- ServiceComponent : Service의 생명주기를 갖습니다. Service가 생성되는 시점에 함께 생성되고, 파괴되는 시점에 함께 파괴됩니다.

 

@Provides를 사용하여 의존성 생성

클래스가 외부 라이브러리를 사용하는 경우(Retrofit, OkHttpClient, Room databases...) 또는 빌더 패턴으로 객체 생성을 하는 경우와 같이 개발자가 생성자를 삽입할 수 없을 때 @Provides 어노테이션을 사용하여 의존성 생성을 할 수 있습니다.

 

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    private const val BASE_URL = "YOUR_BASE_URL"

    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient {
        return OkHttpClient.Builder()
            .addInterceptor(AuthInterceptor())
            .build()
    }

    @Provides
    @Singleton
    fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
        return Retrofit.Builder()
                .client(okHttpClient)
                .baseUrl(BASE_URL)
                .addConverterFactory(GsonConverterFactory.create())
                .build()
    }

    @Provides
    @Singleton
    fun provideApiService(retrofit: Retrofit): ApiService {
        return retrofit.create(ApiService::class.java)
    }
}

 

class RemoteDataSource @Inject constructor(private val apiService: ApiService) {

    suspend fun getCarSearchInfo(
        query: String,
        sort: String? = "accuracy",
        page: Int? = 1,
        size: Int? = 80
    ): Response<CarSearchInfo> =
        apiService.getCarSearchInfo(query, sort, page, size)
}

 

ApiService를 외부(Module)에서 주입받고, RemoteDataSource 또한 @Inject constructor로 외부에 주입이 가능해집니다.

 

Hilt의 사전 정의된 한정자

Hilt에서는 미리 정의 된 한정자를 제공해줍니다. 

예를들어, Context가 필요한 경우에 간편하게 사용할 수 있도록 아래와 같은 한정자를 제공합니다.

 

- @ApplicationContext

- @ActivityContext

 

@Module
@InstallIn(SingletonComponent::class)
object SomethingModule {

    @Provides
    @Singleton
    fun provideSomething(@ApplicationContext context: Context): Something {
        //context 사용
        ...
    }
}

 

Hilt with Jetpack ViewModel

Hilt는 기본적으로 Jetpack ViewModel에 대한 의존성 주입을 제공해주기 때문에, Jetpack의 ViewModel을 사용하는 경우 굉장히 쉽고 유리하게 구현할 수 있습니다!

ViewModel Injection을 위해 app-level의 build.gradle 파일에 의존성을 추가해줍니다.

 

dependencies {
  ...
  implementation 'androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha01'
  // When using Kotlin.
  kapt 'androidx.hilt:hilt-compiler:1.0.0-alpha01'
  // When using Java.
  annotationProcessor 'androidx.hilt:hilt-compiler:1.0.0-alpha01'
  
  implementation 'androidx.fragment:fragment-ktx:1.3.0'
}

 

ViewModel Injection

Jetpack ViewModel은 Android SDK 내부적으로 ViewModel에 대한 Lifecycle을 관리하고 있습니다.

ViewModel 에서 @HiltViewModel 어노테이션과 @Inject 어노테이션을 사용하면 간단하게 ViewModel 의존성 주입을 활성화 할 수 있습니다.

 

@HiltViewModel
class MainViewModel @Inject constructor(
    private val repository: MainRepository,
    private val savedStateHandle: SavedStateHandle
    ) : ViewModel() {
    ...
}

 

생성자 파라미터로 MainRepository를 주입받을수 있고, SavedStateHandle 정보 또한 간편하게 주입 받을 수 있습니다.

 

그렇다면 ViewModel의 경우 @HiltViewModel, @Inject 어노테이션으로 나 자신은 의존성을 제공할 수 있는 인스턴스가 되었는데, 반면에 파라미터의 repository는 주입을 받아야합니다.

별다른 어노테이션도 붙어있지 않은데 어떻게 주입을 받고 있을까요?

해당 코드를 Generated 해보면 알 수 있습니다.

 

/* 디컴파일 해본 소스코드 */

@Inject
public MainViewModel(@NotNull MainRepository repository) {
   ...
   this.repository = repository;
   ...

 

내부적으로 @Inject 어노테이션을 통해 repository를 주입 받고 있기 때문에 가능함을 알수있습니다.

 

다음은 위에서 생성된 MainViewModel을 @AndroidEntryPoint가 붙은 Activity에서 사용하는 예시 입니다.

 

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        ...
    }
}

 

마무리

Hilt는 기존 Dagger에 비해 훨씬 더 쉽고 간편하게 DI 환경 구성을 제공해주고 있습니다.

간단하게 적절한 어노테이션을 달아주는것만으로도 DI 를 손쉽게 구현할 수 있습니다!

즉, Hilt는 DI 환경 구성을 위한 초기 셋업하는 과정과 DI 도입의 진입장벽을 많이 낮춰주고 있음을 느낄 수 있었습니다.

아직 정식버전의 라이브러리는 아니기 때문에 버전이 업데이트되면서 변경되는 부분도 있지만, 기존 Dagger를 기반으로 두기 때문에 코어가 탄탄하고 Google에서 밀고있는 Jetpack 과의 호환성 지원이 제공되기 때문에 앞으로의 발전과 개선이 기대되는 안드로이드 DI 라이브러리입니다!