Compose 기본2026년 03월 02일· 8 min read

15. Compose에서 ViewModel 생성·주입 원리: viewModel()·hiltViewModel()

Compose에서 viewModel()·hiltViewModel()이 왜 같은 인스턴스를 재사용하는지, 어떤 Owner에 붙는지, recomposition과 설정 변경에서 어떻게 유지되는지 내부 동작까지 연결해 설명한다. 실전 코드 포함한다.​​​

15. Compose에서 ViewModel 생성·주입 원리: viewModel()·hiltViewModel()

Compose에서 ViewModel 생성·주입 원리: viewModel()·hiltViewModel()

Compose를 처음 붙이면 화면에 viewModel()을 한 줄로 넣고 끝내고 싶어진다. 그런데 버튼을 몇 번 누르거나 화면 회전을 시키면 값이 초기화되거나, 리스트 스크롤 중에 ViewModel이 새로 만들어진 것처럼 보이는 로그가 찍힌다. 또 NavHost 안팎에서 hiltViewModel()이 서로 다른 인스턴스를 주는 순간부터는 디버깅이 지옥이 된다. 문제의 핵심은 “어디(Owner)에 붙여서 만들었나”와 “Compose가 호출을 몇 번 하더라도 왜 한 번만 생성되게 했나”에 있다.

핵심 개념

Compose에서 ViewModel 생성은 UI 함수 호출 횟수와 분리되어야 한다. Composable은 recomposition 때마다 여러 번 호출될 수 있고, 호출 자체는 객체 생성을 보장하지 않는다. viewModel()이 필요한 이유는 “Composable 호출은 반복되지만 ViewModel은 Owner 수명 동안 1개”라는 규칙을 강제로 걸어두기 때문이다.

핵심은 Owner 세 가지다. Activity/Fragment가 제공하는 ViewModelStoreOwner, Navigation이 제공하는 NavBackStackEntry, 그리고 Hilt가 제공하는 HiltViewModelFactory가 만든 ViewModelProvider.Factory다. viewModel()은 Owner를 찾아 ViewModelStore에 캐시된 인스턴스를 꺼내고(없으면 생성), hiltViewModel()은 그 과정에서 Factory를 Hilt로 바꿔 끼운다.

Compose Runtime 관점에서 viewModel() 호출은 “Slot Table에 저장되는 remember 값”이 아니라 “CompositionLocal로부터 Owner를 읽고, 그 Owner의 저장소(ViewModelStore)에서 인스턴스를 조회”하는 행위다. 그래서 recomposition이 100번 일어나도 ViewModel이 100번 생성되지 않는다. 생성 여부는 Slot Table이 아니라 ViewModelStore가 결정한다.

용어를 사용 맥락으로 정의한다. - ViewModelStoreOwner: ViewModel을 보관하는 저장소를 가진 주체다. Activity/Fragment/NavBackStackEntry가 될 수 있다. - LocalViewModelStoreOwner: Compose 트리에서 현재 Owner를 전달하는 CompositionLocal이다. Navigation Compose가 화면별 Owner를 바꿔치기한다. - key: 같은 Owner 안에서 ViewModel을 여러 개 만들 때 구분자다. key가 같으면 같은 인스턴스다. - Factory: ViewModel 생성자 주입, SavedStateHandle 연결을 담당한다. hiltViewModel()이 주로 개입하는 지점이다. - remember: recomposition 동안 값을 재사용하기 위한 Slot Table 캐시다. ViewModel 생명주기와는 레이어가 다르다.

CounterScreen.kt
1package com.example.vm
2
3import android.util.Log
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
11import androidx.lifecycle.ViewModel
12import androidx.lifecycle.viewmodel.compose.viewModel
13
14class CounterViewModel : ViewModel() {
15    init {
16        Log.d("CounterVM", "init: ${hashCode()}")
17    }
18    var count = 0
19        private set
20    fun inc() { count++ }
21}
22
23@Composable
24fun CounterScreen() {
25    val vm: CounterViewModel = viewModel()
26
27    // recomposition을 강제로 유도하기 위한 UI 상태
28    var uiTick by remember { mutableIntStateOf(0) }
29
30    Button(onClick = {
31        uiTick++
32        vm.inc()
33        Log.d("CounterVM", "uiTick=$uiTick, vm=${vm.hashCode()}, count=${vm.count}")
34    }) {
35        Text("tick=$uiTick / vmCount=${vm.count}")
36    }
37}

이 코드를 실행하고 버튼을 여러 번 누르면 Logcat에 uiTick은 계속 증가하지만, init 로그는 한 번만 찍힌다. CounterScreen()은 클릭마다 recomposition으로 다시 호출되지만 viewModel()은 같은 Owner의 ViewModelStore에서 같은 인스턴스를 계속 가져오기 때문이다. 여기서 init이 여러 번 찍히면 Owner가 매번 달라졌거나(key 포함), 화면이 실제로 dispose되고 다시 composition된 상황이다.

컴포넌트 해부

viewModel()과 hiltViewModel()은 UI 컴포넌트처럼 보이지만, 실체는 “현재 Composition에서 접근 가능한 Owner/Factory를 찾아 ViewModelProvider로 위임”하는 브리지 함수다. 파라미터가 많은 이유는 Compose가 직접 생명주기를 만들지 않기 때문이다. 생명주기는 AndroidX Lifecycle과 Navigation이 쥐고 있고, Compose는 그 컨텍스트를 읽어야 한다.

  • viewModelStoreOwner: 어디에 ViewModel을 저장할지 결정한다. 기본값은 LocalViewModelStoreOwner.current이다.
  • key: 같은 Owner에서 동일 타입 ViewModel을 여러 개 분리한다. 예: 탭별 ViewModel
  • factory: ViewModel 생성 책임자다. Assisted injection이나 커스텀 생성자 연결에 필요하다.
  • extras/CreationExtras: SavedStateHandle, default args 같은 생성 부가정보 전달에 필요하다.
  • initializer: 일부 API에서는 람다로 직접 생성 로직을 제공한다. 테스트에서 자주 쓴다.
  • hiltViewModel의 navBackStackEntry: Navigation 그래프 범위 ViewModel을 만들 때 Owner를 명시한다.
  • SavedStateHandle: 프로세스 죽음 복원까지 고려할 때 생성자에 주입된다.
  • @HiltViewModel: Hilt가 생성 가능한 ViewModel임을 선언한다.
  • @AndroidEntryPoint: Hilt가 Activity/Fragment에 Factory를 주입할 수 있게 한다.
  • NavHost/Composable destination: LocalViewModelStoreOwner를 화면 단위로 바꾸는 장치다.
  • remember: ViewModel 캐시와 무관하지만, Owner 탐색 같은 부수 비용을 줄이는 데 쓰일 수 있다(대개 필요 없다).

