[안드로이드] DI(Depedency injection)란 무엇인가 - 3

개요

이 글은 DI란 무엇인가에 대한 3번째 글로, Dagger에 대한 간단한 개념과 Hilt에 대한 개념을 설명하는 글입니다. Depedency와 Depedency Injection에 대한 개념은 DI란 무멋인가 - 1에 나와있고, 안드로이드에서 DI를 수동으로 적용하는 방법은 DI란 무엇인가 - 2에 나와있습니다. 만약 Depedency, Depedency Injection 등에 대한 개념에 대해 모르신다면 이전 글을 먼저 읽으시는 것을 추천드립니다.


Hilt란?

Hilt는 안드로이드 DI(의존성 주입) 라이브러리입니다. Hilt는 유명한 DI 라이브러리인 Dagger를 기반으로 만들어졌습니다. Hilt는 수동으로 의존성을 주입할 경우 생겨났던 보일러 플레이트 코드들을 줄여주고 자동으로 의존성을 주입해줍니다.

참고로 안드로이드 많이 사용되는 DI 라이브러리는 Dagger, Koin, Hilt가 있습니다. 이 라이브러리들을 비교한 내용은 DI Fragmework 선택지에서 확인할 수 있습니다.

보일러 플레이트 코드

  • 어떤 일을 하기 위해서 꼭 작성해야 하는 코드로 클래스의 getter, setter와 같은 메서드 코드를 의미합니다.
  • 즉, 코드를 길어지게 하고 개발자에게 노동을 강요하는 코드라고 생각하면 됩니다.

Hilt의 목적과 이점

Hilt의 목점과 사용 이점은 아래와 같습니다.

목적

  • Dagger 사용의 단순화
  • 표준화된 컴포넌트와 스코프로 설정과 가독성/이해도를 높이기
  • 쉬운 방법으로 다양한 빌드 타입에 대해 다른 바인딩 제공

이점

  • 코드 재사용
  • 리팩토링의 편의성
  • 테스트의 편의성
  • 컴파일 타임의 정확성(컴파일 타임에 오류를 잡기에 실행시에는 적은 오류가 발생)
  • 런타임 퍼포먼스(Koin은 런타임에 의존성을 주입하기에 런타임 퍼포먼스에 영향을 줌)
  • 보일러 플레이트 감소

기본개념 이해하기

Hilt는 Dagger를 기반으로 만든 라이브러리이기에 Hilt와 관련된 글들을 보면 용어가 Dagger와 많이 겹치는 것 같습니다. 따라서 Hilt에 대해 알아보기 전에 Dagger의 5가지 필수 개념을 알아보겠습니다.

  1. Inject
  2. Component
  3. SubComponent
  4. Module
  5. Scope

  • Inject
    의존성 주입을 요청합니다. Inject 어노테이션으로 주입을 요청하면 연결된 Component가 Module로부터 객체를 생성하여 넘겨줍니다.

  • Component
    연결된 Module을 이용하여 의존성 객체를 생성하고, Inject로 요청받은 인스턴스에 생성한 객체를 주입합니다. 의존성을 요청받고 주입하는 Dagger의 주된 역할을 수행합니다.

  • Subcomponent
    Component는 계층관계를 만들 수 있습니다. Subcomponent는 Inner Class 방식의 하위계층 Component 입니다. Sub의 Sub도 가능합니다. Subcomponent는 Dagger의 중요한 컨셉인 그래프를 형성합니다. Inject로 주입을 요청받으면 Subcomponent에서 먼저 의존성을 검색하고, 없으면 부모로 올라가면서 검색합니다.

  • Module
    Component에 연결되어 의존성 객체를 생성합니다. 생성 후 Scope에 따라 관리도 합니다.

  • Scope
    생성된 객체의 Lifecycle 범위입니다. 안드로이드에서 주로 PerActivity, PerFragment 등으로 화면의 생명주기와 맞추어 사용합니다. Module에서 Scope을 보고 객체를 관리합니다.

Dagger에 대한 기본적인 개념은 Dagger2 시작하기 글에서 참고한 부분입니다.


Hilt Depedency

Hilt를 사용하기 위해서는 아래와 같이 의존성을 설정해야 합니다.

Project 수준의 gradle

buildscript {
    ...
    dependencies {
        ...
        classpath 'com.google.dagger:hilt-android-gradle-plugin:2.38.1'
    }
}

