3. Compose에서 DI 없이 시작해 Hilt로 점진 마이그레이션하기
Compose에서 ViewModel을 수동 생성으로 시작한 뒤 Hilt로 점진 마이그레이션한다. @HiltViewModel과 hiltViewModel()이 런타임에서 어떻게 연결되는지까지 다룬다. Slot Table과 recomposition 관점도 포함한다.
Compose에서 DI 없이 시작해 Hilt로 점진 마이그레이션하기
Compose를 처음 붙일 때 가장 흔한 실수는 ViewModel을 어디서 만들지 결정하지 못한 채 Composable 안에서 new 비슷한 일을 해버리는 것이다. 화면 회전 한 번에 상태가 초기화되거나, 네비게이션 뒤로 가기에서 데이터가 사라지고, "@HiltViewModel인데 왜 생성이 안 되지?" 같은 에러가 터진다. DI를 당장 도입하기 싫어도, 나중에 Hilt로 옮길 길은 열어둬야 한다. 이 글은 그 경로를 내부 동작까지 포함해 설명한다. 나도 처음 Compose로 넘어올 때 ViewModel을 remember로 붙잡아두면 된다고 착각했다. 3시간 삽질 끝에 얻은 로그는 "java.lang.IllegalStateException: Hilt ViewModel factory is not set"였고
핵심 개념
DI 없이 시작한다는 말은 객체 그래프를 프레임워크가 대신 만들지 않는다는 뜻이다. Compose에서는 Composable이 함수 호출이기 때문에, 생성 코드를 아무 데나 두면 재호출(recomposition) 때마다 새 인스턴스를 만들 수 있다. ViewModel은 화면 상태를 유지하기 위한 저장소인데, 생성 위치가 잘못되면 "상태 저장"이라는 목적 자체가 무너진다.
점진적 마이그레이션의 핵심은 생성 책임을 단계적으로 이동시키는 것이다. 1단계는 수동 생성(팩토리 포함), 2단계는 CompositionLocal로 의존성 경계를 만들기, 3단계는 Hilt 컨테이너로 생성 책임을 넘기기다. 이 순서를 지키면 화면 코드는 거의 그대로 두고 생성 경로만 교체된다.
용어를 실행 맥락으로 정의한다. (1) ViewModelStoreOwner는 ViewModel 인스턴스의 생명주기 저장소이다. Compose에서는 LocalViewModelStoreOwner로 전달된다. (2) ViewModelProvider.Factory는 ViewModel 생성 전략이다. 수동 DI에서는 직접 구현하고, Hilt에서는 Hilt가 제공하는 Factory가 들어간다. (3) SavedStateHandle은 NavBackStackEntry의 SavedStateRegistry와 연결돼 프로세스 재생성까지 버틴다. (4) hiltViewModel()은 결국 ViewModel() 호출의 편의 래퍼이며, 핵심은 어떤 Factory를 쓰는가이다.
왜 @HiltViewModel과 hiltViewModel()이 한 쌍으로 설계됐는지 이해가 필요하다. @HiltViewModel은 Dagger/Hilt가 "이 타입은 ViewModel 전용 그래프에서 만든다"는 신호이고, hiltViewModel()은 Compose 트리에서 현재 NavBackStackEntry(또는 Activity/Fragment)를 찾아 그 Owner에 맞는 Factory로 ViewModel을 요청한다. 둘 중 하나만 있으면 생성 경로가 끊긴다.
1package com.example.migration
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.remember
5import androidx.lifecycle.ViewModel
6
7class CounterViewModel : ViewModel() {
8 var count: Int = 0
9 private set
10
11 fun inc() { count++ }
12}
13
14@Composable
15fun WrongWayCounterScreen() {
16 val vm = remember { CounterViewModel() }
17 // 화면 회전, 프로세스 재생성, back stack 복원에서 vm 보장이 없다.
18 // 또한 count는 Compose State가 아니라 UI가 갱신되지 않는다.
19}이 코드를 실행해도 당장 크래시가 나지 않아 더 위험하다. remember는 Slot Table에 값을 저장해 같은 Composition 인스턴스에서만 재사용한다. 예를 들어 (1) 기기 회전으로 Activity가 재생성되면 Composition이 새로 만들어지고 Slot Table도 새로 생성된다. (2) 백그라운드에서 프로세스가 kill된 뒤 복원되면 이전 Slot Table은 존재하지 않는다. (3) NavHost 그래프를 교체하거나 startDestination을 바꾸면 Composition 트리가 통째로 바뀌면서 remember 값이 사라질 수 있다. ViewModelStoreOwner를 통해 관리되는 ViewModel과는 저장 계층이 다르다. 그래서 ViewModel은 remember의 대상이 아니라 Owner 기반 조회의 대상이다.
컴포넌트 해부
이 주제의 '컴포넌트'는 UI 위젯이 아니라 ViewModel을 화면에 주입하는 경로이다. Compose에서 ViewModel을 얻는 API는 크게 viewModel()과 hiltViewModel()로 나뉜다. 둘 다 내부적으로는 같은 ViewModelProvider를 타지만, Factory를 누가 제공하는지가 다르다.
- LocalViewModelStoreOwner: 현재 Composition에서 ViewModelStoreOwner를 제공하는 CompositionLocal
- LocalContext: 대부분의 앱에서 ComponentActivity 컨텍스트를 제공하며, Hilt EntryPoint 접근 시 사용될 수 있다
- NavBackStackEntry: navigation-compose 사용 시 화면 단위 Owner, SavedStateHandle과 연결됨
- ViewModelProvider.Factory: ViewModel 생성 전략, 수동 DI와 Hilt DI의 교체 지점
- CreationExtras: SavedStateHandle, default args 같은 생성 부가정보 묶음
- SavedStateHandle: 키-값 저장소, Nav arguments와 복원에 영향
- key 파라미터: 동일 Owner에서 같은 타입 ViewModel을 여러 개 만들 때 구분자
- qualifier(개념): Hilt에서 같은 타입의 바인딩을 구분하는 어노테이션
- HiltViewModelFactory: Hilt가 제공하는 ViewModel용 Factory 구현체
- @HiltViewModel: ViewModel을 Hilt 그래프에 등록하는 선언
- hiltViewModel(): Compose에서 Hilt Factory를 찾아 ViewModel을 요청하는 진입점
Surface 계층 관점에서 보면, ViewModel 주입은 UI 외곽에서 일어나는 '환경 제공'에 가깝다. CompositionLocal로 Owner나 Factory를 제공해두면, 하위 Content는 생성 책임에서 분리된다. 이 분리가 없으면 Composable이 데이터 생성까지 떠안아서 재사용성이 떨어진다.
Content 계층은 화면 UI와 상태 읽기/이벤트 전달만 담당해야 한다. ViewModel을 얻는 코드는 Content의 최상단(스크린 루트)에서 한 번만 실행되어야 한다. 이유는 두 가지다. 첫째, ViewModel 조회는 cheap하긴 하지만 매 recomposition마다 호출되며, 둘째, 잘못된 Owner를 잡으면 back stack 단위로 상태가 분리되지 않는다.
컴파일러 관점에서 Composable은 (composer, changedFlags) 파라미터가 추가된 형태로 변환되고, viewModel()/hiltViewModel() 호출은 일반 함수 호출로 남는다. 중요한 점은 Compose Runtime이 viewModel을 '기억'해주는 게 아니라, ViewModelStoreOwner가 캐시한다는 점이다. Slot Table에 저장되는 건 remember 값이고, ViewModel은 Owner의 store에 저장된다.
1package com.example.migration
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.CompositionLocalProvider
5import androidx.compose.runtime.staticCompositionLocalOf
6import androidx.lifecycle.ViewModel
7
8interface AppGraph {
9 fun counterRepository(): CounterRepository
10}
11
12class CounterRepository
13
14val LocalAppGraph = staticCompositionLocalOf<AppGraph> {
15 error("AppGraph is not provided")
16}
17
18class CounterVm(val repo: CounterRepository) : ViewModel()
19
20@Composable
21fun AppRoot(graph: AppGraph, content: @Composable () -> Unit) {
22 CompositionLocalProvider(LocalAppGraph provides graph) {
23 content()
24 }
25}이 재구성 코드는 Hilt 도입 전 '경계 만들기'에 해당한다. LocalAppGraph는 Slot Table에 저장되는 값이 아니라 CompositionLocal 맵에 저장된다. CompositionLocal은 트리 경로로 조회되고, provider가 바뀌면 그 하위에서 해당 Local을 읽는 지점이 invalidation 대상이 된다. 이 메커니즘 덕분에 DI 컨테이너 교체를 화면 코드 변경 없이 진행할 수 있다.
1package com.example.migration
2
3import androidx.compose.runtime.Composable
4import androidx.lifecycle.ViewModel
5import androidx.lifecycle.ViewModelProvider
6import androidx.lifecycle.viewmodel.compose.viewModel
7
8class CounterRepository2 {
9 fun initial(): Int = 0
10}
11
12class CounterVm2(private val repo: CounterRepository2) : ViewModel()
13
14class CounterVm2Factory(private val repo: CounterRepository2) : ViewModelProvider.Factory {
15 override fun <T : ViewModel> create(modelClass: Class<T>): T {
16 if (modelClass.isAssignableFrom(CounterVm2::class.java)) {
17 @Suppress("UNCHECKED_CAST")
18 return CounterVm2(repo) as T
19 }
20 error("Unknown ViewModel: $modelClass")
21 }
22}
23
24@Composable
25fun ManualDiScreen(repo: CounterRepository2) {
26 val vm: CounterVm2 = viewModel(factory = CounterVm2Factory(repo))
27 // UI는 vm을 읽고 이벤트만 전달한다.
28}여기서 확인할 포인트는 viewModel(factory=...)가 recomposition마다 호출돼도 Factory가 매번 create를 실행하지 않는다는 점이다. ViewModelProvider는 Owner의 store에서 기존 인스턴스를 먼저 찾는다. create는 '없을 때만' 호출된다. 그래서 화면이 자주 recomposition돼도 ViewModel이 계속 새로 생기지 않는다.
내부 동작 원리
Compose 파이프라인은 Composition → Layout → Drawing 3단계다. ViewModel 주입은 Composition 단계에서 끝난다. Layout은 측정/배치, Drawing은 캔버스 렌더링이다. ViewModel을 어디서 얻든 레이아웃/드로잉 단계에는 영향이 없다. 대신 Composition 단계에서 '어떤 상태를 읽었는지'가 리컴포지션 범위를 결정한다.
State 변경 → Recomposition 흐름은 '읽기 추적'으로 설명된다. mutableStateOf를 읽는 Composable은 Composer가 해당 State 객체에 read를 기록한다. 이후 값이 바뀌면 Snapshot 시스템이 invalidation을 발생시키고, 다음 프레임에 그 read 지점이 포함된 그룹만 다시 호출된다. ViewModel은 Compose State가 아니므로, LiveData/StateFlow를 collectAsState로 변환해 읽기 추적을 만든다.
Slot Table은 Composition의 구조와 remember 값, key 그룹 정보를 저장한다. remember는 Slot Table에 값을 저장해 '같은 위치의 같은 remember 호출'에 대해 재사용한다. 반면 viewModel()/hiltViewModel()은 Slot Table에 저장하지 않는다. 그래서 remember로 ViewModel을 만들면 Slot Table 기반 캐시만 믿게 되고, Owner 기반 생명주기와 어긋난다.
hiltViewModel()이 필요한 이유는 Factory 선택을 자동화하기 위해서다. viewModel()은 기본 Factory를 쓰는데, Hilt 그래프에서 생성돼야 하는 @HiltViewModel은 Hilt 전용 Factory가 필요하다. navigation-compose를 쓰는 경우, Owner를 NavBackStackEntry로 잡아야 SavedStateHandle이 route args와 연결된다. hiltViewModel()은 이 결정을 호출자 대신 수행한다.
내가 처음 겪은 삽질은 '왜 @HiltViewModel인데도 생성이 안 되지'였다. 에러는 "java.lang.IllegalStateException: Hilt ViewModel factory is not set"였고, 원인은 Activity에 @AndroidEntryPoint를 붙이지 않은 것이었다. Compose 코드만 보고 있으면 놓치기 쉽다. Hilt는 Android component에 주입 지점을 만들어야 Factory를 끼워 넣는다.
또 다른 삽질은 back stack 단위 상태 분리 실패였다. 로그로 확인하면 같은 ViewModel 인스턴스 hashCode가 서로 다른 destination에서 재사용됐다. 원인은 LocalViewModelStoreOwner가 Activity를 가리키고 있었고, NavBackStackEntry를 Owner로 쓰지 않았기 때문이다. navigation-compose는 destination마다 Owner를 제공한다. hiltViewModel()은 이 Owner를 우선 사용한다.
1package com.example.migration
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.material3.Button
5import androidx.compose.material3.Text
6import androidx.compose.runtime.Composable
7import androidx.compose.runtime.getValue
8import androidx.compose.runtime.mutableIntStateOf
9import androidx.compose.runtime.remember
10import androidx.compose.runtime.setValue
11
12@Composable
13fun RecomposeTraceDemo() {
14 var clicks by remember { mutableIntStateOf(0) }
15
16 Column {
17 Text(text = "clicks=$clicks")
18 Button(onClick = { clicks++ }) {
19 Text(text = "inc")
20 }
21 }
22}이 코드를 실행하고 Layout Inspector의 Recomposition Counts를 켜면, Button 클릭 시 Text와 Button이 포함된 Column 그룹이 다시 호출되는 것을 본다. 이유는 clicks를 읽는 지점이 Column 내부에 있기 때문이다. clicks를 읽는 범위를 좁히면 recomposition 범위도 줄어든다. ViewModel을 붙일 때도 같은 원리다. collectAsState를 화면 루트에서 읽으면 화면 전체가 자주 다시 호출된다.
1package com.example.migration
2
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.interaction.MutableInteractionSource
5import androidx.compose.foundation.layout.padding
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.runtime.remember
9import androidx.compose.ui.Modifier
10import androidx.compose.ui.semantics.contentDescription
11import androidx.compose.ui.semantics.semantics
12import androidx.compose.ui.unit.dp
13
14@Composable
15fun ModifierOrderDemo(onClick: () -> Unit) {
16 val interaction = remember { MutableInteractionSource() }
17
18 Text(
19 text = "Tap me",
20 modifier = Modifier
21 .padding(16.dp)
22 .semantics { contentDescription = "modifier-order-demo" }
23 .clickable(
24 interactionSource = interaction,
25 indication = null,
26 onClick = onClick
27 )
28 )
29}Modifier는 체이닝 순서가 의미를 가진다. padding이 clickable 앞에 있으면 터치 영역이 padding 포함 영역으로 확장된다. semantics는 접근성 트리에 노드를 추가하는데, clickable과 결합되면 TalkBack이 '버튼'으로 읽을 수 있다. DI 마이그레이션과 직접 관련 없어 보이지만, 실제로는 '상태 읽기 위치'와 'Modifier 적용 순서'가 recomposition과 입력 처리 비용을 바꾼다. 화면 루트에서 불필요한 상태를 읽지 않는 설계가 중요하다.
한 문단 요약: Compose에서 ViewModel은 remember로 붙잡는 대상이 아니라 ViewModelStoreOwner가 캐시하는 대상이다. DI 없이 시작해도 Factory 경계를 만들면 Hilt로 교체가 쉽고, hiltViewModel()은 Owner 선택과 Hilt Factory 연결을 자동화한다.
실습하기
실습 목표는 3단계로 나뉜다. 1단계는 DI 없이도 화면이 안정적으로 동작하는 최소 구조를 만든다. 2단계는 수동 Factory를 통해 의존성을 주입하고, recomposition이 ViewModel 생성에 영향을 주지 않는 것을 확인한다. 3단계는 Hilt로 생성 책임을 넘기고, @HiltViewModel과 hiltViewModel()이 연결되는 지점을 확인한다.
실행 확인 포인트를 명확히 둔다. (1) 화면 회전 후에도 count가 유지되는가 (ViewModelStoreOwner 경로 확인). (2) 네비게이션으로 같은 화면을 두 번 push했을 때 서로 다른 상태가 유지되는가 (NavBackStackEntry Owner 확인). (3) Hilt 적용 후에도 UI 코드는 거의 바뀌지 않는가 (생성 경로만 교체).
1단계: DI 없이, 하지만 ViewModel은 올바르게
DI를 안 쓰더라도 ViewModel은 viewModel()로 얻어야 한다. 이 경로는 Activity/Fragment의 ViewModelStore에 저장되기 때문에 회전에서도 유지된다. remember로 만들면 Composition이 끊기는 순간 같이 사라진다.
실행하면 버튼 클릭으로 숫자가 올라가고, 화면을 회전해도 숫자가 유지된다. 단, 프로세스 킬 후 복원까지는 아직 보장하지 않는다. SavedStateHandle을 붙이기 전 단계이기 때문이다.
1package com.example.migration
2
3import android.os.Bundle
4import androidx.activity.ComponentActivity
5import androidx.activity.compose.setContent
6import androidx.compose.foundation.layout.Column
7import androidx.compose.material3.Button
8import androidx.compose.material3.MaterialTheme
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
11import androidx.compose.runtime.getValue
12import androidx.compose.runtime.mutableIntStateOf
13import androidx.compose.runtime.setValue
14import androidx.lifecycle.ViewModel
15import androidx.lifecycle.viewmodel.compose.viewModel
16
17class PlainCounterVm : ViewModel() {
18 var count by mutableIntStateOf(0)
19 private set
20
21 fun inc() { count++ }
22}
23
24class MainActivity : ComponentActivity() {
25 override fun onCreate(savedInstanceState: Bundle?) {
26 super.onCreate(savedInstanceState)
27 setContent {
28 MaterialTheme { PlainCounterScreen() }
29 }
30 }
31}
32
33@Composable
34fun PlainCounterScreen(vm: PlainCounterVm = viewModel()) {
35 Column {
36 Text(text = "count=${vm.count}")
37 Button(onClick = vm::inc) { Text("inc") }
38 }
39}mutableIntStateOf를 ViewModel에 둔 이유가 중요하다. Compose Runtime은 vm.count를 읽는 Composable을 추적하고, inc()로 값이 바뀌면 해당 읽기 지점만 invalidation 한다. ViewModel이 살아있다는 것과 UI가 갱신된다는 것은 다른 문제인데, 여기서는 둘 다 만족한다.
2단계: 수동 Factory로 의존성 주입 경계 만들기
Hilt 이전에 Factory를 한 번 거치면, 나중에 Hilt Factory로 치환하기 쉽다. 수동 DI는 생성 코드를 명시적으로 드러내서 디버깅이 편하다. 대신 생성 위치를 Activity나 AppRoot 같은 상위로 올려 recomposition과 분리한다.
실행하면 1단계와 화면은 같지만, 로그로 Repository가 한 번만 생성되는지 확인할 수 있다. recomposition이 여러 번 일어나도 ViewModel이 새로 만들어지지 않는지 확인하려면 ViewModel init에서 hashCode를 찍는 방식이 직관적이다.
1package com.example.migration
2
3import android.util.Log
4import androidx.compose.foundation.layout.Column
5import androidx.compose.material3.Button
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.lifecycle.ViewModel
9import androidx.lifecycle.ViewModelProvider
10import androidx.lifecycle.viewmodel.compose.viewModel
11
12class CounterRepo {
13 fun next(current: Int): Int = current + 1
14}
15
16class ManualInjectedVm(private val repo: CounterRepo) : ViewModel() {
17 init {
18 Log.d("ManualInjectedVm", "created: ${hashCode()}")
19 }
20
21 var count = 0
22 private set
23
24 fun inc() { count = repo.next(count) }
25}
26
27class ManualInjectedVmFactory(private val repo: CounterRepo) : ViewModelProvider.Factory {
28 override fun <T : ViewModel> create(modelClass: Class<T>): T {
29 if (modelClass.isAssignableFrom(ManualInjectedVm::class.java)) {
30 @Suppress("UNCHECKED_CAST")
31 return ManualInjectedVm(repo) as T
32 }
33 error("Unknown ViewModel: $modelClass")
34 }
35}
36
37@Composable
38fun ManualFactoryScreen(repo: CounterRepo) {
39 val vm: ManualInjectedVm = viewModel(factory = ManualInjectedVmFactory(repo))
40 Column {
41 Text(text = "count=${vm.count}")
42 Button(onClick = vm::inc) { Text("inc") }
43 }
44}여기서 한 번 더 함정이 있다. vm.count는 Compose State가 아니라서 UI가 갱신되지 않는다. 로그는 찍히는데 화면 숫자가 안 바뀌는 상황이 나온다. 1단계에서 ViewModel 내부에 mutableStateOf를 둔 이유가 이 지점에서 드러난다. 수동 DI와 상태 관리는 별개 축이다.
3단계: Hilt로 생성 책임 교체 (@HiltViewModel + hiltViewModel())
Hilt 적용은 Gradle 플러그인, Application 설정, AndroidEntryPoint, 그리고 ViewModel 어노테이션까지 4군데가 연결돼야 한다. 하나라도 빠지면 런타임에서 Factory를 찾지 못한다. 내가 만난 에러는 대부분 이 연결 고리 단절이었다.
실행하면 1단계와 동일한 UI가 뜬다. 차이는 ViewModel 생성 로그가 Hilt 그래프를 통해 발생한다는 점이다. NavHost를 붙이면 destination 단위로 ViewModel이 분리되는 것도 같이 확인할 수 있다.
1plugins {
2 id("com.android.application")
3 id("org.jetbrains.kotlin.android")
4 id("kotlin-kapt")
5 id("com.google.dagger.hilt.android")
6}
7
8dependencies {
9 implementation("com.google.dagger:hilt-android:2.51.1")
10 kapt("com.google.dagger:hilt-android-compiler:2.51.1")
11
12 implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
13 implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
14}hilt-navigation-compose 의존성이 필요한 이유는 Compose에서 NavBackStackEntry를 Owner로 잡고 Hilt Factory를 연결하는 glue 코드가 그 아티팩트에 있기 때문이다. Hilt 자체만 추가하면 @HiltViewModel은 컴파일되지만, Compose에서 hiltViewModel()을 쓸 수 없다.
1package com.example.migration
2
3import android.app.Application
4import dagger.hilt.android.HiltAndroidApp
5
6@HiltAndroidApp
7class App : Application()Application에 @HiltAndroidApp을 붙이면 Hilt가 컴포넌트 트리를 생성한다. 이때 생성되는 코드는 Dagger 컴포넌트 + 엔트리포인트이며, ViewModel용 Factory도 이 그래프에 연결된다. 이 설정이 빠지면 런타임에서 Factory 자체가 준비되지 않는다.
1package com.example.migration
2
3import android.os.Bundle
4import android.util.Log
5import androidx.activity.ComponentActivity
6import androidx.activity.compose.setContent
7import androidx.compose.foundation.layout.Column
8import androidx.compose.material3.Button
9import androidx.compose.material3.MaterialTheme
10import androidx.compose.material3.Text
11import androidx.compose.runtime.Composable
12import androidx.hilt.navigation.compose.hiltViewModel
13import androidx.lifecycle.ViewModel
14import dagger.hilt.android.AndroidEntryPoint
15import dagger.hilt.android.lifecycle.HiltViewModel
16import javax.inject.Inject
17
18class HiltRepo @Inject constructor() {
19 fun next(current: Int): Int = current + 1
20}
21
22@HiltViewModel
23class HiltCounterVm @Inject constructor(
24 private val repo: HiltRepo
25) : ViewModel() {
26 init {
27 Log.d("HiltCounterVm", "created: ${hashCode()}")
28 }
29
30 var count = 0
31 private set
32
33 fun inc() { count = repo.next(count) }
34}
35
36@AndroidEntryPoint
37class HiltMainActivity : ComponentActivity() {
38 override fun onCreate(savedInstanceState: Bundle?) {
39 super.onCreate(savedInstanceState)
40 setContent { MaterialTheme { HiltCounterScreen() } }
41 }
42}
43
44@Composable
45fun HiltCounterScreen(vm: HiltCounterVm = hiltViewModel()) {
46 Column {
47 Text(text = "count=${vm.count}")
48 Button(onClick = vm::inc) { Text("inc") }
49 }
50}여기서도 vm.count가 Compose State가 아니라서 UI가 갱신되지 않는다. Hilt가 붙었다고 상태 문제가 해결되지 않는다. 실제 앱에서는 StateFlow를 collectAsStateWithLifecycle로 읽거나, ViewModel 내부를 mutableStateOf로 구성한다. DI는 생성 책임, Compose는 상태 읽기 추적이라는 서로 다른 축이다.
심화: Advanced 버전 만들기
실무에서는 화면마다 HiltViewModel을 붙이고 끝나지 않는다. 로딩, 디바운스, 접근성, 아이콘/텍스트 조합, 롱프레스 같은 입력 요구사항이 붙는다. 이때 ViewModel 주입과 UI 상태 읽기 범위를 같이 설계하지 않으면, 버튼 하나 때문에 화면 전체가 매 프레임 recomposition 되는 상황이 생긴다.
사례 1: debounce + loading을 UI 컴포넌트로 밀어넣기
디바운스는 ViewModel에 넣을 수도 있고 UI에 넣을 수도 있다. UI에 넣으면 화면마다 반복 구현이 줄지만, remember 위치가 잘못되면 recomposition마다 타이머가 리셋된다. 이 컴포넌트는 remember로 마지막 클릭 시간을 Slot Table에 저장해 같은 Composition 인스턴스에서만 동작한다.
실행하면 연타해도 400ms 안에서는 onClick이 한 번만 실행된다. loading=true이면 클릭이 무시되고 텍스트 대신 "Loading..."이 표시된다. TalkBack에서는 contentDescription이 우선 읽힌다.
1package com.example.migration
2
3import android.os.SystemClock
4import androidx.compose.foundation.combinedClickable
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.Spacer
7import androidx.compose.foundation.layout.width
8import androidx.compose.material3.Icon
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
11import androidx.compose.runtime.mutableLongStateOf
12import androidx.compose.runtime.remember
13import androidx.compose.runtime.getValue
14import androidx.compose.runtime.setValue
15import androidx.compose.ui.Modifier
16import androidx.compose.ui.semantics.contentDescription
17import androidx.compose.ui.semantics.semantics
18import androidx.compose.ui.unit.dp
19
20@Composable
21fun AdvancedButton(
22 text: String,
23 modifier: Modifier = Modifier,
24 loading: Boolean = false,
25 debounceMs: Long = 400,
26 a11yLabel: String = text,
27 icon: (@Composable () -> Unit)? = null,
28 onLongPress: (() -> Unit)? = null,
29 onClick: () -> Unit
30) {
31 var lastClickAt by remember { mutableLongStateOf(0L) }
32
33 val clickable = modifier
34 .semantics { contentDescription = a11yLabel }
35 .combinedClickable(
36 enabled = !loading,
37 onLongClick = { onLongPress?.invoke() },
38 onClick = {
39 val now = SystemClock.elapsedRealtime()
40 if (now - lastClickAt >= debounceMs) {
41 lastClickAt = now
42 onClick()
43 }
44 }
45 )
46
47 Row(modifier = clickable) {
48 if (loading) {
49 Text(text = "Loading...")
50 return
51 }
52 if (icon != null) {
53 icon()
54 Spacer(Modifier.width(8.dp))
55 }
56 Text(text = text)
57 }
58}combinedClickable을 쓴 이유는 롱프레스와 클릭을 같은 입력 노드에서 처리하기 위해서다. Modifier 체인에서 semantics를 clickable 앞에 두면 접근성 노드가 클릭 가능한 노드와 결합된다. debounce 상태(lastClickAt)는 Slot Table에 저장되며, recomposition으로 함수가 재호출돼도 같은 그룹 위치라면 값이 유지된다.
사례 2: @HiltViewModel + UI 상태 읽기 범위 최소화
ViewModel에서 StateFlow를 노출하고, 화면 루트에서 collect하면 화면 전체가 자주 invalidation 된다. 버튼만 상태를 읽게 분리하면, Slot Table 그룹 단위로 더 작은 범위가 다시 호출된다. 이 차이는 Layout Inspector에서 recomposition count로 바로 확인된다.
실행하면 버튼 텍스트가 "Send (n)"으로 바뀌고, 클릭할 때마다 n이 증가한다. 중요한 관찰은 화면 루트가 아니라 버튼만 자주 recomposition 되는지다. 버튼 외 텍스트는 count를 읽지 않으니 그대로 유지된다.
1package com.example.migration
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.material3.Text
5import androidx.compose.runtime.Composable
6import androidx.compose.runtime.collectAsState
7import androidx.compose.runtime.getValue
8import androidx.hilt.navigation.compose.hiltViewModel
9import androidx.lifecycle.ViewModel
10import dagger.hilt.android.lifecycle.HiltViewModel
11import kotlinx.coroutines.flow.MutableStateFlow
12import kotlinx.coroutines.flow.StateFlow
13import javax.inject.Inject
14
15@HiltViewModel
16class SendVm @Inject constructor() : ViewModel() {
17 private val _count = MutableStateFlow(0)
18 val count: StateFlow<Int> = _count
19
20 fun send() { _count.value = _count.value + 1 }
21}
22
23@Composable
24fun SendScreen(vm: SendVm = hiltViewModel()) {
25 Column {
26 Text(text = "Static header")
27 SendButton(vm = vm)
28 Text(text = "Static footer")
29 }
30}
31
32@Composable
33private fun SendButton(vm: SendVm) {
34 val count by vm.count.collectAsState(initial = 0)
35 AdvancedButton(text = "Send ($count)", onClick = vm::send)
36}내 흑역사 하나는 collectAsState를 화면 루트에서 여러 개 호출해 recomposition이 폭발한 사건이다. 스크롤이 있는 화면에서 프레임 드랍이 있었고, Systrace에서 Choreographer 프레임이 24ms 근처까지 튀었다. 원인은 루트가 여러 StateFlow를 읽고 있어서 작은 변화에도 큰 그룹이 재호출된 것이었다.
교정 방법은 두 가지였다. 첫째, 상태 읽기를 UI 하위로 쪼개 Slot Table 그룹을 작게 만들었다. 둘째, ViewModel에서 UI 모델을 합성해 Flow 수를 줄였다. 디버깅은 Layout Inspector의 recomposition count와, 로그로 Composable 진입 횟수를 같이 봤다. "왜 루트가 다시 호출되지"라는 질문에 답이 나왔다.
자주 하는 실수
@HiltViewModel인데 생성 시 IllegalStateException 발생
증상: 앱 실행 직후 또는 화면 진입 시 "java.lang.IllegalStateException: Hilt ViewModel factory is not set"가 발생한다. hiltViewModel() 호출 라인에서 크래시가 난다.
원인: @AndroidEntryPoint가 Activity/Fragment에 없거나, Application에 @HiltAndroidApp이 없다. Hilt가 Android component에 주입 지점을 만들지 못하면 ViewModelFactory가 Owner에 연결되지 않는다.
해결: Application에 @HiltAndroidApp, 진입 Activity(또는 Fragment)에 @AndroidEntryPoint를 추가한다. 멀티 모듈이면 hilt plugin/kapt 설정이 앱 모듈에 들어가 있는지도 확인한다.
Composable 안에서 ViewModel을 remember로 생성
증상: 화면 회전 후 상태가 초기화되거나, back stack 복원에서 데이터가 사라진다. 프로세스 재생성 후에는 거의 항상 초기화된다.
원인: remember는 Slot Table 기반 캐시라서 Composition 인스턴스가 바뀌면 같이 사라진다. ViewModel이 기대하는 저장소는 ViewModelStoreOwner이며, 생명주기 범위가 다르다.
해결: viewModel()/hiltViewModel()로 Owner 기반 조회를 사용한다. 화면 단위 저장이 필요하면 NavBackStackEntry Owner를 쓰는 navigation-compose 구성으로 옮긴다.
Hilt로 옮겼는데 UI가 갱신되지 않음
증상: 버튼 클릭 로그는 찍히는데 Text 숫자가 바뀌지 않는다. ViewModel 내부 값은 증가한다.
원인: ViewModel의 프로퍼티가 Compose State가 아니거나, Flow/LiveData를 Compose가 읽기 추적할 형태로 변환하지 않았다. DI는 생성만 담당하고 UI 갱신은 Snapshot 읽기 추적이 담당한다.
해결: mutableStateOf/mutableIntStateOf를 사용하거나, StateFlow를 collectAsState(또는 collectAsStateWithLifecycle)로 읽는다. 상태 읽기 위치를 좁혀 recomposition 범위를 통제한다.
NavHost에서 화면별 ViewModel이 분리되지 않음
증상: 서로 다른 destination에서 같은 ViewModel 인스턴스(hashCode)가 재사용된다. 뒤로 가기 후에도 이전 화면 상태가 섞인다.
원인: Owner가 Activity로 잡혀 있다. navigation-compose는 NavBackStackEntry가 destination 단위 Owner인데, 이를 사용하지 않으면 Activity 범위로 ViewModel이 공유된다.
해결: destination Composable 내부에서 hiltViewModel()을 호출해 NavBackStackEntry Owner를 사용한다. 커스텀 Owner를 넘긴다면 LocalViewModelStoreOwner가 무엇을 가리키는지 확인한다.
Hilt 적용 후 빌드가 깨지고 kapt 에러가 쏟아짐
증상: "kaptDebugKotlin FAILED" 또는 "cannot find symbol Dagger..." 류의 에러가 발생한다. @HiltAndroidApp 생성 클래스가 안 보인다는 메시지가 섞인다.
원인: 플러그인 누락(com.google.dagger.hilt.android), kapt 미적용, 또는 모듈 경계에서 hilt-android-compiler가 빠졌다. Kotlin 버전/AGP 버전 조합 문제로 incremental kapt가 불안정한 경우도 있다.
해결: 앱 모듈에 hilt plugin + kotlin-kapt를 적용하고, hilt-android-compiler를 kapt로 추가한다. 캐시 이슈가 의심되면 Clean Project 후 Gradle daemon 재시작까지 수행한다.
1## 빌드 캐시/데몬 영향이 의심될 때 확인 순서
2./gradlew --stop
3./gradlew clean
4./gradlew :app:assembleDebug --no-build-cache --rerun-tasks
5
6## kapt 로그가 필요할 때
7./gradlew :app:kaptDebugKotlin --info이 명령을 실행하면 kapt 단계에서 어떤 어노테이션 프로세서가 실패하는지 로그로 드러난다. Hilt는 생성 코드가 많아서, 에러가 한 줄로 끝나지 않는다. 실패한 첫 원인을 찾는 게 중요하다.
성능 최적화 체크리스트
- ViewModel 생성이 remember가 아닌 viewModel()/hiltViewModel() 경로인지 확인한다
- Activity/Fragment에 @AndroidEntryPoint가 붙어 있는지 확인한다 (Hilt ViewModel factory 연결)
- Application에 @HiltAndroidApp이 붙어 있는지 확인한다 (컴포넌트 트리 생성)
- hilt-navigation-compose 의존성이 있는지 확인한다 (Compose에서 hiltViewModel() 제공)
- NavHost 사용 시 destination 내부에서 hiltViewModel()을 호출해 NavBackStackEntry Owner를 사용한다
- UI 갱신이 필요한 값은 mutableStateOf 또는 StateFlow+collectAsState로 읽기 추적이 되게 만든다
- collectAsState를 화면 루트에 몰아두지 않고, 필요한 하위 컴포저블로 상태 읽기를 분산한다
- Modifier 체인에서 semantics/clickable/padding 순서가 의도한 입력/접근성 동작을 만드는지 확인한다
- ViewModel init 로그(hashCode)로 인스턴스가 destination 단위로 분리되는지 확인한다
- 프로세스 재생성이 중요한 화면은 SavedStateHandle을 사용하고, Owner가 back stack entry인지 점검한다
- 불필요한 객체 할당을 피하기 위해 Factory/Repository를 Composable 내부에서 매번 생성하지 않는다
- Layout Inspector의 Recomposition Counts로 실제 재호출 범위를 측정하고, 의도와 다르면 상태 읽기 위치를 수정한다
자주 묻는 질문
DI 없이 시작해도 나중에 Hilt로 옮기기 쉬운 구조는 무엇인가?
생성 책임을 UI에서 분리하는 구조가 핵심이다. 첫 단계는 viewModel(factory=...)를 써서 ViewModel 생성이 Factory 경로를 통과하게 만드는 것이다. 이때 Factory는 수동 구현이지만, 화면 코드는 ViewModel을 '요청'만 한다. 다음 단계로 CompositionLocal(AppGraph)을 두면 Repository 같은 의존성 생성 위치를 Activity/AppRoot로 올릴 수 있다. 마지막에 Hilt를 붙이면 Factory 구현이 Hilt로 교체되고, 화면은 hiltViewModel()로 진입점을 바꾸는 정도로 끝난다. 학습 키워드는 ViewModelStoreOwner, ViewModelProvider.Factory, CompositionLocal, hilt-navigation-compose이다.
hiltViewModel()이 viewModel()보다 특별한 점은 무엇인가?
둘 다 ViewModelProvider를 통해 ViewModel을 얻지만, Factory 선택과 Owner 선택이 다르다. viewModel()은 기본 Factory를 사용하며, Hilt 그래프에서 생성돼야 하는 @HiltViewModel에는 맞지 않는다. hiltViewModel()은 현재 Composition에서 NavBackStackEntry 같은 적절한 ViewModelStoreOwner를 찾고, 그 Owner에 연결된 Hilt 전용 Factory(HiltViewModelFactory 계열)를 사용한다. 그래서 @HiltViewModel + @AndroidEntryPoint + @HiltAndroidApp이 연결된 환경에서만 정상 동작한다. 학습 키워드는 HiltViewModelFactory, NavBackStackEntry, CreationExtras, SavedStateHandle이다.
왜 Composable 안에서 ViewModel을 remember로 만들면 안 되는가?
remember는 Slot Table에 값을 저장해 같은 Composition 인스턴스에서만 재사용한다. Activity 재생성(회전), 프로세스 재생성, 네비게이션 그래프 재구성처럼 Composition이 새로 만들어지면 Slot Table도 새로 생기고 remember 값도 사라진다. ViewModel이 기대하는 저장 계층은 ViewModelStoreOwner이며, 이 저장소는 생명주기와 연결돼 인스턴스를 유지한다. 따라서 ViewModel은 remember의 캐시 정책과 맞지 않는다. 확인 방법은 ViewModel init에서 hashCode 로그를 찍고 (1) 회전 3회 반복, (2) 최근 앱 목록에서 앱 스와이프 종료 후 재실행, (3) NavHost에서 동일 destination 2번 push 후 pop을 수행했을 때 인스턴스가 유지/분리되는지 보는 것이다. 학습 키워드는 Slot Table, Composition lifecycle, ViewModelStore이다.
@HiltViewModel을 붙였는데도 UI가 갱신되지 않는 이유는 무엇인가?
Hilt는 ViewModel을 '만들어주는' 역할만 하고, Compose가 UI를 다시 호출하게 만드는 '상태 읽기 추적'에는 관여하지 않는다. ViewModel 내부 값이 단순 Int/Boolean이면 Compose는 그 값을 읽어도 변경을 감지하지 못한다. UI 갱신이 필요하면 mutableStateOf 같은 Snapshot State를 사용하거나, StateFlow/LiveData를 collectAsState로 변환해 Compose가 읽기 추적을 하도록 만들어야 한다. 예를 들어 클릭 카운터라면 `var count by mutableIntStateOf(0)`로 바꾸거나, `MutableStateFlow(0)`를 노출하고 버튼 컴포저블에서만 `collectAsStateWithLifecycle()`로 수집한다. 또 collectAsState를 화면 루트에 몰아두면 루트 그룹이 자주 recomposition 되므로, 필요한 하위 컴포저블에서만 수집하는 방식이 안정적이다. 학습 키워드는 Snapshot, read tracking, StateFlow, collectAsStateWithLifecycle이다.
NavHost에서 destination마다 ViewModel을 분리하려면 무엇을 신경 써야 하나?
핵심은 ViewModelStoreOwner가 무엇인지다. Activity를 Owner로 쓰면 destination이 달라도 같은 store를 공유해 ViewModel이 섞인다. navigation-compose는 destination Composable에 진입할 때 NavBackStackEntry를 Owner로 제공하며, SavedStateHandle도 이 엔트리와 연결된다. 따라서 destination 내부에서 hiltViewModel()을 호출하면 기본적으로 NavBackStackEntry Owner를 사용한다. 커스텀으로 Owner를 넘기거나 CompositionLocal을 건드릴 때는 LocalViewModelStoreOwner가 실제로 무엇을 가리키는지 점검해야 한다. 실전 점검은 ViewModel init에서 hashCode를 찍고, A→B→A로 이동한 뒤 A의 hashCode가 처음 A와 같은지(같아야 함), A를 두 번 push(A1, A2)했을 때 A1과 A2의 hashCode가 다른지(달라야 함)를 확인하는 방식이 빠르다. 학습 키워드는 NavBackStackEntry, LocalViewModelStoreOwner, SavedStateHandle이다.
Hilt 마이그레이션 중 수동 Factory와 Hilt Factory가 섞이면 어떤 문제가 생기나?
가장 흔한 문제는 동일 타입 ViewModel을 서로 다른 Factory로 요청해 인스턴스가 예기치 않게 분리되는 것이다. 예를 들어 어떤 화면은 viewModel(factory=ManualFactory)로 만들고, 다른 화면은 hiltViewModel()로 만들면 Owner가 같아도 key가 다르거나 Factory가 달라 생성 경로가 달라진다. 또 @HiltViewModel이 붙은 타입을 수동 Factory로 만들려고 하면 내부 의존성 주입이 빠져 NPE나 생성 실패가 난다. 마이그레이션 단계에서는 화면 단위로 '어떤 생성 경로를 쓰는지'를 명확히 나누고, 동일 화면에서는 한 경로만 사용해야 한다. 학습 키워드는 ViewModelProvider key, Factory precedence, @HiltViewModel scope이다.
Compose Compiler/Runtime 관점에서 hiltViewModel() 호출은 recomposition에 어떤 영향을 주나?
Composable은 컴파일 시 composer와 changed 플래그가 추가된 형태로 변환되고, 함수 본문은 recomposition 때 다시 실행될 수 있다. hiltViewModel() 호출도 본문에 남아 매번 실행될 수 있지만, ViewModelProvider는 Owner의 store에서 기존 인스턴스를 먼저 조회하므로 일반적으로 create 비용이 반복되지 않는다. 다만 호출 자체가 '상태 읽기'는 아니므로 recomposition 트리거가 되지 않는다. 성능 문제는 ViewModel 생성이 아니라, ViewModel이 노출한 Flow/State를 어디서 읽느냐에서 발생한다. Layout Inspector에서 recomposition count를 켜고 collectAsState 위치를 이동시키면 호출 범위가 눈으로 보인다. 학습 키워드는 Composer, Slot Table, invalidation, ViewModelProvider caching이다.