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

7. Compose에서 DI가 필요한 이유: 상태·수명주기 기반 퀴즈로 이해

Compose에서 DI가 왜 필요한지 상태·수명주기 관점에서 퀴즈로 체감한다. remember, Slot Table, recomposition 범위, ViewModel/Repository 생명주기까지 연결한다.




























7. Compose에서 DI가 필요한 이유: 상태·수명주기 기반 퀴즈로 이해

Compose에서 DI가 필요한 이유: 상태·수명주기 기반 퀴즈로 이해

버튼을 눌렀을 뿐인데 네트워크가 두 번 호출되고, 화면 회전 후에는 캐시가 초기화되고, 뒤로 갔다가 다시 들어오면 타이머가 리셋된다. Compose를 처음 쓰면 이런 현상이 ‘상태 관리가 어려워서’처럼 보이지만, 실제 원인은 수명주기 경계(Composition)와 객체 생성 위치가 뒤섞여 있기 때문이다. DI는 편의 기능이 아니라, 재구성(recomposition)과 생명주기 스코프를 분리하기 위한 장치이다.





































핵심 개념

Compose에서 ‘화면’은 클래스 인스턴스가 아니라 함수 호출의 결과이다. 같은 @Composable 함수가 한 프레임 안에서도 여러 번 호출될 수 있고, 상태 변화가 있으면 다음 프레임에 다시 호출된다. 이때 함수 본문에서 객체를 생성하면, 그 객체는 UI 수명주기보다 더 짧은 ‘호출 수명주기’를 가진다. DI가 필요한 첫 번째 이유는 객체의 수명주기를 UI 호출로부터 분리하기 위해서이다.

용어를 상황으로 정의한다. Composition은 Composable 호출 트리를 만들고 Slot Table에 기록하는 단계이다. Recomposition은 Slot Table을 기준으로 ‘어떤 호출을 다시 실행할지’를 결정해 부분 호출을 반복하는 단계이다. Slot Table은 이전 호출의 파라미터/remember 값/노드 구조 정보를 저장하는 테이블이며, 이 저장 덕분에 ‘같은 위치의 호출’이라는 개념이 생긴다. DI는 이 “같은 위치”에 값을 저장하는 remember와 다르게, “같은 화면/같은 기능”에 객체를 묶는 스코프를 제공한다.

초보가 흔히 겪는 착각이 있다. remember가 있으니 DI가 필요 없다고 느끼기 쉽다. remember는 Slot Table에 값을 저장해 recomposition 동안 유지시키는 기능이다. 하지만 remember의 수명은 Composition에 종속된다. 네비게이션으로 화면이 빠지거나 key가 바뀌면 Slot Table의 해당 구간이 통째로 폐기되고, remember 값도 같이 사라진다. 반면 Repository, DB, analytics 같은 객체는 화면이 잠깐 사라져도 유지되거나, 앱 전체에서 공유되어야 하는 경우가 많다.

두 번째 이유는 테스트 가능성과 교체 가능성이다. Composable은 함수라서 파라미터로 의존성을 넘기면 테스트가 쉬워진다. 문제는 의존성이 많아질수록 파라미터가 폭발하고, 화면 전환/상태 복원 과정에서 같은 의존성을 반복해서 전달하게 된다. DI는 ‘어디서 생성하고 어디까지 공유할지’ 규칙을 코드로 고정해, 객체 생성의 우발성을 줄인다.

세 번째 이유는 성능과 부수효과 제어이다. recomposition은 자주 일어난다. 함수 본문에서 매번 새 객체를 만들면 할당이 늘고, equals 비교가 깨져 불필요한 recomposition을 유발한다. 더 치명적인 건 부수효과다. 네트워크 요청 같은 부수효과가 Composable 호출과 결합되면, recomposition마다 다시 실행되는 버그가 터진다. DI는 부수효과를 실행하는 객체를 ‘호출’이 아니라 ‘스코프’에 매달아, 실행 시점을 SideEffect API로만 통제하게 만든다.

BadScreen.kt
1import androidx.compose.runtime.Composable
2import androidx.compose.runtime.mutableStateOf
3import androidx.compose.runtime.remember
4import androidx.compose.runtime.getValue
5import androidx.compose.runtime.setValue
6
7class QuizRepository {
8    fun fetch(): String = "data@" + System.nanoTime()
9}
10
11@Composable
12fun BadScreen() {
13    // recomposition마다 새 인스턴스가 만들어진다
14    val repo = QuizRepository()
15
16    var text by remember { mutableStateOf("tap") }
17    androidx.compose.material3.Button(onClick = { text = repo.fetch() }) {
18        androidx.compose.material3.Text(text)
19    }
20}

이 코드를 실행하고 버튼을 여러 번 누르면 텍스트가 계속 바뀌는 것 자체는 정상처럼 보인다. 하지만 문제는 ‘repo가 매번 새로 만들어진다’는 사실이 UI로 드러나지 않는다는 점이다. Layout Inspector에서 recomposition 횟수가 늘어나는 상황에서, repo 생성이 같이 늘어난다. DI는 이런 객체 생성 위치를 화면 바깥(스코프)으로 밀어내는 도구이다.

컴포넌트 해부

Compose에서 DI를 논할 때 실제로 분해해야 하는 컴포넌트는 Composable 자체가 아니라 ‘의존성을 읽는 지점’이다. 대표적인 선택지는 1) 파라미터로 전달, 2) CompositionLocal로 읽기, 3) ViewModel을 통해 읽기, 4) 외부 DI 컨테이너에서 주입이다. 각각은 Slot Table과 수명주기 경계에 서로 다른 영향을 준다.

  • 파라미터 주입: 가장 명시적이다. 호출 지점이 의존성을 소유한다. Preview/테스트가 쉽다.
  • CompositionLocal: 트리 전체에 암묵적으로 전달한다. 호출 그래프가 짧아지지만, 읽는 쪽에서 의존성이 숨는다.
  • ViewModel 경유: 화면 수명주기(BackStackEntry)에 맞춘 스코프를 얻는다. 구성 변경에도 유지된다.
  • remember로 생성: recomposition에는 안전하지만 화면을 벗어나면 폐기된다.
  • rememberSaveable로 생성: 프로세스 죽음 복원까지 노리지만, 저장 가능한 타입 제약이 있다.
  • 싱글톤(object): 앱 전체 공유는 쉽지만 테스트 격리가 어렵고, 초기화 순서 문제가 생긴다.
  • lazy/Provider: 생성 시점을 늦춘다. recomposition과 결합되면 여러 번 생성되는 실수가 나온다.
  • 키 기반 remember(key): key 변화가 곧 객체 폐기/재생성을 의미한다. 네비게이션 파라미터와 엮이면 버그가 난다.
  • @Stable/@Immutable: 파라미터 변경 감지를 줄여 recomposition 범위를 좁힌다. 하지만 잘못 표시하면 UI가 갱신되지 않는다.
  • DisposableEffect/LaunchedEffect: 부수효과를 Composition 수명주기에 묶는다. 의존성이 바뀌면 재시작된다.
  • NavBackStackEntry: 화면 스코프의 실제 경계이다. ViewModelStoreOwner가 된다.

