Compose 기본2026년 02월 16일· 9 min read

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

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

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

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

Compose를 처음 붙이면 DI 크래시가 가장 억울하게 터진다. 화면은 그려지다가 갑자기 IllegalStateException: ViewModelStoreOwner not found, 혹은 @AndroidEntryPoint 누락 같은 메시지로 죽는다. 더 헷갈리는 지점은 “왜 어떤 화면은 되고 어떤 화면은 안 되지?”이다. 답은 UI 코드가 아니라 Owner/Scope가 언제 어떤 트리로 공급되는지에 있다. 이 글은 크래시를 재현하고, Compose Runtime이 호출을 쌓는 방식과 SlotTable에 남는 흔적까지 따라가며 원인을 분해한다.

핵심 개념

Compose에서 DI 크래시는 대개 “주입 객체가 없어서”가 아니라 “주입을 해석할 Owner/Scope가 현재 Composition에 존재하지 않아서” 터진다. View 시스템에서는 Fragment/Activity가 트리의 뿌리이고, DI 컨테이너도 그 생명주기에 붙는다. Compose는 함수 호출로 UI 트리를 만들기 때문에, 같은 코드라도 어떤 CompositionLocal(Owner 제공자) 아래에서 호출되느냐에 따라 DI 해석 결과가 달라진다.

용어를 실전 맥락으로 잡는다. Owner는 ViewModelStoreOwner, SavedStateRegistryOwner 같은 “저장소를 제공하는 객체”이고, Scope는 그 Owner의 생명주기 범위(ActivityRetained, Activity, NavBackStackEntry, Fragment 등)이다. 주입 누락은 Owner가 없거나 잘못된 Owner를 참조하는 상황이고, 범위 오류는 Owner는 있는데 기대한 Scope가 아니라서 인스턴스가 공유되거나(의도치 않은 싱글톤처럼) 반대로 매번 새로 만들어지는 문제로 나타난다.

Compose Runtime 관점에서 중요한 포인트는 두 가지다. (1) CompositionLocal은 SlotTable에 “현재 위치의 값”으로 기록되고, 자식 호출은 그 값을 암묵적으로 상속받는다. (2) recomposition은 SlotTable의 위치를 기준으로 동일한 호출 지점을 찾아 다시 실행한다. 즉, DI를 해석하는 호출 지점이 recomposition 중에 다른 Owner 아래로 이동하면, 같은 코드라도 다른 Scope로 주입된다.

처음에 나도 “hiltViewModel()은 그냥 ViewModel을 꺼내는 함수” 정도로만 생각했다. 실제로는 LocalViewModelStoreOwner, LocalSavedStateRegistryOwner 같은 CompositionLocal을 읽고, 그 Owner에 연결된 ViewModelProvider를 통해 인스턴스를 만든다. 그래서 Owner가 null이면 바로 크래시가 난다. 이 설계는 Compose가 View/Fragment에 종속되지 않도록 만들기 위한 선택이고, 대신 “Owner를 Composition에 공급하는 책임”이 호출자에게 넘어온다.

OwnerProbe.kt
1package com.example.diintro
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.remember
5import androidx.lifecycle.ViewModel
6import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
7import androidx.lifecycle.viewmodel.compose.viewModel
8
9class CounterVm : ViewModel() {
10    var createdAt: Long = System.currentTimeMillis()
11}
12
13@Composable
14fun OwnerProbe(tag: String) {
15    val owner = LocalViewModelStoreOwner.current
16    val vm: CounterVm = viewModel() // owner가 없으면 여기서 예외가 난다
17    val stamp = remember { vm.createdAt }
18    // 실행하면 tag, owner 유무, stamp를 로그로 찍는 식으로 확인 가능하다
19    // (예제에서는 UI 출력 대신 구조만 보여준다)
20}

이 코드를 Navigation Compose 내부(정상 Owner 제공)에서 호출하면 vm이 만들어지고 stamp가 고정된다. 같은 코드를 Preview나 ComposeView를 잘못 붙인 Activity에서 호출하면 LocalViewModelStoreOwner.current가 null이어서 viewModel()이 즉시 예외를 던진다. “DI가 안 된다”가 아니라 “Owner가 없다”가 원인인 케이스다.

컴포넌트 해부

DI 크래시를 자주 일으키는 컴포넌트는 대체로 세 부류다. (1) hiltViewModel()/viewModel()을 호출하는 Composable, (2) CompositionLocal로 주입값을 읽는 Composable, (3) NavHost/ComposeView처럼 Owner를 공급하거나 끊어먹는 컨테이너다. “어떤 파라미터가 왜 필요하냐”는 질문은 결국 Owner 전달 경로를 추적하는 일로 귀결된다.

  • LocalContext: Android 서비스 접근용이지만 ViewModelOwner를 대체하지 못한다
  • LocalLifecycleOwner: lifecycle 범위 제공, rememberCoroutineScope와 side effect 취소 타이밍에 영향
  • LocalViewModelStoreOwner: viewModel()/hiltViewModel()의 핵심 입력
  • LocalSavedStateRegistryOwner: SavedStateHandle 복원에 필요, navigation과 결합되면 BackStackEntry가 담당
  • NavBackStackEntry: navigation scope의 실체, 같은 route라도 entry가 다르면 ViewModel이 달라진다
  • @AndroidEntryPoint: Hilt가 Activity/Fragment에 컴포넌트를 붙이는 트리거
  • HiltViewModel: ViewModelFactory가 Hilt 그래프에서 생성되도록 하는 계약
  • remember: recomposition 동안 동일 인스턴스 유지, DI 객체를 직접 remember하면 스코프를 깨기 쉽다
  • key()/remember(key): 슬롯 위치가 바뀔 때 상태를 분리하거나 재생성하기 위한 장치
  • CompositionLocalProvider: DI 대체 수단이지만 범위 설계를 잘못하면 누수/중복 생성이 난다
  • DisposableEffect: scope 종료 시 정리 필요할 때 사용, DI 객체의 close 호출 타이밍을 제어
  • LaunchedEffect: owner/route 변경 시 코루틴 재시작, 잘못 걸면 중복 요청이 난다

