[DI] Hilt를 사용한 의존성 주입

Gun Log·2023년 3월 3일
2

DI

목록 보기
2/2
post-thumbnail

이전 글에서 의존성 주입의 전반적인 개념에 대해 알아보았다.
구글에서 권장하는 의존성 주입 라이브러리 Hilt에 대해 알아보도록 하자.


[의존성 주입 되짚어 보기]

  • 의존성 주입이란?
    의존성 주입이란 외부에서 인스턴스를 생성하여 주입하는 것이다.

  • 의존성 주입이 없을 경우 문제점
    의존성 주입 없이 자체적으로 인스턴스를 생성하고 관리하게 되면 본연의 역할 이상을 수행
    하므로 코드가 점점 거대해지며 변경에 민감하여 기존 작성된 코드에 영향을 미칠 수 있고 테스트를 어렵게 만든다.

  • 수동 의존성 주입 시 문제점
    의존성 주입을 수동으로 작업하게 되면 보일러플레이트 코드가 많아지고, 객체 생성에 대한 순서를 고려해야 하며, 컨테이너를 사용하여 재사용하고 관리해야 하고 객체의 수명주기 또한 고려해야 하는 번거로움이 동반된다.

  • 라이브러리를 이용한 자동 의존성 주입의 이점
    수동 의존성 주입과 서비스 로케이터를 통한 방법보다 셋업시간이 꽤 걸려 초기 비용이 많이 들지만, 앱이 커질수록 비용 곡선이 어느 정도의 선까지만 상승한다.
    앱이 중간 정도의 크기로 갈수록 유지관리 비용이 다른 방법들과 큰 차이가 나지 않으며 일정 수준을 넘어설 경우 오히려 비용이 적게 든다.



[Hilt의 등장]


Hilt가 등장하기 전 구글에서는 Dagger 사용을 권장하였다. 하지만 Dagger는 러닝 커브가 높아 개발자들이 선뜻 적용하기엔 부담이 큰 문제가 있었다.

구글은 지속적으로 안드로이드 커뮤니티를 통해 Dagger에 대한 개선사항을 수용하였고,
Dagger 라이브러리의 장점을 살리고 추가적인 기능과 추상화를 통해 Dagger의 프로세스를 단순화하여 사용법을 간편화시킨 Hilt라는 라이브러리를 발표하였으며 이에 대한 사용을 권장하였다.

Hilt는 Android 앱을 위한 Jetpack의 권장 DI 솔루션으로, 좋은 아키텍처 설계에 도움이 되는 일부 Jetpack 라이브러리와 함께 사용할 수 있도록 지원한다.
Hilt는 Dagger 라이브러리 기반으로 빌드되었으며, 이는 Hilt 코드를 컴파일할 때 Dagger 코드로 변환됨을 의미한다.

Dagger와 Hilt를 번역하면 단검과 손잡이다.
Hilt(손잡이)는 Dagger(단검)를 쉽게 사용하기 위해 만들어진 것이다.



[Hilt의 이점]

단순화된 사용
Dagger의 어려운 사용법을 개선한 것이 Hilt이다.
개발자는 서술형으로 의존관계를 설정하지 않고 코드를 단순화하는 Annotation을 통해 작성해야 하는 코드의 양을 줄여줌으로써 의존성 주입 과정을 간소화해준다.
Annotation을 명시하면 Annotation Processor에 의해 자동으로 컴파일 타임에 Dagger 라이브러리 코드를 생성하며 보일러플레이트 코드를 감소시킨다.

낮은 러닝 커브
Dagger에 비해 상대적으로 러닝 커브가 낮아 쉽고 편리한 환경을 제공한다.

테스트 지원
Hilt는 코드를 더 쉽게 테스트할 수 있도록 테스트 코드에서 유용한 어노테이션을 제공한다.
이를 통해 안드로이드 앱에 대한 테스트를 더 쉽게 작성할 수 있다.
이는 버그를 더 일찍 발견하고 앱이 더 안정적이고 신뢰할 수 있도록 보장할 수 있다는 것을 의미한다.

Jetpack 라이브러리 호환
Hilt는 Jetpack 라이브러리 중 ViewModel, Navigation, Compose, WorkManager를 지원하며 잘 호환되어 의존성을 쉽게 주입하고 관리할 수 있다.

컴파일 타임에 생성되는 코드
Hilt는 컴파일 시 의존성 관련 코드를 생성하기 때문에, 런타임에 리플렉션을 사용하는 일부 라이브러리보다 런타임 포퍼먼스가 향상된다.



[예제를 통한 Hilt 사용 방법]

사용자 정보를 화면에 표시하는 간단한 예제를 통해 Hilt를 어떻게 사용하는지 알아보자.


1. Gradle 환경 설정


build.gradle (Project Level)

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

build.gradle (App Level)