Surface 계층과 Content 계층을 분리해서 보면 DI 위치가 더 명확해진다. Surface 계층은 클릭/리플/semantics 같은 ‘UI 외피’를 담당하고, Content 계층은 상태를 읽어 텍스트/아이콘을 배치한다. DI가 섞이면 Content 계층이 네트워크/DB를 직접 호출하는 구조가 되기 쉽고, recomposition이 곧 데이터 레이어 재실행으로 이어진다.

Surface 계층은 Modifier 체인과 InteractionSource를 통해 상태를 전달한다. 이 상태는 UI 프레임 단위로 자주 바뀐다(pressed, hovered 등). 따라서 Surface 내부에서 의존성을 읽으면, 잦은 상태 변화가 의존성 비교/재생성까지 끌고 들어올 수 있다. 의존성은 Content가 아니라 더 바깥 스코프에서 고정하고, Surface는 이벤트만 위로 올리는 구조가 안전하다.

Content 계층은 ‘무엇을 그릴지’를 결정한다. 여기서 필요한 것은 Repository가 아니라 UI 상태 모델이다. Repository는 ViewModel이나 use-case 계층에서 상태를 만들고, Composable은 그 상태를 읽어 그린다. DI는 Repository를 ViewModel에 주입하고, Composable에는 상태만 전달하는 구조를 강제하기 위한 장치가 된다.

QuizCard.kt
1import androidx.compose.runtime.Composable
2import androidx.compose.runtime.Stable
3import androidx.compose.runtime.remember
4import androidx.compose.ui.Modifier
5import androidx.compose.material3.Surface
6import androidx.compose.material3.Text
7
8@Stable
9class QuizUiState(
10    val title: String,
11    val enabled: Boolean
12)
13
14@Composable
15fun QuizCard(
16    state: QuizUiState,
17    modifier: Modifier = Modifier,
18    onClick: () -> Unit
19) {
20    // Surface 계층: 입력/semantics/클릭 영역
21    Surface(modifier = modifier, onClick = onClick) {
22        // Content 계층: 상태를 읽어 그리기
23        Text(text = state.title)
24    }
25}
26
27@Composable
28fun QuizCardPreviewState(): QuizUiState {
29    // Preview에서만 쓰는 더미 상태
30    return remember { QuizUiState(title = "DI quiz", enabled = true) }
31}

이 스케치는 중요한 경계를 드러낸다. QuizCard는 Repository를 모른다. 상태만 안다. 상태는 @Stable로 표시되어 recomposition 비교 비용을 줄일 수 있다. 반대로 Repository를 파라미터로 넣으면, equals/참조 변경에 따라 QuizCard 전체가 쉽게 더럽혀진다. DI는 Repository를 UI 바깥에서 관리하고, UI에는 상태만 흘려보내는 구조를 만들기 위해 필요하다.

QuizCardRealUsage.kt
1import androidx.compose.foundation.layout.padding
2import androidx.compose.material3.MaterialTheme
3import androidx.compose.material3.Surface
4import androidx.compose.material3.Text
5import androidx.compose.runtime.Composable
6import androidx.compose.ui.Modifier
7import androidx.compose.ui.unit.dp
8
9@Composable
10fun QuizCardRealUsage(
11    title: String,
12    modifier: Modifier = Modifier
13) {
14    Surface(
15        modifier = modifier,
16        shape = MaterialTheme.shapes.medium,
17        color = MaterialTheme.colorScheme.surfaceVariant
18    ) {
19        Text(
20            text = title,
21            modifier = Modifier.padding(16.dp),
22            style = MaterialTheme.typography.titleMedium
23        )
24    }
25}

이 사용 예는 의존성이 전혀 없다. 그래서 recomposition이 일어나도 할당이 거의 없다. Compose 설계 의도는 UI를 ‘순수 함수에 가깝게’ 만들고, 의존성은 외부에서 공급하는 쪽이다. DI는 그 공급 경로를 체계화한다.

내부 동작 원리

Compose Runtime은 @Composable 호출을 그대로 실행하지 않고, Composer라는 실행 컨텍스트를 통해 Slot Table에 기록한다. 각 호출은 그룹(group)으로 시작하고 끝난다. remember는 이 그룹 위치에 값을 저장한다. 그래서 remember가 동작하려면 호출 순서가 안정적이어야 하고, 조건문으로 Composable 호출 순서를 바꾸면 저장 위치가 어긋나기 쉽다. DI가 필요한 이유가 여기서 한 번 더 나온다. 의존성 생성이 remember에 기대면, 호출 순서 안정성까지 의존성 수명주기에 영향을 준다.

Compose Compiler는 Composable 함수를 변환해 숨은 파라미터(Composer, changed 플래그)를 추가한다. changed 플래그는 이전 호출과 파라미터가 같은지 비교한 결과를 비트로 들고 있다. 값이 안정적(stable)이라면 더 공격적으로 스킵할 수 있다. 의존성을 파라미터로 넘길 때마다 참조가 바뀌면 changed가 매번 ‘바뀜’으로 찍혀 스킵이 깨진다. DI 컨테이너가 같은 인스턴스를 스코프 내에서 재사용하는 이유가 여기에 있다.