Surface 계층 관점에서 보면, DI는 UI와 무관한데도 Surface가 Owner를 끊는 경우가 있다. 예를 들어 Dialog, Popup, AndroidView(ComposeView 포함)는 별도의 window/뷰 트리를 만들 수 있고, 이때 ViewTree*Owner가 전달되지 않으면 LocalViewModelStoreOwner가 null이 된다. UI는 잘 뜨는데 특정 버튼을 눌러 다이얼로그를 띄우는 순간 hiltViewModel()이 터지는 패턴이 여기서 나온다.

Content 계층은 더 단순하다. Content는 부모가 공급한 Owner/Local을 읽어서 동작한다. 그래서 Content가 “어디서 호출되느냐”가 전부다. 같은 Content라도 NavHost 안에서 호출되면 NavBackStackEntry가 Owner가 되고, Activity의 setContent 최상단에서 호출되면 Activity가 Owner가 된다. 기대한 스코프가 무엇인지(화면 단위인지, 그래프 단위인지, 앱 전역인지)를 먼저 고정해야 한다.

파라미터를 안 쓰면 어떻게 되나도 DI에서 자주 터진다. 예를 들어 hiltViewModel()에 명시적으로 backStackEntry를 넘기지 않으면, 내부는 LocalViewModelStoreOwner에 의존한다. 이 Local이 예상과 다르게 Activity를 가리키면 화면 간 ViewModel이 공유돼 “이전 화면 상태가 다음 화면에 튄다” 같은 버그가 생긴다. 반대로 Local이 null이면 즉시 크래시다.

LocalGraphSketch.kt
1package com.example.dianatomy
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.CompositionLocalProvider
5import androidx.compose.runtime.staticCompositionLocalOf
6import androidx.lifecycle.ViewModel
7
8// 교육용: DI 컨테이너를 CompositionLocal로 흉내 낸다
9interface AppGraph {
10    fun provideAnalytics(): Analytics
11}
12
13class Analytics {
14    fun log(event: String) { /* no-op */ }
15}
16
17val LocalAppGraph = staticCompositionLocalOf<AppGraph> {
18    error("AppGraph not provided")
19}
20
21@Composable
22fun AppSurface(graph: AppGraph, content: @Composable () -> Unit) {
23    // Surface 계층: 그래프를 공급하는 위치가 스코프의 시작점이다
24    CompositionLocalProvider(LocalAppGraph provides graph) {
25        content()
26    }
27}
28
29@Composable
30fun ScreenContent() {
31    // Content 계층: 여기서는 읽기만 한다. 공급이 끊기면 즉시 크래시다
32    val analytics = LocalAppGraph.current.provideAnalytics()
33    analytics.log("screen_impression")
34}

LocalAppGraph.current가 error("AppGraph not provided")를 던지는 순간이 “주입 누락”의 본질이다. Hilt도 결국은 비슷한 형태로 컴포넌트 트리에서 엔트리를 찾는다. 차이는 Hilt는 Activity/Fragment/Navigation owner에 붙은 컴포넌트를 통해 그래프를 찾고, Compose는 그 owner를 CompositionLocal로 전달받는다는 점이다.

DialogOwnerPitfall.kt
1package com.example.dianatomy
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.mutableStateOf
8import androidx.compose.runtime.remember
9import androidx.compose.ui.window.Dialog
10
11@Composable
12fun DialogThatCrashesIfNoOwner() {
13    val open = remember { mutableStateOf(false) }
14    Column {
15        Button(onClick = { open.value = true }) { Text("Open") }
16        if (open.value) {
17            Dialog(onDismissRequest = { open.value = false }) {
18                // 여기에서 hiltViewModel()/viewModel()을 호출하면
19                // Owner 전달이 끊긴 환경에서는 즉시 크래시가 난다
20                Text("Dialog content")
21            }
22        }
23    }
24}

이 코드는 UI만 보면 정상이다. 문제는 Dialog 내부가 별도 window로 뜨면서 ViewTreeOwner 전달이 달라질 수 있다는 점이다. 실제 앱에서 이 패턴에 hiltViewModel()이 들어가면 “버튼 누를 때만” 죽는다. 재현이 불규칙해 보여도 원인은 Owner 경로가 끊긴 지점 하나로 수렴한다.

내부 동작 원리

Compose의 실행 단계는 Composition → Layout → Drawing이다. DI 크래시는 거의 항상 Composition 단계에서 터진다. 이유는 주입 함수(hiltViewModel, viewModel, LocalX.current)가 Composable 호출 중에 실행되고, Owner/Local을 읽는 순간 예외를 던지기 때문이다. Layout/Drawing까지 가지도 못한다.

Composition 단계에서 Compose Runtime은 Composable 호출을 트리로 기록한다. 각 호출 지점은 SlotTable에 그룹으로 저장되고, 그룹 안에는 remember 값, CompositionLocal 변경, 파라미터 비교 결과가 들어간다. recomposition은 “이 호출 지점의 파라미터가 바뀌었나?”를 보고 해당 그룹만 다시 실행한다. DI가 얽히면 “호출 지점은 같은데 Owner가 바뀌는” 상황이 위험하다.

