Android 앱에서 Hilt 사용

이 Codelab에서는 대규모 프로젝트로 확장할 수 있는 견고한 확장형 애플리케이션을 만들기 위한 종속 항목 삽입(DI)의 중요성을 알아봅니다. 여기서는 Hilt를 DI 도구로 사용하여 종속 항목을 관리합니다.

DI는 프로그래밍에 널리 사용되는 기법으로, Android 개발에 적합합니다. DI의 원칙을 따르면 훌륭한 앱 아키텍처를 위한 토대를 마련할 수 있습니다.

DI를 구현하면 다음과 같은 이점이 있습니다.

  • 코드 재사용 가능
  • 리팩터링 편의성
  • 테스트 편의성

Hilt는 독보적인 Android용 종속 항목 삽입 라이브러리로 프로젝트에서 수동 DI를 사용하는 상용구 코드를 줄여줍니다. 종속 항목을 직접 삽입하려면 모든 클래스와 클래스의 종속 항목을 수동으로 생성하고 컨테이너를 사용하여 종속 항목을 재사용하고 관리해야 합니다.

Hilt는 애플리케이션에 DI를 삽입하는 표준 방식으로, 프로젝트의 모든 Android 구성요소에 컨테이너를 제공하고 컨테이너의 수명 주기를 자동으로 관리합니다. 이 방식은 많이 사용하는 DI 라이브러리인 Dagger를 활용한 것입니다.

이 Codelab을 진행하는 동안 코드 버그, 문법 오류, 불명확한 문구 등의 문제가 발생하면 Codelab 왼쪽 하단에 있는 오류 신고 링크를 통해 신고해 주세요.

기본 요건

  • Kotlin 구문 사용 경험
  • 애플리케이션에 종속 항목 삽입이 중요한 이유에 관한 이해

학습할 내용

  • Android 앱에서 Hilt를 사용하는 방법
  • 지속 가능한 앱 제작과 관련된 Hilt 개념
  • 한정자를 사용하여 동일한 유형에 결합을 여러 개 추가하는 방법
  • @EntryPoint를 사용하여 Hilt에서 지원하지 않는 클래스의 컨테이너에 액세스하는 방법
  • 단위 테스트 및 계측 테스트를 사용하여 Hilt를 사용하는 애플리케이션을 테스트하는 방법

필요한 항목

  • Android 스튜디오 4.0 이상

코드 가져오기

GitHub에서 Codelab 코드를 가져옵니다.

$ git clone https://github.com/googlecodelabs/android-hilt

또는 저장소를 ZIP 파일로 다운로드할 수 있습니다.

ZIP 파일 다운로드

Android 스튜디오 열기

이 Codelab을 사용하려면 Android 스튜디오 4.0 이상이 필요합니다. Android 스튜디오를 다운로드하려면 여기를 방문하세요.

샘플 앱 실행

이 Codelab에서는 사용자 상호작용을 로깅하고 Room을 사용하여 로컬 데이터베이스에 데이터를 저장하는 애플리케이션에 Hilt를 추가합니다.

다음 안내에 따라 Android 스튜디오에서 샘플 앱을 엽니다.

  • ZIP 보관 파일을 다운로드했다면 파일을 로컬에서 압축 해제합니다.
  • Android 스튜디오에서 프로젝트를 엽니다.
  • Run execute.png 버튼을 클릭하고 에뮬레이터를 선택하거나 Android 기기를 연결합니다.

위 이미지와 같이 번호가 매겨진 버튼 중 하나와 상호작용할 때마다 로그가 생성되어 저장됩니다. See All Logs 화면에서 이전 상호작용을 모두 볼 수 있습니다. 로그를 삭제하려면 Delete Logs 버튼을 탭합니다.

프로젝트 설정

이 프로젝트는 GitHub의 여러 분기에서 빌드됩니다.

  • master는 개발자가 확인하거나 다운로드한 분기로 Codelab의 시작점입니다.
  • solution에는 이 Codelab의 솔루션이 포함되어 있습니다.

master 분기로 시작하고 각자의 속도에 맞게 Codelab을 단계별로 따라하는 것이 좋습니다.

Codelab을 진행하는 중에 프로젝트에 추가해야 하는 코드 스니펫이 제공됩니다. 코드 스니펫의 댓글에 명시된 코드를 삭제해야 하는 경우도 있을 수 있습니다.

git을 사용하여 solution 분기를 가져오려면 다음 명령어를 사용합니다.

$ git clone -b solution https://github.com/googlecodelabs/android-hilt

또는 다음 위치에서 솔루션 코드를 다운로드합니다.

최종 코드 다운로드

자주 묻는 질문(FAQ)

Hilt를 사용하는 이유

시작 코드를 살펴보면 LogApplication 클래스에 저장된 ServiceLocator 클래스의 인스턴스를 확인할 수 있습니다. ServiceLocator는 요구되는 클래스에서 요청에 따라 가져온 종속 항목을 만들고 저장합니다. 이 클래스는 앱이 소멸될 때 함께 소멸되므로 앱의 수명 주기에 연결되는 종속 항목의 컨테이너라고 생각할 수 있습니다.

Android DI 가이드에 설명한 대로 서비스 로케이터는 비교적 적은 상용구 코드로 시작하지만 확장성도 떨어집니다. Android 앱을 대규모로 개발하려면 Hilt를 사용해야 합니다.

Hilt는 개발자가 직접 작성했어야 하는 코드(예: ServiceLocator 클래스 코드)를 생성하여 Android 애플리케이션에서 수동 DI 또는 서비스 로케이터 패턴을 사용해야 하는 불필요한 상용구를 삭제합니다.

다음 단계에서는 Hilt를 사용하여 ServiceLocator 클래스를 대체합니다. 그다음, 프로젝트에 새 기능을 추가하여 Hilt 기능에 관해 더 살펴보겠습니다.

프로젝트에서 Hilt 사용

Hilt는 이미 master 분기(다운로드한 코드)에 구성되어 있습니다. 다음의 코드는 이미 생성되었으므로 프로젝트에 포함하지 않아도 됩니다. 그렇지만 Android 앱에서 Hilt를 사용하기 위해 무엇이 필요한지 살펴보겠습니다.