app 수준의 gradle

plugins {
  id 'kotlin-kapt'
  id 'dagger.hilt.android.plugin'
}

android {
    ...
}

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

주요 어노테이션의 개념과 예제

이제 Hilt 라이브러리의 주요 어노테이션의 개념과 사용법을 예제와 함께 알아보도록 하겠습니다. 예제에서 사용된 코드는 Codelab - Android Hilt에 나와있는 소스코드를 사용하였습니다.

들어가기전에 간단하게 용어를 정리하겠습니다. Hilt에는 Dagger와 마찬가지로 컴포넌트라는 개념이 있는데 이는 안드로이드 컴포넌트와는 다른 개념이며 컨테이너 역할을 수행하는 클래스라고 생각하면 됩니다. 안드로이드 컴포넌트는 안드로이드 클래스라고 해당 글에서 작성하였습니다. 참고로 컨테이너는 종속 항목들을 가지고 이를 필요로 하는 곳에 주입해주는 클래스로 이에 대한 자세한 내용은 'DI란 무엇인가 - 2' 글에 나와있습니다.

따라서 컴포넌트는 컨테이너이므로 각 컴포넌트 안에 있는 타입들(객체들)을 요청하는 곳(안드로이드 클래스)으로 주입할 수 있는 집합 장소라고 생각하면 됩니다.


@HiltAndroidApp

@HiltAndroidApp 어노테이션은 Hilt 라이브러리를 사용하는 앱이라면 무조건 선언해야 하는 어노테이션으로, Application 안드로이드 클래스에 선언해야합니다. 최상위 컴포넌트(컨테이너)에 해당하며 Hilt 코드를 생성시켜줍니다.

@HiltAndroidApp 예제

/** 생명주기가 app에 해당하는 컴포넌트(컨테이너) 생성
 * 
 * 의존성 주입을 사용할 수 있는 앱의 기본 클래스들(액티비티, 프래그먼트 등..)을 
 * 포함한 Hilt 코드 생성을 발생(트리거)
 * 
 * 최상위 컨테이너로 다른 컨테이너들은 해당 컨테이너가 제공하는 종속 항목에 접근할 수 있음
 * 즉, 계층적 접근이 가능하다
 */
@HiltAndroidApp
class LogApplication : Application() {

    override fun onCreate() {
        super.onCreate()
    }
}

@AndroidEntryPoint, @HiltViewModel

해당 어노테이션이 붙은 안드로이드 클래스는 해당 클래스의 생명주기를 따르는 컴포넌트(컨테이너)를 만듭니다. @AndroidEntryPoint를 사용할 수 있는 안드로이드 클래스는 아래와 같습니다.

  • Activity(AppCompatActivity와 같이 ComponentAcitivty를 상속받는 Activity만 가능)
  • Fragment(androidx.Fragment를 상속받는 Fragment만 가능)
  • View
  • Service
  • BroadcastReceiver
  • ViewModel(@ViewModelComponent 어노테이션 사용)

만약 안드로이드 클래스에 @AndroidEntryPoint 어노테이션을 달았다면, 이 클래스에 의존하는 안드로이드 클래스에도 어노테이션을 달아야 합니다. 즉, Fragment에 어노테이션을 달았다면 Fragment를 호스팅하는 Activity 클래스에도 반드시 어노테이션을 달아야 합니다.

@AndroidEntryPoint 예제

예제 앱은 하나의 액티비티(MainActivity)와 두 개의 프래그먼트(ButtonsFragment, LogsFragment)로 구성된 싱글 액티비티 구조입니다. 두 개의 프래그먼트 모두 의존성을 주입받고 있어서 @AndroidEntryPoint 어노테이션을 달았습니다. 따라서 이를 호스팅하는 액티비티에도 @AndroidEntryPoint 어노테이션을 아래와 같이 달아줍니다.

// MainActivity 가 호스트하고 있는 LogsFragment, ButtonsFragment 가
// Hilt Component 이기에 MainActivity 또한 Component 로 설정
// 액티비티의 생명주기에 해당하는 의존성 컨테이너(컴포넌트) 생성
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
	...
}

// 프래그먼트 생명주기에 해당하는 의존성 컨테이너(컴포넌트)를 생성
@AndroidEntryPoint
class LogsFragment : Fragment() {
	...
}