Surface 계층과 Content 계층으로 나누면 이해가 빨라진다. ViewModel 생성은 화면의 ‘Surface’에 가깝다. 화면을 어떤 Owner(Activity/Graph/Entry) 위에 얹을지 결정하는 단계다. Content는 그 ViewModel을 읽어 UI를 그리는 단계다. 이 분리를 하지 않으면, 리스트 아이템 같은 작은 Content에서 viewModel()을 호출해 Owner 범위를 실수하기 쉽다.

Surface 계층에서 Owner를 결정하지 않으면 기본값(LocalViewModelStoreOwner.current)에 의존한다. Navigation Compose를 쓰면 destination마다 Owner가 NavBackStackEntry로 바뀐다. 같은 ViewModel 타입을 Activity 범위로 공유하고 싶었는데 destination 범위로 잘못 붙으면, 화면 이동 시 init 로그가 계속 찍히는 형태로 나타난다.

Content 계층에서는 ViewModel을 파라미터로 받는 편이 테스트가 쉽고 recomposition 비용도 예측 가능하다. 특히 Preview나 screenshot test에서 viewModel()은 실제 Owner가 없어 실패하거나, 임의 Owner를 구성해야 하는 문제가 생긴다. 그래서 실무에서는 Surface에서만 viewModel()을 호출하고 Content는 순수 함수로 유지하는 패턴이 많이 남는다.

VmSurfaceSketch.kt
1package com.example.vm
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.CompositionLocalProvider
5import androidx.compose.runtime.staticCompositionLocalOf
6import androidx.lifecycle.ViewModel
7
8// 교육용 스케치: 실제 LocalViewModelStoreOwner와는 다르다.
9interface VmOwner {
10    fun <T : ViewModel> getOrCreate(key: String, create: () -> T): T
11}
12
13val LocalVmOwner = staticCompositionLocalOf<VmOwner?> { null }
14
15@Composable
16fun VmSurface(owner: VmOwner, content: @Composable () -> Unit) {
17    // Surface: Owner를 트리 상단에 깔아둔다
18    CompositionLocalProvider(LocalVmOwner provides owner) {
19        content()
20    }
21}
22
23@Composable
24inline fun <reified T : ViewModel> vm(key: String = T::class.java.name, noinline create: () -> T): T {
25    val owner = requireNotNull(LocalVmOwner.current) { "No VmOwner in composition" }
26    // Content가 매번 호출돼도 실제 캐시는 owner가 가진다
27    return owner.getOrCreate(key, create)
28}

이 스케치에서 Slot Table은 owner를 담지 않는다. Slot Table은 LocalVmOwner를 읽는 위치 같은 ‘호출 구조’를 기억하고, 실제 ViewModel 캐시는 VmOwner가 가진 Map 같은 저장소에 있다. AndroidX의 viewModel()도 같은 레이어링을 쓴다. Compose는 “현재 Owner를 CompositionLocal로 전달”하고, Lifecycle 쪽이 “Owner에 매달린 저장소에 캐시”한다.

CounterRoute.kt
1package com.example.vm
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.padding
5import androidx.compose.material3.MaterialTheme
6import androidx.compose.material3.Surface
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.ui.Modifier
10import androidx.compose.ui.unit.dp
11import androidx.lifecycle.viewmodel.compose.viewModel
12
13@Composable
14fun CounterRoute(
15    modifier: Modifier = Modifier,
16    vm: CounterViewModel = viewModel(),
17) {
18    // Surface: ViewModel을 여기서 만든다
19    Surface(color = MaterialTheme.colorScheme.background) {
20        CounterContent(
21            modifier = modifier.padding(16.dp),
22            count = vm.count,
23            onInc = vm::inc
24        )
25    }
26}
27
28@Composable
29private fun CounterContent(
30    modifier: Modifier,
31    count: Int,
32    onInc: () -> Unit
33) {
34    Column(modifier) {
35        Text("count=$count")
36        androidx.compose.material3.Button(onClick = onInc) { Text("+") }
37    }
38}

CounterRoute는 Owner 결정과 주입을 담당하고, CounterContent는 순수 UI에 가깝다. 실행하면 화면 회전(설정 변경) 후에도 ViewModel 인스턴스는 유지되고 count도 유지된다. 단, count를 Compose state로 노출하지 않았기 때문에 UI는 버튼을 눌러도 즉시 갱신되지 않는다. 이 빈틈이 다음 섹션의 핵심이다.

내부 동작 원리

Compose 파이프라인은 Composition → Layout → Drawing 순서로 진행된다. viewModel()은 Composition 단계에서만 의미가 있다. Layout/Drawing은 이미 만들어진 UI 트리를 측정·배치·그리기만 한다. 그래서 viewModel() 호출이 레이아웃 성능을 직접 떨어뜨리는 형태는 드물고, 오히려 “어떤 상태를 읽어서 recomposition 범위를 넓혔나”가 성능을 좌우한다.

Composition에서 Composable 호출은 Slot Table에 그룹으로 기록된다. Compose Compiler는 각 Composable을 (composer, changedFlags) 같은 파라미터를 받는 형태로 바꾸고, runtime은 startGroup/endGroup을 통해 호출 구조를 Slot Table에 쌓는다. 중요한 점은 viewModel()이 Slot Table에 ViewModel을 저장하지 않는다는 사실이다. ViewModel은 Lifecycle 레이어의 저장소(ViewModelStore)에 저장된다.

그럼 recomposition 때 viewModel()은 무엇을 하냐. LocalViewModelStoreOwner.current를 읽는다. CompositionLocal 읽기는 runtime이 추적한다. Owner가 바뀌면(예: 다른 NavBackStackEntry로 이동) 그 지점을 포함한 하위가 invalidation되고 다시 호출된다. Owner가 안 바뀌면 viewModel()은 같은 Owner에서 같은 인스턴스를 가져온다.