Slot Table 관점에서 보면, remember로 만든 객체는 ‘그 호출 위치’에 저장된다. 화면이 네비게이션으로 제거되면 해당 Slot 구간이 통째로 삭제되고, 객체도 같이 GC 대상이 된다. 반면 ViewModel은 ViewModelStore에 저장되어 BackStackEntry가 살아있는 동안 유지된다. DI는 Repository를 ViewModel 스코프나 앱 스코프에 두고, Composable이 사라져도 객체가 유지되도록 만든다.

Recomposition 트리거는 State 읽기이다. mutableStateOf 값을 읽은 Composable은 Snapshot 시스템에 의해 구독자로 등록된다. 값이 바뀌면 해당 구독자 범위가 invalidation 되고, 다음 프레임에 그 범위만 재호출된다. 여기서 중요한 포인트는 ‘State를 어디서 읽느냐’이다. Repository를 Composable에서 직접 만들고 그 안에서 State를 만들면, invalidation 범위가 UI와 데이터 레이어를 한 덩어리로 묶는다.

Modifier 체인은 노드들의 연결 리스트처럼 동작한다. layout, draw, semantics, pointer input 같은 요소가 순서대로 쌓인다. 같은 Modifier라도 새 인스턴스를 매번 만들면 노드 비교가 깨져 레이아웃/드로우 단계까지 불필요하게 더럽혀질 수 있다. DI와 직접 관련 없어 보이지만, ‘객체를 어디서 만들고 얼마나 재사용하느냐’라는 관점에서는 동일한 문제다.

처음에 나도 DI 없이 remember로 Repository를 들고 가면 되지 않나 생각했다. 3시간 삽질 끝에 잡은 버그는 ‘화면 A에서 B로 이동했다가 Back으로 돌아오면 로딩 상태가 초기화’되는 현상이었다. 로그에는 Repository 생성이 다시 찍혔다. 원인은 remember가 네비게이션으로 화면이 빠지는 순간 Slot Table에서 폐기된다는 점이었다. ViewModel 스코프로 올리니 사라졌다.

RecompositionQuiz_NoDI.kt
1import android.util.Log
2import androidx.compose.foundation.layout.Column
3import androidx.compose.material3.Button
4import androidx.compose.material3.Text
5import androidx.compose.runtime.Composable
6import androidx.compose.runtime.LaunchedEffect
7import androidx.compose.runtime.getValue
8import androidx.compose.runtime.mutableStateOf
9import androidx.compose.runtime.remember
10import androidx.compose.runtime.setValue
11
12class CounterRepo {
13    init { Log.d("DI-QUIZ", "CounterRepo created: ${System.identityHashCode(this)}") }
14    fun next(n: Int) = n + 1
15}
16
17@Composable
18fun RecompositionQuiz_NoDI() {
19    val repo = CounterRepo() // recomposition마다 생성될 수 있다
20    var n by remember { mutableStateOf(0) }
21
22    LaunchedEffect(n) {
23        Log.d("DI-QUIZ", "LaunchedEffect(n=$n) repo=${System.identityHashCode(repo)}")
24    }
25
26    Column {
27        Text("n=$n")
28        Button(onClick = { n = repo.next(n) }) { Text("+1") }
29    }
30}

이 코드를 실행하면 Logcat에 CounterRepo created가 여러 번 찍힐 수 있다. 특히 상위에서 상태가 바뀌어 이 Composable이 다시 호출되는 구조라면 더 쉽게 재현된다. 핵심은 LaunchedEffect가 n 키로 재시작되는 것보다, repo 인스턴스가 호출마다 바뀌어 로그 해석이 어려워진다는 점이다. DI는 ‘repo는 같은 스코프에서 같은 인스턴스’라는 전제를 만들어 디버깅 비용을 줄인다.

RecompositionQuiz_WithContainer.kt
1import android.util.Log
2import androidx.compose.foundation.layout.Column
3import androidx.compose.material3.Button
4import androidx.compose.material3.Text
5import androidx.compose.runtime.Composable
6import androidx.compose.runtime.CompositionLocalProvider
7import androidx.compose.runtime.staticCompositionLocalOf
8import androidx.compose.runtime.getValue
9import androidx.compose.runtime.mutableStateOf
10import androidx.compose.runtime.remember
11import androidx.compose.runtime.setValue
12
13interface RepoProvider { val counterRepo: CounterRepo }
14
15class AppContainer : RepoProvider {
16    override val counterRepo: CounterRepo = CounterRepo()
17}
18
19val LocalRepoProvider = staticCompositionLocalOf<RepoProvider> {
20    error("RepoProvider not provided")
21}
22
23@Composable
24fun RecompositionQuiz_WithContainer() {
25    val container = remember { AppContainer() } // 앱 루트에서라면 사실상 앱 스코프
26
27    CompositionLocalProvider(LocalRepoProvider provides container) {
28        CounterScreen_DIStyle()
29    }
30}
31
32@Composable
33private fun CounterScreen_DIStyle() {
34    val repo = LocalRepoProvider.current.counterRepo
35    var n by remember { mutableStateOf(0) }
36
37    Log.d("DI-QUIZ", "CounterScreen repo=${System.identityHashCode(repo)}")
38
39    Column {
40        Text("n=$n")
41        Button(onClick = { n = repo.next(n) }) { Text("+1") }
42    }
43}

이 코드를 실행하면 CounterRepo created는 한 번만 찍히고, CounterScreen에서는 같은 identityHashCode가 유지된다. CompositionLocal은 DI 프레임워크가 없어도 ‘트리 스코프’를 만들 수 있는 원시 도구이다. 다만 이 방식은 스코프 경계를 직접 설계해야 하고, 잘못 두면 화면마다 컨테이너가 새로 생긴다. DI 프레임워크는 이 스코프를 Activity/Navigation/Singleton 같은 단위로 표준화한다.

한 문단 요약: Compose에서 DI는 ‘recomposition으로 Composable이 반복 호출된다’는 전제에서 객체 생성과 부수효과를 UI 호출로부터 분리하기 위한 장치이다. remember는 Slot Table(Composition) 수명주기까지만 보장하고, 앱/화면 스코프는 DI나 ViewModelStore 같은 별도 저장소가 담당한다.

실습하기