// 프래그먼트 생명주기에 해당하는 의존성 컨테이너(컴포넌트)를 생성
@AndroidEntryPoint
class ButtonsFragment : Fragment() {
	...
}

컴포넌트의 생명주기

@AndroidEntryPoint, @HiltViewModel과 같은 어노테이션으로 생성된 컴포넌트는 연결된 안드로이드 클래스의 생명주기를 따른다고 위에서 언급하였습니다. 즉, 각 컴포넌트들은 해당 안드로이드 클래스의 생성 시점부터 파괴되기 전까지 의존성 주입이 가능합니다. 아래는 생성되는 컴포넌트와 생명주기를 나타내는 표입니다.

만약 Activity에 @AndroidEntryPoint 어노테이션을 설정하여 액티비티 컴포넌트를 생성하였다고 가정하면 이 컴포넌트는 Activity가 생성되는(onCreate()) 시점에 함께 생성되고, Activity가 파괴되는(onDestroy()) 시점에 함께 파괴됩니다. 안드로이드 클래스와 생성되는 컴포넌트의 생명주기에 대한 내용은 Dagger Hilt로 안드로이드 의존성 주입 시작하기에 자세히 나와있습니다.


Component Hierarchy(컴포넌트 계층)

생성된 컴포넌트들은 계층 구조를 가지고 있습니다. 이에 따라, 하위 컴포넌트는 상위 컴포넌트에서 제공하는 종속 항목을 제공받을 수 있습니다. 즉, Car라는 클래스를 액티비티 컴포넌트에서 제공한다면, 그 하위 컴포넌트인 프래그먼트에서 이를 제공받을 수 있습니다.

참고로 SingletonComponentActivityComponent에서는 기본적으로 제공하는 타입이 있습니다. SingletonComponent@ApplicationContext를 제공하고 ActivityComponent@ActivityContext를 제공합니다. 만약 Context가 필요하다고 하면 이를 사용해서 접근할 수 있습니다.


@Inject

의존성을 주입하기 위해서는 @Inject 어노테이션을 사용해야 합니다. 이 어노테이션을 필드에 설정하면 Field injection이 되고, 이 어노테이션을 생성자에 설정하면 Constructor injection이 됩니다.

  • Field Injection(필드 주입)

필드 주입을 수행하면 Hilt가 자동으로 의존성을 주입해줍니다. 다만 해당 필드에 접근자로 private은 사용할 수 없습니다. 다음은 LogsFragment에서 DateFormatter 인스턴스를 주입받는 코드입니다.

// 프래그먼트 생명주기에 해당하는 의존성 컨테이너(컴포넌트)를 생성
@AndroidEntryPoint
class LogsFragment : Fragment() {

    // @Inject 어노테이션을 사용하여 의존성 주입
    // 해당 어노테이션을 사용하면 Hilt가 자동으로 의존성을 주입해준다.
    @Inject
    lateinit var dateFormatter: DateFormatter
    
}

필드 주입을 선언해서 Hilt가 자동으로 해당 타입의 객체를 주입(생성)해준다고 해도 해당 타입을 제공하는 방법을 Hilt는 알고 있어야 합니다. Constructor injection을 통해서 Hilt에게 이 방법을 알려줄 수 있습니다.

  • Constructor Injection

생성자 주입은 두 가지의 역할이 있습니다. 첫 번째는 위에서 언급하였듯이 Hilt에게 해당 타입을 제공하는 방법을 알려주는 역할을 수행합니다. 따라서 위의 코드에서 필드 주입을 통해 주입하려는 DateFormatter 타입은 아래와 같이 코드를 작성하면 됩니다.

// Hilt가 주입하기를 원하는 클래스의 생성자에 @Inject 어노테이션을 달면
// Hilt가 해당 타입을 주입하는 방법을 알게되고
// 자동으로 해당 타입을 필요로 하는 곳에(의존하는 곳에) Hilt가 주입해준다.
class DateFormatter @Inject constructor() {

    @SuppressLint("SimpleDateFormat")
    private val formatter = SimpleDateFormat("d MMM yyyy HH:mm:ss")

    fun formatDate(timestamp: Long): String {
        return formatter.format(Date(timestamp))
    }
}