plugins {
  // // Hilt는 KAPT(Kotlin Annotation Processing Tool)의 도움을 받아 컴파일 시 코드를 생성한다.
  kotlin("kapt") 
  id("com.google.dagger.hilt.android")
}

android {
  ...
}

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


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

kapt {
  // correctErrorTypes을 true로 설정할 경우 런타임에서 발생할 수 있는 에러에 대한 검사를 컴파일 시점에 체크한다.
  correctErrorTypes = true

}

아래 내용에서 이해를 돕고자 사용한 코루틴이나 Room DB 같은 라이브러리 의존성은 추가하지 않았다.


2. Application 클래스 설정

@HiltAndroidApp 어노테이션을 통해 Hilt를 활성화 시켜보자.
Application을 상속받는 클래스에 해당 어노테이션을 명시하면 Hilt를 활성화 시키며 의존관계를 설정하기 위한 Hilt 코드가 생성된다.


@HiltAndroidApp
class MyApplication: Application() {
    override fun onCreate() {
        super.onCreate()
        appContext = applicationContext	
    }

	// 이후 내용에서 Hilt에서 제공하는 Context 어노테이션으로 변경 예정
    companion object {
        lateinit  var appContext: Context
    }
}


3. Activity에 의존성 주입

화면에 사용자 정보 리스트를 화면에 표시해보자.
먼저 MainActivity 클래스 상단에 @AndroidEntryPoint 어노테이션을 선언하여 해당 클래스에 의존성 주입이 필요하다는 것을 알려주도록 하자.
MainActivity 클래스의 UserRecyclerAdapter 변수 선언부에 @Inject 어노테이션을 선언하여 필드주입이 필요함을 알리고, Hilt가 UserRecyclerAdapter를 인스턴스화 하는 방법을 알 수 있도록 UserRecyclerAdapter 클래스의 생성자 앞에 @Inject 어노테이션을 선언하여 생성자 주입을 설정하자.


@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    @Inject lateinit var adapter: UserRecyclerAdapter // 필드주입

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

        val recyclerView = findViewById<RecyclerView>(R.id.recycler_view_user)
        recyclerView.adapter = adapter
        
        adapter.submitList(arrayListOf(User("홍길동")))
    }
}
    
class UserRecyclerAdapter @Inject constructor() : // 생성자 주입
    ListAdapter<User, UserRecyclerAdapter.UserViewHolder>(diffUtil) {  
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserViewHolder { ... }

    override fun onBindViewHolder(holder: UserViewHolder, position: Int) { ... }

    override fun getItemCount() { ... }
}


4. Module을 통한 Room 의존성 주입 (with @Provides)

위에서 Hilt에게 UserRecyclerAdapter 를 알려주기 위해 생성자 주입을 하였다.
Room Database를 통해 데이터를 읽어 화면에 표시해보자.

하지만 Room DB는 라이브러리이므로 직접 코드 수정을 통한 생성자 주입을 할 수 없다.
@Module을 통해 Hilt에게 Room DB를 인스턴스화 하는 방법을 알려줄 수 있다.
Hilt는 모듈을 통해 의존성을 주입할 때 기본적으로 모듈 내 반환 타입과 일치하는 메서드를 사용하며, 해당 메서드에는 @Provides 어노테이션을 명시해주어야 한다.

먼저 최상단에 @Module을 선언하고, 이와 함께 @InstallIn 어노테이션을 필수적으로 선언해주어야 한다.
Database는 앱 전반적으로 사용되므로 싱글톤으로 사용하기 위해 SingletonComponent를 지정해 주었다.
마찬가지로 DB를 반환하는 메서드에도 @Singleton Scope를 명시하였는데 이는 @InstallIn에 명시한 컴포넌트의 범위와 일치해야 한다.



@Entity
data class User @Inject constructor(
    @PrimaryKey(autoGenerate = true)
    val userId: Int = 0,
    @ColumnInfo(name = "name") val name: String?
)

@Dao
interface UserDao {
    @Query("SELECT * FROM user")
    suspend fun getUserList(): MutableList<User>
    
    ...
}

@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    companion object {
        const val TABLE_NAME = "user"
        const val COL_USER_ID = "userId"
        const val COL_USER_NAME= "name"
    }

    abstract fun userDao(): UserDao
}

@Module
@InstallIn(SingletonComponent::class)
object DataModule {
    @Provides
	@Singleton
    fun provideDB(): AppDatabase {
        return Room.databaseBuilder(
            MyApplication.appContext,
            AppDatabase::class.java, "User"
        ).build()
    }
}


@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    @Inject
    lateinit var adapter: UserRecyclerAdapter

    @Inject
    lateinit var appDatabase: AppDatabase

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
       	...
		queryUser()
    }
    
    private fun queryUser() {
        lifecycleScope.launch {
            val userList = appDatabase.userDao().getUserList()

            launch(Dispatchers.Main) {
                adapter.submitList(userList.toMutableList())
            }
        }
    }
}