Compose Compiler는 Composable을 변환하면서 Composer 파라미터와 changed 플래그를 추가한다. 개념적으로는 fun Screen(x) 가 fun Screen(x, composer, changed)로 바뀌고, composer.startGroup/endGroup 사이에서 remember 슬롯을 읽고 쓴다. 그래서 remember로 캐시한 값은 “호출 위치”에 귀속된다. DI 객체를 remember로 잡아두면 호출 위치가 유지되는 동안 스코프를 무시하고 계속 살아남는 것처럼 보일 수 있다.

SlotTable 관점에서 Local은 더 미묘하다. CompositionLocalProvider는 SlotTable에 ‘현재 Local 값 스택’을 푸시한다. 자식 그룹은 그 스택을 상속받는다. recomposition 시 Provider가 제거되거나 위치가 바뀌면, 자식이 읽는 Local 값이 달라진다. 그래서 “조건문 안에서 Provider를 켰다/껐다” 같은 코드는 DI에 치명적이다. 화면이 토글될 때마다 주입 스코프가 바뀌고, ViewModel이 재생성되거나 반대로 잘못 공유된다.

내가 3시간 삽질했던 케이스가 이거다. 에러는 IllegalStateException: ViewModelStoreOwner is not provided via LocalViewModelStoreOwner. 로그를 보면 어떤 recomposition 이후에만 터졌다. 원인은 loading 상태일 때는 NavHost 밖에서 임시 화면을 그렸고, loading이 끝나면 NavHost 안으로 들어갔다. 같은 Screen() 호출이 서로 다른 Owner 아래에서 실행되면서 viewModel()이 null owner를 만난 타이밍이 있었다.

한 문단 요약: Compose DI 크래시는 ‘주입 함수가 읽는 Owner/Local이 현재 Composition에 존재하는가’ 문제다. SlotTable은 호출 위치를 기준으로 상태를 묶고, CompositionLocal은 그 위치에 값 스택을 남긴다. Provider/Owner 경계가 조건문·Dialog·ComposeView에서 끊기면, 같은 코드라도 다른 스코프로 평가되거나 즉시 예외가 난다.

recomposition 비교는 파라미터의 안정성(Stable/Immutable)에 크게 좌우된다. unstable 타입을 파라미터로 넘기면 Compose는 매번 changed로 판단해 그룹을 자주 다시 실행한다. DI 자체는 보통 싱글 호출이지만, 그 안에서 side effect(네트워크 요청, analytics)를 걸어두면 재실행이 곧 중복 호출이 된다. 그래서 DI와 성능은 분리된 주제가 아니라 한 덩어리다.

RecomposeTrace.kt
1package com.example.dimechanism
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.SideEffect
8import androidx.compose.runtime.mutableStateOf
9import androidx.compose.runtime.remember
10import androidx.compose.runtime.getValue
11import androidx.compose.runtime.setValue
12
13@Composable
14fun RecomposeTrace() {
15    var count by remember { mutableStateOf(0) }
16    SideEffect {
17        // 실행하면 버튼 클릭마다 이 로그가 다시 찍힌다
18        android.util.Log.d("RecomposeTrace", "recomposed count=$count")
19    }
20
21    Column {
22        Text("count=$count")
23        Button(onClick = { count++ }) { Text("Inc") }
24    }
25}

SideEffect는 composition이 성공적으로 적용된 뒤 매번 실행된다. 여기서 중요한 관찰 포인트는 “count를 읽는 그룹만” 다시 호출되며, 로그가 그만큼 반복된다는 점이다. DI 객체 생성이나 scope 조회를 SideEffect/LaunchedEffect에 섞으면, recomposition 횟수만큼 비용이 늘어난다.

ModifierSemanticsInteraction.kt
1package com.example.dimechanism
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 ModifierOrderMatters(onClick: () -> Unit) {
16    val interaction = remember { MutableInteractionSource() }
17
18    Text(
19        text = "Tap",
20        modifier = Modifier
21            .semantics { contentDescription = "tap-target" }
22            .padding(16.dp)
23            .clickable(
24                interactionSource = interaction,
25                indication = null,
26                onClick = onClick
27            )
28    )
29}

Modifier 체인은 SlotTable이 아니라 노드 체인으로 쌓인다. semantics가 clickable 앞에 있으면 접근성 노드가 padding 포함 영역에 붙고, 반대로 두면 클릭 영역/설명 범위가 달라진다. DI와 직접 관련 없어 보이지만, 실제 디버깅에서는 “어떤 트리에서 호출되었나”를 Layout Inspector로 확인할 때 semantics가 힌트가 된다. 특정 subtree가 Dialog/Popup로 분리됐는지도 여기서 드러난다.

실습하기

실습 목표는 두 가지다. (1) 주입 누락 크래시를 100% 재현한다. (2) 범위 오류(원치 않는 공유/재생성)를 로그로 확인한다. 재현이 되면 해결도 단순해진다. 재현이 안 되는 DI 버그는 고쳐도 다시 돌아온다.

환경은 Hilt + Navigation Compose 조합이 가장 흔하니 그 기준으로 잡는다. 단, 공식 샘플을 길게 복사하지 않고 “왜 여기서 Owner가 생기고 사라지는지”만 남기는 형태로 구성한다.

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.6")
12    implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
13    implementation("com.google.dagger:hilt-android:2.51")
14    kapt("com.google.dagger:hilt-android-compiler:2.51")
15}

이 의존성에서 핵심은 hilt-navigation-compose다. 이 모듈이 NavBackStackEntry를 Owner로 연결해 주입 스코프를 화면 단위로 묶는다. 이게 빠지면 hiltViewModel()이 동작은 해도 기대한 스코프가 깨질 수 있다.

1단계: 주입 누락 크래시를 의도적으로 만든다