라이브러리 종속 항목과 별개로 Hilt는 프로젝트에서 구성된 Gradle 플러그인을 사용합니다. 루트의 build.gradle 파일을 열고 클래스 경로에서 다음의 Hilt 종속 항목을 확인하세요.

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

그런 다음, app 모듈에서 Gradle 플러그인을 사용하기 위해 아래 kotlin-kapt 플러그인을 app/build.gradle 파일의 최상단에 추가하여 파일에 플러그인을 지정합니다.

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

android {
    ...
}

마지막으로, 동일한 app/build.gradle 파일의 프로젝트에 Hilt 종속 항목을 포함합니다.

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

Hilt를 비롯한 모든 라이브러리는 프로젝트를 빌드하고 동기화할 때 다운로드됩니다. 이제 Hilt를 사용해 보세요.

LogApplication 클래스의 ServiceLocator 인스턴스를 사용하고 초기화하는 방식과 마찬가지로, 앱의 수명 주기에 연결된 컨테이너를 추가하려면 @HiltAndroidApp으로 Application 클래스에 주석을 달아야 합니다. LogApplication.kt를 열고 다음과 같이 클래스에 주석을 추가합니다.

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

@HiltAndroidApp은 종속 항목 삽입을 사용할 수 있는 애플리케이션의 기본 클래스가 포함된 Hilt 코드 생성을 트리거합니다. 애플리케이션 컨테이너는 앱의 상위 컨테이너이므로 다른 컨테이너는 이 상위 컨테이너에서 제공하는 종속 항목에 액세스할 수 있습니다.

이제 앱에서 Hilt를 사용할 준비가 되었습니다.

필요 시 클래스의 ServiceLocator에서 종속 항목을 가져오는 대신 Hilt를 사용하여 이러한 종속 항목을 제공합니다. 이제 이 클래스의 ServiceLocator 호출을 대체해 보겠습니다.

ui/LogsFragment.kt 파일을 엽니다. LogsFragmentonAttach에서 필드를 채웁니다. ServiceLocator를 사용하여 직접 LoggerLocalDataSourceDateFormatter 인스턴스를 채우는 대신 Hilt를 사용하여 이러한 유형의 인스턴스를 생성하고 관리할 수 있습니다.

LogsFragment에서 Hilt를 사용하려면 @AndroidEntryPoint로 주석을 달아야 합니다.

@AndroidEntryPoint
class LogsFragment : Fragment() {
    ...
}

Android 클래스에 @AndroidEntryPoint 주석을 달면 Android 클래스 수명 주기를 따르는 종속 항목 컨테이너가 생성됩니다.

@AndroidEntryPoint를 사용하면 Hilt는 LogsFragment의 수명 주기에 연결된 종속 항목 컨테이너를 생성하고 LogsFragment에 인스턴스를 삽입할 수 있습니다. 어떻게 Hilt로 필드를 삽입할 수 있을까요?

@Inject 주석을 사용하여 Hilt에서 삽입하려는 다른 유형의 인스턴스(예: logger, dateFormatter)를 필드에 삽입하도록 할 수 있습니다.

@AndroidEntryPoint
class LogsFragment : Fragment() {

    @Inject lateinit var logger: LoggerLocalDataSource
    @Inject lateinit var dateFormatter: DateFormatter

    ...
}

이를 필드 삽입이라고 합니다.

Hilt에서 이러한 필드를 대신 채워주므로 더이상 populateFields 메서드가 필요하지 않습니다. 클래스에서 메서드를 삭제해 보겠습니다.

@AndroidEntryPoint
class LogsFragment : Fragment() {

    // Remove following code from LogsFragment

    override fun onAttach(context: Context) {
        super.onAttach(context)

        populateFields(context)
    }

    private fun populateFields(context: Context) {
        logger = (context.applicationContext as LogApplication).serviceLocator.loggerLocalDataSource
        dateFormatter =
            (context.applicationContext as LogApplication).serviceLocator.provideDateFormatter()
    }

    ...
}

내부적으로 Hilt는 자동 생성된 LogsFragment의 종속 항목 컨테이너에서 빌드한 인스턴스를 사용하여 onAttach() 수명 주기 메서드 내에서 이러한 필드를 채웁니다.

필드 삽입을 실행하려면 Hilt에서 이러한 종속 항목의 인스턴스를 어떻게 제공하는지 알아야 합니다. 이 경우 Hilt는 LoggerLocalDataSourceDateFormatter의 인스턴스 제공 방법을 알아야 합니다. 하지만, Hilt는 아직 이러한 인스턴스를 제공하는 방법을 모릅니다.

@Inject로 Hilt에 종속 항목 제공 방법 알리기

ServiceLocator.kt 파일을 열어 ServiceLocator의 구현 방법을 확인합니다. provideDateFormatter()를 호출하면 어떻게 항상 다른 DateFormatter 인스턴스가 반환되는지 알 수 있습니다.

이것이 Hilt를 사용하여 얻고 싶은 결과입니다. 다행히 DateFormatter는 다른 클래스에 종속되지 않으므로 현재로서는 전이 종속 항목에 관해 걱정하지 않아도 됩니다.

유형의 인스턴스 제공 방법을 Hilt에 알리려면 삽입하려는 클래스의 생성자에 @Inject 주석을 추가하세요.

util/DateFormatter.kt 파일을 열고 DateFormatter의 생성자에 @Inject 주석을 추가합니다. Kotlin에서 생성자에 주석을 달려면 constructor 키워드도 필요합니다.

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

이로써 Hilt에서 DateFormatter 인스턴스 제공 방법을 알게 됩니다. LoggerLocalDataSource도 같은 방식으로 완료합니다. data/LoggerLocalDataSource.kt 파일을 열고 생성자에 @Inject 주석을 추가합니다.

class LoggerLocalDataSource @Inject constructor(private val logDao: LogDao) {
    ...
}

