기업들의 기술 스택 요구사항을 보면 늘 Hilt
가 빠지지 않는다.
경험이 많지 않은 나는 아직까지 Hilt
의 필요성을 느끼지 못했고 '굳이 의존성 주입을 해야하나?' 라는 생각도 했었다.
또한 Hilt
를 사용해본적이 없어서 언제 어느 상황에서 Hilt
를 사용해야 할지 감도 안 왔었다.
최근 NowInAndroid 클론앱을 만들며 안드로이드를 공부하면서 Hilt
를 왜 쓰는 지 알아보고자 한다.
NIA는 위의 아키텍처 패턴을 따르고 있고 나도 최대한 이를 따르고자 하였다.
Data Layer에 속하는 Repository
가 저장소에서 값을 받아오면 UI Layer의 ViewModel
에서는 Repository
를 호출해 데이터를 가져오고, Compose에서 관찰할 수 있도록 상태를 관리하는 구조로 설계 후 구현을 시작했다.
class UserDataRepository {
private val firestore = FirebaseFirestore.getInstance()
suspend fun getUser(userId: String): User? = suspendCancellableCoroutine { cont ->
firestore.collection("users").document(userId).get()
.addOnSuccessListener { document ->
val user = document.toObject(User::class.java)
cont.resume(user) { }
}
.addOnFailureListener { exception ->
cont.resumeWithException(exception)
}
}
}
UserDataRepository
에서 FirebaseFirestore를 직접 인스턴스화하여 Firestore에 저장된 user정보를 받아온다.
class UserViewModel(private val repository: UserRepository) : ViewModel() {
private val _user = mutableStateOf<User?>(null)
val user: State<User?> = _user
fun fetchUser(userId: String) {
viewModelScope.launch {
try {
val userData = repository.getUser(userId)
_user.value = userData
} catch (e: Exception) {
// 예외 처리 로직
}
}
}
}
UserViewModel
은 UserRepository
를 매개변수로 호출해 데이터를 가져오고, Compose에서 관찰할 수 있도록 상태를 관리한다.
@Composable
fun UserScreen(
userId: String,
viewModel: UserViewModel = viewModel(factory = UserViewModelFactory(UserRepository()))
) {
val user = viewModel.user.value
LaunchedEffect(userId) {
viewModel.fetchUser(userId)
}
Scaffold(
topBar = {
TopAppBar(title = { Text(text = "User Profile") })
}
) { paddingValues ->
//내부로직
}
}
View
인 UserScreen
은 UserViewModel
을 다음과 같은 팩토리로 받아온다.
class UserViewModelFactory(private val repository: UserRepository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(UserViewModel::class.java)) {
return UserViewModel(repository) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
ViewModelProvider.Factory
는 안드로이드 MVVM 아키텍처에서 ViewModel을 생성할 때 커스텀 생성 로직(Repository 의존성 주입)을 적용할 수 있도록 해주는 인터페이스이다.
기본적으로 안드로이드에서는 ViewModelProvider
를 사용하여 ViewModel 인스턴스를 생성하는데 이것은
다음과 같은 기본 생성자로만 생성할 수 있다.
val viewModel = ViewModelProvider(this).get(UserViewModel::class.java)
그런데 만약 ViewModel에 아래처럼 Repository같은 인자를 전달하거나 특정한 초기화 로직이 필요한 경우
class UserViewModel(private val repository: UserRepository) : ViewModel() {
// UserRepository를 받아야 하는 ViewModel
}
ViewModelProvider(this).get(UserViewModel::class.java)
를 호출하면 안드로이드 프레임워크가 기본 생성자만 호출하려고 하기 때문에 에러가 발생한다.
기본 생성자만 호출하려는 이유는 다음과 같다고 한다.
- ViewModelProvider는 내부적으로 ViewModel을 생성하고 OS가 상태를 저장하도록 관리함.
- 기본 생성자가 없는 경우, OS는 ViewModel을 어떻게 초기화해야 할지 모름.
- 만약 액티비티/프래그먼트가 재생성될 때 ViewModel을 다시 만들어야 하는데, 개발자가 직접 생성한 인자를 기억할 방법이 없음.
그렇기에 이런 경우 ViewModelProvider.Factory
를 구현하여 커스텀 팩토리를 만들어야 한다.
그러나 프로젝트의 규모가 커질수록 ViewModel이 많아질 수 있고 이때마다 팩토리를 만들어줘야 한다는 어려움이 있다.
뿐만 아니라 Dao를 비롯한 아키텍처 구성 요소가 늘어나면 늘어날수록 일일이 의존성 관리를 하는 것은 너무도 어렵고 비효율적인 일이다.
여기서 Hilt
의 필요성이 나온다.
가장 먼저 해주어야할 것은 Hilt dependency를 추가하는 것이다.
이것은 Android Developers의 설명을 따라주면 된다.
@HiltAndroidApp
class MyApplication : Application() {
// 초기화 작업 등 필요한 코드 작성
}
Application
클래스를 만들어 app의 최상위에 @HiltAndroidApp
어노테이션을 추가한다.
class MainActivity : ComponentActivity() {
// Hilt가 필요한 의존성 주입 처리
}
그리고 Activity나 Fragment에는 @AndroidEntryPoint
어노테이션을 추가해주어야 한다.
최상위에만 @HiltAndroidApp
을 추가하면 되는 줄 알고 MainActivity
에 추가했다가 에러가 발생했다.
반드시 Application을 상속한 클래스에 붙이자.
@Module
@InstallIn(SingletonComponent::class)
object FirebaseModule {
@Provides
@Singleton
fun provideFirebaseFirestore(): FirebaseFirestore {
return FirebaseFirestore.getInstance()
}
}
FirebaseFirestore 인스턴스를 주입하는 모듈을 만들어준다.
class UserRepository @Inject constructor(
private val firestore: FirebaseFirestore
) {
suspend fun getUser(userId: String): User? = suspendCancellableCoroutine { cont ->
firestore.collection("users").document(userId).get()
.addOnSuccessListener { document ->
val user = document.toObject(User::class.java)
cont.resume(user) { }
}
.addOnFailureListener { exception ->
cont.resumeWithException(exception)
}
}
그리고 Repository 클래스에서 생성자 주입을 활용한다.
이렇게 할 경우 이전 코드에서 직접 인스턴스화했던 FirebaseFirestore를 모듈화하여 결합도를 낮출 수 있다.
또한 기존 코드는 FirebaseFirestore를 다른 클래스에서 사용려면 다른 클래스에서도 직접 인스턴스화를 해야 했으나 Hilt는 인스턴스를 직접 생성하지 않고 생성자 또는 필드 주입을 통해 외부에서 주입받는다.
@HiltViewModel
class UserViewModel @Inject constructor(
private val repository: UserRepository
) : ViewModel() {
// Compose에서 사용할 수 있도록 상태 관리
private val _user = mutableStateOf<User?>(null)
val user: State<User?> = _user
fun fetchUser(userId: String) {
viewModelScope.launch {
try {
val userData = repository.getUser(userId)
_user.value = userData
} catch (e: Exception) {
// 에러 처리
}
}
}
}
@Composable
fun UserScreen(userViewModel: UserViewModel = hiltViewModel()) {
//내부로직
}
이번엔 이전에 팩토리를 만들어주어야 했던 UserViewModel
을 비교해보자
가장 큰 차이는 이전 코드와 달리 팩토리가 없다는 것이다.
별도로 의존성을 주입해주어야 했던 이전 코드와 다르게 Hilt
를 사용하면 @Inejct
어노테이션을 통해 Hilt가 알아서 의존성을 주입해준다.
또한 @HiltViewModel
어노테이션과 생성자 주입을 사용하면 Hilt가 자동으로 ViewModel 인스턴스를 관리하고 Compose에서는 hiltViewModel()
로 ViewModel을 쉽게 가져올 수 있다.
Hilt는 단순히 코드 작성이 편한 것 뿐만 아니라 Hilt 자체적으로 수명 주기를 관리하고 Dagger가 제공하는 컴파일 시간 정확성, 런타임 성능, 확장성의 이점을 갖고있다.
직접 인스턴스를 생성할 때 생기는 강한 결합도를 낮추고 코드의 재사용성과 테스트 용이성, 유연성 그리고 확장성을 높이는 의존성 주입(DI)를 보다 간편하고 효율적으로 사용하고자 Hilt를 사용한다고 볼 수 있다.