5. Compose에서 DI 오버헤드 줄이기: 컴포저블 스코프와 객체 생성 최적화
Jetpack Compose에서 DI 호출·객체 생성이 리컴포지션과 섞일 때 생기는 비용을 Slot Table, 안정성(@Stable) 관점에서 추적하고 최적화 패턴을 제시한다. 140~160자 내외 구성이다. 160자 채움용 문장 추가 없음. 150자대.
Compose에서 DI 오버헤드 줄이기: 컴포저블 스코프와 객체 생성 최적화
Compose를 처음 붙이면 DI는 “필요할 때 주입받으면 되지”라는 감각으로 시작한다. 그런데 버튼을 한 번 눌렀을 뿐인데 로그에 “create FooRepository”가 수십 번 찍히거나, 스크롤 중에 프레임 드랍이 생긴다. 원인은 대개 리컴포지션 경로 안에서 DI 호출과 객체 생성이 반복되기 때문이다. 이 글은 왜 그런 일이 생기는지, Slot Table과 안정성 모델 관점에서 추적하고 끊는 방법을 다룬다. 처음에 나도 “Composable은 함수 호출일 뿐”이라서, 함수 안에서 get()을 부르는 게 뭐가 문제인지 감이 없었다. Layout Inspector에서 Recompose Count가 올라가는데도 화면은 멀쩡해서 더 헷갈렸다. 3시간 삽질 끝에 남은 건 로그 하나였다: 같은 화면에서 같은타
핵심 개념
Compose에서 DI 오버헤드는 “DI 프레임워크가 느리다”가 아니라, “리컴포지션이 DI 호출을 반복시킨다”에 가깝다. Composable은 상태 변화에 따라 같은 함수를 여러 번 호출한다. 그 호출 경로에 객체 생성이나 컨테이너 조회가 섞이면, UI 변경과 무관한 비용이 프레임마다 누적된다. View 시스템에서는 onCreate/onStart 같은 생명주기에 주입 시점을 고정하기 쉬웠지만, Compose는 호출 시점이 곧 생명주기처럼 보이기 때문에 실수하기 쉽다.
용어를 5개만 잡고 간다. (1) Composition: Composable 호출로 UI 트리를 “기록”하는 단계다. (2) Slot Table: 이전 Composition 결과(그룹, 키, remember 값, 파라미터 변화 여부)를 저장하는 런타임 테이블이다. (3) Recomposition: 상태 읽기(read)가 바뀌었을 때 해당 범위의 그룹을 다시 실행하는 과정이다. (4) 안정성(Stable/Immutable): 파라미터가 ‘변하지 않았음’을 런타임이 빠르게 판단하기 위한 계약이다. (5) 컴포저블 스코프: remember/CompositionLocal이 유효한 범위이며, 이 범위를 넘어가면 캐시가 끊긴다.
DI 호출이 왜 리컴포지션과 충돌하는지부터 분해한다. Compose Runtime은 Composable을 실행하면서 “어떤 State를 읽었는지”를 추적한다. 이후 State가 바뀌면 해당 그룹만 재실행한다. 문제는 DI 호출이 State와 무관하게도 “그룹 재실행 때마다 실행된다”는 점이다. 즉, UI가 조금 바뀌는 것과 무관하게 컨테이너 조회/리플렉션/동기화/객체 생성이 반복될 수 있다.
Slot Table 관점에서 보면 remember가 왜 필요한지가 선명해진다. remember는 ‘이 그룹의 이 위치에 값을 저장’한다. 같은 그룹이 같은 순서로 재실행되면, remember는 저장된 값을 다시 돌려준다. DI로 받은 객체를 remember 없이 매번 만들면 Slot Table에 저장될 틈이 없다. 반대로 remember를 쓰면 “리컴포지션은 발생하되 객체 생성은 유지”라는 분리가 가능해진다.
1package com.example.diopt
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.getValue
9import androidx.compose.runtime.mutableIntStateOf
10import androidx.compose.runtime.remember
11import androidx.compose.runtime.setValue
12import androidx.compose.runtime.mutableStateOf
13import androidx.compose.runtime.remember
14
15private class FakeContainer {
16 fun createHeavy(): HeavyService {
17 Log.d("DI", "create HeavyService")
18 return HeavyService()
19 }
20}
21
22private class HeavyService
23
24@Composable
25fun DiOverheadDemo(container: FakeContainer) {
26 var clicks by remember { mutableIntStateOf(0) }
27
28 // 문제 지점: 리컴포지션마다 createHeavy가 호출된다
29 val service = container.createHeavy()
30
31 Column {
32 Text("clicks=$clicks, service=$service")
33 Button(onClick = { clicks++ }) { Text("+1") }
34 }
35}이 코드를 실행하고 버튼을 여러 번 누르면 Logcat에 create HeavyService가 클릭 횟수만큼 찍힌다. UI는 clicks만 바뀌는데도, service 생성이 매번 반복된다. 이유는 clicks가 바뀌면서 DiOverheadDemo 그룹이 리컴포지션되고, 그룹 본문이 재실행되기 때문이다. Slot Table에는 clicks의 remember 값은 저장되지만, service는 remember로 저장하지 않았으니 매번 새로 만든다.
컴포넌트 해부
DI 최적화는 특정 라이브러리(Hilt/Koin) 문법이 아니라, “어디에 생성하고 어디에 보관할지”를 결정하는 일이다. Compose에서 생성 위치는 곧 스코프다. 화면 단위로 유지할지, 네비게이션 엔트리 단위로 유지할지, 혹은 단일 CompositionLocal로 앱 전체에서 공유할지에 따라 Slot Table에 저장되는 위치가 달라진다.
DI 오버헤드를 줄이는 컴포저블의 구성 요소를 파라미터 관점에서 해부한다. 표 대신 목록으로 정리한다. 이런 파라미터들이 왜 필요한지까지 연결해야 “최적화가 안전한지” 판단이 된다.
- container: DI 컨테이너(또는 엔트리 포인트). 호출 비용과 스레드 세이프티가 다르다
- key: remember의 키. 화면 파라미터가 바뀌면 캐시를 버려야 할 때 필요하다
- factory: 객체 생성 람다. 테스트에서 fake를 주입하거나 생성 정책을 바꾸는 지점이다
- scope: 객체 생명주기 경계. CompositionLocal/remember/rememberSaveable/ViewModel 중 어디에 둘지 결정한다
- params: 생성에 필요한 입력값. 안정성(Stable) 여부가 리컴포지션 스킵에 영향을 준다
- onDispose: 화면이 사라질 때 정리할 자원(예: close, cancel). DisposableEffect와 연결된다
- dispatcher: 백그라운드 초기화/프리페치가 필요할 때 코루틴 컨텍스트를 분리한다
- logger: 생성 횟수/타이밍을 관찰하기 위한 훅. 최적화는 관측 가능해야 한다
- qualifier: 같은 타입의 바인딩이 여러 개일 때 선택 기준이다
- cachePolicy: single/per-screen/per-item 같은 정책. 리스트 아이템에서 특히 중요하다
- stableWrapper: @Stable 래퍼로 파라미터 변경 감지를 줄이는 전략
- rememberStrategy: remember vs remember(key) vs rememberUpdatedState 선택 기준
Surface 계층과 Content 계층을 분리해 생각하면 최적화 지점이 보인다. Surface는 ‘외형’과 ‘상호작용 입력’을 담당하고, Content는 ‘데이터를 그리는’ 부분이다. DI는 Content 쪽에서 필요해 보이지만, 실제로는 Content가 자주 리컴포지션되는 구간이라 DI 호출을 두면 비용이 폭발하기 쉽다. DI로 만든 객체는 Surface 바깥(상위)에서 고정하고 Content에는 참조만 내려주는 편이 안전하다.
Surface 계층에서 자주 변하는 값은 pressed/hover/focus 같은 interaction이다. interaction은 프레임당 변할 수 있고, 그 변화를 읽는 Composable은 리컴포지션이 잦다. DI로 만든 서비스가 interaction과 같은 그룹에 있으면, “눌림 애니메이션”이 서비스 생성으로 이어질 수도 있다. 실제로 내가 겪은 케이스는 ripple을 켠 버튼 위에서 DI 조회가 반복되며, 스크롤 중에 GC가 튀었다.
Content 계층은 데이터에 따라 바뀐다. 여기서 중요한 건 “데이터 변경으로 인한 리컴포지션”과 “객체 생성”을 분리하는 것이다. Content는 재실행되어도 된다. 대신 Content가 참조하는 서비스/리포지토리는 동일 인스턴스로 유지되어야 한다. Compose의 remember는 이 분리를 위한 가장 작은 도구다.
1package com.example.diopt
2
3import android.util.Log
4import androidx.compose.runtime.Composable
5import androidx.compose.runtime.DisposableEffect
6import androidx.compose.runtime.remember
7
8class Container {
9 fun <T : Any> get(key: String, factory: () -> T): T {
10 Log.d("DI", "lookup key=$key")
11 return factory()
12 }
13}
14
15class AnalyticsClient {
16 init { Log.d("DI", "create AnalyticsClient") }
17 fun close() { Log.d("DI", "close AnalyticsClient") }
18}
19
20@Composable
21fun rememberAnalytics(container: Container, userId: String): AnalyticsClient {
22 // userId가 바뀌면 다른 클라이언트를 써야 하므로 key를 건다
23 val client = remember(userId) {
24 container.get("analytics:$userId") { AnalyticsClient() }
25 }
26
27 // 화면에서 사라질 때 자원 정리 시점이 명확해진다
28 DisposableEffect(client) {
29 onDispose { client.close() }
30 }
31
32 return client
33}이 코드는 “DI 조회를 remember로 감싼다”가 핵심이 아니다. 핵심은 Slot Table에 저장될 위치를 명확히 지정한다는 점이다. remember(userId)는 같은 그룹/같은 순서로 재실행될 때 캐시를 재사용하고, userId가 바뀌면 캐시를 폐기한다. DisposableEffect는 Composition에서 해당 그룹이 빠질 때 호출되어, View 시스템의 onDestroy 같은 정리 지점을 만든다.
1package com.example.diopt
2
3import androidx.compose.foundation.layout.PaddingValues
4import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.padding
6import androidx.compose.material3.Surface
7import androidx.compose.runtime.Composable
8import androidx.compose.ui.Modifier
9import androidx.compose.ui.graphics.Shape
10import androidx.compose.ui.unit.dp
11
12@Composable
13fun DiOptimizedCard(
14 modifier: Modifier = Modifier,
15 shape: Shape,
16 contentPadding: PaddingValues = PaddingValues(12.dp),
17 content: @Composable () -> Unit
18) {
19 // Surface: 외형과 입력을 담당
20 Surface(modifier = modifier, shape = shape) {
21 // Content: 자주 리컴포지션되는 영역
22 Row(modifier = Modifier.padding(contentPadding)) {
23 content()
24 }
25 }
26}구조를 이렇게 나누면 DI 객체를 어디에 둘지 결정하기 쉬워진다. DiOptimizedCard 내부는 content가 언제든 재실행될 수 있는 공간이다. DI 객체를 content 바깥에서 만들어 content에 전달하면, 리컴포지션은 발생해도 DI 비용은 고정된다. 반대로 content 내부에서 container.get()을 호출하면, padding/shape 변경과 무관한 리컴포지션에도 DI 호출이 따라붙는다.
내부 동작 원리
Compose의 실행 파이프라인은 Composition → Layout → Drawing 순서다. DI 오버헤드는 보통 Composition 단계에서 터진다. Composition은 “무엇을 그릴지”를 결정하기 위해 Composable을 실행하고, 그 실행 결과를 Slot Table에 기록한다. Layout은 측정/배치, Drawing은 실제 렌더링이다. 즉, DI 호출은 화면이 그려지기 전에 CPU를 먼저 태워버릴 수 있다.
Compose Compiler는 @Composable 함수를 변환해 Composer를 인자로 받는 형태로 바꾼다. 실제 생성물은 훨씬 복잡하지만, 핵심은 두 가지다. (1) 그룹 시작/종료를 통해 Slot Table에 위치를 만든다. (2) 파라미터가 ‘변했는지’ 검사해 스킵 가능한지 판단한다. DI 호출이 그룹 본문에 있으면, 스킵되지 않는 한 매번 실행된다.
Slot Table에는 remember 값이 “슬롯”으로 저장된다. 같은 그룹이 같은 순서로 재실행되면 해당 슬롯을 다시 읽는다. 여기서 “왜 순서가 중요하냐”가 나온다. remember 호출 순서가 바뀌면 슬롯 인덱스가 달라져서 다른 값을 읽게 된다. 그래서 조건문 안에서 remember를 호출하면 안 된다는 규칙이 생겼다. DI 캐시를 remember로 할 때도 이 규칙을 그대로 따른다.
Recomposition 시 비교는 크게 두 갈래다. (1) 상태 기반: mutableStateOf 같은 Snapshot State를 읽은 곳을 추적하고, 값이 바뀌면 해당 그룹을 invalidation 한다. (2) 파라미터 기반: 호출 지점에서 파라미터가 이전과 같은지(안정성 규칙 포함) 비교해 스킵한다. DI 객체를 파라미터로 내려줄 때 @Stable/@Immutable 여부가 스킵 가능성에 영향을 준다.
Modifier 체인은 왜 체이닝 방식인가라는 질문이 DI 최적화와 연결된다. Modifier는 불변 리스트처럼 합성되며, 각 노드는 측정/배치/그리기/입력/시맨틱스에 관여한다. Modifier가 바뀌면 해당 노드가 다시 만들어질 수 있다. 리스트 아이템에서 Modifier에 DI 객체를 캡처하면, 스크롤 중 Modifier 재생성이 DI 객체 참조를 끌고 다니며 예기치 않은 수명 연장을 만든다.
한 문단 요약이다. 리컴포지션은 UI 함수 재실행이며, DI 호출/객체 생성이 그 안에 있으면 반복된다. remember/CompositionLocal/ViewModel 같은 스코프 도구로 “객체 생성 위치”를 Slot Table 또는 다른 저장소로 옮기면, UI 변경과 DI 비용이 분리된다.
리컴포지션 경로에서 DI 조회·객체 생성을 분리해야 프레임당 반복 비용이 사라진다. remember는 Slot Table에 캐시를 고정하는 도구이고, 안정성(@Stable/@Immutable)은 파라미터 비교 비용과 스킵 가능성을 결정한다.
1package com.example.diopt
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.getValue
9import androidx.compose.runtime.mutableIntStateOf
10import androidx.compose.runtime.remember
11import androidx.compose.runtime.setValue
12
13private class ExpensiveRepo {
14 init { Log.d("DI", "create ExpensiveRepo") }
15 fun query(): String = "data"
16}
17
18@Composable
19fun RecompositionTraceScreen() {
20 var n by remember { mutableIntStateOf(0) }
21
22 // 해결: Slot Table에 repo를 고정한다
23 val repo = remember { ExpensiveRepo() }
24
25 Column {
26 Text("n=$n, repo=${repo.query()}")
27 Button(onClick = { n++ }) { Text("recompose") }
28 }
29}버튼을 눌러도 create ExpensiveRepo 로그는 1번만 찍힌다. 리컴포지션은 계속 발생하지만, repo 생성은 remember 슬롯에서 재사용된다. 여기서 확인할 포인트는 “repo가 상태를 읽지 않아도” 리컴포지션과 무관하게 본문이 재실행된다는 사실이다. remember가 없으면 재실행마다 init 로그가 찍힌다.
1package com.example.diopt
2
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.interaction.MutableInteractionSource
5import androidx.compose.foundation.layout.Column
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
12
13@Composable
14fun InteractionAndSemanticsDemo(
15 label: String,
16 onClick: () -> Unit
17) {
18 // interactionSource는 pressed 상태를 내보내며, 읽는 곳은 리컴포지션이 잦다
19 val interactionSource = remember { MutableInteractionSource() }
20
21 Column(
22 modifier = Modifier
23 .semantics { contentDescription = "di-demo:$label" }
24 .clickable(
25 interactionSource = interactionSource,
26 indication = null,
27 onClick = onClick
28 )
29 ) {
30 Text("Tap: $label")
31 }
32}이 코드는 DI와 직접 관련 없어 보이지만, 실무에서는 이런 입력/시맨틱스 레이어가 리컴포지션을 자주 만든다. clickable이 만드는 pressed 상태를 UI가 읽으면 그룹이 자주 invalidation 된다. 그 그룹 안에 DI 조회가 있으면, 눌림/해제만으로도 DI 호출이 반복될 수 있다. 그래서 DI 객체는 interaction이 있는 레이어 밖으로 빼는 편이 안전하다.
실습하기
실습 목표는 두 가지다. (1) DI 호출이 리컴포지션에 의해 반복되는 로그를 직접 본다. (2) remember/CompositionLocal로 생성 위치를 옮겨서 로그가 사라지는 걸 확인한다. Logcat 필터는 "DI"로 두면 된다.
1plugins {
2 id("com.android.application")
3 id("org.jetbrains.kotlin.android")
4}
5
6android {
7 namespace = "com.example.diopt"
8 compileSdk = 34
9
10 defaultConfig {
11 applicationId = "com.example.diopt"
12 minSdk = 24
13 targetSdk = 34
14 }
15
16 buildFeatures { compose = true }
17 composeOptions {
18 kotlinCompilerExtensionVersion = "1.5.14"
19 }
20}
21
22dependencies {
23 implementation(platform("androidx.compose:compose-bom:2024.06.00"))
24 implementation("androidx.activity:activity-compose:1.9.0")
25 implementation("androidx.compose.material3:material3")
26 debugImplementation("androidx.compose.ui:ui-tooling")
27}Compose BOM을 쓰면 버전 충돌이 줄어든다. 실습은 DI 라이브러리를 붙이지 않고, 컨테이너 조회/생성 비용을 로그로 흉내 낸다. 실제 Hilt/Koin에서도 핵심은 동일하다. “리컴포지션 경로에서 컨테이너를 몇 번 조회하나”가 관찰 포인트다.
1단계: 반복 생성이 눈에 보이게 만들기
가장 먼저 “왜 문제가 생겼는지”를 눈으로 본다. 버튼을 누를 때마다 create 로그가 찍히면, 리컴포지션이 객체 생성을 끌고 다닌다는 뜻이다. 화면은 정상인데 로그만 폭증하는 패턴이 실제 앱에서 더 위험하다. QA 단계에서는 잘 안 보이고, 스크롤/애니메이션에서만 터지기 때문이다.
1package com.example.diopt
2
3import android.os.Bundle
4import android.util.Log
5import androidx.activity.ComponentActivity
6import androidx.activity.compose.setContent
7import androidx.compose.foundation.layout.Column
8import androidx.compose.material3.Button
9import androidx.compose.material3.MaterialTheme
10import androidx.compose.material3.Text
11import androidx.compose.runtime.Composable
12import androidx.compose.runtime.getValue
13import androidx.compose.runtime.mutableIntStateOf
14import androidx.compose.runtime.remember
15import androidx.compose.runtime.setValue
16
17class MainActivity : ComponentActivity() {
18 override fun onCreate(savedInstanceState: Bundle?) {
19 super.onCreate(savedInstanceState)
20 setContent {
21 MaterialTheme {
22 Step1_BadDiScreen()
23 }
24 }
25 }
26}
27
28private class Container1 {
29 fun provide(): Service1 {
30 Log.d("DI", "provide Service1")
31 return Service1()
32 }
33}
34
35private class Service1
36
37@Composable
38fun Step1_BadDiScreen() {
39 val container = remember { Container1() }
40 var count by remember { mutableIntStateOf(0) }
41
42 // 문제: 리컴포지션마다 provide()가 호출된다
43 val service = container.provide()
44
45 Column {
46 Text("count=$count, service=$service")
47 Button(onClick = { count++ }) { Text("click") }
48 }
49}실행하면 버튼 클릭마다 provide Service1 로그가 찍힌다. container는 remember로 고정했는데도 service는 매번 새로 만들어진다. 이 장면이 “DI 컨테이너를 싱글톤으로 만들면 해결”이 아니라는 증거다. 컨테이너가 고정돼도, 제공 메서드가 매번 호출되면 생성은 매번 일어난다.
2단계: 컴포저블 스코프에 캐시 고정하기
여기서 필요한 건 “서비스를 어디에 저장할지”다. remember는 Slot Table의 현재 그룹 위치에 저장한다. 즉, Step2_GoodDiScreen이 같은 호출 위치에 남아 있는 한 서비스는 유지된다. 버튼 클릭으로 리컴포지션이 발생해도, remember 슬롯에서 값을 재사용한다.
1package com.example.diopt
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.getValue
9import androidx.compose.runtime.mutableIntStateOf
10import androidx.compose.runtime.remember
11import androidx.compose.runtime.setValue
12
13private class Container2 {
14 fun provide(): Service2 {
15 Log.d("DI", "provide Service2")
16 return Service2()
17 }
18}
19
20private class Service2 {
21 init { Log.d("DI", "create Service2") }
22}
23
24@Composable
25fun Step2_GoodDiScreen() {
26 val container = remember { Container2() }
27 var count by remember { mutableIntStateOf(0) }
28
29 val service = remember { container.provide() }
30
31 Column {
32 Text("count=$count, service=$service")
33 Button(onClick = { count++ }) { Text("click") }
34 }
35}실행하면 provide/create 로그가 첫 Composition에서만 찍힌다. 클릭할 때는 count만 바뀌고, service는 유지된다. 이때 Slot Table에는 count의 IntState와 service 인스턴스가 각각 슬롯으로 저장된다. 리컴포지션은 그룹을 재실행하지만, remember는 기존 슬롯을 읽어오므로 생성 경로를 타지 않는다.
3단계: CompositionLocal로 DI 조회 위치를 상위로 올리기
실무에서는 서비스가 여러 Composable에서 필요하다. 그때 props로 계속 내려주면 파라미터가 늘고, 안정성 문제로 스킵이 깨질 수 있다. CompositionLocal은 “암묵적 파라미터”로 컨테이너를 전달한다. 중요한 건 Local에서 꺼내는 호출 위치다. Local에서 꺼낸 뒤 remember로 고정하면, 하위 트리에서 DI 조회가 반복되지 않는다.
1package com.example.diopt
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.CompositionLocalProvider
9import androidx.compose.runtime.getValue
10import androidx.compose.runtime.mutableIntStateOf
11import androidx.compose.runtime.remember
12import androidx.compose.runtime.setValue
13import androidx.compose.runtime.staticCompositionLocalOf
14
15class AppContainer {
16 fun provideRepo(): Repo {
17 Log.d("DI", "provide Repo")
18 return Repo()
19 }
20}
21
22class Repo {
23 init { Log.d("DI", "create Repo") }
24 fun load(): String = "repo-data"
25}
26
27val LocalAppContainer = staticCompositionLocalOf<AppContainer> {
28 error("LocalAppContainer not provided")
29}
30
31@Composable
32fun Step3_LocalDiRoot() {
33 val container = remember { AppContainer() }
34 CompositionLocalProvider(LocalAppContainer provides container) {
35 Step3_LocalDiScreen()
36 }
37}
38
39@Composable
40private fun Step3_LocalDiScreen() {
41 var count by remember { mutableIntStateOf(0) }
42
43 val repo = remember {
44 // Local을 읽는 위치는 이 그룹이며, 여기서 remember로 고정한다
45 LocalAppContainer.current.provideRepo()
46 }
47
48 Column {
49 Text("count=$count, data=${repo.load()}")
50 Button(onClick = { count++ }) { Text("click") }
51 }
52}실행하면 provide/create Repo 로그가 1번만 찍힌다. 여기서 중요한 확인 포인트는 LocalAppContainer.current 자체는 상태가 아니라는 점이다. Local을 제공하는 쪽이 바뀌면 CompositionLocal이 invalidation을 만든다. 그래서 Local로 받은 값을 remember로 고정할 때는 “키를 무엇으로 둘지”를 같이 결정해야 한다. 예를 들어 로그인 userId가 바뀌면 remember(userId)로 캐시를 폐기해야 한다.
심화: Advanced 버전 만들기
실무에서는 DI 오버헤드가 단독으로 오지 않는다. 클릭 디바운스, 로딩 상태, 아이콘/텍스트 조합, 롱프레스, 접근성 라벨 같은 요구사항이 버튼 하나에 몰린다. 이때 “상태는 자주 바뀌는데, 서비스는 고정”이라는 분리를 유지해야 한다. Advanced 컴포넌트는 그 분리의 테스트다.
사례 1: 이벤트 트래킹 DI를 클릭 경로에서 분리
처음에 나도 onClick 안에서 analytics를 컨테이너에서 꺼냈다. 클릭은 드물다고 생각했기 때문이다. 그런데 실제로는 클릭 디바운스 구현을 잘못해서 onClick이 2~3번 연속 호출됐고, Logcat에는 "provide Analytics"가 폭증했다. 더 최악은 StrictMode에서 "disk read on main thread" 경고가 같이 뜬 케이스였다. 컨테이너가 내부에서 lazy 초기화를 하며 파일 접근을 했기 때문이다.
교정은 간단했다. analytics는 remember로 고정하고, onClick에는 ‘최신 람다’만 연결한다. rememberUpdatedState를 쓰면 recomposition으로 onClick 람다가 바뀌어도, 내부에서 참조하는 콜백은 최신으로 유지된다. 이 패턴은 DI 객체와 이벤트 핸들러의 수명을 분리한다.
1package com.example.diopt
2
3import android.os.SystemClock
4import android.util.Log
5import androidx.compose.foundation.layout.Row
6import androidx.compose.material3.Button
7import androidx.compose.material3.CircularProgressIndicator
8import androidx.compose.material3.Icon
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
11import androidx.compose.runtime.LaunchedEffect
12import androidx.compose.runtime.getValue
13import androidx.compose.runtime.mutableLongStateOf
14import androidx.compose.runtime.mutableStateOf
15import androidx.compose.runtime.remember
16import androidx.compose.runtime.rememberUpdatedState
17import androidx.compose.runtime.setValue
18import androidx.compose.ui.Modifier
19import androidx.compose.ui.semantics.contentDescription
20import androidx.compose.ui.semantics.semantics
21
22class Analytics {
23 init { Log.d("DI", "create Analytics") }
24 fun track(name: String) { Log.d("DI", "track=$name") }
25}
26
27@Composable
28fun AdvancedButton(
29 text: String,
30 modifier: Modifier = Modifier,
31 loading: Boolean,
32 debounceMs: Long = 500,
33 icon: (@Composable () -> Unit)? = null,
34 accessibilityLabel: String = text,
35 analytics: Analytics,
36 onClick: () -> Unit,
37 onLongClick: (() -> Unit)? = null
38) {
39 val latestOnClick by rememberUpdatedState(onClick)
40 val latestOnLongClick by rememberUpdatedState(onLongClick)
41
42 var lastClickAt by remember { mutableLongStateOf(0L) }
43
44 Button(
45 modifier = modifier.semantics { contentDescription = accessibilityLabel },
46 enabled = !loading,
47 onClick = {
48 val now = SystemClock.elapsedRealtime()
49 if (now - lastClickAt < debounceMs) return@Button
50 lastClickAt = now
51
52 analytics.track("tap:$text")
53 latestOnClick()
54 }
55 ) {
56 Row {
57 if (loading) CircularProgressIndicator()
58 if (!loading && icon != null) icon()
59 Text(text)
60 }
61 }
62
63 // 롱프레스는 PointerInput까지 가면 코드가 길어지므로, 여기서는 개념만 유지
64 LaunchedEffect(latestOnLongClick) {
65 // 실제 구현에서는 Modifier.pointerInput으로 long press를 처리한다
66 }
67}이 컴포넌트에서 DI 최적화 포인트는 analytics 파라미터다. analytics를 호출자에서 remember로 고정해 넘기면, 버튼이 로딩 상태로 수십 번 리컴포지션되어도 analytics 생성은 1번이다. 반대로 AdvancedButton 내부에서 컨테이너.get()을 하면, loading 토글과 함께 DI 조회가 반복된다. 실행 시 Logcat에서 create Analytics가 1번만 찍히는지 확인한다.
사례 2: 리스트 아이템에서 per-item 스코프를 잘못 잡아 생긴 GC 폭탄
RecyclerView 감각대로 LazyColumn 아이템 안에서 remember를 믿고 서비스를 만들었다가, 스크롤 시점에 생성/폐기가 반복되며 GC가 튀는 경험이 있었다. 증상은 Choreographer 경고였다: "Skipped 40 frames!". 원인은 아이템이 화면 밖으로 나가면 Composition에서 제거되고, Slot Table의 그룹도 사라져 remember 캐시가 같이 사라진다. 다시 스크롤로 들어오면 다시 생성된다.
해결은 스코프를 ‘아이템’이 아니라 ‘화면’ 또는 ‘ViewModel’로 올리는 것이다. 아이템별로 꼭 달라야 하는 값만 remember(key=itemId)로 만들고, 무거운 DI 객체는 상위에서 공유한다. 리스트 아이템은 리컴포지션도 많고, 구성도 자주 바뀌므로 DI 생성 지점으로 최악이다.
1package com.example.diopt
2
3import android.util.Log
4import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.lazy.LazyColumn
6import androidx.compose.foundation.lazy.items
7import androidx.compose.material3.MaterialTheme
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.runtime.remember
11
12class ImageLoader {
13 init { Log.d("DI", "create ImageLoader") }
14 fun load(id: Int): String = "img:$id"
15}
16
17@Composable
18fun ListScreenOptimized(ids: List<Int>) {
19 // 화면 스코프에 고정
20 val loader = remember { ImageLoader() }
21
22 LazyColumn {
23 items(ids, key = { it }) { id ->
24 ListRow(id = id, loader = loader)
25 }
26 }
27}
28
29@Composable
30private fun ListRow(id: Int, loader: ImageLoader) {
31 // 아이템별로 달라야 하는 가벼운 값만 생성
32 val label = remember(id) { "Row#$id" }
33 Column {
34 Text(label, style = MaterialTheme.typography.bodyLarge)
35 Text(loader.load(id))
36 }
37}스크롤해도 create ImageLoader 로그는 1번만 찍힌다. 반대로 ListRow 안에서 remember { ImageLoader() }를 두면, 아이템이 화면에서 제거될 때마다 캐시가 폐기되어 다시 생성된다. LazyColumn은 아이템 Composition을 적극적으로 버리기 때문에, Slot Table 캐시만 믿고 per-item DI를 두는 건 위험하다.
내 흑역사 하나 더 남긴다. Hilt를 붙인 프로젝트에서 @Composable 내부에서 EntryPointAccessors.fromApplication(...)을 매번 호출했다. 화면은 잘 돌아갔지만, 프로파일링에서 main thread에 synchronized 블록이 찍혔다. 원인은 엔트리포인트 조회가 내부 캐시를 타더라도 락을 잡는 경로가 있었기 때문이다. 교정은 엔트리포인트를 remember로 고정하고, 실제 의존성은 ViewModel이나 remember로 한 단계 더 고정하는 방식으로 바꿨다.
또 다른 삽질은 안정성 문제였다. DI로 만든 객체를 data class 파라미터에 넣어 내려보냈는데, 그 data class가 @Immutable이 아니어서 매 리컴포지션마다 파라미터가 ‘변한 것’으로 취급됐다. Recompose Count가 예상보다 높아졌고, 원인을 찾는 데 시간이 걸렸다. 해결은 UI 모델을 불변으로 유지하고, 변하는 건 State로 분리하는 방식이었다.
자주 하는 실수
실수 1: Composable 본문에서 container.get()을 직접 호출
증상: 버튼 클릭이나 텍스트 입력처럼 사소한 상태 변경에도 DI 생성 로그가 반복된다. GC가 늘고, 스크롤 중 프레임 드랍이 섞인다.
원인: 리컴포지션은 Composable 본문을 재실행한다. get() 호출은 상태와 무관해도 매번 실행된다. 컨테이너가 싱글톤이어도 제공 메서드가 팩토리면 객체가 매번 새로 만들어진다.
해결: remember/remember(key)로 DI 결과를 Slot Table에 고정하거나, 더 상위(화면 루트, ViewModel, CompositionLocal Provider)로 생성 위치를 올린다. key는 로그인 사용자, 라우트 파라미터처럼 수명 경계를 결정하는 값으로 둔다.
실수 2: LazyColumn 아이템 안에서 무거운 의존성을 remember로 생성
증상: 스크롤할 때마다 create 로그가 다시 찍힌다. 프로파일러에서 Allocation이 스크롤과 함께 톱니 모양으로 튄다.
원인: 아이템 Composition은 화면 밖으로 나가면 제거된다. Slot Table의 해당 그룹이 사라지면서 remember 캐시도 같이 사라진다. 다시 들어오면 새 그룹이 만들어져 다시 생성된다.
해결: 무거운 DI 객체는 화면 스코프(리스트를 소유한 Composable)나 ViewModel 스코프에 둔다. 아이템에서는 id 기반의 가벼운 파생 값만 remember(id)로 만든다.
실수 3: remember 키를 안 걸어 수명이 길어져 잘못된 사용자/세션을 참조
증상: 로그아웃 후 다른 계정으로 로그인했는데도 이전 계정 데이터가 섞인다. 네트워크 헤더에 이전 토큰이 남는 식의 버그가 나온다.
원인: remember { ... }는 해당 그룹이 유지되는 동안 값을 유지한다. 사용자/라우트가 바뀌어도 그룹이 유지되면 캐시가 갱신되지 않는다.
해결: remember(userId)처럼 수명 경계를 키로 명시한다. 토큰/사용자/워크스페이스 같은 값이 바뀌면 캐시를 폐기해야 한다. 정리 작업이 필요하면 DisposableEffect로 close/cancel을 연결한다.
실수 4: 안정성이 깨진 UI 모델을 파라미터로 내려 스킵이 무너짐
증상: 값이 안 바뀌는데도 Recompose Count가 높다. 특히 상위에서 data class를 새로 만들어 내려보낼 때 심해진다.
원인: Compose는 파라미터가 Stable/Immutable이면 더 공격적으로 스킵한다. 불안정한 타입은 equals 비교가 아니라 “변했을 수 있음”으로 처리되어 재실행이 늘 수 있다.
해결: UI 모델은 불변으로 유지하고(@Immutable 가능하면 적용), 변하는 값은 State로 분리한다. DI 객체를 UI 모델에 끼워 넣지 말고, 참조는 별도 파라미터로 분리하거나 상위 스코프로 올린다.
실수 5: onClick 람다에 DI 객체를 캡처해 수명 연장/메모리 누수 유사 현상
증상: 화면을 나갔는데도 네트워크 콜백이 남아 있거나, Activity가 GC되지 않는 것처럼 보인다. LeakCanary에서 람다 캡처 체인이 길게 나온다.
원인: 람다가 DI 객체나 Context를 캡처하고, 그 람다가 더 긴 수명(예: remember로 저장된 이벤트 핸들러, long-lived coroutine)에 묶이면 객체 수명이 늘어난다.
해결: rememberUpdatedState로 최신 람다만 유지하고, DI 객체는 명시적 스코프(remember, ViewModel)에서 관리한다. Context가 필요하면 LocalContext.current를 즉시 전달하지 말고 필요한 순간에 읽는 방식으로 캡처를 줄인다.
1package com.example.diopt
2
3import androidx.compose.material3.Button
4import androidx.compose.material3.Text
5import androidx.compose.runtime.Composable
6import androidx.compose.runtime.getValue
7import androidx.compose.runtime.rememberUpdatedState
8
9@Composable
10fun ClickHandlerSafe(onClick: () -> Unit) {
11 val latestOnClick by rememberUpdatedState(onClick)
12 Button(onClick = { latestOnClick() }) {
13 Text("safe")
14 }
15}이 패턴은 DI 최적화와 직접 연결된다. onClick이 바뀔 때마다 Button이 가진 람다 인스턴스를 교체하지 않아도 되고, 람다 캡처로 인한 수명 연장을 줄인다. DI 객체를 캡처해야 한다면, 그 객체의 스코프를 먼저 고정한 뒤 캡처한다.
성능 최적화 체크리스트
- DI 조회(get/entryPoint)를 Composable 본문 최상단에서 바로 호출하지 않는다. 호출이 필요하면 remember로 감싼다.
- remember 키를 수명 경계(예: userId, routeId, workspaceId)로 명시한다. 키 없이 캐시하면 세션 버그가 난다.
- LazyColumn 아이템 내부에서 무거운 객체(Repo/Client/Parser)를 만들지 않는다. 화면 스코프로 올린다.
- DI 객체를 UI 모델(data class)에 넣어 전달하지 않는다. 안정성 깨짐과 책임 혼합이 동시에 생긴다.
- CompositionLocal에서 current를 읽는 위치를 고정하고, 그 결과를 remember로 캐시한다. 하위에서 반복 조회하지 않는다.
- DisposableEffect로 close/cancel 같은 정리를 연결한다. View 시스템의 onDestroy에 해당하는 지점을 만든다.
- 람다 캡처로 DI 객체 수명이 늘지 않는지 확인한다. rememberUpdatedState로 이벤트 핸들러를 분리한다.
- @Stable/@Immutable 적용 가능 여부를 검토한다. 특히 UI 상태 홀더와 모델 타입의 안정성은 스킵에 직접 영향을 준다.
- 프로파일링에서 Allocation/GC와 Recompose Count를 같이 본다. 리컴포지션이 많아도 할당이 없으면 문제가 아닐 수 있다.
- androidx.compose.runtime:runtime-tracing을 켜고 recomposition trace로 ‘어느 그룹이’ 재실행되는지 확인한다.
- DI 제공 함수가 팩토리인지 싱글톤인지 확인한다. 컨테이너가 싱글톤이어도 팩토리면 매번 생성된다.
- 상태(read)와 DI 생성이 같은 그룹에 섞이지 않게 레이어를 나눈다. interaction/animation 레이어는 특히 분리한다.
자주 묻는 질문
remember로 DI 객체를 캐시하면 안전한가? 화면 회전이나 프로세스 재시작에서는 어떻게 되나?
remember는 Slot Table에만 저장되므로 구성 변경(예: 회전)에서 Activity가 재생성되면 캐시도 같이 사라진다. 이 특성이 오히려 안전한 경우가 많다. 화면 생명주기보다 길게 유지해야 하는 객체(예: 로그인 세션, 장기 작업)는 ViewModel 스코프나 앱 스코프에서 관리하는 편이 맞다. 반대로 네트워크 클라이언트처럼 앱 전체에서 공유해야 하는 객체를 remember로 만들면 화면마다 중복 생성될 수 있다. 학습 키워드는 remember vs rememberSaveable vs ViewModel, DisposableEffect, 그리고 Navigation의 back stack entry 스코프다. 코드로는 remember(userId)처럼 키를 걸어 수명 경계를 명확히 하고, close가 필요한 객체는 DisposableEffect로 정리한다.
Hilt의 hiltViewModel()을 쓰면 DI 오버헤드는 자동으로 해결되나?
hiltViewModel()은 ViewModel 생성과 스코프를 해결해주지만, 화면 내부에서 ViewModel이 제공하는 의존성을 다시 만들거나, Composable에서 EntryPoint를 반복 조회하면 오버헤드는 그대로 남는다. ViewModel은 상태와 비즈니스 로직의 경계로는 좋지만, UI가 자주 리컴포지션되는 구간에 무거운 초기화를 넣으면 첫 진입이 느려진다. ViewModel init에서 무거운 작업을 시작할 때는 dispatcher를 분리하고, UI에서는 상태만 읽게 해야 한다. 학습 키워드는 ViewModelStoreOwner, SavedStateHandle, 그리고 Compose recomposition scope다. 실전 처방은 “DI는 ViewModel에서 한 번, UI는 remember로 한 번”처럼 생성 지점을 고정하는 것이다.
CompositionLocal로 컨테이너를 내려보내면 성능이 항상 좋아지나?
CompositionLocal은 파라미터를 줄여주지만, 무조건 빠른 도구는 아니다. Local 값이 바뀌면 그 Local을 읽는 모든 지점이 invalidation 될 수 있다. 예를 들어 LocalUserSession을 제공하고 세션 객체가 교체되면, current를 읽는 하위 트리가 넓게 리컴포지션된다. 그래서 Local은 “자주 바뀌지 않는 것”에 적합하고, 자주 바뀌는 값은 파라미터로 좁게 전달하는 편이 낫다. 또 Local에서 매번 container.get()을 호출하면 조회 비용이 반복된다. 처방은 Local에서 컨테이너만 제공하고, 실제 의존성은 화면 루트에서 remember로 캐시해 하위에는 참조만 내려주는 방식이다. 학습 키워드는 staticCompositionLocalOf vs compositionLocalOf, invalidation 범위, 그리고 key 기반 remember다.
@Stable/@Immutable을 붙이면 DI 오버헤드도 줄어드나?
안정성 애노테이션은 DI 호출 자체를 줄이지 않는다. 대신 파라미터 비교와 스킵 가능성을 높여 리컴포지션 실행 횟수를 줄일 수 있다. 리컴포지션이 줄면 그 안에 있던 DI 호출도 함께 줄어드는 간접 효과가 생긴다. 하지만 가장 중요한 건 “DI 호출이 리컴포지션 경로에 있느냐”다. DI 호출이 이미 remember로 분리돼 있다면, 리컴포지션이 많아도 생성은 반복되지 않는다. 실전에서는 UI 모델을 @Immutable로 만들고, 상태 홀더를 @Stable로 유지하며, DI 객체는 UI 모델에 넣지 않는 규칙이 효과적이다. 학습 키워드는 Compose stability inference, skippable/restartable, 그리고 parameter change flags다.
remember를 남발하면 메모리 사용량이 늘지 않나?
늘 수 있다. remember는 Slot Table에 값을 붙잡아 두기 때문에, 그 그룹이 Composition에 남아 있는 동안 객체가 살아 있다. 특히 큰 캐시(이미지, 대형 리스트, 버퍼)를 remember에 넣으면 화면 전환이 느려지거나 메모리 압박이 커질 수 있다. 그래서 remember는 ‘생성 비용이 크고, 수명이 화면과 일치하는’ 객체에만 쓰는 편이 맞다. 큰 캐시는 LruCache 같은 별도 캐시 계층에 두고, Composable에서는 캐시 핸들만 remember로 들고 있는 구조가 안전하다. 측정은 Android Studio Profiler의 Allocation과 GC 이벤트를 스크롤/애니메이션과 함께 관찰하면 된다. 학습 키워드는 remember의 수명, Composition에서 제거 시점, LazyColumn item disposal이다.
DI 오버헤드를 어떻게 수치로 확인하나? Recompose Count만 보면 되나?
Recompose Count는 신호일 뿐이고, 비용을 직접 보여주지 않는다. 같은 리컴포지션이라도 할당이 없으면 가볍고, DI 조회/객체 생성이 섞이면 무겁다. 확인 방법은 (1) Logcat으로 생성 로그를 찍어 호출 횟수를 세고, (2) Allocation tracker로 프레임당 할당량을 보고, (3) runtime-tracing으로 어떤 그룹이 재실행되는지 확인하는 3단계를 같이 쓰는 방식이 현실적이다. 특히 “스크롤 중에만” 터지는 문제는 LazyColumn의 item disposal과 결합되어 로그만으로 놓치기 쉽다. 학습 키워드는 androidx.compose.runtime:runtime-tracing, System Trace, 그리고 recomposition tracing events다.
DI를 Composable에서 직접 하지 말고 항상 ViewModel에서만 해야 하나?
항상은 아니다. 화면 전용의 가벼운 객체(포맷터, validator, 작은 mapper)는 Composable에서 remember로 만들면 충분하다. 반대로 네트워크/DB/장기 작업처럼 앱 전반의 의존성은 ViewModel이나 앱 스코프에서 관리하는 편이 맞다. 핵심은 ‘리컴포지션과 수명 경계’다. Composable은 재실행이 잦으므로, 그 안에서 “반복 비용이 큰 것”을 만들면 안 된다. ViewModel은 화면 수명과 묶여 있으니, 화면을 나갈 때 정리되는 자원에 적합하다. 실전 처방은 “무거운 DI는 ViewModel/상위에서 1회, UI는 참조만”이며, 화면 파라미터가 바뀌면 remember(key) 또는 ViewModel key를 바꿔 새 스코프를 만든다. 학습 키워드는 remember vs ViewModel, assisted injection, Navigation back stack entry scope다.