ServiceLocator 클래스를 다시 열어보면 공개 필드로 LoggerLocalDataSource가 있는 것을 확인할 수 있습니다. 즉, ServiceLocator는 호출될 때마다 항상 동일한 LoggerLocalDataSource 인스턴스를 반환합니다. 이를 '인스턴스 범위를 컨테이너로 지정'이라고 합니다. Hilt에서 이것을 어떻게 처리할 수 있을까요?

주석을 사용하여 인스턴스의 범위를 컨테이너로 지정할 수 있습니다. Hilt는 수명 주기가 다른 여러 컨테이너를 생성할 수 있으므로 이러한 컨테이너로 범위가 지정된 다양한 주석이 있습니다.

인스턴스 범위를 애플리케이션 컨테이너로 지정하는 주석은 @Singleton입니다. 이 주석을 사용하면 유형이 다른 유형의 종속 항목으로 사용되는지 또는 삽입된 필드여야 하는지와 관계없이 애플리케이션 컨테이너에서 항상 같은 인스턴스를 제공합니다.

Android 클래스에 연결된 모든 컨테이너에는 동일한 로직을 적용할 수 있습니다. 문서에서 범위 지정 주석의 전체 목록을 확인할 수 있습니다. 예를 들어, 활동 컨테이너에서 항상 특정 유형에 관해 동일한 인스턴스를 제공하게 하려면 유형에 @ActivityScoped 주석을 달면 됩니다.

위에서 언급한 대로 애플리케이션 컨테이너에서 항상 동일한 LoggerLocalDataSource 인스턴스를 제공하도록 하기 위해 클래스에 @Singleton 주석을 추가합니다.

@Singleton
class LoggerLocalDataSource @Inject constructor(private val logDao: LogDao) {
    ...
}

이제 Hilt에서 LoggerLocalDataSource 인스턴스 제공 방법을 알고 있습니다. 하지만, 이번에는 유형에 전이 종속 항목이 있습니다. LoggerLocalDataSource 인스턴스를 제공하려면 Hilt에서 LogDao 인스턴스 제공 방법도 알아야 합니다.

그러나, LogDao는 인터페이스이므로 생성자가 없기 때문에 생성자에 @Inject로 주석을 달 수 없습니다. Hilt에 이러한 유형의 인스턴스 제공 방법을 어떻게 알릴 수 있을까요?

모듈을 사용하여 Hilt에 결합을 추가합니다. 즉, 모듈을 사용하여 Hilt에 다양한 유형의 인스턴스 제공 방법을 알려 줍니다. 인터페이스나 프로젝트에 포함되지 않은 클래스와 같이 생성자가 삽입될 수 없는 유형의 결합을 Hilt 모듈에 포함합니다. 빌더를 사용하여 인스턴스를 생성해야 하는 OkHttpClient를 한 예로 들 수 있습니다.

Hilt 모듈은 @Module @InstallIn 주석이 달린 클래스입니다. @Module은 Hilt에 모듈임을 알려 주고 @InstallIn은 어느 컨테이너에서 Hilt 구성요소를 지정하여 결합을 사용할 수 있는지 Hilt에 알려 줍니다. Hilt 구성요소는 컨테이너로 간주할 수 있으며 구성요소 전체 목록은 여기에서 확인할 수 있습니다.

Hilt에서 삽입할 수 있는 Android 클래스마다 연결된 Hilt 구성요소가 있습니다. 예를 들어, Application 컨테이너는 ApplicationComponent와 연결되며 Fragment 컨테이너는 FragmentComponent와 연결됩니다.

모듈 만들기

결합을 추가할 수 있는 Hilt 모듈을 만들어 보겠습니다. hilt 패키지 아래 di라는 새 패키지를 만들고 패키지 내에 DatabaseModule.kt라는 새 파일을 만듭니다.

LoggerLocalDataSource는 애플리케이션 컨테이너로 범위가 지정되므로 애플리케이션 컨테이너에서 LogDao 결합을 사용할 수 있어야 합니다. 여기서는 애플리케이션 컨테이너에 연결된 Hilt 구성요소 클래스(예: ApplicationComponent:class)를 전달하여 @InstallIn 주석으로 요구사항을 지정합니다.

package com.example.android.hilt.di

@InstallIn(ApplicationComponent::class)
@Module
object DatabaseModule {

}

ServiceLocator 클래스 구현에서 LogDao 인스턴스는 logsDatabase.logDao()를 호출하여 가져옵니다. 따라서, LogDao 인스턴스를 제공하기 위해 AppDatabase 클래스에 전이 종속 항목이 있습니다.

@Provides로 인스턴스 제공

Hilt 모듈에 있는 함수에 @Provides 주석을 달아 Hilt에 생성자가 삽입될 수 없는 유형의 제공 방법을 알려 줄 수 있습니다.

@Provides 주석이 있는 함수 본문은 Hilt에서 이 유형의 인스턴스를 제공해야 할 때마다 실행됩니다. @Provides 주석이 있는 함수의 반환 유형은 Hilt에 결합 유형 또는 유형의 인스턴스 제공 방법을 알려 줍니다. 함수 매개변수는 유형의 종속 항목입니다.

이 경우 DatabaseModule 클래스에 이 함수가 포함됩니다.

@Module
object DatabaseModule {

    @Provides
    fun provideLogDao(database: AppDatabase): LogDao {
        return database.logDao()
    }
}

위의 코드는 Hilt에 LogDao 인스턴스를 제공할 때 database.logDao()가 실행되어야 한다고 알려 줍니다. AppDatabase가 전이 종속 항목이므로 Hilt에 이 유형의 인스턴스 제공 방법도 알려 줘야 합니다.

AppDatabase는 Room에서 생성하지 않으므로 프로젝트에서 소유하지 않는 다른 클래스이기 때문에 ServiceLocator 클래스에서 데이터베이스 인스턴스를 빌드하는 방식과 비슷하게 @Provides 함수를 사용하여 제공할 수도 있습니다.

@Module
object DatabaseModule {

    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext appContext: Context): AppDatabase {
        return Room.databaseBuilder(
            appContext,
            AppDatabase::class.java,
            "logging.db"
        ).build()
    }

    @Provides
    fun provideLogDao(database: AppDatabase): LogDao {
        return database.logDao()
    }
}