생성자 주입의 두 번째 역할은 필드 주입과 마찬가지로 종속 항목을 주입받도록 Hilt에게 요청합니다. 생성자 안에 타입을 선언한다면 Hilt는 이를 종속 항목으로 파악하고 해당 타입을 자동으로 주입해줍니다.

즉, 정리하자면 현재 클래스를 종속 항목으로 주입하거나, 현재 클래스에서 종속 항목을 주입받으려면 생성자 주입을 사용하면 됩니다. 다만, 인터페이스를 구현하는 클래스나 외부 라이브러리(Room, Retrofit) 객체들은 @Inject 어노테이션을 사용해서 주입할 수 없습니다. 이들은 다른 어노테이션을 사용해야하는데 아래에서 자세히 알아보도록 하겠습니다.


scoped annotation

Hilt에는 주입되는 객체의 scope(범위)를 지정하는 어노테이션이 있습니다. 이 어노테이션을 사용하는 이유는 단순합니다. 객체의 범위를 scoped 어노테이션을 통해 연결된 컴포넌트로 지정하는 것입니다. 이 어노테이션을 사용하면 해당 타입은 scoped 어노테이션으로 지정된 컴포넌트(컨테이너)에서 모두 같은 인스턴스로 제공됩니다. 그림을 통해 쉽게 알아보도록 하겠습니다.

안드로이드 클래스 - 컴포넌트 - Scope 어노테이션

scope 어노테이션이 지정되지 않은 타입(해당 타입이 요청될때마다 다른 객체를 주입)

scope 어노테이션이 지정된 타입(같은 객체를 주입)

기본적으로 scope 어노테이션을 선언하지 않는다면 unscoped(default) 입니다. 기본적으로 선언하지 않으면, 첫 번째 사진처럼 의존성 객체가 요청되어 주입될 때마다 새로운 객체가 주입됩니다. 반면, 만약 @Singleton 어노테이션을 사용해서 해당 타입의 Scope를 SingletonComponent로 설정한다면 앱 내내 같은 객체가 의존성으로 주입됩니다. 하지만 scope 어노테이션은 비용이 꽤나 들기에 사용을 최소한으로 해야합니다.

@Singleton 어노테이션을 사용해 SingletonComponent로 scope를 지정한 코드

// Singleton Scope를 사용하여 앱이 실행되어 있는 동안에 같은 LoggerLocalDataSource 객체가 전달
// 생성자 주입을 통해 해당 클래스를 제공하는 방법을 Hitl에게 알리고
// 파라미터로 선언된 LogDao를 종속 항목으로 Hilt에게 제공받는다.
@Singleton
class LoggerLocalDataSource @Inject constructor(
    private val logDao: LogDao
)

@Module, @Provide

이미 언급했지만 Hilt에서는 인터페이스나 외부 라이브러리 클래스는 생성자 삽입을 사용할 수 없습니다. 즉, 클래스의 생성자에 @Inject 어노테이션을 설정해서 해당 타입을 종속 항목으로 제공하는 방법을 Hilt 알려줄 수가 없습니다. 이를 위해서 @Module 이라는 어노테이션이 존재합니다.

@Module 어노테이션을 사용하면 Hilt 모듈이 만들어지고 이 모듈은 인터페이스나 외부 라이브러리의 클래스와 같이 생성자 삽입을 사용할 수 없는 타입을 Hilt에서 주입할 수 있게 만들어줍니다. @Module 어노테이션은 @InstallIn 어노테이션과 무조건 함께 쓰이는데, 이 어노테이션은 어떤 Hilt 컴포넌트에서 종속 항목을 주입해줄지 지정하는 역할을 수행합니다. 지정할 수 있는 컴포넌트로는 안드로이드 클래스 - 컴포넌트 - scope 어노테이션에서 보았던 것들이 있습니다.

예제 코드에서는 Room 라이브러리를 사용해서 데이터베이스에 로그를 저장합니다. 그런데 Room 라이브러리는 외부 라이브러리이기에 생성자 삽입을 사용해서 의존성을 주입할 수 없습니다. 따라서 Hilt Module을 만들어서 의존성을 제공해야 하는데 코드를 보도록 하겠습니다.