실습 목표는 ‘DI가 없을 때 생기는 버그를 눈으로 확인’하는 것이다. 화면에 퀴즈가 나오고, 선택지를 누르면 점수가 올라간다. 첫 번째 버전은 Composable 안에서 Repository를 만들고, 두 번째 버전은 컨테이너를 루트에 두고, 세 번째 버전은 ViewModel 스코프로 올린다. 실행 중 Logcat과 화면 상태를 동시에 관찰하면 차이가 드러난다.

app/build.gradle.kts
1plugins {
2    id("com.android.application")
3    id("org.jetbrains.kotlin.android")
4}
5
6android {
7    namespace = "com.example.diquiz"
8    compileSdk = 34
9    defaultConfig { minSdk = 24 }
10    buildFeatures { compose = true }
11    composeOptions { kotlinCompilerExtensionVersion = "1.5.15" }
12}
13
14dependencies {
15    implementation(platform("androidx.compose:compose-bom:2024.10.00"))
16    implementation("androidx.activity:activity-compose:1.9.2")
17    implementation("androidx.compose.material3:material3")
18    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.5")
19}

버전은 예시이며 프로젝트 환경에 맞춰 조정한다. 중요한 건 lifecycle-viewmodel-compose가 있어야 ViewModel 스코프 실습이 가능하다는 점이다. 의존성을 추가하고 Sync 후 실행하면 된다.

1단계: DI 없이 만들기(버그를 일부러 만든다)

화면에 ‘문제’와 버튼 2개가 나오고, 버튼을 누르면 점수가 올라간다. 그리고 화면 상단에는 Repository 생성 횟수가 표시된다. recomposition이 일어날 때마다 생성 횟수가 바뀌면, 객체 수명주기가 UI 호출에 묶여 있다는 뜻이다.

재현 포인트는 상위에서 타이머를 돌려 매초 상태를 바꾸는 것이다. 타이머가 바뀌면 화면 전체가 재호출되고, 그때마다 Repository가 새로 만들어진다. 실제 앱에서는 상위에서 theme, window insets, navigation state가 바뀌는 것만으로도 비슷한 일이 난다.

Step1_NoDI.kt
1import android.os.Bundle
2import androidx.activity.ComponentActivity
3import androidx.activity.compose.setContent
4import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.padding
6import androidx.compose.material3.Button
7import androidx.compose.material3.MaterialTheme
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.runtime.LaunchedEffect
11import androidx.compose.runtime.getValue
12import androidx.compose.runtime.mutableIntStateOf
13import androidx.compose.runtime.mutableStateOf
14import androidx.compose.runtime.remember
15import androidx.compose.runtime.setValue
16import androidx.compose.ui.Modifier
17import androidx.compose.ui.unit.dp
18import kotlinx.coroutines.delay
19
20class MainActivity : ComponentActivity() {
21    override fun onCreate(savedInstanceState: Bundle?) {
22        super.onCreate(savedInstanceState)
23        setContent { MaterialTheme { Step1_NoDI() } }
24    }
25}
26
27private class QuizRepo {
28    companion object { var created = 0 }
29    init { created++ }
30    fun correct() = true
31}
32
33@Composable
34fun Step1_NoDI() {
35    var tick by remember { mutableIntStateOf(0) }
36    LaunchedEffect(Unit) {
37        while (true) { delay(1000); tick++ }
38    }
39
40    val repo = QuizRepo() // tick 때문에 화면이 재호출되면 repo도 재생성된다
41    var score by remember { mutableStateOf(0) }
42
43    Column(Modifier.padding(16.dp)) {
44        Text("tick=$tick")
45        Text("repoCreated=${QuizRepo.created}")
46        Text("score=$score")
47        Button(onClick = { if (repo.correct()) score++ }) { Text("Choice A") }
48        Button(onClick = { score += 0 }) { Text("Choice B") }
49    }
50}

실행하면 tick은 1초마다 증가한다. repoCreated도 같이 증가하면 실패한 설계이다. 클릭을 안 해도 객체가 계속 만들어진다. 이 지점에서 ‘DI는 편의가 아니라 수명주기 분리’라는 감각이 생긴다.

2단계: 컨테이너를 루트에 두고 스코프를 고정한다

Repository를 화면 함수 밖으로 옮기되, 전역 싱글톤이 아니라 CompositionLocal로 전달한다. 이 방식은 Hilt 같은 도구 없이도 DI의 핵심(스코프)을 체감할 수 있다. 실행하면 tick은 계속 바뀌지만 repoCreated는 1에서 멈춘다.

여기서 중요한 관찰은 Slot Table이 아니라 ‘컨테이너가 기억되는 위치’이다. container를 Step2 함수 안에서 remember하면 Step2의 Composition이 살아있는 동안 유지된다. 앱 루트(setContent 최상단)에 두면 사실상 앱 수명주기와 비슷해진다.

Step2_WithContainer.kt
1import androidx.compose.runtime.Composable
2import androidx.compose.runtime.CompositionLocalProvider
3import androidx.compose.runtime.staticCompositionLocalOf
4import androidx.compose.runtime.remember
5
6private class QuizRepo2 {
7    companion object { var created = 0 }
8    init { created++ }
9    fun correct() = true
10}
11
12private class AppContainer2(val repo: QuizRepo2 = QuizRepo2())
13
14private val LocalContainer2 = staticCompositionLocalOf<AppContainer2> {
15    error("Container missing")
16}
17
18@Composable
19fun Step2_WithContainer(content: @Composable () -> Unit) {
20    val container = remember { AppContainer2() }
21    CompositionLocalProvider(LocalContainer2 provides container) {
22        content()
23    }
24}
25
26@Composable
27fun Step2_ScreenBody(
28    tick: Int,
29    onChoiceA: () -> Unit,
30    score: Int
31) {
32    androidx.compose.foundation.layout.Column(
33        androidx.compose.ui.Modifier.padding(16.dp)
34    ) {
35        androidx.compose.material3.Text("tick=$tick")
36        androidx.compose.material3.Text("repoCreated=${QuizRepo2.created}")
37        androidx.compose.material3.Text("score=$score")
38        androidx.compose.material3.Button(onClick = onChoiceA) {
39            androidx.compose.material3.Text("Choice A")
40        }
41    }
42}
43
44@Composable
45fun Step2_Run() {
46    var tick by remember { androidx.compose.runtime.mutableIntStateOf(0) }
47    androidx.compose.runtime.LaunchedEffect(Unit) {
48        while (true) { kotlinx.coroutines.delay(1000); tick++ }
49    }
50
51    Step2_WithContainer {
52        val repo = LocalContainer2.current.repo
53        var score by remember { androidx.compose.runtime.mutableStateOf(0) }
54        Step2_ScreenBody(tick = tick, score = score, onChoiceA = { if (repo.correct()) score++ })
55    }
56}