Hilt에서 항상 동일한 데이터베이스 인스턴스를 제공하도록 하려면 @Provides provideDatabase 메서드에 @Singleton 주석을 추가합니다.

각 Hilt 컨테이너는 맞춤 결합에 종속 항목으로 삽입될 수 있는 일련의 기본 결합을 제공합니다. 이는 applicationContext의 사례로, 액세스하려면 필드에 @ApplicationContext 주석을 달아야 합니다.

앱 실행

이제 Hilt는 LogsFragment에 인스턴스를 삽입하는 데 필요한 모든 정보를 갖고 있습니다. 그러나, Hilt는 앱을 실행하기 전에 작동을 위해 Fragment를 호스팅하는 Activity를 알아야 합니다. MainActivity@AndroidEntryPoint로 주석을 달아야 합니다.

ui/MainActivity.kt 파일을 열고 MainActivity@AndroidEntryPoint 주석을 추가합니다.

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

이제 앱을 실행하고 모든 부분이 전과 같이 제대로 동작하는지 확인할 수 있습니다.

MainActivity에서 ServiceLocator 호출을 삭제하도록 앱을 계속 리팩터링해 보겠습니다.

MainActivityprovideNavigator(activity: FragmentActivity) 함수를 호출하여 ServiceLocator에서 AppNavigator 인스턴스를 가져옵니다.

AppNavigator는 인터페이스이므로 생성자 삽입을 사용할 수 없습니다. 인터페이스에 사용할 구현을 Hilt에 알리려면 Hilt 모듈 내 함수에 @Binds 주석을 사용하면 됩니다.

@Binds 주석은 추상 함수에 달아야 합니다(이 함수는 추상 함수이므로 코드를 포함하지 않고 클래스도 추상화되어야 함). 추상 함수의 반환 유형은 구현을 제공하려는 인터페이스(예: AppNavigator)입니다. 구현은 인터페이스 구현 유형(예: AppNavigatorImpl)으로 고유한 매개변수를 추가하여 지정됩니다.

이전에 생성한 DatabaseModule 클래스에 정보를 추가할 수 있을까요? 아니면 새 모듈이 필요할까요? 새 모듈을 만들어야 하는 데는 다음과 같은 몇 가지 이유가 있습니다.

  • 효율적인 구성을 위해 모듈 이름은 제공하는 정보 유형을 전달해야 합니다. 예를 들어, DatabaseModule이라는 모듈에 탐색 결함을 포함하는 것은 적절하지 않습니다.
  • DatabaseModule 모듈은 ApplicationComponent에 설치되므로 애플리케이션 컨테이너에서 결합을 사용할 수 있습니다. 새 탐색 정보(예: AppNavigator)에는 활동의 특정 정보가 필요합니다(AppNavigatorImplActivity를 종속 항목으로 포함하기 때문). 따라서, Application 컨테이너가 아닌 Activity 정보를 사용할 수 있는 Activity 컨테이너에 설치해야 합니다.
  • Hilt 모듈에는 비정적 결합 메서드와 추상 결합 메서드를 모두 포함할 수 없으므로 동일한 클래스에 @Binds@Provides 주석을 배치하면 안 됩니다.

di 폴더에 NavigationModule.kt라는 새 파일을 만듭니다. 여기에서는 위에 설명한 것처럼 @Module@InstallIn(ActivityComponent::class) 주석이 달린 NavigationModule이라는 새 추상 클래스를 만들어 보겠습니다.

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

    @Binds
    abstract fun bindNavigator(impl: AppNavigatorImpl): AppNavigator
}

모듈 내에서 AppNavigator의 결합을 추가할 수 있습니다. 이는 Hilt에 알려 주고 있는 인터페이스를 반환하는 추상 함수(예: AppNavigator)이며 매개변수는 인터페이스의 구현(예: AppNavigatorImpl)입니다.

이제 AppNavigatorImpl 인스턴스 제공 방법을 Hilt에게 알려 줘야 합니다. 이 클래스처럼 생성자를 삽입할 수 있는 경우 생성자에 @Inject 주석을 추가하기만 하면 됩니다.

navigator/AppNavigatorImpl.kt 파일을 열고 다음과 같이 합니다.

class AppNavigatorImpl @Inject constructor(
    private val activity: FragmentActivity
) : AppNavigator {
    ...
}

AppNavigatorImplFragmentActivity에 종속됩니다. AppNavigator 인스턴스가 Activity 컨테이너에 제공될 때(이 인스턴스는 NavigationModuleActivityComponent에 설치되어 있으므로 Fragment 컨테이너와 View 컨테이너에서도 사용할 수 있음) 사전 정의된 결합으로 제공되므로 이미 FragmentActivity를 사용할 수 있습니다.

활동에서 Hilt 사용

이제 Hilt에서 AppNavigator 인스턴스를 삽입할 수 있는 모든 정보를 보유하고 있습니다. MainActivity.kt 파일을 열고 다음 안내를 따릅니다.

  1. Hilt에서 가져올 수 있도록 navigator 필드에 @Inject 주석을 추가합니다.
  2. 공개 상태 수정자 private을 삭제합니다.
  3. onCreate 함수에서 navigator 초기화 코드를 삭제합니다.

새 코드는 다음과 같아야 합니다.

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    @Inject lateinit var navigator: AppNavigator

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        if (savedInstanceState == null) {
            navigator.navigateTo(Screens.BUTTONS)
        }
    }

    ...
}

앱 실행

앱을 실행하여 예상대로 작동하는지 확인할 수 있습니다.

리팩터링 완료

종속 항목을 가져오기 위해 아직 ServiceLocator를 사용하는 클래스는 ButtonsFragment뿐입니다. Hilt는 ButtonsFragment에 필요한 모든 유형의 제공 방법을 이미 알고 있으므로 클래스에서 필드 삽입만 실행하면 됩니다.