가장 흔한 누락은 @AndroidEntryPoint를 빼먹는 경우다. Activity가 Hilt 컴포넌트를 만들지 못하면, 내부적으로 EntryPoint 접근 시점에 예외가 난다. 실행하면 앱이 뜨자마자 죽거나, 특정 화면 진입 순간 죽는다.

실행 후 Logcat에서 “Hilt”로 필터링하면 원인을 바로 볼 수 있다. 메시지 예시는 ‘... does not implement GeneratedComponent’ 계열이다. 이 메시지는 “주입 그래프가 없다”를 의미한다.

App.kt
1package com.example.practice
2
3import android.app.Application
4import dagger.hilt.android.HiltAndroidApp
5
6@HiltAndroidApp
7class App : Application() {
8    // empty
9}

Application에 @HiltAndroidApp이 없으면 앱 시작 시점부터 그래프가 없다. 이 상태에서 어떤 @AndroidEntryPoint도 의미가 없어진다. 주입 누락을 디버깅할 때는 Activity보다 먼저 Application을 확인하는 이유가 여기 있다.

MainActivity.kt
1package com.example.practice
2
3import android.os.Bundle
4import androidx.activity.ComponentActivity
5import androidx.activity.compose.setContent
6import androidx.compose.material3.MaterialTheme
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.ui.tooling.preview.Preview
10import dagger.hilt.android.AndroidEntryPoint
11
12@AndroidEntryPoint
13class MainActivity : ComponentActivity() {
14    override fun onCreate(savedInstanceState: Bundle?) {
15        super.onCreate(savedInstanceState)
16        setContent { MaterialTheme { AppRoot() } }
17    }
18}
19
20@Composable
21fun AppRoot() {
22    Text("AppRoot")
23}
24
25@Preview
26@Composable
27private fun PreviewAppRoot() {
28    MaterialTheme { AppRoot() }
29}

여기서 @AndroidEntryPoint를 지우고 실행하면, 이후 단계에서 hiltViewModel()을 호출하는 순간 크래시가 난다. “왜 Activity에 어노테이션이 필요하냐”는 질문의 답은 간단하다. Hilt는 Activity에 컴포넌트를 붙여야 ViewModelFactory/EntryPoint를 제공할 수 있고, Compose는 그 Activity가 제공하는 Owner를 CompositionLocal로 전달받기 때문이다.

2단계: 범위 오류를 로그로 확인한다

범위 오류는 크래시보다 더 자주 발생한다. 화면 A와 B가 서로 다른 ViewModel이어야 하는데 같은 인스턴스를 공유하거나, 반대로 같은 화면인데도 recomposition/route 변화 때마다 새로 만들어진다. 실행하면 createdAt 타임스탬프가 의도와 다르게 바뀌는 것으로 확인한다.

NavHost에서 각 route의 NavBackStackEntry가 ViewModelStoreOwner가 된다. 그래서 같은 route라도 entry가 새로 생기면 ViewModel도 새로 생긴다. popUpTo 설정이나 singleTop 설정이 여기와 직결된다.

StampScreen.kt
1package com.example.practice
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.material3.Button
5import androidx.compose.material3.Text
6import androidx.compose.runtime.Composable
7import androidx.hilt.navigation.compose.hiltViewModel
8import androidx.lifecycle.ViewModel
9import dagger.hilt.android.lifecycle.HiltViewModel
10import javax.inject.Inject
11
12@HiltViewModel
13class StampVm @Inject constructor() : ViewModel() {
14    val createdAt: Long = System.currentTimeMillis()
15}
16
17@Composable
18fun StampScreen(
19    label: String,
20    onNext: () -> Unit,
21    vm: StampVm = hiltViewModel()
22) {
23    Column {
24        Text("$label createdAt=${vm.createdAt}")
25        Button(onClick = onNext) { Text("Next") }
26    }
27}

이 화면을 두 route에 붙이고 왕복하면, createdAt이 route별로 분리되는지 확인 가능하다. 분리가 안 되면 Owner가 Activity로 올라가 공유되고 있을 확률이 높다. 분리가 과하게 되면(같은 route인데 계속 바뀜) entry가 계속 새로 만들어지는 네비게이션 설정 문제일 가능성이 높다.

NavApp.kt
1package com.example.practice
2
3import androidx.compose.runtime.Composable
4import androidx.navigation.NavHostController
5import androidx.navigation.compose.NavHost
6import androidx.navigation.compose.composable
7import androidx.navigation.compose.rememberNavController
8
9@Composable
10fun NavApp(navController: NavHostController = rememberNavController()) {
11    NavHost(navController = navController, startDestination = "a") {
12        composable("a") {
13            StampScreen(label = "A") { navController.navigate("b") }
14        }
15        composable("b") {
16            StampScreen(label = "B") { navController.popBackStack() }
17        }
18    }
19}

실행하면 A에서 Next로 B로 이동한다. A와 B의 createdAt이 같으면 스코프가 새지 않은지 의심해야 한다. 반대로 A로 돌아왔을 때 createdAt이 바뀌면 BackStackEntry가 유지되지 않았거나, popUpTo로 entry를 날리고 있는지 확인한다.

3단계: Dialog/ComposeView에서 Owner 끊김을 재현하고 고친다

현업에서 가장 많이 터지는 건 Dialog, BottomSheet, AndroidView(특히 ComposeView) 경계다. 실행하면 평소에는 정상인데, 다이얼로그를 띄우는 순간 viewModel()이 owner를 못 찾아 죽는다. 이 패턴은 “UI 트리가 분리되었는데 Owner 전달을 안 했다”로 요약된다.