/*
LogDao를 종속 항목으로 요청하는 LoggerLocalDataSource 클래스가
application container로 scoped를 지정했기에(@Singleton)
LogDao을 제공하는 Module의 컨테이너를 SingletonComponent로 지정

@Provides 어노테이션을 사용하는 모듈은 object 키워드를 사용해서 객체 선언
*/
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
    // @Provides 어노테이션을 사용하여 Hilt에 생성자 삽입을 할 수 없는
    // 타입을 제공하는 방법을 알려줌(RoomDatabase는 외부 라이브러리이므로 생성자 삽입 불가능)
    // Hilt에서 항상 동일한 데이터베이스를 제공하기 위해서 @Singleton 어노테이션 사용
    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext appContext: Context): AppDatabase {
        return Room.databaseBuilder(
            appContext,
            AppDatabase::class.java,
            "logging.db"
        ).build()
    }

    // @Provides 어노테이션을 사용하여 Hilt에 생성자 삽입을 할 수 없는 타입을
    // 제공하는 방법을 알려줌(LogDao는 인터페이스이므로 생성자 삽입 불가능)
    // 리턴타입은 제공하려는 타입(바인딩 타입)
    // 파라미터는 종속항목
    @Provides
    fun provideLogDao(database: AppDatabase): LogDao {
        return database.logDao()
    }
}

@Module@InstallIn 어노테이션을 사용해서 모듈을 생성하였습니다. 모듈의 안에는 메서드를 통해서 인터페이스와 외부 라이브러리 클래스들을 종속 항목으로 제공하고 있는데 메서드에 @Provides라는 어노테이션이 있습니다. 해당 어노테이션을 설정하면 생성자 삽입을 사용할 수 없는 타입을 제공하는 방법을 Hilt에게 알려줄 수 있습니다. 함수의 매개변수는 해당 타입의 종속 항목이고 반환 타입이 인터페이스 또는 외부 라이브러리 클래스에 해당합니다.

위의 코드에서는 Room 데이터베이스 클래스와 LogDao 인터페이스를 @Provides 어노테이션을 사용해서 종속 항목으로 지정하였습니다. 참고로 Room Database에 @Singleton 어노테이션을 지정한 이유는 Room 데이터베이스 객체는 초기화에 많은 비용이 들기에, 초기화를 한번만 진행하고 그 이후에는 항상 같은 객체를 제공하도록 설정하기 위한 것입니다.

@Provides 어노테이션은 인터페이스와 외부 라이브러리를 제공할 때 사용할 수 있고, 단지 인터페이스만을 제공하려고 한다면 @Binds 어노테이션을 사용할 수 있습니다.


@Binds

해당 어노테이션 또한 Hilt 모듈에서 사용할 수 있는 어노테이션이므로 우선 @Module@InstallIn 어노테이션을 사용해서 Hilt 모듈을 생성해야 합니다. 다만 @Binds 어노테이션은 추상 메서드를 사용해서 인터페이스를 제공하므로, 클래스 또한 추상 클래스로 선언해야 합니다.

우선 인터페이스와 인터페이스를 구현하는 클래스를 선언하고 @Binds 어노테이션을 사용하는 방법을 알아보도록 하겠습니다.

인터페이스와 인터페이스의 구현체

interface AppNavigator {
    // Navigate to a given screen.
    fun navigateTo(screen: Screens)
}

// 해당 클래스는 생성자 삽입을 사용할 수 있기에
// @Inject 어노테이션 사용
class AppNavigatorImpl @Inject constructor(
    private val activity: FragmentActivity
) : AppNavigator {

    override fun navigateTo(screen: Screens) {
        val fragment = when (screen) {
            Screens.BUTTONS -> ButtonsFragment()
            Screens.LOGS -> LogsFragment()
        }

        activity.supportFragmentManager.beginTransaction()
            .replace(R.id.main_container, fragment)
            .addToBackStack(fragment::class.java.canonicalName)
            .commit()
    }
}

// MainActivity.kt
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
	
    // 필드 주입을 통해 AppNavigator 타입의 종속 항목 요청
    @Inject
    lateinit var navigator: AppNavigator
    ...
}

AppNavigator 인터페이스와 이를 구현하는 AppNavigatorImpl 클래스입니다. 이 인터페이스는 MainActivity에서 종속 항목으로 요청됩니다.

@Binds 어노테이션 사용

// Navigation을 요청하는 곳이 Activity 이므로
// Activity Component로 지정하여 액티비티 컨테이너에서 종속 항목을 주입해주도록 설정
@Module
@InstallIn(ActivityComponent::class)
abstract class NavigationModule {