이전에 배운 대로 클래스를 Hilt에서 삽입한 필드로 만들려면 다음과 같이 합니다.

  1. ButtonsFragment@AndroidEntryPoint 주석을 추가합니다.
  2. loggernavigator 필드의 비공개 수정자를 삭제하고 @Inject 주석을 추가합니다.
  3. 필드 초기화 코드(예: onAttachpopulateFields 메서드)를 삭제합니다.

ButtonsFragment 코드는 다음과 같습니다.

@AndroidEntryPoint
class ButtonsFragment : Fragment() {

    @Inject lateinit var logger: LoggerLocalDataSource
    @Inject lateinit var navigator: AppNavigator

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.fragment_buttons, container, false)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        ...
    }
}

LoggerLocalDataSource 인스턴스는 유형의 범위가 애플리케이션 컨테이너로 지정되므로 LogsFragment에서 사용한 인스턴스와 동일하게 됩니다. 하지만, AppNavigator 인스턴스는 관련된 Activity 컨테이너로 범위를 지정하지 않았기 때문에 MainActivity 인스턴스와 다릅니다.

이 시점에서 ServiceLocator 클래스는 더 이상 종속 항목을 제공하지 않으므로 프로젝트에서 완전히 삭제해도 됩니다. 이 클래스의 사용은 인스턴스를 유지했던 LogApplication 클래스에 유일하게 남아있습니다. 이 클래스는 더 이상 필요하지 않으므로 이 클래스를 삭제해 보겠습니다.

LogApplication 클래스를 열고 ServiceLocator 사용을 삭제합니다. Application 클래스의 새 코드는 다음과 같습니다.

@HiltAndroidApp
class LogApplication : Application()

이제 프로젝트에서 ServiceLocator 클래스를 완전히 삭제해도 됩니다. 테스트에서 ServiceLocator를 계속 사용하므로 AppTest 클래스에서도 사용하는 부분을 삭제합니다.

관련된 기본 콘텐츠

지금까지 배운 것으로 충분히 Android 애플리케이션에서 종속 항목 삽입 도구로 Hilt를 사용할 수 있습니다.

이제부터 다양한 상황에서 Hilt 고급 기능을 사용하는 방법을 배우기 위해 앱에 새 기능을 추가해 보겠습니다.

프로젝트에서 ServiceLocator 클래스를 삭제하고 Hilt의 기본 사항을 알았다면 이제 앱에 새 기능을 추가하여 다른 Hilt 기능을 살펴보겠습니다.

이 섹션에서 설명하는 내용은 다음과 같습니다.

  • 활동 컨테이너로 범위를 지정하는 방법
  • 한정자의 정의, 한정자가 해결하는 문제 및 한정자 사용 방법

이를 알아보려면 앱에 다른 동작이 필요합니다. 앱 세션 동안 로그 기록을 목적으로 로그 저장소를 데이터베이스에서 메모리 내 목록으로 바꾸겠습니다.

LoggerDataSource 인터페이스

이제 데이터 소스를 인터페이스로 추상화해 보겠습니다. 다음 콘텐츠를 사용하여 data 폴더에 LoggerDataSource.kt라는 새 파일을 만듭니다.

package com.example.android.hilt.data

// Common interface for Logger data sources.
interface LoggerDataSource {
    fun addLog(msg: String)
    fun getAllLogs(callback: (List<Log>) -> Unit)
    fun removeLogs()
}

LoggerLocalDataSourceButtonsFragmentLogsFragment, 두 프래그먼트에서 사용됩니다. LoggerDataSource 인스턴스를 사용하기 위해 두 프래그먼트를 사용하도록 리팩터링해야 합니다.

LogsFragment를 열고 LoggerDataSource 유형의 logger 변수를 만듭니다.

@AndroidEntryPoint
class LogsFragment : Fragment() {

    @Inject lateinit var logger: LoggerDataSource
    ...
}

ButtonsFragment에도 동일한 작업을 합니다.

@AndroidEntryPoint
class ButtonsFragment : Fragment() {

    @Inject lateinit var logger: LoggerDataSource
    ...
}

다음은 LoggerLocalDataSource에서 이 인터페이스를 구현하도록 하겠습니다. data/LoggerLocalDataSource.kt 파일을 열고 다음을 실행합니다.

  1. LoggerDataSource 인터페이스를 구현합니다.
  2. 위에서 구현한 인터페이스의 메서드를 override로 표시합니다.
@Singleton
class LoggerLocalDataSource @Inject constructor(
    private val logDao: LogDao
) : LoggerDataSource {
    ...
    override fun addLog(msg: String) { ... }
    override fun getAllLogs(callback: (List<Log>) -> Unit) { ... }
    override fun removeLogs() { ... }
}

이제 메모리에 로그를 저장하는 LoggerDataSource의 또 다른 구현(LoggerInMemoryDataSource라고 함)을 작성해 보겠습니다. 다음 콘텐츠를 사용하여 data 폴더에 LoggerInMemoryDataSource.kt라는 새 파일을 만듭니다.

package com.example.android.hilt.data

import java.util.LinkedList

class LoggerInMemoryDataSource : LoggerDataSource {

    private val logs = LinkedList<Log>()

    override fun addLog(msg: String) {
        logs.addFirst(Log(msg, System.currentTimeMillis()))
    }

    override fun getAllLogs(callback: (List<Log>) -> Unit) {
        callback(logs)
    }

    override fun removeLogs() {
        logs.clear()
    }
}

활동 컨테이너로 범위 지정

LoggerInMemoryDataSource를 구현 세부정보로 사용할 수 있으려면 이 유형의 인스턴스 제공 방법을 Hilt에 알려야 합니다. 위와 마찬가지로, 클래스 생성자에 @Inject 주석을 추가합니다.

class LoggerInMemoryDataSource @Inject constructor(
) : LoggerDataSource { ... }

여기서 사용하는 애플리케이션은 하나의 활동(단일 활동 애플리케이션이라고도 함)으로만 구성되므로 Activity 컨테이너에 LoggerInMemoryDataSource 인스턴스가 있어야 하고 Fragment에서 인스턴스를 재사용해야 합니다.