이 단계에서 DI의 장점과 단점이 같이 보인다. 장점은 수명주기 고정이다. 단점은 LocalContainer2를 어디에 제공하느냐에 따라 스코프가 달라진다는 점이다. 네비게이션 그래프마다 다른 컨테이너를 제공하면 화면 스코프, 앱 루트에 제공하면 앱 스코프가 된다.

3단계: ViewModel 스코프로 올려 화면 수명주기를 얻는다

화면이 잠깐 사라졌다가 돌아와도 상태를 유지하려면, Composition 수명주기보다 긴 저장소가 필요하다. ViewModelStore는 그 역할을 한다. Repository를 ViewModel에 주입하고, Composable은 ViewModel이 내보내는 상태만 읽는다. 실행하면 회전(구성 변경) 후에도 repoCreated가 증가하지 않는다.

관찰 포인트는 recomposition 범위이다. tick이 바뀌어도 ViewModel과 Repository는 재생성되지 않는다. tick은 UI 상태이고, 데이터 레이어는 화면 스코프에 고정된다. 이게 DI가 겨냥하는 구조이다.

Step3_WithViewModel.kt
1import androidx.compose.foundation.layout.Column
2import androidx.compose.foundation.layout.padding
3import androidx.compose.material3.Button
4import androidx.compose.material3.Text
5import androidx.compose.runtime.Composable
6import androidx.compose.runtime.getValue
7import androidx.compose.runtime.mutableIntStateOf
8import androidx.compose.runtime.mutableStateOf
9import androidx.compose.runtime.remember
10import androidx.compose.runtime.setValue
11import androidx.compose.ui.Modifier
12import androidx.compose.ui.unit.dp
13import androidx.lifecycle.ViewModel
14import androidx.lifecycle.viewmodel.compose.viewModel
15
16private class QuizRepo3 {
17    companion object { var created = 0 }
18    init { created++ }
19    fun correct() = true
20}
21
22private class QuizVm(
23    private val repo: QuizRepo3 = QuizRepo3()
24) : ViewModel() {
25    var score: Int = 0
26        private set
27
28    fun chooseA() {
29        if (repo.correct()) score++
30    }
31}
32
33@Composable
34fun Step3_WithViewModel(vm: QuizVm = viewModel()) {
35    var tick by remember { mutableIntStateOf(0) }
36    androidx.compose.runtime.LaunchedEffect(Unit) {
37        while (true) { kotlinx.coroutines.delay(1000); tick++ }
38    }
39
40    // ViewModel의 score는 Compose State가 아니라서 별도 트리거가 필요하다
41    // 실무라면 StateFlow/MutableState로 노출한다
42    var scoreUi by remember { mutableStateOf(vm.score) }
43
44    Column(Modifier.padding(16.dp)) {
45        Text("tick=$tick")
46        Text("repoCreated=${QuizRepo3.created}")
47        Text("score=$scoreUi")
48        Button(onClick = { vm.chooseA(); scoreUi = vm.score }) { Text("Choice A") }
49    }
50}

여기서는 교육용으로 ViewModel 내부 score를 단순 Int로 두고 UI에서 scoreUi로 당겨왔다. 실무에서는 StateFlow나 mutableStateOf로 노출해 ‘State 읽기’가 invalidation을 만들게 해야 한다. 이 단계의 핵심은 DI가 ViewModel과 결합될 때 화면 수명주기를 얻는다는 점이다.

심화: Advanced 버전 만들기

실무에서는 ‘의존성 생성 위치’만 해결해도 절반은 먹고 들어간다. 나머지 절반은 이벤트 폭주, 로딩 중 중복 클릭, 접근성 라벨 누락 같은 문제다. 이 문제들은 recomposition과 직접 맞닿아 있다. 버튼이 다시 그려질 때마다 debounce 상태가 초기화되면 중복 클릭이 다시 살아난다. long press 처리에서 InteractionSource를 새로 만들면 pressed 상태가 튄다. 그래서 Advanced 컴포넌트는 UI 상태와 의존성 상태를 분리해야 한다.

사례 1: debounce는 UI 호출이 아니라 스코프에 묶여야 한다

처음에 내가 만든 debounce 버튼은 remember { var lastClick = 0L } 형태였다. 화면 회전 후에 debounce가 풀려 결제 버튼이 연속으로 눌렸다. QA가 남긴 로그는 ‘onClick called twice within 120ms’였다. 원인은 remember 수명이 Composition이라 회전 시 초기화된다는 점이었다. 해결은 lastClick을 ViewModel로 올리거나, 최소한 rememberSaveable로 올리는 것이다.

또 한 번은 onClick 람다 안에서 analytics.track을 직접 호출했다가 recomposition과 상관없는 중복 이벤트가 찍혔다. 원인은 click handler가 상위 상태 변경으로 재구성되면서 다른 람다 인스턴스로 교체되고, 테스트 더블에서 호출 횟수 추적이 꼬인 것이었다. DI로 analytics를 스코프에 고정하고, 이벤트는 ViewModel에서 단일 경로로 처리하니 재현이 멈췄다.