상태 변경 → recomposition 트리거를 추적하면 실수가 줄어든다. ViewModel 안의 일반 Int 필드는 Compose가 추적하지 못한다. Compose가 추적하는 것은 State<T> 읽기다. 그래서 ViewModel을 만들었는데 UI가 안 바뀌면, 대개 ViewModel이 StateFlow/LiveData/mutableStateOf 중 하나로 ‘관찰 가능한 상태’를 노출하지 않았기 때문이다.

Slot Table 관점에서 recomposition 비교는 “같은 호출 위치에서 같은 파라미터가 들어왔는지”를 본다. @Stable/@Immutable이 붙은 타입은 필드 변경 여부를 더 공격적으로 생략할 수 있다. ViewModel 자체는 보통 안정 타입으로 취급되며(참조 동일), 문제는 ViewModel이 노출하는 UI state의 안정성이다. 매 recomposition마다 새 data class를 만들면 그 scope가 다시 그려진다.

Modifier chain, semantics, interactionSource는 ViewModel 주입과 직접 연관이 없어 보이지만, 실제로는 이벤트가 상태 변경을 만들고 그 상태가 recomposition을 만든다. 클릭 이벤트에서 ViewModel 함수를 호출하고, 그 함수가 StateFlow 값을 바꾸면, collectAsState가 읽는 State가 변경되고, 그 읽기 위치부터 recomposition이 시작된다. 이 연결 고리를 끊어 생각하면 ‘왜 여기만 다시 그려지지?’ 같은 혼란이 생긴다.

FlowCounterScreen.kt
1package com.example.vm
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.compose.runtime.collectAsState
9import androidx.compose.runtime.getValue
10import androidx.lifecycle.ViewModel
11import androidx.lifecycle.viewmodel.compose.viewModel
12import kotlinx.coroutines.flow.MutableStateFlow
13import kotlinx.coroutines.flow.StateFlow
14
15class FlowCounterViewModel : ViewModel() {
16    private val _count = MutableStateFlow(0)
17    val count: StateFlow<Int> = _count
18
19    init {
20        Log.d("FlowCounterVM", "init: ${hashCode()}")
21    }
22
23    fun inc() {
24        _count.value = _count.value + 1
25    }
26}
27
28@Composable
29fun FlowCounterScreen(vm: FlowCounterViewModel = viewModel()) {
30    val count by vm.count.collectAsState()
31
32    Column {
33        Text("count=$count")
34        Button(onClick = vm::inc) { Text("+") }
35    }
36}

이 코드를 실행하면 버튼 클릭 즉시 Text가 증가한다. collectAsState()가 Composition에서 count를 읽고, StateFlow 값 변경이 snapshot state로 변환되며, 그 읽기 위치가 invalidation 된다. init 로그는 여전히 한 번만 찍힌다. ViewModel 생성과 UI 갱신은 서로 다른 메커니즘이고, 둘을 섞으면 디버깅 포인트가 흐려진다.

InteractionAndSemanticsDemo.kt
1package com.example.vm
2
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.interaction.MutableInteractionSource
5import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.padding
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.runtime.remember
10import androidx.compose.ui.Modifier
11import androidx.compose.ui.semantics.contentDescription
12import androidx.compose.ui.semantics.semantics
13import androidx.compose.ui.unit.dp
14
15@Composable
16fun InteractionAndSemanticsDemo(
17    label: String,
18    onTap: () -> Unit,
19) {
20    // interactionSource를 remember하지 않으면 recomposition마다 새 인스턴스가 생긴다.
21    val interaction = remember { MutableInteractionSource() }
22
23    Column(
24        Modifier
25            .padding(16.dp)
26            .semantics { contentDescription = "tap-target:$label" }
27            .clickable(
28                interactionSource = interaction,
29                indication = null,
30                onClick = onTap
31            )
32    ) {
33        Text("Tap: $label")
34        Text("TalkBack에서 contentDescription을 확인한다")
35    }
36}

이 코드를 실행한 뒤 Layout Inspector의 Semantics 트리에서 contentDescription이 노출되는 것을 확인할 수 있다. interactionSource를 remember로 고정하지 않으면, press 상태 같은 상호작용 정보가 recomposition마다 리셋되어 ripple/pressed 상태가 튀는 현상이 발생한다. ViewModel 주입과 별개로, ‘recomposition은 객체를 다시 만들 수 있다’는 사실이 UI 이벤트 계층에서도 그대로 드러난다.

실습하기

실습 목표는 두 가지다. 첫째, viewModel()이 어떤 Owner에 붙는지 눈으로 확인한다. 둘째, hiltViewModel()이 Factory를 바꿔치기해서 생성자 주입이 되는 지점을 확인한다. 로그로 hashCode를 찍고, 화면 이동/뒤로가기/회전에서 인스턴스가 유지되는 범위를 직접 본다.

app/build.gradle.kts
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("androidx.activity:activity-compose:1.9.3")
10    implementation("androidx.compose.material3:material3:1.2.1")
11    implementation("androidx.navigation:navigation-compose:2.8.5")
12    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
13
14    implementation("com.google.dagger:hilt-android:2.51.1")
15    kapt("com.google.dagger:hilt-android-compiler:2.51.1")
16}

이 의존성 구성이 없으면 hiltViewModel()이 컴파일은 되더라도 런타임에 Factory를 찾지 못해 크래시가 난다. 실제로 처음에 3시간 삽질했던 로그가 "java.lang.IllegalStateException: Hilt ViewModelFactory is not available"였고, 원인은 @AndroidEntryPoint를 Activity에 붙이지 않은 것이었다. Compose 코드만 보고 있으면 절대 안 보이는 문제다.

1단계: viewModel() 기본 생성과 Owner 확인

첫 화면에서 viewModel()을 호출하고, 버튼으로 recomposition을 유도한다. 이어서 다른 화면으로 이동한 뒤 돌아오면 ViewModel 인스턴스가 유지되는지 확인한다. Navigation destination 범위 Owner라면, back stack에 남아 있는 동안은 유지되고 pop되면 사라진다.

실행하면 A 화면에서 init 로그가 1회 찍힌다. B로 이동했다가 뒤로 오면 A의 init이 다시 찍히지 않는다(스택 유지). A를 pop해서 완전히 제거한 뒤 다시 진입하면 init이 다시 찍힌다. 이 차이가 Owner 수명이다.