LoggerInMemoryDataSource의 범위를 Activity 컨테이너로 지정하여 메모리에 로깅하는 동작을 실행할 수 있습니다. 생성된 모든 Activity에는 자체 컨테이너, 즉 다른 인스턴스가 있습니다. 로거가 종속 항목으로 필요하거나 필드 삽입에 필요한 경우 각 컨테이너에는 LoggerInMemoryDataSource의 동일한 인스턴스가 제공됩니다. 또한, 구성요소 계층구조 아래의 컨테이너에도 동일한 인스턴스가 제공됩니다.

구성요소로 범위 지정 문서에 따라 유형의 범위를 Activity 컨테이너로 지정하려면 유형에 @ActivityScoped 주석을 추가해야 합니다.

@ActivityScoped
class LoggerInMemoryDataSource @Inject constructor(
) : LoggerDataSource { ... }

현재 Hilt는 LoggerInMemoryDataSourceLoggerLocalDataSource의 인스턴스 제공 방법은 알고 있지만, LoggerDataSource의 경우는 어떨까요? Hilt는 LoggerDataSource를 요청 시 사용할 구현에 관해 모릅니다.

이전 섹션에서 살펴본 것처럼, 모듈에서 @Binds 주석을 사용하여 Hilt에 사용할 구현을 알려 줄 수 있습니다. 하지만, 동일한 프로젝트에서 두 구현을 모두 제공해야 한다면 어떻게 할까요? 예를 들어, 앱이 실행되는 동안 LoggerInMemoryDataSource를 사용하고 Service에서는 LoggerLocalDataSource를 사용하는 경우입니다.

동일한 인터페이스를 위한 두 개의 구현

di 폴더에 LoggingModule.kt이라는 새 파일을 만들어 보겠습니다. LoggerDataSource의 다양한 구현은 서로 다른 컨테이너로 범위가 지정되므로 동일한 모듈을 사용할 수 없습니다. LoggerInMemoryDataSource의 범위는 Activity 컨테이너로, LoggerLocalDataSourceApplication 컨테이너로 지정됩니다.

다행히 방금 만든 파일에서 두 모듈의 결합을 모두 정의할 수 있습니다.

package com.example.android.hilt.di

@InstallIn(ApplicationComponent::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
}

@Binds 메서드에 범위 지정 주석이 있어야 하므로(유형의 범위가 지정된 경우) 위의 함수에 @Singleton@ActivityScoped 주석이 달립니다. @Binds 또는 @Provides가 특정 유형의 결합으로 사용되면 유형의 범위 지정 주석은 더 이상 사용되지 않으므로 다른 구현 클래스에서 이러한 주석을 삭제할 수 있습니다.

지금 프로젝트를 빌드하려고 하면 DuplicateBindings 오류가 표시됩니다.

error: [Dagger/DuplicateBindings] com.example.android.hilt.data.LoggerDataSource is bound multiple times

이는 LoggerDataSource 유형이 Fragment에 삽입되어 있지만, Hilt는 같은 유형에 두 개의 결합이 있어 어느 구현을 사용해야 하는지 모르기 때문입니다. Hilt는 어떤 구현을 사용할지 어떻게 알 수 있을까요?

한정자 사용

Hilt에 동일한 유형의 다른 구현(여러 개의 결합)을 제공하는 방법을 알리려면 한정자를 사용하면 됩니다.

각 한정자는 결합을 식별하는 데 사용되므로 구현별로 한정자를 정의해야 합니다. Android 클래스에 유형을 삽입할 때 또는 이 유형을 다른 클래스의 종속 항목으로 포함할 때는 모호성을 피하기 위해 한정자 주석을 사용해야 합니다.

한정자는 주석일 뿐이므로 모듈을 추가했던 LoggingModule.kt 파일에서 정의할 수 있습니다.

package com.example.android.hilt.di

@Qualifier
annotation class InMemoryLogger

@Qualifier
annotation class DatabaseLogger

이제 이러한 한정자는 각 구현을 제공하는 @Binds(또는 필요한 경우 @Provides) 함수에 주석을 달아야 합니다. 전체 코드를 확인하고 @Binds 메서드에서 한정자 사용을 알아보세요.

package com.example.android.hilt.di

@Qualifier
annotation class InMemoryLogger

@Qualifier
annotation class DatabaseLogger

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

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

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

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

또한, 이러한 한정자는 삽입하려는 구현과 함께 삽입 지점에서 사용해야 합니다. 이 경우에는 Fragment에서 LoggerInMemoryDataSource 구현을 사용하겠습니다.

LogsFragment를 열고 로거 필드에 @InMemoryLogger 한정자를 사용하여 LoggerInMemoryDataSource 인스턴스를 삽입하도록 Hilt에 알려 줍니다.

@AndroidEntryPoint
class LogsFragment : Fragment() {

    @InMemoryLogger
    @Inject lateinit var logger: LoggerDataSource
    ...
}

ButtonsFragment에서도 동일한 작업을 실행합니다.

@AndroidEntryPoint
class ButtonsFragment : Fragment() {

    @InMemoryLogger
    @Inject lateinit var logger: LoggerDataSource
    ...
}

사용하려는 데이터베이스 구현을 변경하려면 삽입된 필드에 @InMemoryLogger 대신 @DatabaseLogger 주석을 추가하면 됩니다.

앱 실행

앱을 실행한 다음 버튼으로 상호작용하고 '모든 로그 보기' 화면에 적절한 로그가 표시되는지 관찰하여 실행한 작업을 확인할 수 있습니다.

로그는 이제 데이터베이스에 저장되지 않습니다. 로그는 세션 간에 지속되지 않으며, 앱을 닫고 다시 열 때마다 빈 로그 화면이 표시됩니다.

이제 앱이 Hilt로 완전히 이전되었으므로 프로젝트에 있는 계측 테스트도 이전할 수 있습니다. 앱 기능을 확인하는 테스트는 app/androidTest 폴더의 AppTest.kt 파일에 있습니다. 테스트를 열어보세요.