    // interface를 binding(Hilt가 제공하는 타입)으로 추가하기 위해서 @Binds 어노테이션 사용
    // 반환 타입은 Hilt에게 종속 항목으로 알릴 interface
    // 파라미터는 interface를 구현하는 객체
    @Binds
    abstract fun bindNavigator(impl: AppNavigatorImpl): AppNavigator
}

@Module@InstallIn 어노테이션을 사용해서 Hilt 모듈을 생성하였고, 모듈 클래스는 위에서 언급했듯이 추상 클래스로 선언하였습니다. 메서드에는 @Binds 어노테이션을 선언했고 반환 타입은 제공하려는 인터페이스, 파라미터는 인터페이스를 구현하는 객체를 넣습니다.

정리하자면 다음과 같습니다.

  • @Module: 모듈을 생성하는 어노테이션이고
  • @InstallIn: 어떤 컴포넌트에서 모듈안에 선언된 타입들을 주입해 줄지를 결정하는 어노테이션
  • @Provide: 인터페이스와 외부 라이브러리를 제공하는 방법을 Hilt에게 알려주는 어노테이션
  • @Binds: 인터페이스를 제공하는 방법을 Hilt에게 알려주는 어노테이션

@Qualifier

잠깐 MainActivity.kt에서 Navigation 인터페이스를 종속 항목으로 요청했던 코드를 다시 보도록 하겠습니다.

// MainActivity.kt
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
	
    // 필드 주입을 통해 AppNavigator 타입의 종속 항목 요청
    @Inject
    lateinit var navigator: AppNavigator
    ...
}

이 코드를 보면 약간 궁금한 점이 생기시는 분이 계실수도 있습니다. 만약 AppNavigator 인터페이스를 구현하는 클래스가 두 개라면 Hilt는 어떻게 이들을 구별할 수 있을까요?? @Qualifer 어노테이션을 사용하면 이들을 구별하는 방법을 Hilt에게 알려줄 수 있습니다. 예시 코드와 함께 알아보도록 하곘습니다.

Codelab 예제 코드에는 로그를 기록하고 확인하고 지우는 Data Source가 있습니다. 하나는 Memory를 통해 로그를 남기고, 하나는 Local Database를 통해 로그를 남깁니다. 따라서 이 둘의 기능의 틀은 똑같기에 인터페이스를 선언하고 이 두 클래스가 이를 구현하도록 만들었습니다.

인터페이스와 이를 구현하는 클래스

// Logger data source를 위한 공통 인터페이스
interface LoggerDataSource {
    fun addLog(msg: String)
    fun getAllLogs(callback: (List<Log>) -> Unit)
    fun removeLogs()
}

// LoggerDataSource 인터페이스를 구현하는 또다른 클래스
// ActivityScope를 사용하여 ActivityComponent 에서 같은 LoggerInMemoryDataSource 객체를 획득할 수 있다.
@ActivityScoped
class LoggerInMemoryDataSource @Inject constructor(
    
): LoggerDataSource {
	...
}

/ LoggerDataSource 인터페이스를 구현하는 클래스
// Singleton Scope를 사용하여 앱이 실행되어 있는 동안에 같은 LoggerLocalDataSource 객체가 전달
@Singleton
class LoggerLocalDataSource @Inject constructor(
    private val logDao: LogDao
): LoggerDataSource {
	...
}

LoggerDataSource 인터페이스를 선언하고 이를 구현하는 LoggerInMemoryDataSource 클래스와 LoggerLocalDataSource 클래스를 선언하였습니다. 이제 LoggerDataSource 인터페이스를 제공하기 위해서 Hilt 모듈을 생성하고 @Provide 또는 @Binds 어노테이션을 사용해서 이를 제공하는 방법을 Hilt에게 알려줘야 합니다. 아래는 @Binds 어노테이션을 사용한 코드입니다.

Module 선언

// LoggerDataSource 인터페이스를 구현하는 LoggerInMemoryDataSource와
// LoggerLocalDataSource는 서로 다른 컴포넌트로 scope가 지정되었기에
// 같은 모듈에 선언할 수 없다.
@InstallIn(SingletonComponent::class)
@Module
abstract class LoggingDatabaseModule {

