들어가며

기업들의 기술 스택 요구사항을 보면 늘 Hilt가 빠지지 않는다.
경험이 많지 않은 나는 아직까지 Hilt의 필요성을 느끼지 못했고 '굳이 의존성 주입을 해야하나?' 라는 생각도 했었다.
또한 Hilt를 사용해본적이 없어서 언제 어느 상황에서 Hilt를 사용해야 할지 감도 안 왔었다.
최근 NowInAndroid 클론앱을 만들며 안드로이드를 공부하면서 Hilt를 왜 쓰는 지 알아보고자 한다.

NIA는 위의 아키텍처 패턴을 따르고 있고 나도 최대한 이를 따르고자 하였다.
Data Layer에 속하는 Repository가 저장소에서 값을 받아오면 UI Layer의 ViewModel에서는 Repository를 호출해 데이터를 가져오고, Compose에서 관찰할 수 있도록 상태를 관리하는 구조로 설계 후 구현을 시작했다.

Hilt 적용 이전

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) {
                // 예외 처리 로직
            }
        }
    }
}

UserViewModelUserRepository를 매개변수로 호출해 데이터를 가져오고, 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 ->
        //내부로직
    }
}

ViewUserScreenUserViewModel을 다음과 같은 팩토리로 받아온다.

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 의존성 주입)을 적용할 수 있도록 해주는 인터페이스이다.

Hilt를 적용하지 않을 때의 어려움

기본적으로 안드로이드에서는 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 적용

가장 먼저 해주어야할 것은 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를 사용한다고 볼 수 있다.

profile
안드로이드 외길

0개의 댓글

관련 채용 정보

Powered by GraphCDN, the GraphQL CDN