MainActivity.kt
1package com.example.vm
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.Text
10import androidx.compose.runtime.Composable
11import androidx.compose.runtime.getValue
12import androidx.compose.runtime.mutableIntStateOf
13import androidx.compose.runtime.remember
14import androidx.compose.runtime.setValue
15import androidx.lifecycle.ViewModel
16import androidx.lifecycle.viewmodel.compose.viewModel
17import androidx.navigation.compose.NavHost
18import androidx.navigation.compose.composable
19import androidx.navigation.compose.rememberNavController
20
21class OwnerProbeViewModel : ViewModel() {
22    init { Log.d("OwnerProbeVM", "init: ${hashCode()}") }
23    var clicks = 0
24        private set
25    fun click() { clicks++ }
26}
27
28class MainActivity : ComponentActivity() {
29    override fun onCreate(savedInstanceState: Bundle?) {
30        super.onCreate(savedInstanceState)
31        setContent { AppNav() }
32    }
33}
34
35@Composable
36private fun AppNav() {
37    val nav = rememberNavController()
38    NavHost(navController = nav, startDestination = "a") {
39        composable("a") { ScreenA(onGo = { nav.navigate("b") }) }
40        composable("b") { ScreenB(onBack = { nav.popBackStack() }) }
41    }
42}
43
44@Composable
45private fun ScreenA(onGo: () -> Unit) {
46    val vm: OwnerProbeViewModel = viewModel()
47    var uiTick by remember { mutableIntStateOf(0) }
48
49    Column {
50        Text("A vm=${vm.hashCode()} clicks=${vm.clicks} uiTick=$uiTick")
51        Button(onClick = { uiTick++; vm.click() }) { Text("A: recomposition") }
52        Button(onClick = onGo) { Text("Go B") }
53    }
54}
55
56@Composable
57private fun ScreenB(onBack: () -> Unit) {
58    Column {
59        Text("B")
60        Button(onClick = onBack) { Text("Back") }
61    }
62}

이 코드는 의도적으로 ViewModel 값을 Compose state로 노출하지 않는다. 그래서 vm.clicks가 증가해도 Text가 즉시 갱신되지 않을 수 있다. 그 현상 자체가 ‘ViewModel 생성/주입’과 ‘UI 관찰’이 분리되어 있다는 증거다. 다음 단계에서 Flow로 바꿔 UI 갱신을 연결한다.

2단계: ViewModel 상태를 Flow로 노출하고 recomposition 범위 확인

StateFlow를 collectAsState로 읽는 위치가 recomposition 범위를 결정한다. Text 하나만 count를 읽게 만들면 버튼 클릭 때 Column 전체가 아니라 그 Text가 속한 그룹만 다시 호출된다. Layout Inspector의 Recomposition Count(옵션)와 로그를 같이 보면 감이 온다.

실행하면 count가 바뀔 때마다 Text가 바뀌고, init은 한 번이다. 그리고 collectAsState를 Screen 루트에서 읽으면 Screen 전체가 다시 호출된다. 반대로 작은 Composable로 분리하면 그 부분만 다시 호출된다. 이 차이가 성능과 직결된다.

StateDrivenScreen.kt
1package com.example.vm
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.compose.runtime.collectAsState
9import androidx.compose.runtime.getValue
10import androidx.lifecycle.ViewModel
11import androidx.lifecycle.viewmodel.compose.viewModel
12import kotlinx.coroutines.flow.MutableStateFlow
13import kotlinx.coroutines.flow.StateFlow
14
15class UiStateVm : ViewModel() {
16    private val _count = MutableStateFlow(0)
17    val count: StateFlow<Int> = _count
18
19    fun inc() { _count.value++ }
20}
21
22@Composable
23fun StateDrivenScreen(vm: UiStateVm = viewModel()) {
24    Log.d("StateDriven", "compose Screen")
25    Column {
26        CountText(vm)
27        Button(onClick = vm::inc) { Text("+") }
28    }
29}
30
31@Composable
32private fun CountText(vm: UiStateVm) {
33    Log.d("StateDriven", "compose CountText")
34    val count by vm.count.collectAsState()
35    Text("count=$count")
36}

Logcat에서 버튼 클릭마다 "compose CountText"만 반복해서 찍히고 "compose Screen"은 덜 찍히는 패턴을 기대할 수 있다(구성에 따라 다를 수 있다). 중요한 점은 상태 읽기 위치가 recomposition 범위를 만든다는 사실이다. ViewModel을 어디서 주입하느냐(Surface)와 상태를 어디서 읽느냐(Content)는 분리해서 설계하는 편이 안전하다.

3단계: hiltViewModel()로 생성자 주입 + NavGraph 범위 공유

hiltViewModel()은 ViewModelProvider.Factory를 Hilt가 제공하는 Factory로 바꾼다. 그래서 생성자에 Repository 같은 의존성을 넣을 수 있고, SavedStateHandle도 같이 받을 수 있다. Navigation에서 그래프 범위 공유를 하려면, 같은 NavBackStackEntry(그래프 엔트리)를 Owner로 전달해야 한다.

실행하면 ScreenA와 ScreenB가 같은 그래프 Owner를 참조할 때 hashCode가 동일하게 찍힌다. 반대로 destination 기본 Owner를 쓰면 화면마다 다른 hashCode가 나온다. 이 차이를 로그로 확인하면 “왜 공유가 안 되지?” 같은 문제를 Owner 관점으로 바로 풀 수 있다.