고치는 방법은 두 갈래다. (1) 가능한 한 DI 호출을 분리된 subtree 밖으로 끌어올린다. (2) 정말 필요하면 적절한 Owner를 명시적으로 전달한다. 후자는 실수하면 스코프가 꼬이기 쉬워서, 먼저 1번을 기본으로 둔다.

SafeDialogPattern.kt
1package com.example.practice
2
3import androidx.compose.material3.Button
4import androidx.compose.material3.Text
5import androidx.compose.runtime.Composable
6import androidx.compose.runtime.mutableStateOf
7import androidx.compose.runtime.remember
8import androidx.compose.ui.window.Dialog
9import androidx.hilt.navigation.compose.hiltViewModel
10
11@Composable
12fun SafeDialogPattern() {
13    // DI 호출을 Dialog 밖에서 끝낸다
14    val vm: StampVm = hiltViewModel()
15    val open = remember { mutableStateOf(false) }
16
17    Button(onClick = { open.value = true }) {
18        Text("Open dialog createdAt=${vm.createdAt}")
19    }
20
21    if (open.value) {
22        Dialog(onDismissRequest = { open.value = false }) {
23            Text("Dialog uses existing vm createdAt=${vm.createdAt}")
24        }
25    }
26}

실행하면 Dialog 내부에서도 createdAt이 동일하게 유지된다. 여기서 중요한 점은 “Dialog가 어떤 Owner를 가지든 상관없게 만든다”는 것이다. DI 호출을 경계 밖으로 끌어올리면 Owner 끊김이 크래시로 이어질 여지가 크게 줄어든다.

심화: Advanced 버전 만들기

실무에서는 “주입이 된다/안 된다”를 넘어서, 스코프를 의도적으로 설계해야 한다. 화면 단위 ViewModel, 그래프 단위 ViewModel, 앱 전역 싱글톤이 섞인다. Compose는 호출 위치 기반이라서, 스코프가 UI 구조 변경에 휘둘리기 쉽다. 그래서 스코프 경계를 UI 컴포넌트가 아니라 네비게이션/호스트 레이어에서 고정하는 편이 사고가 적다.

사례 1: 그래프 단위 ViewModel을 공유해야 하는데 화면 단위로 쪼개지는 문제

결제 플로우처럼 A→B→C 화면이 하나의 상태 머신을 공유해야 할 때가 있다. 각 화면에서 hiltViewModel()을 그냥 호출하면 NavBackStackEntry 기준으로 화면마다 ViewModel이 생긴다. 실행하면 A에서 입력한 값이 B에서 사라지고, C에서 다시 null이 된다.

해결은 ‘부모 그래프의 BackStackEntry’를 Owner로 쓰는 방식이다. Navigation Compose에서는 중첩 그래프 route를 잡고, 그 entry를 기준으로 ViewModel을 만든다. 이렇게 하면 화면이 바뀌어도 같은 entry가 유지되는 동안 하나의 ViewModel이 공유된다.

GraphScopedVm.kt
1package com.example.advanced
2
3import androidx.compose.runtime.Composable
4import androidx.hilt.navigation.compose.hiltViewModel
5import androidx.navigation.NavBackStackEntry
6import androidx.navigation.NavHostController
7
8@Composable
9inline fun <reified VM : androidx.lifecycle.ViewModel> graphScopedHiltViewModel(
10    navController: NavHostController,
11    parentRoute: String,
12    currentEntry: NavBackStackEntry
13): VM {
14    // educational sketch: 핵심은 parentEntry를 owner로 삼는다는 점이다
15    val parentEntry = navController.getBackStackEntry(parentRoute)
16    return hiltViewModel(parentEntry)
17}

이 함수는 “왜 owner를 명시해야 하나”를 그대로 보여준다. hiltViewModel()은 기본적으로 currentEntry(LocalViewModelStoreOwner)를 owner로 사용한다. parentEntry를 넘기면 스코프가 부모 그래프로 올라가고, 화면 이동 중에도 인스턴스가 유지된다. 반대로 parentRoute를 잘못 주면 IllegalArgumentException으로 즉시 죽어서, 디버깅 포인트가 명확해진다.

사례 2: DI 객체를 remember로 캐시해서 스코프를 깨뜨린 문제

내 흑역사 하나가 이거다. 네트워크 레이어를 Hilt로 주입받아야 했는데, Composable 안에서 remember { Service(...) }로 만들어버렸다. 처음엔 잘 되는 것처럼 보였고, 심지어 화면 전환에서도 살아남았다. 몇 시간 뒤 메모리 릭과 중복 요청이 같이 터졌다.

증상은 두 가지였다. (1) 화면을 닫아도 OkHttp connection pool이 유지되며 요청이 계속 살아 있었다. (2) recomposition이 잦은 화면에서 remember 위치가 바뀌는 순간 서비스가 새로 만들어져 토큰 갱신이 꼬였다. 원인은 remember가 DI 스코프가 아니라 SlotTable 위치에 귀속된다는 점을 무시한 것이다.

교정은 “Composable은 객체를 만들지 말고, 주입된 객체를 사용만 한다”로 정리된다. 정말 UI 범위 캐시가 필요하면 remember는 값(계산 결과) 수준에서만 쓰고, 수명/정리는 ViewModel이나 DI 스코프에 맡긴다.