5. Module을 통한 Room 의존성 주입 (with @Binds)

현재 Local DB를 통해 데이터를 조회하고 있지만, 추후 다른 Repository를 통해 데이터를 조회할 수 있도록 확장될 수 있기 때문에 Local DB를 Repository에서 관리되도록 수정해보자.


class UserRepository @Inject constructor(private val dataSource: UserDataSource) {
    suspend fun getUserList(): MutableList<User> {
        return dataSource.getUserList()
    }
}

interface UserDataSource {
    suspend fun getUserList(): MutableList<User>
}

class UserLocalDataSource @Inject constructor(private val appDatabase: AppDatabase) : UserDataSource {
    override suspend fun getUserList(): MutableList<User> {
        return appDatabase.userDao().getUserList()
    }
}

UserRepository는 생성자 주입을 통해 MainActivity에서 사용할 예정이다.
생성자 파라미터에 UserDataSource는 인터페이스로 정의되어 있어 생성자 주입이 불가능하다.
이에 따라 Module에서 Hilt가 UserDataSource의 구현 클래스를 인스턴스화 하기위한 절차를 명시해주어야 한다.
코드를 간결하게 하기 위해 @Provides가 아닌 @Binds 어노테이션을 사용해보자.


@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
    @Binds
    abstract fun bindUserLocalDataSource(userLocalDataSource: UserLocalDataSource): UserDataSource

    companion object {
        @Provides
        @Singleton
        fun provideDB(): AppDatabase {
            return Room.databaseBuilder(
                MyApplication.appContext,
                AppDatabase::class.java, "User"
            ).build()
        }
    }

}

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
	
    ...
    
    @Inject
    lateinit var userRepository: UserRepository

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
       	...
		queryUser()
    }
    
    private fun queryUser() {
        lifecycleScope.launch {
            val userList = userRepository.getUserList()
            adapter.submitList(userList.toMutableList())
        }
    }
}

@Binds를 사용하기 위해선 Module의 선언을 interface나 abstract class로 선언해야 한다.
Module의 abstract 메서드 반환타입 또한 abstract class 혹은 interface로 명시되어야 한다.
또한 메서드는 필수적으로 한 개의 파라미터가 요구되는데, Hilt에 의해 파라미터의 클래스가 반환타입의 구현 클래스로 매핑되며 반환된다.
abstract class로 선언할 경우 @Provides 어노테이션을 사용할 수 없으므로, 필요시 companion object를 통해 사용하도록 하자.


6. Hilt에서 제공하는 Context 어노테이션 사용

위에서 DataModule 클래스에서 Room 데이터베이스 생성을 위해 Application Context가 필요해 MyApplication 클래스에서 Context 세팅하여 제공하는 작업을 했었다.
Hilt에서는 Application, Activity 각각 상황에 맞는 Context 어노테이션을 제공하며 이를 통해 간편하게 Context를 주입할 수 있으므로 이를 활용해보자.



@HiltAndroidApp
class MyApplication: Application() {
    override fun onCreate() {
        super.onCreate()
        // appContext = applicationContext	
    }

    /* companion object {
        lateinit  var appContext: Context
    } */
}


@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
	...
	companion object {
        @Provides
        @Singleton
        fun provideDB(@ApplicationContext context: Context): AppDatabase {
            return Room.databaseBuilder(
                context,
                AppDatabase::class.java, "User"
            ).build()
        }
    }
}


7. 동일한 반환 타입을 가지는 클래스에 대한 여러 유형의 인스턴스 제공

위에서 Room 데이터베이스를 통해 데이터를 표시하였지만, DB를 조작하지 않고 Mock 객체를 생성하여 테스트용 데이터를 반환하는 코드를 작성한다고 가정해 보자.

먼저 UserDataSource 인터페이스를 확장하는 UserMockDataSource를 작성해보자.


class UserMockDataSource @Inject constructor(): UserDataSource {
    override suspend fun getUserList(): MutableList<User> {
        return arrayListOf(User(1,"홍길동(Mock)"), User(2, "김길동(Mock)"))
    }
}

이에 따라 DataModule 클래스에도 위에서 작성한 UserLocalDataSource와 같이 UserMockDataSource 또한 인스턴스 생성 방법을 명시해야 한다.
하지만 Module은 기본적으로 동일한 반환타입을 가지는 메서드를 작성할 수 없기 때문에 Qualifier 어노테이션을 통해 Hilt가 어떤 메서드를 사용해야 하는지 식별할 수 있게 만들어주어야 한다.


@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Qualifier
    @Retention(AnnotationRetention.BINARY)
    annotation class UserLocalDataSourceQualifier

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

    @Binds
    @UserLocalDataSourceQualifier
    abstract fun bindUserLocalDataSource(userLocalDataSource: UserLocalDataSource): UserDataSource

    @Binds
    @UserMockDataSourceQualifier
    abstract fun bindUserMockDataSource(userMockDataSource: UserMockDataSource): UserDataSource

    companion object {
        @Provides
        @Singleton
        fun provideDB(@ActivityContext context: Context): AppDatabase {
            return Room.databaseBuilder(
                context,
                AppDatabase::class.java, "User"
            ).build()
        }
    }

}