AdvancedButton.kt
1import android.os.SystemClock
2import androidx.compose.foundation.layout.Row
3import androidx.compose.foundation.layout.Spacer
4import androidx.compose.foundation.layout.width
5import androidx.compose.material3.CircularProgressIndicator
6import androidx.compose.material3.Icon
7import androidx.compose.material3.MaterialTheme
8import androidx.compose.material3.Surface
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
11import androidx.compose.runtime.Immutable
12import androidx.compose.runtime.remember
13import androidx.compose.ui.Modifier
14import androidx.compose.ui.semantics.contentDescription
15import androidx.compose.ui.semantics.semantics
16import androidx.compose.ui.unit.dp
17
18@Immutable
19data class AdvancedButtonState(
20    val loading: Boolean,
21    val enabled: Boolean,
22    val label: String,
23    val a11yLabel: String = label
24)
25
26class DebounceGate(private val windowMs: Long) {
27    private var last: Long = 0L
28    fun tryEnter(now: Long = SystemClock.elapsedRealtime()): Boolean {
29        if (now - last < windowMs) return false
30        last = now
31        return true
32    }
33}
34
35@Composable
36fun AdvancedButton(
37    state: AdvancedButtonState,
38    modifier: Modifier = Modifier,
39    icon: (@Composable () -> Unit)? = null,
40    debounceGate: DebounceGate = remember { DebounceGate(windowMs = 600) },
41    onLongPress: (() -> Unit)? = null,
42    onClick: () -> Unit
43) {
44    val clickableEnabled = state.enabled && !state.loading
45
46    Surface(
47        modifier = modifier.semantics { contentDescription = state.a11yLabel },
48        shape = MaterialTheme.shapes.medium,
49        color = if (clickableEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
50        onClick = {
51            if (!clickableEnabled) return@Surface
52            if (!debounceGate.tryEnter()) return@Surface
53            onClick()
54        },
55        onLongClick = {
56            if (!clickableEnabled) return@Surface
57            onLongPress?.invoke()
58        }
59    ) {
60        Row(Modifier.padding(12.dp)) {
61            if (state.loading) {
62                CircularProgressIndicator(strokeWidth = 2.dp)
63                Spacer(Modifier.width(8.dp))
64            } else if (icon != null) {
65                icon()
66                Spacer(Modifier.width(8.dp))
67            }
68            Text(state.label, color = MaterialTheme.colorScheme.onPrimary)
69        }
70    }
71}

이 코드에서 관찰할 지점은 debounceGate의 기본값이다. remember로 두면 recomposition에는 안전하지만, 화면이 사라지면 초기화된다. 결제/로그인처럼 강한 보장이 필요하면 debounceGate를 ViewModel에서 만들고 파라미터로 주입하는 편이 낫다. DI가 ‘버튼 컴포넌트’에도 필요한 이유가 여기서 나온다. UI 자체가 아니라, UI가 의존하는 시간/정책 객체의 수명주기 때문이다.

사례 2: DI로 정책 객체를 주입해 테스트를 고정한다

debounce와 clock을 분리하면 테스트가 쉬워진다. SystemClock.elapsedRealtime은 테스트에서 제어가 어렵다. Clock 인터페이스를 만들고 DI로 주입하면, 클릭 두 번 사이 시간을 마음대로 조작할 수 있다. Compose UI 테스트에서 flaky가 줄어드는 이유는 ‘시간’이 결정적이기 때문이다.

AdvancedButtonDemo.kt
1import androidx.compose.material.icons.Icons
2import androidx.compose.material.icons.filled.Favorite
3import androidx.compose.material3.Icon
4import androidx.compose.runtime.Composable
5import androidx.compose.runtime.mutableStateOf
6import androidx.compose.runtime.remember
7import androidx.compose.runtime.getValue
8import androidx.compose.runtime.setValue
9import androidx.compose.ui.Modifier
10import androidx.compose.ui.tooling.preview.Preview
11
12@Composable
13fun AdvancedButtonDemo(modifier: Modifier = Modifier) {
14    var loading by remember { mutableStateOf(false) }
15    var count by remember { mutableStateOf(0) }
16
17    AdvancedButton(
18        state = AdvancedButtonState(
19            loading = loading,
20            enabled = true,
21            label = if (loading) "Loading" else "Like ($count)",
22            a11yLabel = "좋아요 버튼, 현재 $count 회"
23        ),
24        modifier = modifier,
25        icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
26        onLongPress = { loading = !loading },
27        onClick = { count++ }
28    )
29}
30
31@Preview(showBackground = true)
32@Composable
33fun PreviewAdvancedButtonDemo() {
34    AdvancedButtonDemo()
35}

실행하면 기본 클릭은 카운트를 올리고, 길게 누르면 loading 토글이 된다. loading 중에는 클릭이 막히고, 빠르게 연타하면 debounce로 인해 카운트가 일정 속도 이상으로 오르지 않는다. 이 동작이 recomposition과 무관하게 유지되려면 debounceGate의 수명주기를 어디에 둘지 결정해야 한다. 그 결정이 곧 DI 설계이다.

자주 하는 실수

1) Composable 본문에서 Repository/UseCase를 new로 만든다

증상: 화면에 들어오기만 해도 네트워크/DB가 반복 실행되거나, Logcat에 ‘created’ 로그가 tick처럼 증가한다. 특정 단말에서만 OOM이 나고, 프로파일러에서 Allocation이 UI 프레임마다 튄다.

원인: Composable은 재호출될 수 있는 함수이고, 본문은 recomposition마다 다시 실행된다. 객체 생성이 Slot Table에 저장되지 않으면 호출마다 새 인스턴스가 된다. equals 비교가 깨져 changed 플래그가 매번 바뀌는 것도 흔한 부작용이다.

해결: 의존성은 ViewModel/컨테이너/DI 스코프에서 만들고 Composable에는 상태나 인터페이스만 전달한다. 임시로는 remember로 막을 수 있지만, remember 수명은 Composition이라 화면 전환 시 초기화된다.

2) remember를 DI 대용으로 쓰고 화면 전환에서 상태가 날아간다

증상: 뒤로 갔다가 다시 들어오면 캐시/토큰/타이머가 초기화된다. 사용자는 ‘앱이 기억을 못 한다’고 느끼고, 개발자는 ‘왜 remember가 안 먹지’로 시간을 쓴다.

원인: remember는 Slot Table의 특정 그룹에 저장된다. 네비게이션으로 해당 Composition이 제거되면 그룹이 삭제되고 remember 값도 같이 폐기된다. key가 바뀌어 그룹이 새로 만들어져도 동일하게 초기화된다.