HiltNavActivity.kt
1package com.example.vm
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.Text
10import androidx.compose.runtime.Composable
11import androidx.hilt.navigation.compose.hiltViewModel
12import androidx.lifecycle.SavedStateHandle
13import androidx.lifecycle.ViewModel
14import androidx.navigation.NavBackStackEntry
15import androidx.navigation.compose.NavHost
16import androidx.navigation.compose.composable
17import androidx.navigation.compose.navigation
18import androidx.navigation.compose.rememberNavController
19import dagger.hilt.android.AndroidEntryPoint
20import dagger.hilt.android.lifecycle.HiltViewModel
21import javax.inject.Inject
22
23class FakeRepo @Inject constructor() {
24    fun load(): String = "repo@${hashCode()}"
25}
26
27@HiltViewModel
28class HiltProbeViewModel @Inject constructor(
29    private val repo: FakeRepo,
30    private val savedStateHandle: SavedStateHandle,
31) : ViewModel() {
32    init {
33        Log.d("HiltProbeVM", "init vm=${hashCode()} repo=${repo.load()}")
34    }
35    fun id(): String = "vm=${hashCode()} repo=${repo.load()}"
36}
37
38@AndroidEntryPoint
39class HiltNavActivity : ComponentActivity() {
40    override fun onCreate(savedInstanceState: Bundle?) {
41        super.onCreate(savedInstanceState)
42        setContent { HiltNavApp() }
43    }
44}
45
46@Composable
47private fun HiltNavApp() {
48    val nav = rememberNavController()
49    NavHost(navController = nav, startDestination = "graph") {
50        navigation(startDestination = "a", route = "graph") {
51            composable("a") { entry -> GraphScreenA(entry, onGo = { nav.navigate("graph/b") }) }
52            composable("b") { entry -> GraphScreenB(entry, onBack = { nav.popBackStack() }) }
53        }
54    }
55}
56
57@Composable
58private fun GraphScreenA(entry: NavBackStackEntry, onGo: () -> Unit) {
59    val graphEntry = rememberGraphEntry(entry, route = "graph")
60    val vm: HiltProbeViewModel = hiltViewModel(graphEntry)
61
62    Column {
63        Text("A ${vm.id()}")
64        Button(onClick = onGo) { Text("Go B") }
65    }
66}
67
68@Composable
69private fun GraphScreenB(entry: NavBackStackEntry, onBack: () -> Unit) {
70    val graphEntry = rememberGraphEntry(entry, route = "graph")
71    val vm: HiltProbeViewModel = hiltViewModel(graphEntry)
72
73    Column {
74        Text("B ${vm.id()}")
75        Button(onClick = onBack) { Text("Back") }
76    }
77}
78
79@Composable
80private fun rememberGraphEntry(entry: NavBackStackEntry, route: String): NavBackStackEntry {
81    // 교육용: 실제로는 navController.getBackStackEntry(route)를 쓰는 패턴이 많다.
82    // 여기서는 entry를 통해 상위 그래프 엔트리를 찾는 개념만 남긴다.
83    return entry
84}

여기서 rememberGraphEntry는 교육용 더미다. 실제 앱에서는 navController.getBackStackEntry("graph")를 사용해 그래프 엔트리를 가져오고, 그 엔트리를 hiltViewModel(owner)로 넘겨 공유 범위를 만든다. 공유가 안 될 때는 거의 항상 ‘owner를 destination 엔트리로 썼다’가 원인이고, 로그의 hashCode 비교가 가장 빠른 확인 방법이다.

심화: Advanced 버전 만들기

실무에서는 ViewModel 주입 자체보다 “주입 위치를 고정하고, 이벤트 폭주와 로딩 상태를 UI에서 일관되게 처리”하는 쪽이 더 많은 시간을 잡아먹는다. 특히 Compose는 클릭이 연속으로 들어오면 onClick이 그대로 연속 실행되고, 그 결과 ViewModel의 네트워크 호출이 중복으로 터지는 일이 흔하다.

요약: viewModel()·hiltViewModel()은 Slot Table 캐시가 아니라 ViewModelStore 캐시를 사용한다. 인스턴스 범위는 Owner(Activity/BackStackEntry/Graph)가 결정하고, UI 갱신은 ViewModel이 노출하는 관찰 가능한 상태(StateFlow 등)를 어디서 읽느냐가 결정한다.

사례 1: 로딩 + 디바운스 + 접근성 라벨을 가진 AdvancedButton

버튼은 UI 컴포넌트지만, ViewModel 이벤트 폭주를 막는 마지막 방어선이 되기도 한다. ViewModel에서 중복 호출을 막는 게 1차 방어선이고, UI에서 디바운스를 걸어 2차 방어선을 만들면 실수로 두 번 탭했을 때 서버에 2번 쏘는 사고를 줄일 수 있다. 실제로 결제 화면에서 이 문제를 한 번 맞고 나서, 버튼을 공통 컴포넌트로 올렸다.

AdvancedButton.kt
1package com.example.vm
2
3import android.os.SystemClock
4import androidx.compose.foundation.combinedClickable
5import androidx.compose.foundation.interaction.MutableInteractionSource
6import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.Spacer
8import androidx.compose.foundation.layout.width
9import androidx.compose.material3.CircularProgressIndicator
10import androidx.compose.material3.Icon
11import androidx.compose.material3.MaterialTheme
12import androidx.compose.material3.Surface
13import androidx.compose.material3.Text
14import androidx.compose.runtime.Composable
15import androidx.compose.runtime.remember
16import androidx.compose.runtime.rememberUpdatedState
17import androidx.compose.ui.Alignment
18import androidx.compose.ui.Modifier
19import androidx.compose.ui.semantics.contentDescription
20import androidx.compose.ui.semantics.semantics
21import androidx.compose.ui.unit.dp
22
23@Composable
24fun AdvancedButton(
25    text: String,
26    modifier: Modifier = Modifier,
27    loading: Boolean = false,
28    enabled: Boolean = true,
29    debounceMs: Long = 600L,
30    a11yLabel: String = text,
31    icon: (@Composable () -> Unit)? = null,
32    onClick: () -> Unit,
33    onLongClick: (() -> Unit)? = null,
34) {
35    val interaction = remember { MutableInteractionSource() }
36    val latestClick = rememberUpdatedState(onClick)
37    val latestLong = rememberUpdatedState(onLongClick)
38
39    val gate = remember { ClickGate(debounceMs) }
40
41    Surface(
42        modifier = modifier
43            .semantics { contentDescription = a11yLabel }
44            .combinedClickable(
45                enabled = enabled && !loading,
46                interactionSource = interaction,
47                indication = null,
48                onClick = {
49                    if (gate.tryPass()) latestClick.value.invoke()
50                },
51                onLongClick = {
52                    if (gate.tryPass()) latestLong.value?.invoke()
53                }
54            ),
55        color = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
56        contentColor = MaterialTheme.colorScheme.onPrimary,
57        tonalElevation = 2.dp,
58        shape = MaterialTheme.shapes.medium
59    ) {
60        Row(verticalAlignment = Alignment.CenterVertically) {
61            if (loading) {
62                CircularProgressIndicator(strokeWidth = 2.dp)
63                Spacer(Modifier.width(8.dp))
64            }
65            if (icon != null) {
66                icon()
67                Spacer(Modifier.width(8.dp))
68            }
69            Text(text)
70        }
71    }
72}
73
74private class ClickGate(private val debounceMs: Long) {
75    private var last = 0L
76    fun tryPass(): Boolean {
77        val now = SystemClock.elapsedRealtime()
78        if (now - last < debounceMs) return false
79        last = now
80        return true
81    }
82}