이제 아래와 같이 코드의 큰 수정 없이 UserRepository에서 주입 받을 UserDataSource에 대한 Qualifier 어노테이션 변경만으로 필요에 따른 각 유형에 해당하는 UserDataSource를 사용할 수 있다.


class UserRepository @Inject constructor(
    // @DataModule.UserLocalDataSourceQualifier
    @DataModule.UserMockDataSourceQualifier
    private val userDataSource: UserDataSource
) {
    suspend fun getUserList(): MutableList<User> {
        return userDataSource.getUserList()
    }
}


8. Hilt가 지원하지 않는 클래스(ContentProvider)에 의존성 주입

Hilt 의존성 주입은 @HiltAndroidApp 어노테이션을 Application 클래스에 명시를 하면, Application onCreate() 가 호출되어 앱이 활성화되는 시점에 Hilt의 Component 클래스들이 자동으로 생성되어 의존성 주입을 관리하는 Hilt 코드에 의해 이루어진다.

하지만 ContentProvider는 Application보다 먼저 실행될 수 있으며 위에서 다루었던 생성자, 필드를 통한 주입이 불가능하다.
이에 따라 Hilt는 ContentProvider를 직접적으로 지원하지 않는다.

Hilt는 이러한 상황에서도 의존성 주입을 사용할 수 있도록 @EntryPoint를 제공한다.


[ContentProvider를 통한 데이터를 제공하는 앱]

class ExampleContentProvider : ContentProvider() {

	@EntryPoint
    @InstallIn(SingletonComponent::class)
    interface ExampleContentProviderEntryPoint {
        fun getAppDatabase(): AppDatabase
    }
    
    override fun query(uri: Uri,
                       projection: Array<out String>?,
                       selection: String?,
                       selectionArgs: Array<out String>?,
                       sortOrder: String?): Cursor? {
        ...
                       
        return getAppDatabase().userDao().getUserList()
    }
    
    fun getAppDatabase(): AppDatabase {
        val appContext = context?.applicationContext ?: throw IllegalStateException()
        val hiltEntryPoint = EntryPointAccessors.fromApplication(
                appContext,
                ExampleContentProviderEntryPoint::class.java
            )

        return hiltEntryPoint.getAppDatabase()
    }
}

외부 앱에서 query 요청이 왔을 시 DB를 통해 Cursor를 반환하는 코드에서, AppDatabase를 주입 받기 위해 EntryPoint를 작성해야 한다.
ExampleContentProviderEntryPoint와 같이 인터페이스로 선언 되어야 하며, @EntryPoint 어노테이션을 명시해야 한다.
작성한 EntryPoint는 EntryPointAccessors를 통해 접근이 가능하다.

query를 요청하는 앱으로 돌아가, 위 앱에서 제공하는 데이터를 읽어오는 코드를 작성해보자.


[ContentProvider를 통한 데이터를 제공받는 앱]

class UserProviderDataSource @Inject constructor(
    private val contentResolver: ContentResolver
) : UserDataSource {

    override suspend fun getUserList(): MutableList<User> {
    	val cursor = contentResolver.query(
        	Constants.URI_EXAMPLE,
            arrayOf(AppDatabase.COL_USER_ID, AppDatabase.COL_USER_NAME),
            null,
            null,
            AppDatabase.COL_USER_ID)

		val userList = arrayListOf<User>()

		cursor?.let {
        	while (cursor.moveToNext()) {
            	val id = cursor.getInt(cursor.getColumnIndex(AppDatabase.COL_USER_ID))
                val name = cursor.getString(cursor.getColumnIndex(AppDatabase.COL_USER_NAME))
                userList.add(User(id, name))
            }

            cursor.close()
        }
            
		return userList
    }
}


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

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

	@Binds
    @UserProviderDataSourceQualifier
    abstract fun bindUserProviderDataSource(userProviderDataSource: UserProviderDataSource): UserDataSource
    
    companion object {
        
        ...
        
        @Provides
        fun provideContentResolver(@ApplicationContext context: Context): ContentResolver {
            return context.contentResolver
        }
    }

}

class UserRepository @Inject constructor(
//    @DataModule.UserLocalDataSourceQualifier
//    @DataModule.UserMockDataSourceQualifier
    @DataModule.UserProviderDataSourceQualifier
    private val userDataSource: UserDataSource
) {
    suspend fun getUserList(): MutableList<User> {
        return userDataSource.getUserList()
    }
}

이제 위와 같이 UserRepository에서 주입 받을 UserDataSource Qualifier 어노테이션을 @UserProviderDataSourceQualifier로 변경하면 ContentProvider를 통한 데이터 조회가 이뤄진다.