해결: 화면 수명주기를 원하면 ViewModelStoreOwner 범위에 올린다. 앱 수명주기를 원하면 Application/Singleton 스코프(컨테이너)로 올린다. 상태 복원이 목적이면 rememberSaveable을 쓰되 저장 가능한 타입 제약을 고려한다.

3) CompositionLocal을 남발해 의존성 흐름이 숨는다

증상: 코드 리뷰에서 “이 화면은 어디서 repo를 받지?”라는 질문이 반복된다. Preview에서 error("... not provided")가 터지고, 테스트에서 의존성 교체가 어렵다.

원인: CompositionLocal은 트리 암묵 전달이라 호출 시그니처에 의존성이 드러나지 않는다. 제공 위치가 멀어질수록 스코프 경계가 흐려지고, 어느 화면에서 어떤 인스턴스를 쓰는지 추적이 어려워진다.

해결: 앱 전역(테마, 로케일, 로깅 정책)처럼 진짜로 전역인 것만 Local로 둔다. 화면 기능 의존성은 ViewModel 파라미터나 factory로 명시한다. Preview용 Provider를 별도로 만들어 제공 누락을 막는다.

4) @Stable/@Immutable을 잘못 붙여 UI가 갱신되지 않는다

증상: 상태 값이 바뀌었는데 Text가 그대로다. 디버거로 값은 변했는데 화면은 멈춘다. 재현이 간헐적이라 더 괴롭다.

원인: 안정성 애노테이션은 Runtime의 변경 감지 전략에 영향을 준다. 실제로는 내부 필드가 변하는데 @Immutable로 선언하면, Runtime은 “바뀌지 않는다”는 가정 하에 스킵할 수 있다. changed 플래그가 거짓으로 남아 호출이 생략될 수 있다.

해결: 불변 데이터는 data class + val만 사용하고, 내부 가변 컬렉션을 숨기지 않는다. 안정성은 성능 최적화 도구이지 기능 도구가 아니다. 의심되면 애노테이션을 제거하고 recomposition 카운터로 확인한다.

5) SideEffect/LaunchedEffect 키를 의존성 인스턴스로 둔다

증상: 로딩이 반복 시작되고 취소된다. 애니메이션이 초기화된다. 네트워크가 연속으로 취소/재시작되며 서버에 499 같은 로그가 남는다.

원인: LaunchedEffect(key)는 key가 바뀌면 코루틴을 취소하고 새로 시작한다. DI가 없어서 의존성을 매번 새로 만들면 key가 매번 바뀌고, effect가 계속 재시작된다. Composer 입장에서는 정상 동작이다.

해결: effect 키는 안정적인 식별자(id, route, userId)로 둔다. 의존성은 스코프에서 고정한다. 네트워크 호출은 ViewModel에서 수행하고 UI에서는 상태만 관찰한다.

EffectKeyMistake.kt
1import androidx.compose.runtime.Composable
2import androidx.compose.runtime.LaunchedEffect
3import androidx.compose.runtime.remember
4
5class UnstableApi
6
7@Composable
8fun BadEffectKey() {
9    val api = UnstableApi() // 매 호출마다 새 인스턴스
10    LaunchedEffect(api) {
11        // api가 바뀌면 매번 취소/재시작된다
12        kotlinx.coroutines.delay(10)
13    }
14}
15
16@Composable
17fun GoodEffectKey(userId: String) {
18    val api = remember { UnstableApi() }
19    LaunchedEffect(userId) {
20        // 진짜로 다시 시작해야 하는 조건만 키로 둔다
21        kotlinx.coroutines.delay(10)
22    }
23}

성능 최적화 체크리스트

  • Composable 본문에서 new/Builder 호출이 있는지 grep한다(Repo, UseCase, OkHttp, Room).
  • remember로 막은 의존성이 네비게이션 이동/구성 변경에서 유지되어야 하는지 요구사항을 문장으로 적는다.
  • 의존성 스코프를 3단계로 나눈다: 앱 스코프(싱글톤), 화면 스코프(ViewModel), 호출 스코프(remember).
  • LaunchedEffect/DisposableEffect 키가 인스턴스 참조가 아닌 안정적인 식별자(id/route)인지 확인한다.
  • CompositionLocal 제공 위치를 트리에서 시각화한다(어느 NavGraph/Activity에서 제공되는지).
  • @Stable/@Immutable을 붙인 타입에 내부 가변 컬렉션(var, MutableList)이 숨어 있지 않은지 점검한다.
  • 상태 모델( UiState )은 불변(data class) + copy 패턴으로 만들고, Repository는 상태를 직접 들고 있지 않게 한다.
  • Modifier 생성이 매 프레임 반복되지 않게 상수 Modifier는 val로 빼거나 remember로 고정한다(특히 semantics, pointerInput).
  • 클릭/스크롤 같은 interaction 상태가 의존성 재생성으로 튀지 않게 remember { MutableInteractionSource() } 사용 여부를 확인한다.
  • Layout Inspector에서 recomposition count가 높은 노드를 찾고, 그 노드가 의존성을 읽는지(CompositionLocal.current) 확인한다.
  • Android Studio Profiler에서 Allocation을 10초 기록하고, tick 같은 주기 이벤트에서 객체 생성이 증가하는지 본다.
  • Preview가 CompositionLocal 미제공으로 터지지 않게 Preview 전용 Provider를 만든다.

자주 묻는 질문

Compose에서 DI가 꼭 필요한가? 파라미터로 다 넘기면 되지 않나?

파라미터 전달은 가장 좋은 기본값이다. 문제는 화면이 커지면 ‘상태’가 아니라 ‘의존성 그래프’를 전달하게 된다는 점이다. 예를 들어 QuizScreen이 Repo, Analytics, Dispatcher, Clock, FeatureFlag를 받기 시작하면, 상위 호출은 이들을 매번 조합해 내려보내야 하고 네비게이션 경계마다 같은 코드를 반복한다. 더 나쁜 경우는 상위가 recomposition될 때마다 새 인스턴스를 만들어 참조가 바뀌고, changed 플래그가 매번 바뀌어 스킵이 깨진다. DI는 생성 위치와 스코프를 한 곳에 고정해 ‘같은 화면이면 같은 인스턴스’라는 전제를 만든다. 학습 키워드는 ViewModelStoreOwner, scoping, constructor injection, stability 이다.