이 코드를 실행하면 로딩 중에는 클릭이 막히고, 600ms 안에 연속 탭하면 두 번째 탭이 무시된다. rememberUpdatedState를 쓰지 않으면 recomposition으로 onClick 람다가 바뀔 때 ClickGate가 이전 람다를 잡는 형태의 버그가 생길 수 있다. 이 버그는 재현이 어려워서, 나는 실제로 디버그 로그를 onClick 내부에 심고도 “왜 최신 로직이 안 타지?”를 한참 헤맸다.

사례 2: ViewModel 주입 위치 고정 + UI 이벤트는 단방향으로 흘리기

AdvancedButton을 ViewModel과 직접 결합하면 Preview가 망가지고, 재사용이 어려워진다. 대신 Route에서 hiltViewModel()로 주입하고, Content에는 상태와 이벤트만 내려보낸다. 이벤트는 ViewModel 함수로 올라가고, 결과 상태는 Flow로 내려온다. 이 구조가 단방향 데이터 흐름을 강제한다.

SubmitRoute.kt
1package com.example.vm
2
3import android.util.Log
4import androidx.compose.foundation.layout.Column
5import androidx.compose.material3.Text
6import androidx.compose.runtime.Composable
7import androidx.compose.runtime.collectAsState
8import androidx.compose.runtime.getValue
9import androidx.hilt.navigation.compose.hiltViewModel
10import androidx.lifecycle.ViewModel
11import dagger.hilt.android.lifecycle.HiltViewModel
12import javax.inject.Inject
13import kotlinx.coroutines.flow.MutableStateFlow
14import kotlinx.coroutines.flow.StateFlow
15
16data class SubmitUiState(
17    val loading: Boolean = false,
18    val message: String = "idle"
19)
20
21@HiltViewModel
22class SubmitViewModel @Inject constructor() : ViewModel() {
23    private val _ui = MutableStateFlow(SubmitUiState())
24    val ui: StateFlow<SubmitUiState> = _ui
25
26    fun submit() {
27        if (_ui.value.loading) return
28        _ui.value = _ui.value.copy(loading = true, message = "submitting...")
29        Log.d("SubmitVM", "submit called")
30        // 교육용: 실제로는 viewModelScope에서 네트워크 호출 후 상태 갱신
31        _ui.value = _ui.value.copy(loading = false, message = "done")
32    }
33}
34
35@Composable
36fun SubmitRoute(vm: SubmitViewModel = hiltViewModel()) {
37    val ui by vm.ui.collectAsState()
38    SubmitContent(
39        loading = ui.loading,
40        message = ui.message,
41        onSubmit = vm::submit
42    )
43}
44
45@Composable
46private fun SubmitContent(
47    loading: Boolean,
48    message: String,
49    onSubmit: () -> Unit
50) {
51    Column {
52        Text("message=$message")
53        AdvancedButton(
54            text = "Submit",
55            loading = loading,
56            a11yLabel = "submit-button",
57            onClick = onSubmit,
58            onLongClick = { Log.d("SubmitUI", "long press") }
59        )
60    }
61}

이 구조에서 recomposition은 ui를 읽는 SubmitRoute부터 시작된다. SubmitContent는 파라미터가 바뀔 때만 다시 호출된다. message만 바뀌면 Text와 버튼 텍스트가 포함된 그룹이 갱신되고, loading이 바뀌면 CircularProgressIndicator가 나타난다. Slot Table은 SubmitContent 호출 위치와 파라미터 변경 여부를 기반으로 스킵/실행을 결정한다.

내 흑역사 1: LazyColumn item에서 hiltViewModel()을 호출했다가 스크롤 중에 init 로그가 계속 찍힌 적이 있다. 원인은 아이템이 composition에서 들어왔다 나가며 dispose되었고, item이 참조한 Owner가 예상과 달라 ViewModel 범위가 작아졌기 때문이다. 해결은 Route에서 한 번만 주입하고, 아이템에는 필요한 상태만 내려보내는 방식으로 바꾼 것이다.

내 흑역사 2: Navigation 그래프 공유 ViewModel을 만들고 싶어서 그냥 hiltViewModel()을 두 화면에서 호출했는데 서로 다른 인스턴스가 나왔다. Logcat에 vm hashCode가 화면마다 달랐고, 에러는 없어서 더 위험했다. 원인은 그래프 엔트리를 Owner로 넘기지 않았던 것이라, navController.getBackStackEntry("graph")를 Owner로 전달하자 즉시 해결됐다.

자주 하는 실수

1) ViewModel 값이 바뀌는데 UI가 갱신되지 않는다

증상: 버튼을 눌러 ViewModel의 count를 올렸는데 Text가 그대로다. Logcat에는 count 증가가 찍히지만 화면은 멈춰 있다.

원인: Compose는 일반 필드 변경을 관찰하지 않는다. Composition에서 읽는 것은 State<T>여야 하고, Flow/LiveData는 collectAsState/observeAsState로 State로 변환돼야 한다.

해결: ViewModel은 StateFlow 또는 mutableStateOf로 UI state를 노출한다. 그리고 상태 읽기 위치를 좁혀 recomposition 범위를 제한한다. 상태를 읽는 곳이 곧 invalidation 시작점이다.

2) 화면 이동할 때마다 ViewModel이 새로 만들어진다

증상: init 로그가 화면 진입마다 찍힌다. 뒤로가기로 돌아와도 다시 init이 찍혀 상태가 초기화된다.

원인: Owner 범위가 destination 단위로 잡혔고, 해당 destination이 pop되거나 dispose되며 ViewModelStore가 비워졌다. 또는 key를 매번 다른 값으로 만들어 같은 타입을 계속 새로 만들었다.