9.ViewModel 의존성 주입

Hilt는 ViewModel을 포함한 Jetpack 라이브러리를 지원한다.
MainActivity에서 Repository를 통해 데이터를 요청하는 비즈니스 로직을 ViewModel로 옮겨보자.


@HiltViewModel
class UserViewModel @Inject constructor(private val userRepository: UserRepository) : ViewModel() {
    val userLiveData = MutableLiveData<List<User>>()
    val insertResultLiveData = MutableLiveData<Long>()

    fun getUserList() = viewModelScope.launch {
        userLiveData.value = userRepository.getUserList()
    }
}

ViewModel 클래스 상단에 @HiltViewModel 어노테이션을 명시하여 Hilt에게 ViewModel임을 알려주면, Hilt는 ViewModel임을 인식하고 ViewModel Factory를 자동으로 생성해 준다.
이와 함께 @Inject 어노테이션을 통해 생성자 주입이 가능하도록 설정해 주어야 한다.
이렇게 작성된 ViewModel은 @AndroidEntryPoint 어노테이션이 명시된 클래스에서 간단하게 사용할 수 있다.



@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
	
    ...
    
    private val viewModel: UserViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
       	...
		
        viewModel.userLiveData.observe(this) {
            adapter.submitList(it.toMutableList())
        }
        
        viewModel.getUserList()
    }
}

Activity 또는 Fragment에서 ViewModelProvider를 통하지 않고 간단하게 by viewModels() 와 같은 형태로 간단하게 위임 지정이 가능하다.


[Annotation]

Hilt는 Annotation Processor를 통해 코드를 컴파일할 때 자동으로 생성하여 개발자가 수작업해야 하는 보일러 플레이트 코드를 줄여준다.

개발자는 어노테이션을 이용해 Hilt에게 아래와 같은 정보를 알려줄 수 있다.

  • 의존성 주입을 사용할 안드로이드 클래스
  • 의존성 주입이 필요한 변수
  • 의존성 주입이 이뤄질 클래스를 인스턴스화 하는 방법
  • 라이브러리에서 제공되는 클래스를 인스턴스화 하는 방법
  • 추상 클래스나 인터페이스의 구현클래스를 인스턴스화 하는 방법
  • 동일한 반환타입을 가지는 메서드에 대한 식별 방법
  • 의존성 주입 인스턴스의 생성/소멸에 대한 생명주기

@HiltAndroidApp

Hilt를 활성화 시키는 기본 설정 어노테이션
최상위 계층인 Application을 상속받는 클래스에 @HiltAndroidApp 어노테이션을 선언 함으로써,
Hilt가 의존성을 관리하는 데 사용되는 Hilt 컴포넌트들을 생성한다.


@AndroidEntryPoint

의존성 주입이 필요한 안드로이드 클래스에 선언하는 어노테이션
Hilt가 지원하는 Android 클래스를 상속받는 클래스에 @AndroidEntryPoint 어노테이션을 명시하면 Hilt의 관리 대상이 되며 명시한 클래스 내에서 의존성 주입을 사용할 수 있다.
@AndroidEntryPoint어노테이션을 명시하면 해당 클래스가 의존하는 Android 클래스, 예로 Fragment에 해당 어노테이션을 명시할 경우 Fragment를 사용하는 Activity에도 어노테이션을 명시해야 한다.

  • Hilt가 지원하는 Android 클래스
    - Activity
    - Fragment
    - View
    - Service
    - BroadcastReceiver
  • 💡 Android 클래스에 대한 Hilt 지원 예외 사항 Hilt는 AppCompatActivity와 같은 ComponentActivity를 확장하는 액티비티만 지원한다.

    Hilt는 androidX 라이브러리의 일부인 안드로이드의 Jetpack 컴포넌트와 함께 작동한다. ComponentActivity는 Hilt가 올바르게 작동하는 데 필요한 라이프사이클 및 상태 관리 기능을 제공하는 Jetpack 액티비티의 기본 클래스이며, Activity와 같이 ComponentActivity를 확장하지 않는 다른 활동에는 필요한 기능이 없으므로 지원되지 않는다.


    Hilt는 androidx.Fragment를 확장하는 프래그먼트만 지원한다.

    android.app.Fragment는 Deprecated된 클래스이며 androidx.fragment.app과는 수명 주기와 동작이 달라 지원되지 않는다.


    Hilt는 retained 프래그먼트를 지원하지 않는다.

    액티비티가 화면 회전과 같은 configurationChange로 인해 다시 생성될 때, retained 프래그먼트는 다시 생성되지 않으므로 해당 프래그먼트는 재생성되기 전 액티비티를 참조하고 있게 되며 이로인해 메모리 누수가 발생할 수 있기 때문에 지원되지 않는다.


@Inject