AdvancedButton.kt
1package com.example.advanced
2
3import androidx.compose.foundation.layout.Row
4import androidx.compose.material3.CircularProgressIndicator
5import androidx.compose.material3.Icon
6import androidx.compose.material3.IconButton
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.runtime.getValue
10import androidx.compose.runtime.mutableLongStateOf
11import androidx.compose.runtime.mutableStateOf
12import androidx.compose.runtime.remember
13import androidx.compose.runtime.setValue
14import androidx.compose.ui.Modifier
15import androidx.compose.ui.semantics.contentDescription
16import androidx.compose.ui.semantics.semantics
17import androidx.compose.ui.unit.dp
18import kotlinx.coroutines.CoroutineScope
19import kotlinx.coroutines.launch
20
21@Composable
22fun AdvancedButton(
23    text: String,
24    modifier: Modifier = Modifier,
25    enabled: Boolean = true,
26    loading: Boolean = false,
27    debounceMs: Long = 600L,
28    a11yLabel: String = text,
29    icon: @Composable (() -> Unit)? = null,
30    onLongPress: (() -> Unit)? = null,
31    scope: CoroutineScope,
32    onClick: suspend () -> Unit
33) {
34    var lastClickAt by remember { mutableLongStateOf(0L) }
35    var internalEnabled by remember { mutableStateOf(true) }
36
37    val clickableEnabled = enabled && internalEnabled && !loading
38
39    IconButton(
40        onClick = {
41            val now = System.currentTimeMillis()
42            if (now - lastClickAt < debounceMs) return@IconButton
43            lastClickAt = now
44            internalEnabled = false
45            scope.launch {
46                try { onClick() } finally { internalEnabled = true }
47            }
48        },
49        enabled = clickableEnabled,
50        modifier = modifier.semantics { contentDescription = a11yLabel }
51    ) {
52        Row {
53            if (loading) {
54                CircularProgressIndicator(strokeWidth = 2.dp)
55            } else {
56                if (icon != null) icon()
57                Text(text)
58            }
59        }
60    }
61}

이 버튼은 DI와 직접 연결되지 않지만, DI 크래시 디버깅에서 자주 필요한 ‘재현 장치’ 역할을 한다. debounce가 없으면 recomposition/다중 클릭으로 네비게이션이 중복 호출되고, 그 결과 back stack entry가 꼬여 scope 오류가 난다. loading은 상태 변화를 만들고, a11yLabel은 Layout Inspector에서 노드 추적을 쉽게 만든다.

AdvancedButtonDemo.kt
1package com.example.advanced
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.rememberCoroutineScope
5import androidx.compose.material3.MaterialTheme
6import androidx.compose.material3.Text
7import androidx.compose.ui.tooling.preview.Preview
8import kotlinx.coroutines.delay
9
10@Composable
11fun AdvancedButtonDemo() {
12    val scope = rememberCoroutineScope()
13    AdvancedButton(
14        text = "Pay",
15        loading = false,
16        debounceMs = 800L,
17        a11yLabel = "pay-button",
18        icon = { Text("$") },
19        onLongPress = null,
20        scope = scope,
21        onClick = {
22            delay(300)
23            android.util.Log.d("AdvancedButton", "clicked")
24        }
25    )
26}
27
28@Preview
29@Composable
30private fun PreviewAdvancedButtonDemo() {
31    MaterialTheme { AdvancedButtonDemo() }
32}

실행하면 클릭 로그가 800ms 이내 중복으로 찍히지 않는다. 이 한 가지가 네비게이션 중복 push를 줄이고, 결과적으로 ‘같은 화면인데 ViewModel이 여러 개 생김’ 같은 scope 오류를 체감상 크게 줄인다. DI 문제는 종종 입력 이벤트 문제로 증폭되기 때문에, UI 이벤트 제어도 디버깅 도구로 취급하는 편이 낫다.

자주 하는 실수

1) @AndroidEntryPoint 누락으로 GeneratedComponent 예외

증상: 앱 실행 또는 특정 화면 진입 시점에 java.lang.IllegalStateException: ... does not implement GeneratedComponent 같은 메시지로 크래시가 난다. hiltViewModel() 호출 라인에서 터지는 경우가 많다.

원인: Activity/Fragment가 Hilt 컴포넌트를 생성하지 못해 EntryPoint를 제공하지 못한다. Compose는 주입을 ‘함수 호출 시점’에 평가하므로, UI가 어느 정도 그려진 뒤 특정 Composable에서 갑자기 죽는 형태로 보일 수 있다.

해결: Application에 @HiltAndroidApp, 진입 Activity/Fragment에 @AndroidEntryPoint를 붙인다. 멀티 모듈이면 AndroidEntryPoint가 선언된 클래스가 실제 런처인지도 확인한다. 크래시 라인보다 한 단계 위(호스트) 어노테이션을 먼저 의심한다.

2) Preview/단위 Composable 실행에서 LocalViewModelStoreOwner null

증상: Preview가 아예 렌더링되지 않거나, IllegalStateException: ViewModelStoreOwner is not provided via LocalViewModelStoreOwner가 뜬다. 런타임에서는 정상인데 Preview만 죽는 경우도 흔하다.

원인: Preview는 Activity/NavHost 같은 Owner 제공자가 없다. viewModel()/hiltViewModel()은 Owner를 CompositionLocal에서 읽는데, Preview 환경에서는 그 Local이 비어 있다.

해결: Preview에서는 ViewModel을 파라미터로 주입받는 형태로 분리하고, Preview용 fake 구현을 넘긴다. 또는 CompositionLocalProvider로 Owner를 억지로 만들기보다, UI와 상태를 분리해서 ‘상태 없는 UI’만 Preview한다.

3) Dialog/Popup/BottomSheet 내부에서 hiltViewModel() 호출

증상: 평소 화면은 정상인데, 다이얼로그를 띄우는 순간에만 ViewModelStoreOwner 관련 예외가 난다. 로그는 Dialog content 내부 Composable을 가리킨다.

원인: 분리된 window/subcomposition 경계에서 ViewTreeOwner 전달이 달라진다. 특히 AndroidView로 ComposeView를 섞거나, 커스텀 다이얼로그를 쓰면 재현 확률이 올라간다.