해결: 공유 범위를 명시한다. Activity 범위 공유면 Activity가 Owner가 되게 하고, 그래프 범위 공유면 navController.getBackStackEntry("graph")를 Owner로 넘긴다. key는 안정적으로 고정한다.

3) hiltViewModel()에서 런타임 크래시가 난다

증상: 실행 즉시 "IllegalStateException" 또는 "Hilt ViewModelFactory is not available" 같은 메시지로 크래시가 난다.

원인: @AndroidEntryPoint가 Activity/Fragment에 없거나, Hilt 플러그인/kapt 설정이 빠져 Factory 주입이 되지 않는다. 또는 @HiltViewModel을 빼먹어 Hilt가 생성 경로를 모른다.

해결: Application에 @HiltAndroidApp, Activity에 @AndroidEntryPoint를 붙인다. gradle에 hilt-android와 compiler를 넣고 kapt를 켠다. ViewModel 클래스에 @HiltViewModel과 @Inject 생성자를 둔다.

4) Preview에서 viewModel()/hiltViewModel()이 실패한다

증상: Preview가 "No ViewModelStoreOwner was provided" 또는 Hilt 관련 예외로 렌더링이 안 된다.

원인: Preview는 실제 Activity/NavBackStackEntry/Hilt 컨테이너가 없다. LocalViewModelStoreOwner.current가 null이거나, Hilt가 초기화되지 않는다.

해결: Route/Content 분리를 한다. Preview는 Content만 렌더링하고, ViewModel은 파라미터로 주입한다. 필요하면 fake state를 만들고 이벤트는 빈 람다로 둔다.

5) LazyColumn 안에서 viewModel()을 호출해 범위가 꼬인다

증상: 스크롤할 때 ViewModel init이 반복되거나, 아이템마다 같은 ViewModel을 공유해 의도치 않은 상태 공유가 생긴다.

원인: 아이템은 composition에서 자주 들어왔다 나간다. viewModel()을 아이템에서 호출하면 Owner 범위가 생각보다 작거나, key가 item마다 달라져 인스턴스가 폭증한다.

해결: 리스트 상단 Route에서 필요한 ViewModel을 한 번만 주입한다. 아이템에는 UI state 조각과 이벤트만 전달한다. item 단위 상태가 필요하면 rememberSaveable이나 별도 state holder를 쓴다.

WrongList.kt
1package com.example.vm
2
3import androidx.compose.foundation.lazy.LazyColumn
4import androidx.compose.foundation.lazy.items
5import androidx.compose.material3.Text
6import androidx.compose.runtime.Composable
7import androidx.lifecycle.viewmodel.compose.viewModel
8
9@Composable
10fun WrongList() {
11    LazyColumn {
12        items((0 until 100).toList()) { i ->
13            // 위험: item 수명과 ViewModel 수명이 섞인다
14            val vm: OwnerProbeViewModel = viewModel(key = "item-$i")
15            Text("$i vm=${vm.hashCode()}")
16        }
17    }
18}

이 코드는 item 100개에 대해 ViewModel 100개를 만들 수 있다. 스크롤로 item이 재구성되면 key가 유지되지 않는 상황까지 겹쳐 디버깅이 어려워진다. 리스트 화면은 보통 하나의 ViewModel이 데이터를 들고 있고, 아이템은 그 데이터 일부를 그리는 구조가 안정적이다.

성능 최적화 체크리스트

  • viewModel()을 호출하는 위치가 Route(화면 진입점)인지 확인한다. LazyColumn item, 작은 재사용 컴포넌트에서 호출하지 않는다.
  • Owner 범위를 로그로 검증한다. ViewModel init에서 hashCode를 찍고 화면 이동/뒤로가기/회전에서 유지 범위를 직접 확인한다.
  • Navigation을 쓴다면 destination 범위인지 graph 범위인지 명시한다. 공유가 필요하면 navController.getBackStackEntry("graph")를 Owner로 전달한다.
  • 같은 타입 ViewModel을 여러 개 쓰면 key를 안정적으로 고정한다. recomposition마다 바뀌는 값(시간, 랜덤)을 key로 쓰지 않는다.
  • ViewModel이 노출하는 UI state가 Compose가 관찰 가능한 형태(StateFlow/LiveData/mutableStateOf)인지 확인한다.
  • collectAsState를 화면 루트에서 무조건 읽지 않는다. 상태 읽기 위치를 좁혀 recomposition 범위를 줄인다.
  • 람다 캡처로 인해 오래된 콜백이 실행되는지 점검한다. 디바운스/게이트 같은 객체를 remember로 고정했다면 rememberUpdatedState로 최신 람다를 참조한다.
  • Hilt 사용 시 Application(@HiltAndroidApp)과 Activity/Fragment(@AndroidEntryPoint) 어노테이션 누락을 먼저 의심한다.
  • Preview/screenshot test를 위해 Route/Content를 분리한다. Content는 ViewModel이 아니라 state와 이벤트를 파라미터로 받게 한다.
  • 프로세스 죽음 복원이 필요하면 SavedStateHandle을 사용하고, key/route argument가 CreationExtras로 전달되는지 확인한다.
  • Layout Inspector에서 Semantics 트리와 recomposition 카운트를 함께 본다. ‘보이는 트리’와 ‘다시 호출되는 범위’가 다를 수 있다.
  • 불필요한 객체 할당을 찾는다. recomposition마다 생성되는 InteractionSource, 리스트 변환(map), formatter 등을 remember로 고정한다.

자주 묻는 질문

viewModel()은 remember랑 뭐가 다른가? 둘 다 캐시처럼 보인다

remember는 Slot Table에 값을 저장해 같은 composition 위치에서 recomposition 동안 재사용하게 만든다. composition이 dispose되면 값도 같이 사라진다. 반면 viewModel()은 CompositionLocal로부터 ViewModelStoreOwner를 얻고, 그 Owner가 가진 ViewModelStore에서 인스턴스를 조회한다. 캐시 위치가 Slot Table이 아니라 Lifecycle 저장소라서, recomposition과 무관하게 유지된다. 확인 방법은 ViewModel init 로그를 찍고 recomposition을 100번 유도해도 init이 1번인지 보는 것이다. 학습 키워드는 Slot Table, CompositionLocal, ViewModelStoreOwner이다.