필드 주입이나 생성자 주입 시 사용되는 어노테이션

  • 필드 주입
    - 안드로이드 클래스를 상속받는 클래스 중 @AndroidEntryPoint가 명시된 클래스 내의 필드에 의존성 주입 필요시 사용한다.
    - @Inject 어노테이션을 통해 지연 초기화를 선언하여 해당 필드에 의존성을 주입 받을 수 있다.
    - Hilt가 필드 주입을 해주기 위해선 주입 받으려는 클래스에 생성자 주입을 명시하거나, @Module을 정의하여 인스턴스화 방법을 알려주어야 한다.
    - 안드로이드 클래스가 생성되는 시기, Activity를 예를 들면 super.onCreate() 시점에 주입이 이뤄지며 해당 시점 이후부터 사용할 수 있다..

  • 생성자 주입
    - 의존 객체를 인스턴스화 하는 방법을 Hilt에게 알려주는 역할을 한다.
    • @Inject 어노테이션과 함께 생성자를 명시함으로써 Hilt가 해당 클래스를 인스턴스화 하는 방법을 알게 된다.
    • 생성자 주입 시 생성자에 파라미터가 추가될 경우, 해당 파라미터에 해당하는 클래스 또한 생성자 주입을 명시하거나 Module에서 인스턴스화 하는 방법을 명시해야 한다.

💡 @Inject는 Dagger/Hilt가 아닌 자바에서 제공되는 어노테이션이다.


@Module

생성자를 통한 주입이 불가능한 경우 @Module 어노테이션을 통해 Hilt에게 인스턴스를 생성하는 방법을 직접적으로 알려주는 역할을 한다.
인터페이스는 생성자를 가질 수 없으므로 생성자를 통한 주입이 불가능하다.
또한 외부 라이브러리의 클래스와 같이 직접 코드를 소유하지 않은 경우에도 생성자를 통한 주입을 할 수 없다.
이 경우 Module을 통해 @Binds 또는 @Provides 어노테이션을 사용하여 Hilt가 주입해야 할 의존 클래스의 인스턴스를 만드는 방법을 지정할 수 있다.

  • Module 내에 @Provides와 @Binds로 인스턴스화 하는 방법을 알려줄 때
    Hilt는 메서드의 이름이 아닌 주입하려는 인스턴스의 타입과 동일한 메서드의 반환 타입을 가지는 메서드 찾아 인스턴스를 제공한다.
  • 반환 타입이 같은 두 메서드가 존재할 시 에러가 발생하며, 이 경우 아래에서 설명할 Qualifier 어노테이션을 추가로 명시하여 구분할 수 있도록 지정해야 한다.

@Provides

외부 라이브러리 또는 빌더 패턴으로 인스턴스를 생성해야 하는 경우 @Provides 어노테이션을 통해 Hilt에게 해당 클래스를 인스턴스화 하는 방법을 알려줄 수 있다.
메서드 반환 타입은 의존성이 주입될 타입을 의미하고, 메서드 파라미터를 명시하여 의존객체 생성자에 전달하는 것과 같이 추가적인 작업이 가능하다.
메서드 본문에는 Hilt가 인스턴스를 생성하기 위한 방법을 작성한다.

@Provides 어노테이션으로 provideA 메서드를 작성하였을 때 provideA 메서드에 파라미터가 존재할 경우 해당 파라미터에 대한 생성 방법 또한 알려주어야 한다.
라이브러리나 안드로이드 내부 클래스가 아닌, 해당 파라미터 클래스의 소유권이 있으면 해당 클래스에서 @Inject를 통한 생성자 주입을 이용하여 Hilt에게 알려주는 방법이 있고, provideA 메서드와 같이 @Provides를 통해 별도의 메서드를 작성하여 해당 파라미터의 인스턴스 방법을 Hilt에게 알려주는 방법이 있다.


@Binds

interface 혹은 abstract class에 대한 주입이 필요한 경우 구현 클래스를 파라미터로 전달하여 반환 타입에 해당 구현 클래스로 매핑시켜주는 역할을 한다.
@Binds를 사용하기 위해선 Module의 선언을 interface나 abstract class로 선언해야 한다.
@Provides를 통해 인스턴스화 할 수 있지만 @Binds를 사용할 경우 구현 부를 작성하지 않아도 Hilt에서 자체적인 Module에 대한 구현 클래스를 생성하여 코드가 간결해지는 효과를 얻을 수 있다.

@Binds 어노테이션이 명시된 메서드의 경우 파라미터가 하나여야 하며, 해당 파라미터가 곧 반환되는 구현 타입이 된다. 또한 해당 구현 타입 클래스에는 @Inject를 통한 생성자 주입이 요구된다.

