`hiltViewModel()`을 호출하면 내부에서는 실제로 무슨 일이 벌어질까요?
hiltViewModel()을 호출하면 내부에서는 실제로 무슨 일이 벌어질까요?
val viewModel: ProfileViewModel = hiltViewModel(). 코드는 딱 한 줄입니다. 그런데 리포지토리와 SavedStateHandle, 거기에 다른 의존성 세 개까지 생성자에서 요구하는 ViewModel이 완전히 조립된 채로 툭 튀어나옵니다. 여기서 hiltViewModel()을 Compose가 기본으로 제공하는 viewModel()로 바꾸면, 같은 화면이 열리는 순간 크래시가 납니다. Compose는 그 생성자를 무엇으로 채워야 하는지 전혀 모르기 때문입니다. 그 한 줄 안쪽 어딘가에는 Dagger 그래프까지 찾아가는 길을 아는 무언가가 있습니다. 인자를 하나도 넘기지 않았는데도 말입니다.
이번 글에서는 hiltViewModel()을 호출한 순간부터 주입된 ViewModel이 돌아오기까지 그 사이에서 실제로 무슨 일이 일어나는지를 깊이 파고듭니다. androidx.hilt의 실제 소스를 따라가며 composition local에서 ViewModelStoreOwner를 꺼내 오는 컴포저블, Context 체인을 거슬러 올라가 액티비티를 찾아내는 팩토리, Hilt ViewModel과 일반 ViewModel이 나란히 공존하는 비결인 위임 구조, 그리고 런타임 인자를 assisted 생성자까지 실어 나르는 CreationExtras 경로를 차례로 살펴봅니다. 글을 다 읽고 나면 사람들이 결국 이슈 트래커까지 찾아가게 만드는 두 가지 물음에 구체적으로 답하실 수 있습니다. Expected an activity context는 왜 발생하는지, 그리고 ViewModel은 어떻게 내비게이션 목적지에 묶이는지입니다.
viewModel()과 주입받는 생성자 사이의 간극
Compose에는 이미 ViewModel을 가져오는 수단이 마련되어 있습니다. androidx.lifecycle.viewmodel.compose의 viewModel() 함수는 현재 ViewModelStoreOwner에게 인스턴스를 요청하고, 아직 없으면 ViewModelProvider.Factory로 하나를 만들어 냅니다. 문제는 기본으로 쓰이는 팩토리가 감당할 수 있는 범위가 딱 거기까지라는 점입니다. 이 팩토리는 리플렉션으로 클래스를 인스턴스화하는데, 생성자가 아무 인자도 받지 않거나 SavedStateHandle 하나만 받는 경우라면 그 방식으로 충분하지만 그 밖의 생성자에는 손도 대지 못합니다.
방금 말한 '그 밖의 생성자'가 바로 @HiltViewModel 생성자입니다. 리포지토리 하나, 유스케이스 하나처럼 Dagger 그래프만이 만들 줄 아는 실제 의존성들을 죽 나열해 두었으니까요. 기본 팩토리에는 그 그래프로 가는 길이 없어서 그냥 포기합니다. 빠진 조각은 Hilt에 연결된 또 다른 ViewModelProvider.Factory이고, 그 팩토리를 대신 끼워 넣는 것 하나가 hiltViewModel()이 하는 일의 전부입니다.
hiltViewModel()을 위에서부터 읽어 내려가기
다음은 androidx.hilt.lifecycle.viewmodel.compose에 있는 단순 오버로드의 현재 소스입니다. 길이는 짧지만 모든 줄이 제 몫을 하고 있습니다.
@Composable
public inline fun <reified VM : ViewModel> hiltViewModel(
viewModelStoreOwner: ViewModelStoreOwner =
checkNotNull(LocalViewModelStoreOwner.current) {
"No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
},
key: String? = null,
): VM {
val factory = rememberHiltViewModelFactory(viewModelStoreOwner.defaultViewModelProviderFactory)
return viewModel(viewModelStoreOwner, key, factory = factory)
}
이 ViewModel이 어느 범위에 묶일지는 viewModelStoreOwner의 기본값에서 결정됩니다. LocalViewModelStoreOwner.current는 가장 가까운 프로바이더가 넣어 둔 값을 그대로 돌려줍니다. NavHost 목적지 안이라면 NavBackStackEntry가, 그 바깥이라면 대개 호스트인 ComponentActivity가 됩니다. 아무도 값을 넣어 두지 않았다면 checkNotNull이 곧바로 실패하면서 알아보기 쉬운 메시지를 남기므로, 한참 뒤에 영문 모를 크래시를 만나는 일은 없습니다.
함수 본문은 두 단계로 나뉩니다. 먼저 viewModelStoreOwner.defaultViewModelProviderFactory를 폴백(fallback)으로 건네 Hilt를 아는 팩토리를 만들고, 그다음 나머지를 전부 평범한 Compose viewModel()에 넘깁니다. 두 번째 호출은 Hilt를 쓰지 않을 때 부르는 바로 그 함수와 똑같습니다. 달라진 것이라고는 그 함수가 건네받는 팩토리 하나뿐입니다.
팩토리를 Context에 묶어 두기
팩토리는 리컴포지션(Recomposition)마다 새로 만들어지지 않습니다. rememberHiltViewModelFactory가 팩토리를 붙잡아 두는데, 이때 팩토리의 동작을 바꿀 수 있는 입력 두 개를 키로 삼습니다.