    @Singleton
    @Binds
    abstract fun bindDatabaseLogger(impl: LoggerLocalDataSource): LoggerDataSource
}

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

    @ActivityScoped
    @Binds
    abstract fun bindInMemoryLogger(impl: LoggerInMemoryDataSource): LoggerDataSource
}

종속 항목을 요청하는 ButtonsFragment

// LoggerDataSource를 요청하는 ButtonsFragment
// 프래그먼트 생명주기에 해당하는 의존성 컨테이너(컴포넌트)를 생성
@AndroidEntryPoint
class ButtonsFragment : Fragment() {

    @Inject
    lateinit var logger: LoggerDataSource

이제 Module을 선언해서 인터페이스를 제공하는 방법을 Hilt에게 알려주었습니다. 그리고
ButtonsFragment에서 LoggerDataSource 인터페이스타입을 Hilt에게 주입해달라고 요청하였습니다. 하지만 이와 같이 사용한다면 error: [Dagger/DuplicateBindings] com.example.android.hilt.data.LoggerDataSource is bound multiple times와 같은 에러가 발생합니다. 그 이유는 Hilt가 LoggerDataSource를 구현하는 클래스 중에서 어떠한 타입을 주입해주어야 할지 모르기 때문입니다. 따라서 어떤 타입을 제공해야할지 Hilt에게 알려줘야 하는데, 이를 위해서 @Qualifier 어노테이션을 사용하는 것입니다.

@Qualifier 어노테이션 지정

@InstallIn(SingletonComponent::class)
@Module
abstract class LoggingDatabaseModule {

    // Qualifier 지정
    @DatabaseLogger
    @Singleton
    @Binds
    abstract fun bindDatabaseLogger(impl: LoggerLocalDataSource): LoggerDataSource
}

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

    // Qualifier 지정
    @InMemoryLogger
    @ActivityScoped
    @Binds
    abstract fun bindInMemoryLogger(impl: LoggerInMemoryDataSource): LoggerDataSource
}

// Qualifier(한정자) 정의
// LoggerDataSource 인터페이스를 구현하는 두 클래스(LoggerInMemoryDataSource, LoggerLocalDataSource)를
// Hilt는 구별하지 못하기에 이를 Hilt에게 알려줘서 구별하도록 하기위해서 사용한다.
@Qualifier
annotation class InMemoryLogger

// Qualifier(한정자) 정의
@Qualifier
annotation class DatabaseLogger

종속 항목을 요청하는 ButtonsFragment

@AndroidEntryPoint
class ButtonsFragment : Fragment() {

    // Field Injection
    // LoggerInMemoryDataSource를 삽입하는 지점에도 Qualifier 선언
    // 이제 Hilt는 logger가 LoggerInMemoryDataSource 타입임을 안다
    @InMemoryLogger
    @Inject
    lateinit var logger: LoggerDataSource

InMemoryLogger와 DatabaseLogger 어노테이션 클래스를 선언하며 @Qualifier 어노테이션을 지정하였습니다. 각 한정자는 타입을 식별하는데 사용되기에 구현별로 선언하였고, 선언한 어노테이션들을 메서드에 지정하였습니다. 이제 종속 항목을 요청하는 곳에서도 필요한 구현체에 맞는 어노테이션을 지정하면, Hilt는 이를 구별하여 적절한 타입을 주입해줍니다.


결론

  • Hilt는 여러 어노테이션을 사용하면 자동으로 컴포넌트가 생성되고 종속 항목들을 주입해주기에 익숙해진다면 편리하게 사용할 수 있는 라이브러입니다.

  • 계속해서 공부하며 추가로 알게되는 부분은 새로 글을 작성하거나 해당 글에 추가하겠습니다.
    * Hilt test, @EntryPoint

  • 만약 이 글을 읽으며 Hilt에 대해 알아보려고 하신다면, 참조에 달아놓은 글들도 꼭 읽어보시기 바랍니다.

어노테이션 정리


참조
안드로이드 developer - Depedency injection with hilt
Depedency Injection on Android with Hilt
Depedency Injection: Dagger-Hilt vs Koin
DI 기본개념부터 사용법까지, Dagger2 시작하기
Hilt 개념 설명 및 사용법
Hilt for DroidKnights

틀린 부분은 댓글로 남겨주시면 수정하겠습니다..!!

좋은 웹페이지 즐겨찾기