@Provides에서 다룬 내용과 같이, @Binds 어노테이션이 명시된 메서드내 파라미터가 되는 클래스의 소유권이 있으면 해당 파라미터가 되는 클래스에 @Inject 어노테이션을 명시하여 생성 방법을 알려줄 수 있다.
하지만 소유권이 없는 클래스가 포함되면 해당 파라미터 클래스를 인스턴스화 하는 방법을 알려주기 위해 @Provides를 사용해야 한다.
하지만 같은 Module 영역 내에 @Provides와 @Binds를 모두 사용할 수 없다.
이때 다른 모듈에서 @Provides로 해당 파라미터를 정의하거나, companion object를 통해 해당 모듈 내에 @Provides를 명시하여 인스턴스 생성 방법을 알려 줄 수 있다.


@InstallIn

@EntryPoint 혹은 @Module에서 함께 쓰이는 어노테이션으로, 의존성을 제공하는 모듈이 어떤 컴포넌트에 설치될지 지정한다.

Hilt는 생성된 각각의 컴포넌트 클래스를 Android 클래스의 수명 주기에 따라 자동으로 생성하며 제거한다.

Hilt 컴포넌트인젝터 대상생성 시기소멸 시기
SingletonComponentApplicationApplication#onCreate()Application 소멸 시점
ActivityRetainedComponentN/AActivity#onCreate()Activity#onDestroy()
ActivityComponentActivityActivity#onCreate()Activity#onDestroy()
ViewModelComponentViewModelViewModel 생성 시점ViewModel 소멸 시점
FragmentComponentFragmentFragment#onAttach()Fragment#onDestroy()
ViewComponentViewView#super()View 소멸 시점
ViewWithFragmentComponent@WithFragmentBindings
어노테이션이 지정된 View
View#super()View 소멸 시점
ServiceComponentServiceService#onCreate()Service#onDestroy()

Hilt는 SingletonComponent에서 직접 broadcast receiver를 주입하므로 broadcast receiver의 컴포넌트를 생성하지 않는다.


각 컴포넌트는 계층 구조의 형태를 가지며 이를 통해 구조적인 아키텍처를 표현하는 방식으로 의존성을 관리할 수 있다.

예를 들어 모듈에 @InstallIn(ActivityComponent::class)로 지정하였다면, 해당 모듈은 ActivityComponent에 설치되게 되며, 액티비티에서 해당 모듈을 사용할 수 있음을 의미한다.
또한 ActivityComponent는 각 액티비티별로 생명주기에 의해 생성되고 소멸된다.
해당 모듈에서 제공되는 주입은 컴포넌트 계층 구조에 의해 하위 컴포넌트들은 상위 컴포넌트 레벨의 모듈에서 제공하는 주입에 직계관계에 한하여 상위 호환되어 접근할 수 있다.

직계관계 상위호환은 컴포넌트 계층구조에서 ViewComponent는 ActivityComponent, ActivityRetainedComponent, SingletonComponent에 접근이 가능하다는 의미이다. 하지만 ViewComponent는 ServiceComponent에 접근이 불가능하다.

다르게 표현하면 View를 상속받은 클래스나 @InstallIn을 ViewComponent로 지정한 모듈에서는 ActivityComponent, ActivityRetainedComponent, SingletonComponent로 설정한 모듈에서 제공하는 의존성 주입은 가능하다.
반대로 Activity 혹은 Application을 상속받은 클래스나 @InstallIn을 ActivityComponent, ActivityRetainedComponent, SingletonComponent로 지정한 모듈에서는 하위 계층에서 제공하는 의존성 주입을 사용할 수 없다.

적용하다 보면 상위에서 하위로도 호환되는 것으로 보일 때가 있는데, @Injection 어노테이션을 통한 생성자 주입인 경우일 것이다. 이는 모듈에서 @Provides 혹은 @Binds를 통해 인스턴스화 된 것이 아니라, 생성자 주입에 의해 인스턴스화 된 것이다.


Qualifier

하나의 클래스에 대해 여러 인스턴스로 구분하여 의존성을 제공해야 하는 경우 사용된다.
Module에서의 @Provides@Binds를 통해 인스턴스 생성 방법을 알려주어도 반환 타입이 동일한 메서드가 존재할 경우 에러가 발생한다.
Hilt는 Module 내 주입되어야 하는 인스턴스의 타입과 동일한 반환 타입을 가지는 메서드를 찾아 인스턴스를 주입한다.
이 경우 Qualifier를 이용해서 구분하여 사용하도록 할 수 있다.


Context 어노테이션

Hilt는 Context에 대한 어노테이션을 제공한다.
Context 유형에 따라 @ApplicationContext, @ActivityContext 둘 중 하나를 사용하면 된다.
아래 소스를 예시로 특정 Activity에서 AnalyticsAdapter를 의존성 주입 받게 되면 해당 Activity의 Context가 생성자에 주입된다.

class AnalyticsAdapter @Inject constructor(
    @ActivityContext private val context: Context,
) {
	    // Use ActivityContext
  }

class MainActivity : AppCompatActivity() {
	@Inject lateinit var adapter: AnalyticsAdapter
	