hiltViewModel()은 viewModel()이랑 내부적으로 뭐가 다른가

둘 다 ViewModelProvider를 통해 인스턴스를 얻는 구조는 같다. 차이는 Factory 선택이다. viewModel()은 기본 Factory(또는 전달된 factory)를 사용하고, hiltViewModel()은 Hilt가 제공하는 ViewModelFactory를 선택해 @HiltViewModel + @Inject 생성자를 호출할 수 있게 만든다. 그래서 Repository 같은 의존성을 생성자에 넣을 수 있고 SavedStateHandle도 함께 주입된다. 크래시가 나면 대개 @AndroidEntryPoint 누락이나 gradle의 hilt 컴파일러 설정 문제다. 학습 키워드는 ViewModelProvider.Factory, HiltViewModelFactory, CreationExtras이다.

NavHost 안에서 두 화면이 같은 ViewModel을 공유하려면 왜 Owner를 넘겨야 하나

Navigation Compose는 destination마다 LocalViewModelStoreOwner를 NavBackStackEntry로 바꾼다. 그래서 각 화면에서 viewModel()/hiltViewModel()을 그냥 호출하면 “각 destination 엔트리”에 저장된 서로 다른 ViewModel을 얻게 된다. 공유를 원하면 같은 저장소를 바라보게 해야 하므로, 그래프 엔트리(navController.getBackStackEntry("graph"))를 Owner로 전달해 동일 ViewModelStore를 사용하게 만든다. 로그로 vm hashCode를 찍어 동일한지 확인하는 방식이 가장 빠르다. 학습 키워드는 NavBackStackEntry, graph-scoped ViewModel이다.

Compose는 recomposition이 자주 일어나는데 viewModel() 호출 비용은 괜찮나

viewModel()은 recomposition마다 호출될 수 있지만, 대부분은 Owner 조회(CompositionLocal 읽기)와 ViewModelStore에서 Map 조회 수준이다. 실제 비용을 키우는 쪽은 viewModel() 자체보다, 그 ViewModel에서 꺼낸 상태를 화면 루트에서 넓게 읽어 recomposition 범위를 크게 만드는 설계다. 측정은 Layout Inspector의 recomposition count, 그리고 Log.d로 특정 Composable 호출 빈도를 찍어 보는 방식이 현실적이다. 학습 키워드는 invalidation 범위, state read 위치, skip group이다.

왜 ViewModel에 Int 필드만 두면 UI가 안 바뀌나. ViewModel이면 알아서 되는 줄 알았다

ViewModel은 생명주기 범위에서 객체를 유지해 주지만, Compose에 ‘무엇이 바뀌었는지’ 신호를 주지 않는다. Compose는 snapshot state(State<T>)를 읽은 위치를 추적하고, 그 state가 바뀔 때만 해당 위치부터 recomposition을 건다. 그래서 ViewModel은 StateFlow/LiveData/mutableStateOf로 UI state를 노출해야 하고, Composable은 collectAsState/observeAsState로 읽어야 한다. 이 구성이 갖춰지면 버튼 클릭 → ViewModel state 변경 → 읽기 위치 invalidation → 다음 프레임 recomposition 흐름이 만들어진다. 학습 키워드는 Snapshot, StateFlow, collectAsState이다.

Preview에서 hiltViewModel()을 쓰고 싶은데 계속 실패한다

Preview는 실제 Activity도, Hilt 컨테이너도, NavBackStackEntry도 없다. 그래서 LocalViewModelStoreOwner가 null이거나 Hilt Factory가 준비되지 않아 실패한다. 해결은 Route/Content 분리다. Route에서 hiltViewModel()로 주입하고, Content는 (state, onEvent)만 받게 만든다. Preview는 Content에 fake state를 넣고 onEvent는 빈 람다로 둔다. ViewModel 로직을 Preview에서까지 돌리고 싶으면 별도 테스트(Compose UI test)에서 HiltTestRule로 컨테이너를 띄우는 편이 맞다. 학습 키워드는 Preview limitations, dependency inversion이다.

같은 화면에서 같은 타입 ViewModel을 두 개 쓰고 싶다. key를 왜 줘야 하나

ViewModelStore는 (클래스 + key) 조합으로 인스턴스를 저장한다. key가 없으면 기본 key(클래스명 기반)로 하나만 존재한다. 같은 타입을 두 개 쓰려면 key로 구분해 저장소에서 다른 엔트리를 만들게 해야 한다. 다만 key를 recomposition마다 바뀌는 값으로 만들면 매번 새 ViewModel을 만들게 된다. 탭 id, 문서 id처럼 안정적인 식별자를 key로 쓰고, 화면이 pop될 때 해당 Owner의 ViewModelStore가 정리되는지도 함께 고려해야 한다. 학습 키워드는 ViewModel key, store entry, lifecycle scope이다.

관련 글

4. Compose에서 DI 주입 누락·Scope 오류 크래시를 원인까지 추적하는 법
Compose 기본2026.02.16

4. Compose에서 DI 주입 누락·Scope 오류 크래시를 원인까지 추적하는 법

Compose에서 DI 주입 누락·Scope 불일치로 터지는 크래시를 재현하고, Composition/SlotTable/Recomposition 관점에서 원인을 추적해 고치는 흐름을 다룬다. Hilt 예제로 실습 포함. 150자 내외 구성이다. 150자

14. Compose에서 ViewModel 역할: UI State와 비즈니스 로직을 분리하는 이유
Compose 기본2026.03.02

14. Compose에서 ViewModel 역할: UI State와 비즈니스 로직을 분리하는 이유

Jetpack Compose에서 ViewModel이 왜 필요한지, 상태(State) 소유와 이벤트 처리, recomposition·Slot Table 관점에서 UI/로직 분리를 이해한다. 성능 함정도 함께 다룬다. 2025-03-01 기준 작성. (150자)

20. Compose 기초: Composable 함수와 재구성(Recomposition)이 일어나는 이유
Compose 기본2026.03.04

20. Compose 기초: Composable 함수와 재구성(Recomposition)이 일어나는 이유

Composable 함수가 왜 순수 함수처럼 보이지만 상태를 가진 UI로 동작하는지, Slot Table과 Recomposition 비교 로직까지 내부 관점으로 설명한다. remember와 Stable 설계 이유 포함. (154자)​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​