프로젝트에서 ServiceLocator 클래스를 삭제했기 때문에 컴파일되지 않는 것을 확인할 수 있습니다. 클래스에서 @After tearDown 메서드를 삭제하여 더 이상 사용하지 않는 ServiceLocator의 참조를 삭제합니다.

androitTest 테스트는 에뮬레이터에서 실행됩니다. happyPath 테스트에서는 'Button 1'을 탭할 때 데이터베이스에 로그가 기록된 것을 확인합니다. 앱에서 메모리 내 데이터베이스를 사용하므로 테스트가 종료된 후에는 모든 로그가 사라집니다.

Hilt를 사용한 UI 테스트

Hilt는 프로덕션 코드에서 발생하는 것처럼 UI 테스트에 종속 항목을 삽입합니다.

Hilt는 각 테스트의 새로운 구성요소 집합을 자동 생성하므로 Hilt를 사용한 테스트에는 유지관리가 필요하지 않습니다.

테스트 종속 항목 추가

Hilt는 프로젝트에 추가해야 하는 hilt-android-testing이라는 코드를 더 쉽게 테스트할 수 있도록 테스트 관련 주석이 있는 추가 라이브러리를 사용합니다. 또한, Hilt에서 androidTest 폴더에 클래스 관련 코드를 생성해야 하므로 주석 프로세서도 이 위치에서 실행할 수 있어야 합니다. 이렇게 하려면 app/build.gradle 파일에 두 개의 종속 항목을 포함해야 합니다.

이러한 종속 항목을 추가하려면 app/build.gradle을 열고 dependencies 섹션 하단에 이 구성을 추가합니다.

...
dependencies {

    // Hilt testing dependency
    androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
    // Make Hilt generate code in the androidTest folder
    kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version"
}

맞춤 TestRunner

Hilt를 사용하여 계측된 테스트는 Hilt를 지원하는 Application에서 실행되어야 합니다. 라이브러리에는 이미 UI 테스트를 실행하는 데 사용할 수 있는 HiltTestApplication이 포함되어 있습니다. 테스트에 사용할 Application을 지정하려면 프로젝트에서 새 테스트 실행기를 생성합니다.

androidTest 폴더 아래 AppTest.kt 파일과 같은 수준에 CustomTestRunner라는 이름의 새 파일을 생성합니다. CustomTestRunnerAndroidJUnitRunner에서 확장되며 다음과 같이 구현합니다.

class CustomTestRunner : AndroidJUnitRunner() {

    override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
        return super.newApplication(cl, HiltTestApplication::class.java.name, context)
    }
}

그런 다음, 이 테스트 실행기를 계측 테스트에 사용하도록 프로젝트에 알려야 합니다. app/build.gradle 파일의 testInstrumentationRunner 속성에 테스트 실행기를 명시합니다. 파일을 열고 기본 testInstrumentationRunner 콘텐츠를 아래와 같이 바꿉니다.

...
android {
    ...
    defaultConfig {
        ...
        testInstrumentationRunner "com.example.android.hilt.CustomTestRunner"
    }
    ...
}
...

이제 UI 테스트에서 Hilt를 사용할 준비가 되었습니다.

Hilt를 사용하는 테스트 실행

다음으로 에뮬레이터 테스트 클래스에서 Hilt를 사용하기 위해 다음을 실행해야 합니다.

  1. 각 테스트의 Hilt 구성요소 생성을 담당하는 @HiltAndroidTest 주석을 추가합니다.
  2. 구성요소의 상태를 관리하고 테스트에 삽입을 실행하는 데 사용되는 HiltAndroidRule을 사용합니다.

AppTest에 포함해 보겠습니다.

@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
class AppTest {

    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    ...
}

이제 클래스 정의 또는 테스트 메서드 정의 옆의 재생 버튼을 사용하여 테스트를 실행하면 에뮬레이터가 시작하고(에뮬레이터가 구성된 경우) 테스트가 통과됩니다.

테스트 및 기능(예: 필드 삽입 또는 테스트에서 결합 교체)에 관해 자세히 알아보려면 문서를 확인하세요.

Codelab의 이 섹션에서는 Hilt에서 지원하지 않는 클래스에 종속 항목을 삽입하기 위해 사용하는 @EntryPoint 주석의 사용법을 알아봅니다.

앞에서 본 바와 같이 Hilt는 가장 일반적인 Android 구성요소를 지원합니다. 그러나, Hilt에서 직접 지원하지 않거나 Hilt를 사용할 수 없는 클래스에 필드를 삽입해야 할 수 있습니다.

이러한 경우에는 @EntryPoint를 사용하면 됩니다. 진입점은 종속 항목을 삽입하는 데 Hilt를 사용할 수 없는 코드에서 Hilt가 제공하는 객체를 가져올 수 있는 경계 지점입니다. 이는 Hilt에서 관리하는 컨테이너에 코드가 처음 진입하는 지점입니다.

사용 사례

애플리케이션 프로세스 외부로 로그를 내보내려고 합니다. 이를 위해 ContentProvider를 사용해야 합니다. 현재 소비자가 특정한 하나의 로그(id 지정)를 쿼리하거나 ContentProvider를 사용하는 앱의 모든 로그를 쿼리하는 것만 허용하고 있습니다. Room 데이터베이스를 사용하여 데이터를 가져올 예정입니다. 따라서, LogDao 클래스는 Cursor 데이터베이스를 사용하여 필수 정보를 반환하는 메서드를 노출해야 합니다. LogDao.kt 파일을 열고 인터페이스에 다음 메서드를 추가합니다.

@Dao
interface LogDao {
    ...

    @Query("SELECT * FROM logs ORDER BY id DESC")
    fun selectAllLogsCursor(): Cursor

    @Query("SELECT * FROM logs WHERE id = :id")
    fun selectLogById(id: Long): Cursor?
}

그런 다음 새로운 ContentProvider 클래스를 만들고 query 메서드를 재정의하여 로그와 함께 Cursor를 반환해야 합니다. 새 contentprovider 디렉터리 아래 LogsContentProvider.kt라는 새 파일을 만들고 다음 콘텐츠를 포함합니다.

