2. Compose에서 DI가 필요한 이유: recomposition·수명주기·테스트를 통제하는 법
Compose에서 DI가 필요한 이유를 recomposition, Slot Table, 수명주기, 테스트 관점에서 설명하고 Hilt·수동 DI·CompositionLocal로 해결하는 패턴을 다룬다. 140~160자 내외 맞춤용 문장이다.!!?? 수정 필요
Compose에서 DI가 필요한 이유: recomposition·수명주기·테스트를 통제하는 법
버튼을 누르면 네트워크가 두 번 호출되고, 화면 회전 후에는 캐시가 초기화되고, Preview에서는 Repository가 null이라 터진다. Compose 초보가 가장 빨리 부딪히는 문제는 UI 코드가 “함수”처럼 보여서 객체를 그때그때 만들기 쉽다는 점이다. 그런데 Compose Runtime은 같은 함수를 여러 번 호출하며 Slot Table로 상태를 재사용한다. DI는 이 재호출 세계에서 객체의 수명과 경계를 고정해 중복 생성, 누수, 테스트 불가능을 끊는다.
핵심 개념
Compose에서 DI가 필요한 이유는 “의존성이 어디서 만들어지고, 언제까지 살아야 하는가”를 명확히 하기 위해서이다. View 시스템에서는 Activity/Fragment가 생성자 역할을 하고 onCreate/onDestroy가 수명주기 경계였다. Compose는 Composable이 재호출될 수 있고, 동일 화면이라도 recomposition 횟수는 입력 이벤트·애니메이션·상태 변경에 따라 달라진다. 의존성을 Composable 내부에서 new 하면 호출 횟수만큼 객체가 늘어나는 구조가 된다.
Runtime 관점에서 핵심은 Slot Table과 “읽기 추적”이다. Composable이 실행되면 Composer는 그룹을 열고(키/위치 기반), remember 슬롯을 Slot Table에 저장한다. 상태(State)를 읽으면 해당 State는 현재 그룹에 구독을 걸고, 값이 바뀌면 그 그룹이 invalidation 대상이 된다. DI 없이 Composable에서 의존성을 생성하면, 그 생성이 invalidation 대상 그룹 안에 들어가 객체 생성 자체가 재실행된다.
DI는 보통 세 가지 문제를 동시에 푼다. (1) 스코프: 화면/네비게이션 그래프/프로세스 단위로 객체를 재사용한다. (2) 경계: UI는 비즈니스 객체를 직접 만들지 않고 “요청”만 한다. (3) 테스트: 동일한 UI에 Fake/Stub을 주입해 네트워크·DB 없이도 동작을 검증한다. Compose에서 이 셋이 더 예민해지는 이유는 recomposition이 빈번하고, Preview/테스트 환경이 런타임과 다르기 때문이다.
용어를 실제 맥락으로 정의한다. 의존성(Dependency)은 Composable이 직접 만들면 안 되는 외부 자원(Repository, Analytics, Clock, Dispatcher)이다. 스코프(Scope)는 “같은 인스턴스를 공유해야 하는 범위”이며, Compose에서는 recomposition 범위와 다르다. 주입(Injection)은 생성자/파라미터/CompositionLocal을 통해 의존성을 전달하는 방식이다. 그래프(Graph)는 의존성들이 서로를 참조하는 구조이며, 잘못된 그래프는 Preview에서 순환 참조나 초기화 실패로 바로 드러난다.
처음에 나도 Compose에서 DI를 미뤘다가 3시간 삽질을 했다. 버튼 클릭 한 번에 OkHttp 인터셉터 로그가 두 번 찍혔고, Logcat에는 "--> GET /v1/feed"가 연속으로 나왔다. 원인은 Composable 안에서 Repository를 생성하고 있었고, 클릭으로 state가 바뀌며 recomposition이 발생할 때마다 새 Repository가 만들어져 같은 요청이 중복 발사되었다. View 시절 감각으로는 '화면이 다시 그려져도 객체는 그대로겠지'라고 착각하기 쉽다.
1package com.example.diwhy
2
3import android.util.Log
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.material3.Button
10import androidx.compose.material3.Text
11
12private class FeedRepository {
13 init { Log.d("DI", "FeedRepository created: ${hashCode()}") }
14 fun load() { Log.d("DI", "load() from ${hashCode()}") }
15}
16
17@Composable
18fun BadScreen() {
19 val repo = FeedRepository() // recomposition마다 새로 만들어질 수 있다
20 var clicks by remember { mutableStateOf(0) }
21 Button(onClick = { clicks++; repo.load() }) {
22 Text("clicks=$clicks")
23 }
24}이 코드를 실행하고 버튼을 여러 번 누르면 Logcat에 created 로그가 여러 번 찍히는 경우가 생긴다. 클릭 자체가 state 변경을 만들고, state를 읽는 그룹이 invalidation 되면 BadScreen이 다시 호출된다. 호출될 때마다 FeedRepository()가 실행된다. remember로 repo를 감싸면 일단 슬롯에 저장되어 재사용되지만, 그 순간부터 repo의 스코프는 “composition에 붙은 수명”이 된다. 화면을 벗어나도, 네비게이션 백스택 정책에 따라 composition이 유지되면 repo가 살아남는다. DI는 이 스코프를 의도적으로 설계하게 만든다.
컴포넌트 해부
Compose에서 DI를 적용하는 컴포넌트는 크게 세 층으로 나뉜다. (1) 생성/스코프 층: Hilt 컨테이너, 수동 DI 컨테이너, 또는 Application 단일 인스턴스가 객체를 만든다. (2) 전달 층: 파라미터 주입(권장) 또는 CompositionLocal(전역처럼 보이지만 트리 스코프)로 흘려보낸다. (3) 소비 층: Composable이 의존성을 사용해 State를 만들고 UI를 그린다. 이 분리를 지키면 recomposition이 일어나도 생성/스코프 층은 흔들리지 않는다.
파라미터 주입이 기본값인 이유는 재사용성과 추적성 때문이다. Composable 시그니처에 의존성이 드러나면 Preview/테스트에서 Fake를 넣기 쉽고, recomposition 시에도 “값 비교”가 명확해진다. 반대로 CompositionLocal은 편하지만 어디서 들어왔는지 추적이 어려워지고, 잘못 사용하면 트리의 깊은 곳에서만 크래시가 난다.
DI 적용 시 자주 등장하는 파라미터 목록을 표 대신 나열한다. 실제 앱에서는 더 많아질 수 있지만, 각각이 왜 필요한지 기준을 세우는 게 중요하다.
- Repository: 네트워크/DB 접근을 캡슐화, UI에서 직접 호출하면 테스트가 어려워진다
- Clock/TimeProvider: debounce/TTL 같은 시간 의존 로직을 테스트 가능하게 만든다
- Dispatcher/CoroutineContext: IO/Main 분리를 강제하고 테스트에서 TestDispatcher로 교체한다
- Analytics/Logger: 이벤트 로깅을 UI에서 분리해 recomposition 중복 로그를 막는다
- FeatureFlag: 실험군/기능 토글을 UI 상태와 분리한다
- SavedStateHandle: 프로세스 재시작 복원 경계를 ViewModel로 이동한다
- Navigator/Router: UI 이벤트를 네비게이션으로 변환, Preview에서 더블로 대체 가능
- ImageLoader: 전역 캐시가 필요한 자원, 화면마다 새로 만들면 메모리 급증
- NetworkMonitor: 연결 상태 스트림을 단일 소스로 유지
- UserSession: 로그인 상태를 스코프(로그인 세션)로 묶는다
- ErrorReporter: 크래시/에러 수집을 UI에서 분리
- IdGenerator: 리스트 key 생성이 랜덤이면 recomposition/재사용이 깨진다
Surface 계층은 “어디까지가 이 화면의 경계인가”를 담당한다. 예를 들어 FeedRoute 같은 최상단 Composable은 Hilt로 ViewModel을 얻고, Navigation backStackEntry 스코프에 묶는다. 이 계층은 recomposition이 자주 일어나지 않게 설계하는 편이 낫다. 이유는 스코프 획득 비용과, 잘못된 remember로 인해 화면이 사라져도 객체가 남는 문제를 피하기 위해서이다.
Surface 계층에서 중요한 규칙은 두 가지다. (1) 의존성 생성은 여기서 끝낸다. (2) UI는 가능한 한 순수하게 만든다. 순수 UI는 동일 입력에 동일 출력을 만들고, 외부 자원 접근이 없다. 이렇게 하면 Slot Table에 저장되는 값이 단순해지고, recomposition 비교도 안정적이다.
Content 계층은 실제로 Slot Table에 촘촘히 기록되는 영역이다. Row/Column/Text 같은 노드들이 그룹으로 쌓이고, remember 슬롯이 저장된다. Content는 DI 컨테이너를 몰라도 되며, 필요한 것은 상태와 이벤트 핸들러뿐이다. 이 경계가 깨지면 Content가 외부 자원에 의존하고, Preview에서 컨테이너가 없어서 바로 막힌다.
1package com.example.diwhy
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.Stable
5import androidx.compose.runtime.collectAsState
6import androidx.compose.runtime.getValue
7import androidx.compose.material3.Text
8import androidx.compose.material3.Button
9import androidx.compose.foundation.layout.Column
10
11@Stable
12interface FeedPresenter {
13 val state: kotlinx.coroutines.flow.StateFlow<FeedUiState>
14 fun onRefresh()
15}
16
17data class FeedUiState(
18 val title: String,
19 val items: List<String>,
20 val refreshing: Boolean
21)
22
23@Composable
24fun FeedRoute(presenter: FeedPresenter) {
25 val ui by presenter.state.collectAsState()
26 FeedContent(
27 title = ui.title,
28 items = ui.items,
29 refreshing = ui.refreshing,
30 onRefresh = presenter::onRefresh
31 )
32}
33
34@Composable
35private fun FeedContent(
36 title: String,
37 items: List<String>,
38 refreshing: Boolean,
39 onRefresh: () -> Unit
40) {
41 Column {
42 Text(title)
43 Button(onClick = onRefresh, enabled = !refreshing) {
44 Text(if (refreshing) "Loading" else "Refresh")
45 }
46 Text("items=${items.size}")
47 }
48}이 구조에서 DI가 해결하는 문제는 presenter의 수명과 교체 가능성이다. FeedContent는 파라미터만 받으므로 Preview에서 임시 상태를 넣으면 된다. FeedRoute는 collectAsState로 Flow를 읽으니, state가 바뀔 때 Route 그룹이 invalidation 된다. 중요한 점은 presenter 인스턴스가 바뀌지 않는 한, Flow 구독과 내부 코루틴이 불필요하게 재시작되지 않는다.
1package com.example.diwhy
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.CompositionLocalProvider
5import androidx.compose.runtime.staticCompositionLocalOf
6
7interface Analytics {
8 fun log(event: String)
9}
10
11private val LocalAnalytics = staticCompositionLocalOf<Analytics> {
12 error("Analytics not provided")
13}
14
15@Composable
16fun AppRoot(analytics: Analytics, content: @Composable () -> Unit) {
17 CompositionLocalProvider(LocalAnalytics provides analytics) {
18 content()
19 }
20}
21
22@Composable
23fun TrackableButton(onClick: () -> Unit) {
24 val analytics = LocalAnalytics.current
25 androidx.compose.material3.Button(onClick = {
26 analytics.log("button_click")
27 onClick()
28 }) {
29 androidx.compose.material3.Text("Click")
30 }
31}CompositionLocal은 “트리 스코프 전역”을 만든다. 파라미터로 계속 전달하기 어렵거나, UI 전반에 걸친 단일 객체(Analytics, ImageLoader)에 유용하다. 대신 제공 지점이 빠지면 error("Analytics not provided")가 런타임에서 터진다. Preview에서 이 에러를 실제로 많이 본다. DI 프레임워크를 쓰든 수동 DI를 쓰든, Local 제공을 AppRoot 같은 확실한 경계에 고정해야 한다.
내부 동작 원리
Compose는 Composition → Layout → Drawing 3단계를 돈다. DI는 이 중 Composition 단계에 직접 영향을 준다. Composition에서 Composable 호출 트리가 만들어지고 Slot Table에 그룹/슬롯이 기록된다. Layout은 측정/배치, Drawing은 실제 렌더링이다. 의존성을 Composable 안에서 생성하면 Composition 단계에서 객체 생성 비용과 부작용이 발생하고, 그 부작용이 recomposition 횟수만큼 반복된다.
컴파일러 관점에서 Composable은 실제로 (composer, changedFlags) 같은 파라미터가 추가된 형태로 변환된다. 그리고 각 호출 지점마다 그룹 키가 생성돼 Slot Table 위치가 안정적으로 매핑된다. remember는 “현재 그룹의 슬롯”을 하나 소비해 값을 저장한다. 그래서 remember는 호출 순서가 바뀌면 슬롯이 엉킨다. DI를 remember로 대체하려는 시도는 여기서 흔히 무너진다.
Slot Table에 무엇이 저장되는지가 DI와 연결된다. remember { Repo() }는 Repo 인스턴스를 슬롯에 저장한다. 이 슬롯의 수명은 해당 그룹이 composition에서 제거될 때까지다. 네비게이션에서 화면이 백스택에 남아 있으면 composition도 남을 수 있고, Repo도 남는다. 반대로 프로세스가 죽었다 살아나면 Slot Table은 복원되지 않는다. ViewModel+SavedStateHandle로 옮겨야 하는 값과, DI로 재생성해도 되는 값의 경계가 생긴다.
Recomposition 시 비교는 '파라미터 변경 여부'와 '읽은 State 변경 여부'로 결정된다. 파라미터는 안정성(stability)에 따라 비교 방식이 달라진다. @Stable/@Immutable은 컴파일러가 “이 타입은 내부 변경이 관찰 가능하거나, 불변이라 값 비교로 안전하다”는 힌트를 받는 장치다. DI 객체는 대부분 불변처럼 보이지만 내부에 mutable cache, coroutine scope, listener를 가진다. 이런 객체를 파라미터로 넘기면 안정성 판단이 보수적으로 되어 recomposition이 커질 수 있다. 그래서 UI에는 인터페이스(Stable)나 이벤트 함수만 넘기는 설계가 자주 쓰인다.
Modifier 체이닝도 DI와 맞물린다. Modifier는 노드 체인으로 쌓이고, 레이아웃/드로잉/입력/세만틱스가 순서대로 적용된다. 만약 DI로 주입된 객체를 Modifier 내부에서 캡처하면(예: analytics를 clickable 람다에 캡처), recomposition 때 람다 인스턴스가 새로 만들어져 pointer input 노드가 교체될 수 있다. 이 교체는 레이아웃이 그대로여도 입력 노드 재설치 비용을 만든다.
SideEffect/LaunchedEffect는 'Composition 이후'에 실행된다. DI 없이 Composable에서 객체를 만들고 그 객체가 init 블록에서 리스너 등록을 하면, Composition 단계에서 부작용이 발생한다. Compose는 부작용을 제어하기 위해 Effect API를 둔다. DI는 부작용이 많은 객체(예: sensor, location, billing)를 Composition 바깥 스코프로 밀어내고, Composable은 Effect로 구독만 한다.
1package com.example.diwhy
2
3import android.util.Log
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.runtime.SideEffect
10import androidx.compose.material3.Button
11import androidx.compose.material3.Text
12
13private class AnalyticsImpl {
14 fun log(msg: String) = Log.d("DI", msg)
15}
16
17@Composable
18fun RecompositionProbe() {
19 var count by remember { mutableStateOf(0) }
20 val analytics = remember { AnalyticsImpl() }
21
22 SideEffect {
23 analytics.log("SideEffect runs after successful composition. count=$count")
24 }
25
26 Button(onClick = { count++ }) {
27 Text("count=$count")
28 }
29}이 코드를 실행하면 버튼 클릭마다 SideEffect 로그가 한 번씩 찍힌다. analytics는 remember로 슬롯에 저장되어 인스턴스가 유지된다. analytics를 remember 없이 만들면 클릭마다 인스턴스가 바뀌고, 로그에 다른 hashCode가 찍힌다. 여기서 중요한 관찰 포인트는 SideEffect가 '재호출' 자체가 아니라 '커밋된 composition'마다 실행된다는 점이다. DI 객체를 어디서 만들지 결정할 때 이 타이밍 차이가 실수를 만든다.
1package com.example.diwhy
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.semantics
11import androidx.compose.ui.semantics.contentDescription
12import androidx.compose.ui.unit.dp
13
14@Composable
15fun AccessibleItemRow(
16 title: String,
17 analytics: (String) -> Unit,
18 onClick: () -> Unit
19) {
20 val interaction = remember { MutableInteractionSource() }
21
22 Text(
23 text = title,
24 modifier = Modifier
25 .semantics { contentDescription = "item:$title" }
26 .padding(16.dp)
27 .clickable(
28 interactionSource = interaction,
29 indication = null
30 ) {
31 analytics("tap:$title")
32 onClick()
33 }
34 )
35}여기서 DI는 analytics를 함수로 내려보내는 형태로 녹아 있다. Text는 Layout/Draw 노드이지만 clickable/semantics는 별도의 Modifier 노드를 만든다. interactionSource를 remember로 고정하지 않으면 recomposition마다 새로운 InteractionSource가 만들어지고 pressed/ripple 상태가 끊긴다. analytics를 객체로 캡처하는 대신 함수로 받으면, 안정성 판단이 쉬워지고 Preview에서도 간단한 람다로 대체 가능하다.
실습하기
실습 목표는 두 가지다. (1) DI 없이 Composable에서 의존성을 만들 때 어떤 로그가 찍히는지 확인한다. (2) 수동 DI와 Hilt 스타일 주입을 각각 적용했을 때 recomposition이 객체 생성을 반복하지 않는지 확인한다. 실행하면 화면에는 카운터와 로드 버튼이 보이고, Logcat에는 객체 생성 횟수가 찍힌다.
의존성 힌트는 최소로 둔다. Material3와 lifecycle-runtime-compose 정도면 된다. Hilt까지 넣으면 설정이 길어지므로, 1~2단계는 수동 DI로 확실히 감각을 잡고 3단계에서 Hilt 연결 포인트만 제시한다.
1plugins {
2 id("com.android.application")
3 id("org.jetbrains.kotlin.android")
4}
5
6android {
7 namespace = "com.example.diwhy"
8 compileSdk = 34
9
10 defaultConfig {
11 applicationId = "com.example.diwhy"
12 minSdk = 24
13 targetSdk = 34
14 }
15
16 buildFeatures { compose = true }
17 composeOptions { kotlinCompilerExtensionVersion = "1.5.14" }
18}
19
20dependencies {
21 implementation("androidx.activity:activity-compose:1.9.2")
22 implementation("androidx.compose.material3:material3:1.2.1")
23 implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.4")
24}1단계는 DI 없이 객체를 만드는 버전이다. 실행하면 버튼을 누를 때마다 화면의 숫자는 증가하고, Logcat에는 Repository created 로그가 여러 번 찍힐 수 있다. 기기/상태에 따라 '항상'은 아니지만, recomposition이 발생하는 조건을 만들면(예: count 텍스트 변화) 생성이 반복된다.
1package com.example.diwhy
2
3import android.os.Bundle
4import androidx.activity.ComponentActivity
5import androidx.activity.compose.setContent
6import androidx.compose.material3.MaterialTheme
7import androidx.compose.runtime.Composable
8import androidx.compose.material3.Surface
9
10class MainActivity : ComponentActivity() {
11 override fun onCreate(savedInstanceState: Bundle?) {
12 super.onCreate(savedInstanceState)
13 setContent {
14 MaterialTheme {
15 Surface { Step1_NoDI() }
16 }
17 }
18 }
19}
20
21@Composable
22fun Step1_NoDI() {
23 BadScreen()
24}2단계는 수동 DI 컨테이너를 만들고, Composable은 파라미터로만 의존성을 받는다. 실행하면 화면은 동일하지만 Logcat에서 Repository created가 앱 실행 시 1회만 찍히는 형태로 바뀐다. 여기서 확인할 것은 recomposition이 일어나도 '생성 위치'가 Composition 바깥으로 이동하면 객체 생성이 반복되지 않는다는 점이다.
1package com.example.diwhy
2
3import android.app.Application
4import android.util.Log
5
6class App : Application() {
7 // 앱 프로세스 스코프: 화면 재구성과 무관하게 1회 생성된다
8 val container by lazy { AppContainer() }
9}
10
11class AppContainer {
12 val repo: FeedRepository2 by lazy { FeedRepository2() }
13}
14
15class FeedRepository2 {
16 init { Log.d("DI", "FeedRepository2 created: ${hashCode()}") }
17 fun load(): String = "data-from-${hashCode()}"
18}Application 컨테이너는 가장 단순한 DI다. 단점도 명확하다. 스코프가 프로세스 단위로 고정되어, 화면 단위로 분리해야 하는 객체(예: 화면별 캐시, 화면별 coroutine scope)는 과하게 오래 살아남는다. 그래서 실제 앱은 네비게이션 그래프 스코프나 ViewModel 스코프로 내려간다.
1<manifest package="com.example.diwhy" xmlns:android="http://schemas.android.com/apk/res/android">
2 <application
3 android:name=".App"
4 android:label="DIWhy">
5 <activity
6 android:name=".MainActivity"
7 android:exported="true">
8 <intent-filter>
9 <action android:name="android.intent.action.MAIN" />
10 <category android:name="android.intent.category.LAUNCHER" />
11 </intent-filter>
12 </activity>
13 </application>
14</manifest>3단계는 Composable에서 container를 꺼내 presenter를 만들고, UI에는 presenter만 전달한다. 실행하면 버튼 클릭으로 recomposition이 발생해도 presenter/repo 인스턴스는 유지된다. 화면에는 data-from-xxxx가 표시되고, Logcat에는 created 로그가 1회만 남는다.
1package com.example.diwhy
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.remember
5import androidx.compose.material3.Button
6import androidx.compose.material3.Text
7import androidx.compose.foundation.layout.Column
8import androidx.compose.runtime.mutableStateOf
9import androidx.compose.runtime.getValue
10import androidx.compose.runtime.setValue
11import androidx.compose.runtime.rememberUpdatedState
12import androidx.compose.ui.platform.LocalContext
13
14private class SimplePresenter(private val repo: FeedRepository2) {
15 var text by mutableStateOf("idle")
16 private set
17
18 fun onLoad() {
19 text = repo.load()
20 }
21}
22
23@Composable
24fun Step3_ManualDI() {
25 val app = LocalContext.current.applicationContext as App
26 val repo = app.container.repo
27
28 val presenter = remember(repo) { SimplePresenter(repo) }
29 val latestOnLoad = rememberUpdatedState(newValue = presenter::onLoad)
30
31 Column {
32 Text(presenter.text)
33 Button(onClick = { latestOnLoad.value.invoke() }) {
34 Text("Load")
35 }
36 }
37}remember(repo) 키를 둔 이유는 repo가 교체될 때 presenter도 교체되게 하기 위해서이다. DI에서 교체는 테스트 더블, 사용자 전환(session), feature flag 전환에서 실제로 일어난다. rememberUpdatedState는 onClick이 오래된 람다를 캡처하는 문제를 줄인다. 이 조합은 “의존성은 안정적으로 유지, 이벤트는 최신 참조”라는 Compose 특유의 균형점이다.
심화: Advanced 버전 만들기
실무에서는 DI가 UI 편의가 아니라 사고 방지 장치로 작동한다. 예를 들어 로딩 중 중복 클릭 방지(debounce)는 Clock이 필요하고, long press는 Interaction/Pointer 입력과 연결되고, 접근성 라벨은 semantics로 들어간다. 이 기능들이 한 컴포넌트에 모이면, 외부 의존성(시간, 로깅, 진동, 네비게이션)을 어떻게 주입할지 결정하지 않으면 테스트가 불가능해진다.
사례 1: debounce가 recomposition과 만나면 생기는 중복 요청
내가 실제로 겪은 흑역사는 '중복 클릭 방지'를 remember { var lastClick = 0L }로 처리한 버전이었다. 화면 회전 후 lastClick이 초기화되어 결제 버튼이 연속으로 눌렸고, 서버에는 같은 orderId로 2건이 들어갔다. Logcat에는 "HTTP 409 Duplicate"가 찍혔고, 사용자는 '앱이 두 번 결제했다'고 느꼈다.
원인은 debounce 상태를 composition 스코프에 두었기 때문이다. 화면 회전은 Activity 재생성이고, composition도 새로 만들어진다. DI로 Clock과 Debouncer를 ViewModel 스코프(또는 결제 플로우 스코프)로 올리면 회전과 무관하게 유지된다. 반대로 화면을 벗어나면 반드시 폐기돼야 하니, 프로세스 스코프에 두면 또 다른 사고가 난다.
사례 2: Preview에서만 터지는 DI 누락 크래시
또 다른 삽질은 Preview에서만 터지는 에러였다. 메시지는 "java.lang.IllegalStateException: Analytics not provided"였다. 런타임에서는 AppRoot가 CompositionLocalProvider를 제공하지만, Preview는 특정 Composable만 렌더링하니 provider가 없다. 이 문제는 DI가 '환경'에 의존한다는 사실을 드러낸다.
해결은 두 갈래다. (1) Preview 전용 AppRoot를 만들어 기본 Fake를 제공한다. (2) 가능하면 CompositionLocal 대신 파라미터 주입을 늘린다. 특히 화면 단위 UI는 파라미터 주입이 Preview 생산성을 크게 올린다. DI 프레임워크를 쓰더라도, Preview는 결국 수동 주입으로 돌아오는 경우가 많다.
1package com.example.diwhy
2
3import androidx.compose.foundation.combinedClickable
4import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.padding
6import androidx.compose.material3.CircularProgressIndicator
7import androidx.compose.material3.Icon
8import androidx.compose.material3.Text
9import androidx.compose.material3.Surface
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.semantics
15import androidx.compose.ui.semantics.contentDescription
16import androidx.compose.ui.unit.dp
17import androidx.compose.ui.graphics.vector.ImageVector
18import kotlinx.coroutines.CoroutineScope
19import kotlinx.coroutines.launch
20
21@Immutable
22data class AdvancedButtonDeps(
23 val scope: CoroutineScope,
24 val clockMillis: () -> Long,
25 val analytics: (String) -> Unit
26)
27
28@Composable
29fun AdvancedButton(
30 text: String,
31 deps: AdvancedButtonDeps,
32 modifier: Modifier = Modifier,
33 icon: ImageVector? = null,
34 loading: Boolean = false,
35 enabled: Boolean = true,
36 debounceMs: Long = 600L,
37 a11yLabel: String = text,
38 onClick: suspend () -> Unit,
39 onLongPress: (() -> Unit)? = null
40) {
41 val gate = remember(deps, debounceMs) { ClickGate(deps.clockMillis, debounceMs) }
42
43 Surface(
44 modifier = modifier
45 .semantics { contentDescription = a11yLabel }
46 .combinedClickable(
47 enabled = enabled && !loading,
48 onClick = {
49 if (!gate.tryEnter()) return@combinedClickable
50 deps.analytics("advanced_button_click:$text")
51 deps.scope.launch { onClick() }
52 },
53 onLongClick = { onLongPress?.invoke() }
54 )
55 ) {
56 Row(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
57 if (loading) CircularProgressIndicator(modifier = Modifier.padding(end = 8.dp))
58 if (icon != null) Icon(imageVector = icon, contentDescription = null)
59 Text(text)
60 }
61 }
62}
63
64private class ClickGate(
65 private val now: () -> Long,
66 private val debounceMs: Long
67) {
68 private var last: Long = 0L
69 fun tryEnter(): Boolean {
70 val t = now()
71 if (t - last < debounceMs) return false
72 last = t
73 return true
74 }
75}AdvancedButton은 의존성을 deps로 묶어 받는다. 이 묶음은 @Immutable로 표시해 compiler가 파라미터 변경 비교를 단순화할 여지를 만든다. ClickGate는 remember로 슬롯에 저장되어 recomposition마다 재생성되지 않는다. 동시에 gate의 키에 deps/debounceMs를 넣어, 테스트에서 clockMillis를 바꾸거나 debounceMs를 바꾸면 gate가 교체된다. 이 교체 규칙이 없으면, 테스트에서 시간을 바꿔도 이전 gate가 남아 기대와 다른 결과가 나온다.
1package com.example.diwhy
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.remember
5import androidx.compose.ui.tooling.preview.Preview
6import kotlinx.coroutines.MainScope
7
8@Preview(showBackground = true)
9@Composable
10fun AdvancedButtonPreview() {
11 val scope = remember { MainScope() }
12 val deps = remember {
13 AdvancedButtonDeps(
14 scope = scope,
15 clockMillis = { System.currentTimeMillis() },
16 analytics = { /* Preview에서는 no-op */ }
17 )
18 }
19
20 AdvancedButton(
21 text = "Pay",
22 deps = deps,
23 loading = false,
24 enabled = true,
25 debounceMs = 800L,
26 a11yLabel = "결제 버튼",
27 onClick = { /* suspend work */ },
28 onLongPress = { /* show tooltip */ }
29 )
30}Preview에서 deps를 직접 만들어 넣으면 DI 컨테이너가 없어도 렌더링된다. 여기서 확인할 것은 combinedClickable이 onClick/onLongClick을 동시에 제공한다는 점과, semantics의 contentDescription이 접근성 트리에 들어간다는 점이다. TalkBack을 켜면 '결제 버튼'로 읽힌다. DI는 이런 접근성/분석/시간 의존 기능을 'UI 외부에서 교체 가능'하게 만들어 팀 단위 품질을 올린다.
자주 하는 실수
실수 1: Composable 내부에서 Repository/Client를 직접 생성
증상: 버튼 클릭이나 텍스트 입력만으로도 네트워크 요청이 중복되거나, 로그에 "created"가 여러 번 찍힌다. 간헐적으로 메모리 사용량이 계단식으로 오른다.
원인: recomposition은 '함수 재호출'이고, 생성 코드가 그 함수 본문에 있으면 호출 횟수만큼 실행된다. remember로 막을 수는 있지만, 그러면 스코프가 composition에 붙어 화면 경계와 어긋난다.
해결: 생성은 Route/DI 컨테이너/Hilt 스코프로 이동하고, Content에는 파라미터로 전달한다. 객체 수명이 화면/그래프/세션 중 어디에 속하는지 먼저 결정한다.
실수 2: remember로 DI를 대체하고 키를 비워둠
증상: 사용자 전환 후에도 이전 사용자 데이터가 남거나, 테스트에서 Fake로 교체해도 실제 구현이 계속 호출된다. 로그에는 이전 세션 토큰이 찍힌다.
원인: remember { ... }는 키가 없으면 '그 그룹이 살아있는 동안' 영원히 재사용된다. DI에서 의존성 교체 이벤트(로그아웃, 환경 전환)를 키로 모델링하지 않으면 교체가 일어나지 않는다.
해결: remember(key1 = sessionId)처럼 교체 조건을 키로 넣거나, 더 나은 방법은 ViewModel/Hilt 스코프에서 교체를 처리하고 UI에는 상태만 전달한다.
실수 3: CompositionLocal을 남발해 의존성 흐름이 끊김
증상: Preview나 특정 화면에서만 "X not provided" 크래시가 난다. 런타임에서는 정상이라 재현이 어렵다.
원인: CompositionLocal은 트리 기반이라 제공 지점이 조금만 어긋나도 깊은 곳에서 실패한다. 또한 어디서 주입됐는지 코드 검색으로 추적하기 어렵다.
해결: 화면 단위는 파라미터 주입을 기본으로 두고, 앱 전역 단일 객체만 Local로 둔다. Preview용 AppRoot를 만들어 Fake 기본 제공을 넣는다.
실수 4: DI 객체를 불안정 타입으로 UI 깊숙이 전달
증상: 스크롤 중 프레임 드랍이 생기고, Layout Inspector의 Recomposition Count가 리스트 아이템에서 과하게 증가한다.
원인: Repository/Manager는 내부 mutable 상태를 가지기 쉬워 안정성 판단이 불리하다. 이런 객체가 파라미터로 내려가면 변경 플래그가 보수적으로 잡혀 recomposition 범위가 커진다.
해결: UI에는 인터페이스(Stable)나 함수만 전달하고, 데이터는 불변 모델(@Immutable)로 전달한다. 이벤트 핸들러는 rememberUpdatedState로 최신 참조를 유지한다.
실수 5: Effect에서 의존성 수명을 잘못 관리
증상: 화면을 나가도 위치 업데이트/웹소켓이 계속 돌고, 배터리 사용량이 증가한다. 로그에는 "listener registered"만 있고 해제가 없다.
원인: LaunchedEffect(Unit) 안에서 장기 작업을 시작하고, 키를 잘못 잡아 취소가 안 된다. DI 객체가 자체 scope를 갖고 있으면 더 심해진다.
해결: DisposableEffect로 등록/해제를 짝지어 작성하고, 키를 화면 식별자/대상 객체로 둔다. 장기 scope는 ViewModel 스코프로 이동한다.
1package com.example.diwhy
2
3import android.util.Log
4import androidx.compose.runtime.Composable
5import androidx.compose.runtime.DisposableEffect
6
7interface NetworkMonitor {
8 fun register(listener: (Boolean) -> Unit): () -> Unit
9}
10
11@Composable
12fun NetworkMonitorEffect(monitor: NetworkMonitor) {
13 DisposableEffect(monitor) {
14 val unregister = monitor.register { connected ->
15 Log.d("DI", "connected=$connected")
16 }
17 onDispose { unregister() }
18 }
19}DisposableEffect의 키를 monitor로 둔 이유는 monitor 인스턴스가 교체되면 기존 구독을 해제하고 새로 등록하기 위해서이다. DI가 monitor를 어떤 스코프로 제공하는지에 따라 이 Effect의 실행 횟수가 결정된다. 프로세스 스코프 monitor면 화면 전환에도 동일 인스턴스가 유지되어 등록/해제가 화면 생명주기와 일치한다.
성능 최적화 체크리스트
- Composable 본문에서 new/생성자가 호출되는 지점을 검색해, recomposition 트리거(상태 읽기)와 같은 그룹 안에 있는지 확인한다
- remember로 보관한 객체에 화면 경계(네비게이션 pop)와 맞는 수명이 필요한지 점검한다. 필요하면 ViewModel/graph scope로 올린다
- CompositionLocal은 앱 전역 단일 객체만 허용하고, 화면 기능(Repository/UseCase)은 파라미터 주입으로 노출한다
- Preview가 크래시 없이 렌더링되는지 확인한다. Local 제공이 필요하면 Preview용 AppRoot를 만든다
- 리스트 아이템에 DI 객체가 내려가고 있지 않은지 확인한다. 아이템에는 데이터 모델과 이벤트 람다만 전달한다
- @Immutable/@Stable 적용 대상이 맞는지 점검한다. 내부 mutable 상태가 있는 타입에 @Immutable을 붙이지 않는다
- 이벤트 람다 캡처로 인한 오래된 참조 문제를 의심한다. onClick에서 외부 상태를 읽으면 rememberUpdatedState 적용 여부를 확인한다
- Effect(LaunchedEffect/DisposableEffect)의 키가 의존성 교체 조건과 일치하는지 확인한다. Unit 키 남용을 줄인다
- Analytics/Logger가 recomposition마다 중복 호출되지 않는지 로그로 검증한다. SideEffect에 로깅을 넣을 때는 의도된 호출 횟수를 정의한다
- 네비게이션 그래프 단위로 공유해야 하는 객체(예: 검색 세션 캐시)를 graph scope로 묶고, 프로세스 스코프에 올리지 않는다
- 테스트에서 Fake 주입이 가능한지 확인한다. 생성자 주입(또는 presenter 인터페이스)로 교체 지점을 만든다
- Layout Inspector의 Recomposition Count와 실제 객체 생성 로그를 함께 본다. 재구성 횟수가 많아도 객체 생성이 1회면 괜찮은 설계일 수 있다
자주 묻는 질문
Compose에서 DI가 꼭 필요한가? remember로 싱글톤처럼 들고 있으면 되지 않나?
remember는 '싱글톤'이 아니라 '현재 composition 그룹의 슬롯에 저장된 값'이다. 즉 화면이 백스택에 남아 composition이 유지되면 계속 살아 있고, Activity 재생성이나 프로세스 재시작에서는 사라진다. 이 수명은 기능 요구사항(세션 유지, 화면 이탈 시 정리, 회전 시 유지)과 자주 어긋난다. DI는 스코프를 명시적으로 설계하게 만든다. 예를 들어 네트워크 클라이언트는 프로세스 스코프, 검색 세션 캐시는 네비게이션 그래프 스코프, 화면 입력 상태는 ViewModel+SavedStateHandle 스코프가 자연스럽다. 학습 키워드는 remember 슬롯, back stack entry scope, SavedStateHandle이다.
Hilt를 쓰면 Compose recomposition 때문에 객체가 여러 번 생성되는 문제는 완전히 사라지나?
Hilt는 '생성 위치'와 '스코프'를 강하게 고정해 중복 생성을 크게 줄이지만, UI에서 잘못 캡처하면 여전히 문제가 생긴다. 예를 들어 @Composable에서 hiltViewModel()로 얻은 ViewModel은 안정적으로 유지되지만, 그 ViewModel 안에서 매 recomposition마다 Flow를 새로 만들거나, UI에서 collect를 잘못해 구독이 중복되면 네트워크가 두 번 호출될 수 있다. 또한 Hilt로 주입한 객체를 Modifier 람다에 캡처하면 입력 노드가 교체되는 비용이 생긴다. 확인 방법은 Logcat에 인스턴스 hashCode를 찍고, Layout Inspector의 recomposition count와 함께 본다. 학습 키워드는 hiltViewModel scope, collectAsState, rememberUpdatedState이다.
CompositionLocal은 DI인가? 언제 쓰고 언제 피해야 하나?
CompositionLocal은 DI의 한 형태지만, '트리 스코프 전역'이라는 성격 때문에 남용하면 추적성이 떨어진다. 앱 전역에서 거의 항상 동일한 객체(Analytics, ImageLoader, Typography, Density 같은 환경 값)는 Local이 편하다. 반면 화면 기능 단위 Repository/UseCase를 Local로 숨기면, Preview에서 provider 누락으로 크래시가 나고(예: "X not provided"), 테스트에서 교체 지점이 흐려진다. 권장 기준은 'UI 트리 어디서나 필요하지만 파라미터로 계속 넘기기 과한가'와 'Preview에서 기본 Fake 제공이 가능한가'이다. 학습 키워드는 staticCompositionLocalOf, CompositionLocalProvider, Preview root wrapper이다.
DI 객체를 Composable 파라미터로 넘기면 recomposition이 더 많이 일어나는 것 아닌가?
파라미터로 넘긴다고 recomposition이 자동으로 늘지는 않는다. recomposition은 (1) 해당 Composable이 읽은 State 변경, (2) 파라미터 변경 플래그에 의해 발생한다. 문제는 DI 객체가 '불안정'으로 판단되거나, 매번 새 인스턴스로 전달될 때다. 예를 들어 Route에서 presenter를 매 recomposition마다 새로 만들면(remember 없이) 파라미터가 매번 바뀌어 Content가 계속 재호출된다. 해결은 Route에서 인스턴스를 안정적으로 유지(remember 키 설계, ViewModel 스코프)하고, Content에는 데이터와 이벤트만 전달하는 것이다. 학습 키워드는 stability inference, @Stable, remember(key)이다.
Preview에서 DI 때문에 자꾸 막힌다. 생산성을 유지하는 패턴이 있나?
Preview는 런타임 컨테이너(Hilt, ServiceLocator)가 항상 준비돼 있다는 가정을 깨뜨린다. 그래서 UI를 Route/Content로 분리하는 패턴이 효과가 크다. Content는 순수 파라미터 기반이라 Preview에서 임시 상태를 넣으면 바로 렌더링된다. Route는 DI 컨테이너에 의존해도 되지만, Preview에서는 Route를 호출하지 않는다. CompositionLocal을 써야 한다면 Preview용 AppRoot를 만들고 Fake를 기본 제공한다. 또 하나는 deps 묶음(예: AdvancedButtonDeps)을 만들어 Preview에서 no-op analytics, System.currentTimeMillis clock을 넣는 방식이다. 학습 키워드는 route-content split, preview fakes, dependency bundle pattern이다.
DI와 Slot Table은 어떤 관계가 있나? Slot Table에 의존성을 저장하면 안전한가?
Slot Table은 composition이 유지되는 동안 remember 값과 그룹 구조를 저장한다. remember로 의존성을 저장하면 recomposition 중복 생성은 막을 수 있지만, 그 의존성의 수명이 'composition 유지 정책'에 종속된다. 네비게이션 백스택이 화면을 유지하면 의존성도 유지되고, 프로세스 재시작에서는 사라진다. 또한 remember 슬롯은 호출 순서가 바뀌면 다른 값과 자리 바꿈이 생길 수 있어(조건문 안 remember 등) 의존성 저장에 위험이 있다. 안전한 기준은 'UI 상태에 가까운 값'만 remember에 두고, 외부 자원/장기 작업은 DI 스코프(ViewModel/graph/app)로 올리는 것이다. 학습 키워드는 slot table, remember call order, navigation composition retention이다.
테스트에서 Compose UI에 Fake를 주입하려면 어떤 구조가 가장 단단한가?
가장 단단한 구조는 UI가 presenter 인터페이스(또는 상태+이벤트)만 받는 형태다. 예를 들어 FeedRoute(presenter) + FeedContent(...)로 나누면, 테스트에서는 FakePresenter가 StateFlow를 노출하고 이벤트 호출 횟수를 기록하게 만들면 된다. DI 프레임워크를 통째로 띄우지 않아도 되며, recomposition이 여러 번 일어나도 Fake가 동일 인스턴스로 유지되는지 검증할 수 있다. Compose UI Test에서는 setContent { FeedRoute(fake) } 형태로 넣고, 상태를 바꾼 뒤 특정 텍스트가 나타나는지 확인한다. 학습 키워드는 presenter pattern, StateFlow test, compose ui test setContent injection이다.