해결: DI 호출을 경계 밖으로 끌어올려서 인스턴스를 전달한다. 정말 내부에서 생성해야 하면 owner를 명시적으로 넘기되, 스코프가 의도대로인지(createdAt 로그 같은) 검증 코드를 같이 둔다.

4) 화면 단위여야 할 ViewModel이 Activity로 공유됨

증상: A 화면에서 입력한 값이 B 화면에 그대로 남는다. 또는 뒤로 가기 후 다시 들어오면 이전 상태가 남아있다. 크래시는 없어서 더 늦게 발견된다.

원인: LocalViewModelStoreOwner가 Activity를 가리키는 구조에서 viewModel()을 호출했다. NavHost 내부가 아니라 Activity 최상단이나 잘못된 CompositionLocalProvider 아래에서 호출할 때 발생한다.

해결: ViewModel 생성 지점을 NavHost의 composable 블록 내부로 옮긴다. navigation scope가 필요하면 hiltViewModel(backStackEntry) 형태로 owner를 명확히 한다. 상태 공유가 필요하면 오히려 부모 그래프 entry로 올려 의도적으로 공유한다.

5) remember로 DI 객체를 캐시해서 수명/정리 타이밍이 꼬임

증상: 화면을 닫아도 리소스가 해제되지 않거나, 특정 recomposition 이후 객체가 새로 만들어져 중복 요청이 생긴다. 힙 덤프에서 동일한 타입 인스턴스가 여러 개 보인다.

원인: remember는 SlotTable 위치 기반 캐시라서 DI 스코프와 무관하다. 조건문/키 변경으로 슬롯이 이동하면 객체가 재생성되고, 이동하지 않으면 화면 수명보다 오래 살아남는 것처럼 보일 수 있다.

해결: 수명 관리가 필요한 객체는 ViewModel/DI 스코프에서 만든다. Composable에서는 주입된 참조를 사용만 한다. UI 계산 결과만 remember/derivedStateOf로 캐시한다.

WrongRememberDI.kt
1package com.example.mistakes
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.remember
5
6class HeavyClient {
7    fun close() { /* release */ }
8}
9
10@Composable
11fun WrongRememberDI() {
12    // 증상 재현: recomposition/슬롯 이동에 따라 생성/해제가 꼬인다
13    val client = remember { HeavyClient() }
14    // client.close()를 호출할 곳이 없다
15}

이 코드를 실제로 돌리면 close 타이밍이 없다. DisposableEffect로 close를 붙일 수도 있지만, 그 자체가 ‘UI 트리 생명주기’에 묶인다. DI 스코프가 책임져야 할 리소스가 UI에 붙는 순간, 디버깅 난이도가 급상승한다.

성능 최적화 체크리스트

  • hiltViewModel()/viewModel() 호출 지점이 NavHost composable 블록 내부인지 확인한다(Owner가 Activity로 새지 않게).
  • Dialog/Popup/BottomSheet 내부에서 ViewModel 생성이 필요한지 재검토하고, 가능하면 외부에서 생성해 전달한다.
  • Preview 대상 Composable에서 hiltViewModel()을 직접 호출하지 않고, 상태/의존성을 파라미터로 받는 구조로 분리한다.
  • 네비게이션 중복 navigate를 막기 위해 클릭 debounce 또는 singleTop을 적용하고, back stack entry가 의도치 않게 늘지 않는지 확인한다.
  • createdAt 같은 타임스탬프를 ViewModel에 넣고 화면 전환 시 인스턴스 재사용/재생성을 로그로 검증한다.
  • 중첩 그래프 공유 상태가 필요하면 parent graph의 getBackStackEntry(route)를 owner로 사용하는지 확인한다.
  • remember로 네트워크/DB/클라이언트 객체를 만들지 않았는지 검색하고, ViewModel/DI 스코프로 이동한다.
  • CompositionLocalProvider를 조건문(if)으로 켰다/껐다 하는 구조가 있는지 확인하고, Provider 경계를 고정한다.
  • LocalViewModelStoreOwner.current가 null이 될 수 있는 경계(AndroidView/ComposeView/다른 Window)가 있는지 Layout Inspector로 트리 분리를 확인한다.
  • @AndroidEntryPoint 누락 여부를 런처 Activity/호스트 Fragment 기준으로 점검하고, Application의 @HiltAndroidApp도 같이 확인한다.
  • SavedStateHandle을 쓰는 ViewModel이면 LocalSavedStateRegistryOwner가 제공되는 경로인지 확인하고, navigation-compose 버전 호환을 점검한다.
  • recomposition 과다로 side effect가 중복 실행되지 않게 LaunchedEffect/SideEffect 키를 owner/route 기반으로 고정했는지 확인한다.

자주 묻는 질문

hiltViewModel()이 왜 LocalViewModelStoreOwner에 의존하나? 그냥 전역 그래프에서 꺼내면 안 되나?

hiltViewModel()은 ‘전역에서 아무 ViewModel이나 꺼내는’ API가 아니라, ViewModel의 핵심 계약인 수명과 저장소(ViewModelStore)에 붙어 생성하기 위한 API다. ViewModel은 화면 회전, 프로세스 재생성, back stack 유지 같은 이벤트에서 살아남거나 죽어야 하고, 그 판단 기준이 Owner다. Compose는 View/Fragment에 고정되지 않으니 Owner를 파라미터로 강제하지 않고 CompositionLocal로 전달받는다. 그래서 LocalViewModelStoreOwner가 없으면 예외가 난다. 학습 키워드는 ViewModelStoreOwner, SavedStateRegistryOwner, CompositionLocal, NavBackStackEntry다.