remember로 Repository를 만들면 왜 DI와 같은 효과가 안 나오나?

remember는 Slot Table의 특정 그룹 위치에 값을 저장해 recomposition 동안 유지한다. 하지만 그 그룹이 사라지면 값도 같이 사라진다. 네비게이션으로 화면이 제거되거나, key 변화로 그룹이 재구성되면 remember 값은 초기화된다. Repository가 캐시, 세션, debounce 같은 정책을 들고 있으면 사용자 경험이 깨진다. 반면 DI나 ViewModel은 Composition 밖 저장소(ViewModelStore, Singleton container)에 객체를 둔다. 그래서 UI 호출이 반복되어도 객체는 유지된다. 학습 키워드는 Slot Table, remember lifetime, Navigation back stack, ViewModel scope 이다.

CompositionLocal은 DI인가? 쓰면 안 좋은가?

CompositionLocal은 DI의 ‘전달 메커니즘’ 중 하나다. 트리 전체에 값을 암묵적으로 전달하니 파라미터 폭발을 막는다. 하지만 남발하면 의존성이 숨고, 제공 위치가 멀어져 스코프 경계가 흐려진다. Preview에서 “not provided” 에러가 터지기 쉬운 것도 이 때문이다. 권장 패턴은 전역 성격(Theme, Density, Locale, Logging policy)처럼 정말로 트리 전체에 필요한 것만 Local로 두고, 화면 기능 의존성(Repo, UseCase)은 ViewModel 생성 시 주입하거나 Composable 파라미터로 명시하는 것이다. 학습 키워드는 staticCompositionLocalOf, provider 위치, preview provider, explicit dependencies 이다.

Hilt 같은 DI 프레임워크를 안 쓰고도 Compose에서 DI를 할 수 있나?

가능하다. 핵심은 ‘객체 생성 위치’와 ‘스코프’를 분리하는 것이다. 예시로 AppContainer를 만들고 setContent 최상단에서 remember로 한 번 생성한 뒤 CompositionLocal로 내려보내면 앱 스코프를 얻는다. 화면 스코프는 ViewModel을 쓰면 된다. 다만 수동 DI는 스코프 조합이 복잡해질수록 실수가 늘어난다. 예를 들어 NavGraph마다 다른 Repo 인스턴스를 쓰려면 제공 위치를 네비게이션 그래프 경계로 맞춰야 한다. 프레임워크는 이 경계를 표준화하고, 테스트에서 교체를 쉽게 만든다. 학습 키워드는 container pattern, scoping, factory, NavBackStackEntry 이다.

recomposition이 자주 일어나면 DI가 성능에 어떤 영향을 주나?

DI 자체가 recomposition을 줄여주지는 않는다. 대신 ‘recomposition이 일어나도 비용이 커지지 않게’ 만든다. Composable 본문에서 Repo를 만들면 매 호출마다 할당이 생기고, 그 Repo를 key로 둔 LaunchedEffect가 재시작되면 코루틴 취소/재시작 비용이 추가된다. 반면 DI로 같은 인스턴스를 재사용하면 changed 플래그가 안정적으로 유지되고, 파라미터 비교에서 스킵이 더 잘 일어난다. 측정은 Allocation(Profiler), recomposition count(Layout Inspector), trace(Perfetto/Compose tracing)로 한다. 학습 키워드는 stability, changed flags, allocation, effect restart 이다.

ViewModel이 있으면 DI는 필요 없나? ViewModel 안에서 new 하면 되지 않나?

ViewModel은 화면 스코프 저장소일 뿐, 의존성 그래프를 구성해주지는 않는다. ViewModel 안에서 new를 해도 동작은 하지만, 테스트에서 교체가 어려워지고, 여러 ViewModel이 같은 Repository를 공유해야 할 때 중복 인스턴스가 생긴다. 예를 들어 QuizVm과 ResultVm이 같은 SessionRepo를 써야 하면, 생성 위치가 분산되면 캐시 일관성이 깨진다. DI는 Repository를 앱/기능 스코프에 두고, ViewModel에는 인터페이스를 주입해 재사용과 테스트 더블을 쉽게 만든다. 학습 키워드는 ViewModelFactory, constructor injection, shared repository, test doubles 이다.

Compose Compiler가 changed 플래그로 스킵한다는데, 의존성 전달이 왜 문제인가?

Composable은 컴파일 시 Composer와 changed 비트를 받는 형태로 변환된다. 런타임은 이전 호출의 파라미터와 현재 파라미터를 비교해 ‘바뀐 것만 다시 실행’하려 한다. 그런데 의존성 객체를 매번 새로 만들어 전달하면 참조가 매번 달라져 changed가 계속 true가 된다. 그 결과 스킵이 깨지고, 하위 트리까지 더럽혀져 recomposition 범위가 넓어진다. DI 컨테이너는 같은 스코프에서 같은 인스턴스를 재사용해 참조 안정성을 제공한다. 추가로 @Stable/@Immutable 같은 안정성 메타데이터는 비교 전략을 바꿔 스킵을 돕는다. 학습 키워드는 composer, changed flags, skipping, stability inference 이다.

관련 글

25. Compose State와 MutableState: remember가 UI를 자동 갱신하는 이유
Compose 기본2026.03.05

25. Compose State와 MutableState: remember가 UI를 자동 갱신하는 이유

Jetpack Compose의 State/MutableState, remember가 필요한 이유, Slot Table 저장 방식과 리컴포지션 범위를 내부 동작 관점에서 설명한다. 초보가 흔히 겪는 버그까지 다룬다. 2026-03 기준 실무 팁 포함.

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

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

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

12. Compose State로 UI가 바뀌는 이유: mutableStateOf와 remember의 내부
Compose 기본2026.03.01

12. Compose State로 UI가 바뀌는 이유: mutableStateOf와 remember의 내부

mutableStateOf와 remember로 UI가 갱신되는 이유를 Compose Runtime 관점에서 설명한다. Slot Table, recomposition 추적, 안정성(@Stable)까지 연결한다. 초보도 내부 흐름을 잡는다.','primaryKeywords':['Jetpack Compose State','m