    override fun onCreate(savedInstanceState: Bundle?) {
    	super.onCreate(savedInstanceState)
        ...
        // Use AnalyticsAdapter
    }
}

Scope

의존성 주입 시 범위를 지정하여 공유되어야 하는 인스턴스를 설정할 때 쓰이는 어노테이션이다.
Hilt는 기본적으로 Scope가 지정되어있지 않으며, 주입 요청 시 새로운 인스턴스를 생성하여
제공한다.
Hilt Component에 해당하는 Scope을 통해 Component의 수명주기에 맞춰진 인스턴스를 공유할 수 있도록 설정할 수 있으며, Scope는 Component 레벨과 동일해야 한다.



@Module
@InstallIn(ViewComponent::class)
class Module {
    @Provides
    @ViewScoped
    fun privdeA(): A {
        return A()
    }
}

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
    	super.onCreate(savedInstanceState)
        createCustomView1()	// 커스텀 뷰 생성
		createCustomView2()	// 커스텀 뷰 생성
    }
}

@AndroidEntryPoint
class View1: View {
	@Inject lateinit var a1: A
    @Inject lateinit var a2: A
}

@AndroidEntryPoint
class View2: View {
	@Inject lateinit var a1: A
    @Inject lateinit var a2: A
}

위 예시에서 모듈을 ViewComponent에 설치하였기 때문에, View1, View2 각각의 클래스에 해당 컴포넌트가 생성되며 모듈에서 제공하는 A 인스턴스에 대한 의존성 주입을 받을 수 있다.
Component 및 Scope에 따른 A 인스턴스들의 주소값은 아래와 같다.

@InstallIn@ScopeView1View2
ViewComponent@ViewScopeda1 : @4f9d10a1 : @775f42f
a2 : @4f9d10a2 : @775f42f
ActivityComponent@ActivityScopeda1 : @4f9d10a1 : @4f9d10
a1 : @4f9d10a1 : @4f9d10

@EntryPoint

Hilt가 지원하지 않는 클래스에 필드 삽입 시 사용되는 어노테이션이다.
위에서 설명한 바와 같이 Hilt는 @AndroidEntryPoint 어노테이션을 통해 Application, Activity, Fragment, Service, View 등 자주 사용되는 일반적인 안드로이드 클래스를 지원한다.
하지만 지원하지 않는 안드로이드 클래스도 존재하며 그러한 예로 ContentProvider를 볼 수 있다.
ContentProvider에 @AndroidEntryPoint 및 필드 주입, 생성자 주입 시 정상적으로 의존성 주입이 되지 않는다.
Hilt에는 ContentProviderComponent가 없기 때문이다.
ContentProvider에서 Hilt를 통해 의존성 주입을 받기 위해선 주입 받고자 하는 타입별로 인터페이스를 정의하고 @EntryPoint 어노테이션을 명시해야 한다.
EntryPoint에 접근하기 위해선 EntryPointAccessors를 통해 가능하다. 파라미터로 전달되는 Context는 InstallIn에 설정한 Component 레벨을 따라야 한다.

ContentProvider는 Application보다 먼저 실행될 수 있어 Hilt Component에서 지원되지 않는다.


@HiltViewModel

ViewModel 주입 시 사용하는 어노테이션이다.
안드로이드 AAC Viewmodel을 상속받는 클래스에 @HiltViewModel을 명시하여 사용한다.
@AndroidEntryPoint를 명시한 Activity나 Fragment에서 ViewModelProvider 또는 by viewModels()을 통해 ViewModel을 사용할 수 있다.
@HiltViewModel을 명시하게 되면 Hilt는 컴파일 시 ViewModelComponent 가 생성된다.

액티비티, 프래그먼트에서 각각 by viewModels() 키워드를 통해 위임을 지정하기 위해선 gradle에 아래와 같은 의존성을 추가해야 한다.

  • implementation 'androidx.fragment:fragment-ktx:x.x.x'
  • implementation 'androidx.activity:activity-ktx:x.x.x'



의존성 주입 라이브러리를 통해 더욱 쉽게 적은 코드로 의존성을 관리할 수 있지만, 복잡한 동작이 래핑 되었다는 것을 잊지 말자.
상황에 적절하지 않은 명령을 내리면 그에 따른 비효율적인 코드가 생성되어 의도하지 않은 결과나 문제가 발생할 수 있다.
특히 컴포넌트와 범위 지정을 잘 이해하여 주입되는 인스턴스가 올바른 생명주기에 맞춰 동작하도록 주의를 기울여 메모리 누수를 방지하도록 하자.

단순한 명령으로 복잡한 코드가 작성되는 만큼 라이브러리의 기본 동작을 잘 이해하고 효과적이고 적절하게 사용하는 것이 중요하다.



참고자료

profile
Android Developer

1개의 댓글

comment-user-thumbnail
2024년 3월 25일

제가 지금까지 본 Hilt글 중 최고입니다.. !

잘보고 갑니다 :)

답글 달기