Preview에서 hiltViewModel()을 쓰고 싶다. Owner를 임의로 만들어 넣으면 되나?

기술적으로는 가능하지만, 대부분의 팀에서 유지보수 비용이 더 크다. Preview는 UI 형태를 확인하는 도구이고, DI/Owner를 억지로 흉내 내면 Preview가 ‘실제 런타임과 다른 스코프’로 동작해 오히려 착시를 만든다. 더 안전한 패턴은 (1) ScreenRoute(상태 생성, hiltViewModel 호출)와 (2) ScreenUi(상태 표시)로 분리하는 것이다. Preview는 ScreenUi만 대상으로 하고, 상태는 fake 데이터 클래스로 넣는다. 키워드는 state hoisting, route/ui split, fake implementation, @Preview parameterization이다.

왜 어떤 화면은 ViewModel이 공유되고 어떤 화면은 분리되나? 코드가 똑같은데도 다르다.

코드는 같아도 호출되는 Composition 트리가 다르면 Owner가 달라진다. NavHost 내부 composable 블록에서 호출하면 Owner는 NavBackStackEntry가 되고, Activity 최상단에서 호출하면 Owner는 Activity가 된다. 또 중첩 그래프를 쓰면 같은 화면이라도 어떤 그래프 entry 아래에 붙었는지에 따라 스코프가 바뀐다. 이 차이는 SlotTable이 아니라 CompositionLocal 값 스택 차이로 발생한다. 디버깅은 createdAt 로그를 찍고, Layout Inspector로 해당 Composable이 어느 subtree(특히 Dialog/Popup/AndroidView 경계)에 있는지 확인하는 방식이 빠르다. 키워드는 NavBackStackEntry, getBackStackEntry, LocalViewModelStoreOwner, Layout Inspector다.

Dialog/BottomSheet에서만 ViewModelStoreOwner not found가 뜬다. 왜 거기서만 Owner가 사라지나?

Dialog/Popup/BottomSheet는 UI가 별도 window 또는 별도 subcomposition으로 구성될 수 있다. 이 경계에서는 ViewTreeOwner(뷰 트리로 전달되는 lifecycle/viewmodel/savedstate owner)가 자동으로 이어지지 않는 경우가 있고, 그러면 Compose가 CompositionLocal로 제공하던 Owner도 null이 된다. 해결책은 두 가지다. 첫째, ViewModel을 다이얼로그 밖에서 만들고 다이얼로그에는 상태만 전달한다. 둘째, 정말 내부에서 필요하면 owner를 명시적으로 넘긴다(hiltViewModel(parentEntry) 같은 형태). 실전에서는 첫째가 안정적이다. 키워드는 Dialog, Popup, ViewTreeLifecycleOwner, subcomposition, owner hoisting이다.

remember로 DI 객체를 캐시하면 왜 위험한가? recomposition에서 다시 만들지 않으니 더 낫지 않나?

remember는 ‘DI 스코프’가 아니라 ‘SlotTable의 호출 위치’에 묶인 캐시다. 호출 위치가 유지되면 화면 수명보다 오래 살아남는 것처럼 보일 수 있고, 조건문/키 변경으로 위치가 바뀌면 예고 없이 재생성된다. 네트워크 클라이언트나 리소스 핸들을 remember로 만들면 close 타이밍이 없어지고, DisposableEffect로 붙여도 UI 트리 수명에 종속된다. DI는 원래 수명 관리(생성/정리)를 스코프로 통제하려고 쓰는 도구라서, remember로 우회하면 스코프 설계가 무너진다. 키워드는 SlotTable, remember scope, DisposableEffect, lifecycle vs composition lifecycle이다.

그래프 단위로 ViewModel을 공유하는 패턴에서 parentRoute를 잘못 주면 어떻게 디버깅하나?

getBackStackEntry(parentRoute)는 해당 route가 back stack에 없으면 IllegalArgumentException을 던진다. 이 예외는 오히려 좋은 신호다. ‘공유 스코프가 시작되는 지점이 실제로 존재하는가’를 즉시 알려준다. 디버깅은 (1) NavHost에 중첩 navigation(route="flow")를 만들고, (2) 그 그래프 안의 화면들에서 parentRoute="flow"를 사용하며, (3) navigate 시 popUpTo 설정으로 flow entry를 날리고 있지 않은지 확인한다. 로그로는 navController.backQueue를 출력해 entry 존재 여부를 확인할 수 있다. 키워드는 nested graph, backQueue, popUpTo, graph-scoped ViewModel이다.

recomposition이 DI 크래시를 유발할 수도 있나? 상태만 바뀌는데 왜 주입이 다시 평가되나?

상태 변경 자체가 DI를 다시 평가하는 건 아니다. 문제는 DI 호출이 있는 Composable 그룹이 recomposition 대상이 되면, 그 호출 라인이 다시 실행된다는 점이다. 그 사이에 Owner/Local 경계가 바뀌면(조건문으로 Provider를 켰다/껐다, 화면 구조가 loading에 따라 바뀜, Dialog subtree로 이동) 같은 코드가 다른 Owner 아래에서 실행되며 null을 만날 수 있다. 또 recomposition이 잦으면 side effect 안에서 DI 객체를 사용한 네트워크 요청이 중복 실행되는 형태로 버그가 커진다. 해결은 Owner 경계를 고정하고, DI 호출을 안정적인 상위 레이어로 끌어올리며, LaunchedEffect 키를 route/owner 기반으로 설계하는 것이다. 키워드는 recomposition scope, CompositionLocal stability, conditional UI tree, LaunchedEffect key다.

관련 글

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

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

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

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

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

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

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자)