package com.example.android.hilt.contentprovider

import android.content.ContentProvider
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.UriMatcher
import android.database.Cursor
import android.net.Uri
import com.example.android.hilt.data.LogDao
import dagger.hilt.EntryPoint
import dagger.hilt.EntryPoints
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ApplicationComponent
import java.lang.UnsupportedOperationException

/** The authority of this content provider.  */
private const val LOGS_TABLE = "logs"

/** The authority of this content provider.  */
private const val AUTHORITY = "com.example.android.hilt.provider"

/** The match code for some items in the Logs table.  */
private const val CODE_LOGS_DIR = 1

/** The match code for an item in the Logs table.  */
private const val CODE_LOGS_ITEM = 2

/**
 * A ContentProvider that exposes the logs outside the application process.
 */
class LogsContentProvider: ContentProvider() {

    private val matcher: UriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply {
        addURI(AUTHORITY, LOGS_TABLE, CODE_LOGS_DIR)
        addURI(AUTHORITY, "$LOGS_TABLE/*", CODE_LOGS_ITEM)
    }

    override fun onCreate(): Boolean {
        return true
    }

    /**
     * Queries all the logs or an individual log from the logs database.
     *
     * For the sake of this codelab, the logic has been simplified.
     */
    override fun query(
        uri: Uri,
        projection: Array<out String>?,
        selection: String?,
        selectionArgs: Array<out String>?,
        sortOrder: String?
    ): Cursor? {
        val code: Int = matcher.match(uri)
        return if (code == CODE_LOGS_DIR || code == CODE_LOGS_ITEM) {
            val appContext = context?.applicationContext ?: throw IllegalStateException()
            val logDao: LogDao = getLogDao(appContext)

            val cursor: Cursor? = if (code == CODE_LOGS_DIR) {
                logDao.selectAllLogsCursor()
            } else {
                logDao.selectLogById(ContentUris.parseId(uri))
            }
            cursor?.setNotificationUri(appContext.contentResolver, uri)
            cursor
        } else {
            throw IllegalArgumentException("Unknown URI: $uri")
        }
    }

    override fun insert(uri: Uri, values: ContentValues?): Uri? {
        throw UnsupportedOperationException("Only reading operations are allowed")
    }

    override fun update(
        uri: Uri,
        values: ContentValues?,
        selection: String?,
        selectionArgs: Array<out String>?
    ): Int {
        throw UnsupportedOperationException("Only reading operations are allowed")
    }

    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
        throw UnsupportedOperationException("Only reading operations are allowed")
    }

    override fun getType(uri: Uri): String? {
        throw UnsupportedOperationException("Only reading operations are allowed")
    }
}

getLogDao(appContext) 호출이 컴파일되지 않는 것을 볼 수 있습니다. Hilt 애플리케이션 컨테이너에서 LogDao 종속 항목을 가져와 구현해야 합니다. 하지만, Hilt에서는 @AndroidEntryPoint로 활동을 사용하여 삽입하는 것처럼 즉시 ContentProvider에 삽입하는 것을 지원하지 않습니다.

@EntryPoint 주석이 달린 새 인터페이스를 만들어 액세스해야 합니다.

@EntryPoint의 실제 사례

진입점은 원하는 각 결합 유형에 접근자 메서드를 사용하는 인터페이스입니다(한정자 포함). 또한, 진입점을 설치할 구성요소를 지정하려면 인터페이스에 @InstallIn 주석을 달아야 합니다.

권장사항은 이를 사용하는 클래스 내에 새 진입점 인터페이스를 추가하는 것입니다. 따라서, LogsContentProvider.kt 파일에 인터페이스를 포함합니다.

class LogsContentProvider: ContentProvider() {

    @InstallIn(ApplicationComponent::class)
    @EntryPoint
    interface LogsContentProviderEntryPoint {
        fun logDao(): LogDao
    }

    ...
}

Application 컨테이너의 인스턴스에서 종속 항목을 가져와야 하므로 인터페이스를 @EntryPoint로 주석 처리하고 ApplicationComponent에 설치합니다. 이 인터페이스 내에서 액세스하려는 결합 관련 메서드(여기서는 LogDao)를 노출합니다.

진입점에 액세스하려면 EntryPointAccessors의 적절한 정적 메서드를 사용하세요. 매개변수는 구성요소 인스턴스이거나 구성요소 소유자 역할을 하는 @AndroidEntryPoint 객체여야 합니다. 매개변수로 전달하는 구성요소와 EntryPointAccessors 정적 메서드가 모두 @EntryPoint 인터페이스의 @InstallIn 주석에 있는 Android 클래스와 일치하는지 확인합니다.

이제 위의 코드에서 누락된 getLogDao 메서드를 구현할 수 있습니다. 위의 LogsContentProviderEntryPoint 클래스에서 정의한 진입점 인터페이스를 사용해 보겠습니다.

class LogsContentProvider: ContentProvider() {
    ...

    private fun getLogDao(appContext: Context): LogDao {
        val hiltEntryPoint = EntryPointAccessors.fromApplication(
            appContext,
            LogsContentProviderEntryPoint::class.java
        )
        return hiltEntryPoint.logDao()
    }
}

applicationContext를 정적 EntryPoints.get 메서드 및 @EntryPoint 주석이 달린 인터페이스의 클래스에 전달하는 방법을 살펴보세요.

이제 Hilt에 익숙해졌으므로 Android 앱에 추가할 수 있습니다. 이 Codelab에서 배운 내용은 다음과 같습니다.

  • @HiltAndroidApp을 사용하여 Application 클래스에서 Hilt를 설정하는 방법
  • @AndroidEntryPoint를 사용하여 다양한 Android 수명 주기 구성요소에 종속 항목 컨테이너를 추가하는 방법
  • 모듈을 사용하여 Hilt에 특정 유형 제공 방법을 알려 주는 방법
  • 한정자를 사용하여 특정 유형에 여러 결합을 제공하는 방법
  • Hilt를 사용하여 앱을 테스트하는 방법
  • @EntryPoint가 유용한 경우와 사용 방법