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

6. Compose에서 DI 없이 시작하기: 왜 결국 필요해지는지 체감하는 흐름

Compose 초보가 DI 없이 시작했다가 상태 보존·재구성·테스트에서 왜 한계가 오는지, Runtime/Slot Table 관점으로 원인을 추적한다. Hilt 없이도 설계 감각을 만든다. 150자 내외 구성이다. 150자 내외 구성이다. 150자 내외 구성이다.

6. Compose에서 DI 없이 시작하기: 왜 결국 필요해지는지 체감하는 흐름

Compose에서 DI 없이 시작하기: 왜 결국 필요해지는지 체감하는 흐름

Compose를 처음 쓰면 화면이 금방 나온다. 문제는 ‘데이터는 어디서 가져오지?’에서 시작한다. 전역 싱글톤으로 Repository를 꺼내 쓰면 당장 동작은 한다. 그런데 버튼을 눌러 상태를 바꾸는 순간, 어떤 Composable이 다시 호출되는지 감이 없으면 화면이 튀고 로그가 폭발한다. 화면 회전이나 프로세스 재시작에서 상태가 날아가고, 테스트에서는 Fake 주입이 막힌다. 이 글은 DI 없이 출발해, 왜 결국 의존성 경계가 필요해지는지 Compose Runtime 관점으로 체감시키는 흐름이다.

핵심 개념

DI 없는 시작은 ‘의존성을 한 군데에서 만들어 두고 아무 데서나 꺼내 쓰는’ 형태가 된다. View 시스템에서도 Application 싱글톤, Service Locator, static holder로 흔히 시작했다. Compose에서 이 방식이 특히 빨리 한계에 닿는 이유는 UI 함수가 자주 다시 호출되기 때문이다. 호출이 반복되면 ‘언제 생성되었나’ ‘누가 들고 있나’가 곧 버그가 된다.

용어를 기능 맥락으로 정의한다. Composition은 Composable 호출의 기록을 Slot Table에 쌓는 과정이다. Recomposition은 기록을 재사용하면서 일부 구간만 다시 실행하는 과정이다. remember는 Slot Table의 특정 슬롯에 값을 저장해 “같은 위치의 다음 호출”에서 재사용하게 만드는 장치다. Stability(@Stable/@Immutable)는 ‘파라미터가 바뀌었는지’ 비교 비용과 리컴포지션 범위를 줄이기 위한 힌트다. DI는 객체 그래프를 ‘수명주기 단위로’ 묶어 생성/교체/테스트를 가능하게 만드는 경계다.

DI 없이도 화면은 나온다. 문제는 객체 생성 위치가 UI 함수 안으로 스며들 때 생긴다. Composable은 이벤트로 여러 번 호출된다. 호출마다 Repository를 새로 만들면 네트워크 클라이언트가 중복 생성되고, Flow 구독이 겹치고, 로그가 두 배로 찍힌다. 반대로 전역 싱글톤으로 고정하면 화면 단위로 교체가 불가능해 테스트가 막힌다. Compose에서는 이 둘 사이의 ‘수명주기’를 명확히 잡지 않으면 상태와 의존성이 서로를 오염시킨다.

처음에 나도 “remember로 Repository를 기억하면 되지 않나”라고 생각했다. 실제로 3시간 삽질 끝에 남은 로그는 ‘collect called’가 화면 진입 때마다 누적되는 형태였다. 원인은 remember가 ‘화면 트리에서 같은 위치’에만 묶일 뿐, 프로세스/백스택/테스트 스코프를 대체하지 못한다는 점이었다. remember는 UI 상태 캐시이고, DI는 객체 그래프의 생명주기 설계다.

ProfileHeader_NoDI.kt
1package com.example.nodi
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.remember
5
6class UserRepository {
7    fun loadUserName(): String = "Kim"
8}
9
10@Composable
11fun ProfileHeader_NoDI() {
12    // UI 호출 위치에 의존성 생성이 스며드는 순간부터 문제가 시작된다
13    val repo = remember { UserRepository() }
14    val name = repo.loadUserName()
15    androidx.compose.material3.Text(text = "Hello, $name")
16}

이 코드를 실행하면 화면은 정상 출력된다. 하지만 확인해야 하는 포인트는 ‘repo가 언제까지 살아있나’이다. Slot Table의 해당 위치가 유지되는 동안만 repo가 재사용된다. 네비게이션으로 화면이 제거되면 슬롯이 사라지고 repo도 사라진다. 반대로 repo를 전역으로 만들면 슬롯과 무관하게 살아남아 테스트나 멀티 계정 전환 같은 요구사항에서 발목을 잡는다. DI가 필요한 이유는 remember가 해결하지 못하는 스코프(화면/그래프/프로세스/테스트)를 다루기 때문이다.

컴포넌트 해부

DI를 도입하기 전 단계에서 가장 자주 만드는 컴포넌트는 ‘화면 진입 시 데이터를 읽고, 버튼으로 갱신하는 카드/리스트’다. 여기서는 Repository를 직접 참조하는 Composable과, 의존성을 인자로 받는 Composable을 나눠 해부한다. 핵심은 “UI는 상태를 보여주고 이벤트를 내보내며, 의존성은 바깥에서 주입된다”라는 경계를 손으로 체감하는 것이다.

Compose 컴포넌트는 보통 Surface(외피)와 Content(내용) 계층으로 나뉜다. Surface는 배경, 모양, 클릭, elevation, semantics 같은 ‘컨테이너 책임’을 가진다. Content는 텍스트/아이콘/로딩 같은 ‘표현 책임’을 가진다. DI 관점에서는 Surface가 의존성을 가지면 재사용성이 급락한다. Content는 더더욱 순수해야 Preview와 테스트가 쉬워진다.

  • onClick: 이벤트를 외부로 올린다. 내부에서 Repository를 호출하면 재사용이 막힌다.
  • enabled: 비활성 상태는 UI 레벨에서 결정되는 경우가 많다. 의존성 레벨과 섞이면 조건이 꼬인다.
  • loading: 네트워크/DB 작업 중 표시. 작업 자체는 외부(UseCase/Repo)에서 하고 UI는 상태만 받는다.
  • label: 화면 문구. 문자열 리소스/다국어와 결합되기 쉬워 순수 파라미터로 둔다.
  • icon: 아이콘 슬롯. null 허용으로 레이아웃 분기 비용을 줄인다.
  • modifier: 외부에서 레이아웃/클릭 영역/테스트 태그를 붙인다. 내부에서 고정하면 레이아웃 재사용이 깨진다.
  • shape: Surface의 clip/outline 계산에 영향. 동일 shape 재사용은 할당을 줄인다.
  • colors: 상태별 색. 테마와 결합되므로 외부에서 주입 가능해야 한다.
  • contentPadding: 터치 타깃과 시각적 패딩 분리. 내부 고정은 접근성 이슈를 만든다.
  • interactionSource: ripple/press 상태 공유. 외부에서 주입하면 상위에서 상태를 관찰할 수 있다.
  • semanticsLabel: 접근성 라벨. 테스트에서도 노출 포인트가 된다.
  • testTag: UI 테스트 식별자. modifier에 붙이는 게 일반적이다.

Surface 계층의 역할을 분리하면 ‘클릭 처리’가 어디에 있어야 하는지 명확해진다. 클릭은 UI 이벤트이고, 데이터 갱신은 의존성(UseCase/Repo)에서 일어난다. 둘을 같은 함수에 넣으면 버튼 클릭 하나가 네트워크 호출과 얽히고, 재구성 때 람다 캡처가 커진다. 이 분리는 Slot Table에서도 이점이 있다. Surface는 파라미터가 안정적이면 스킵되고, Content만 다시 그릴 수 있다.

LoadingActionChip_Sketch.kt
1package com.example.nodi
2
3import androidx.compose.foundation.layout.Row
4import androidx.compose.foundation.layout.Spacer
5import androidx.compose.foundation.layout.padding
6import androidx.compose.foundation.layout.width
7import androidx.compose.material3.CircularProgressIndicator
8import androidx.compose.material3.Surface
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
11import androidx.compose.ui.Modifier
12import androidx.compose.ui.semantics.contentDescription
13import androidx.compose.ui.semantics.semantics
14import androidx.compose.ui.unit.dp
15
16@Composable
17fun LoadingActionChip_Sketch(
18    label: String,
19    loading: Boolean,
20    onClick: () -> Unit,
21    modifier: Modifier = Modifier,
22    semanticsLabel: String = label
23) {
24    Surface(
25        onClick = onClick,
26        modifier = modifier.semantics { contentDescription = semanticsLabel }
27    ) {
28        Row(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
29            if (loading) {
30                CircularProgressIndicator(strokeWidth = 2.dp)
31                Spacer(Modifier.width(8.dp))
32            }
33            Text(text = label)
34        }
35    }
36}

이 스케치 코드는 Surface와 Content가 분리되는 이유를 눈으로 보여준다. loading이 true로 바뀌면 Row 내부 분기만 바뀐다. onClick은 바깥에서 주입되어야 한다. onClick 내부에서 repo.refresh()를 직접 호출하면, Preview에서 네트워크가 돌거나 테스트에서 Fake로 바꾸기 어려워진다. Slot Table 관점에서는 loading이 읽히는 위치가 정확히 추적되어 해당 그룹만 재호출된다.

ProfileCard.kt
1package com.example.nodi
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.padding
5import androidx.compose.material3.MaterialTheme
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.ui.Modifier
9import androidx.compose.ui.graphics.RectangleShape
10import androidx.compose.ui.tooling.preview.Preview
11import androidx.compose.ui.unit.dp
12
13@Composable
14fun ProfileCard(
15    title: String,
16    loading: Boolean,
17    onReload: () -> Unit,
18    modifier: Modifier = Modifier
19) {
20    Column(modifier.padding(16.dp)) {
21        Text(text = title, style = MaterialTheme.typography.titleMedium)
22        LoadingActionChip_Sketch(
23            label = "Reload",
24            loading = loading,
25            onClick = onReload,
26            modifier = Modifier.padding(top = 12.dp),
27            semanticsLabel = "reload-profile"
28        )
29    }
30}
31
32@Preview
33@Composable
34private fun ProfileCardPreview() {
35    ProfileCard(title = "User", loading = true, onReload = {}, modifier = Modifier)
36}

Preview에서 로딩 스피너가 보이고, ‘Reload’ 텍스트가 오른쪽에 붙는다. 이 시점에서 DI가 없어도 UI 개발은 충분히 진행된다. 하지만 데이터 로딩을 붙이는 순간 선택지가 갈린다. 1) Composable 내부에서 Repository를 만든다 2) 화면 상단에서 만들어 내려준다 3) ViewModel이나 외부 컨테이너에서 관리한다. DI의 필요성은 2)와 3)에서 ‘어디까지가 같은 스코프인가’가 복잡해지는 순간 폭발한다.

내부 동작 원리

Compose Runtime은 Composable 호출을 트리로 저장하지 않고, 선형 구조인 Slot Table에 ‘그룹’ 단위로 기록한다. 컴파일러는 각 Composable을 변환해 Composer 파라미터와 changed 플래그를 추가하고, 그룹 시작/종료를 삽입한다. 개발자가 보는 함수 호출은, 런타임에서 그룹 키와 슬롯 인덱스를 전진시키는 절차로 바뀐다.

DI 없이 Repository를 Composable 안에서 만들면, 그 객체는 Slot Table의 remember 슬롯에 붙거나(remember 사용 시) 매 호출마다 새로 생성된다(remember 미사용 시). 전자는 ‘UI 트리 위치’에 묶인다. 후자는 재구성 때마다 할당이 발생한다. 둘 다 ‘화면 스코프’나 ‘네비게이션 그래프 스코프’를 표현하지 못한다. 그래서 화면 전환에서 객체가 사라지거나, 반대로 전역으로 올려 두면 교체가 불가능해진다.

Recomposition 트리거는 State 읽기다. mutableStateOf를 읽는 Composable 그룹은 Snapshot 시스템에 의해 구독자로 등록된다. 값이 바뀌면 해당 그룹이 invalidation 되고, 다음 프레임에서 다시 호출된다. 이때 changed 비교는 파라미터 안정성에 의존한다. @Stable/@Immutable이 없는 타입은 보수적으로 ‘바뀌었을 수 있다’로 처리되어 더 많은 그룹이 재호출될 수 있다.

Modifier 체이닝은 데이터 구조 관점에서 단일 Modifier가 아니라 연결 리스트(혹은 결합 구조)로 누적된다. 순서가 의미를 가진다. padding 다음 clickable과 clickable 다음 padding은 hit test 영역이 달라진다. DI와 연결되는 지점은 semantics/testTag 같은 디버깅·테스트 포인트다. 의존성 생성이 UI에 섞이면 semantics가 흔들리고 테스트가 불안정해진다.

처음에 겪었던 실수 하나는 ‘Composable에서 Flow를 collect하면서 Repository를 매번 생성’한 케이스였다. Logcat에는 "Subscribed"가 화면 클릭마다 늘었고, 어느 순간 "java.lang.IllegalStateException: Snapshot apply failed"가 나왔다. 원인은 재구성 동안 객체가 바뀌며 구독이 겹치고, 스냅샷 적용 타이밍이 꼬인 것이었다. 해결은 의존성은 안정적인 상위 스코프에서 만들고, UI는 State만 받게 만드는 쪽이었다.

한 문단 요약: Compose에서 DI가 필요해지는 지점은 ‘UI 함수가 자주 다시 호출되는 구조’와 ‘의존성 객체의 수명주기’가 충돌할 때다. remember는 Slot Table 위치에 묶인 캐시이고, DI는 화면/그래프/테스트 단위로 객체 그래프를 생성·교체하는 경계다.

RecomposeProbe.kt
1package com.example.nodi
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.getValue
8import androidx.compose.runtime.mutableIntStateOf
9import androidx.compose.runtime.remember
10import androidx.compose.runtime.setValue
11import androidx.compose.ui.tooling.preview.Preview
12
13@Composable
14fun RecomposeProbe() {
15    var count by remember { mutableIntStateOf(0) }
16    Column {
17        Text(text = "count=$count")
18        Button(onClick = { count++ }) {
19            Text(text = "increment")
20        }
21        // count를 읽지 않는 영역은 리컴포지션 스킵 후보가 된다
22        StaticFooter()
23    }
24}
25
26@Composable
27private fun StaticFooter() {
28    Text(text = "footer")
29}
30
31@Preview
32@Composable
33private fun RecomposeProbePreview() {
34    RecomposeProbe()
35}

이 코드를 실행하면 버튼 클릭마다 count 텍스트만 바뀌고 footer는 그대로 보인다. Layout Inspector의 Recomposition Counts를 켜면(디버그 빌드) count를 읽는 그룹이 증가한다. footer가 증가하지 않는다면 스킵이 일어난 것이다. 여기서 중요한 연결은 ‘의존성이 UI 안에 있으면’ footer 같은 정적 영역도 함께 invalidation 되는 구조를 쉽게 만든다는 점이다. 예를 들어 footer에서 전역 싱글톤 상태를 읽어버리면, 예상치 못한 범위가 다시 호출된다.

ModifierOrderProbe.kt
1package com.example.nodi
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.Role
11import androidx.compose.ui.semantics.semantics
12import androidx.compose.ui.semantics.testTag
13import androidx.compose.ui.unit.dp
14
15@Composable
16fun ModifierOrderProbe(onClick: () -> Unit) {
17    val interaction = remember { MutableInteractionSource() }
18
19    Text(
20        text = "Tap me",
21        modifier = Modifier
22            .padding(24.dp)
23            .clickable(
24                interactionSource = interaction,
25                indication = null,
26                role = Role.Button,
27                onClick = onClick
28            )
29            .semantics { testTag = "tap-text" }
30    )
31}

padding이 clickable 앞에 있어서 터치 영역이 패딩까지 포함된다. 반대로 clickable 뒤에 padding을 붙이면 텍스트 영역만 클릭된다. 이 차이는 Slot Table이 아니라 Modifier 체인 순서에서 결정된다. DI와의 접점은 interactionSource처럼 상태를 외부로 끌어올릴 때 생긴다. 상위에서 같은 interactionSource를 주입하면 여러 컴포넌트의 press 상태를 묶어 관찰할 수 있다. 반대로 매번 remember로 만들면 컴포넌트 단위로만 고립된다.

실습하기

실습 목표는 ‘DI 없이’ 화면을 만들고, 요구사항이 하나씩 추가될 때 어떤 코드가 망가지기 시작하는지 확인하는 것이다. 여기서는 Hilt를 쓰지 않는다. 대신 Service Locator(전역) → 파라미터 주입 → CompositionLocal(국소) 순으로 이동하면서, 각각이 Slot Table/리컴포지션/테스트에 어떤 영향을 주는지 눈으로 확인한다.

빌드 설정은 최소만 필요하다. Material3와 tooling preview 정도면 된다. 버전은 프로젝트 템플릿에 맞춘다. 중요한 건 ‘런타임 동작’ 확인이라서, 의존성 목록을 길게 늘리지 않는다.

app/build.gradle.kts
1plugins {
2    id("com.android.application")
3    id("org.jetbrains.kotlin.android")
4}
5
6android {
7    namespace = "com.example.nodi"
8    compileSdk = 34
9
10    defaultConfig {
11        applicationId = "com.example.nodi"
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.0")
22    implementation("androidx.compose.material3:material3:1.2.1")
23    debugImplementation("androidx.compose.ui:ui-tooling:1.6.8")
24    debugImplementation("androidx.compose.ui:ui-tooling-preview:1.6.8")
25}

이 설정으로 앱을 실행하면 기본 Compose Activity가 뜬다. 이후 단계별 코드를 붙여 넣고, 화면에서 버튼을 눌렀을 때 텍스트 변경과 로그 출력이 어떻게 달라지는지 확인한다. 특히 ‘의존성 생성 위치’가 바뀔 때 리컴포지션 범위가 커지는지, 객체가 몇 번 생성되는지 로그로 확인한다.

1단계: 전역 Service Locator로 출발

전역 객체는 처음엔 편하다. 어디서든 AppGraph.repo를 꺼내 쓰면 된다. 실행하면 정상 동작한다. 하지만 화면이 두 개로 늘어나거나, 사용자 로그아웃으로 계정이 바뀌는 순간 전역은 ‘교체 비용’을 UI 전체에 전파한다.

확인 포인트는 생성 로그다. 화면을 재구성해도 Repository가 새로 생성되지 않는다는 점은 장점처럼 보인다. 대신 테스트에서는 Fake로 바꾸기 어렵고, 멀티 프로세스/멀티 계정에서 전역 상태가 섞이는 문제가 생긴다.

MainActivity_ServiceLocator.kt
1package com.example.nodi
2
3import android.os.Bundle
4import android.util.Log
5import androidx.activity.ComponentActivity
6import androidx.activity.compose.setContent
7import androidx.compose.foundation.layout.Column
8import androidx.compose.material3.Button
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
11import androidx.compose.runtime.getValue
12import androidx.compose.runtime.mutableStateOf
13import androidx.compose.runtime.remember
14import androidx.compose.runtime.setValue
15
16object AppGraph {
17    val repo: UserRepository by lazy {
18        Log.d("AppGraph", "create UserRepository")
19        UserRepository()
20    }
21}
22
23class MainActivity : ComponentActivity() {
24    override fun onCreate(savedInstanceState: Bundle?) {
25        super.onCreate(savedInstanceState)
26        setContent { Screen_ServiceLocator() }
27    }
28}
29
30@Composable
31fun Screen_ServiceLocator() {
32    var loading by remember { mutableStateOf(false) }
33    val name = AppGraph.repo.loadUserName()
34
35    Column {
36        Text(text = "Hello, $name")
37        Button(onClick = { loading = !loading }) {
38            Text(text = if (loading) "stop" else "start")
39        }
40        Text(text = "loading=$loading")
41    }
42}

앱 실행 직후 Logcat에 create UserRepository가 한 번 찍힌다. 버튼을 눌러도 다시 찍히지 않는다. 이게 전역의 달콤함이다. 대신 다음 단계에서 ‘테스트에서 Fake 주입’이나 ‘로그아웃 시 repo 교체’를 하려면 AppGraph 자체를 갈아엎거나, 전역을 mutable로 만들어 더 위험해진다. DI가 필요해지는 첫 신호다.

2단계: 파라미터 주입으로 경계 만들기

Repository를 Composable 파라미터로 받으면 Preview와 테스트가 쉬워진다. 동시에 리컴포지션 비교가 명확해진다. 파라미터가 같은 인스턴스면, 컴파일러가 생성한 changed 플래그에서 ‘변경 없음’으로 판단될 가능성이 커진다(타입 안정성에 따라 다르다).

확인 포인트는 화면의 최상단에서만 의존성을 만들고, UI는 순수 함수처럼 유지하는 것이다. 이 구조는 DI 프레임워크 없이도 ‘수동 DI’로 충분히 큰 앱까지 버틴다. 다만 의존성 개수가 늘면 생성 코드가 Activity/Navigation에 쌓인다.

Screen_ParamInjection.kt
1package com.example.nodi
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.mutableStateOf
10import androidx.compose.runtime.remember
11import androidx.compose.runtime.setValue
12import androidx.compose.ui.tooling.preview.Preview
13
14class UserRepository2 {
15    init { Log.d("Repo", "create UserRepository2") }
16    fun loadUserName(): String = "Kim"
17    fun refresh(): String = "Kim #${System.currentTimeMillis() % 1000}"
18}
19
20@Composable
21fun Screen_ParamInjection(repo: UserRepository2) {
22    var name by remember { mutableStateOf(repo.loadUserName()) }
23    var loading by remember { mutableStateOf(false) }
24
25    Column {
26        Text(text = "Hello, $name")
27        LoadingActionChip_Sketch(
28            label = if (loading) "Loading..." else "Reload",
29            loading = loading,
30            onClick = {
31                loading = true
32                name = repo.refresh()
33                loading = false
34            }
35        )
36    }
37}
38
39@Preview
40@Composable
41private fun ScreenParamPreview() {
42    Screen_ParamInjection(repo = UserRepository2())
43}

Preview를 열면 create UserRepository2가 미리보기 렌더링 때 찍힌다. 앱 런타임에서는 Activity에서 repo를 한 번 만들고 Screen_ParamInjection에 전달하면 된다. 이 단계에서 DI 프레임워크의 필요성은 ‘repo 외에 api, db, logger, dispatcher…’가 늘어날 때 체감된다. 생성자 파라미터가 8개를 넘기 시작하면, 화면 진입 코드가 객체 그래프 조립 코드로 변한다.

3단계: CompositionLocal로 앱 스코프 흉내 내기

CompositionLocal은 전역과 파라미터 주입의 중간이다. 트리 아래로 암묵적으로 전달되지만, 전역처럼 앱 전체를 오염시키지 않는다. 대신 “어디서 제공했나”가 숨겨져 디버깅이 어려워질 수 있다. DI 프레임워크는 이 제공 지점을 모듈/컴포넌트로 구조화해준다.

확인 포인트는 Local 값이 바뀌면 그 값을 읽는 하위가 모두 invalidation 된다는 점이다. Local로 repo를 제공하고, 계정 전환으로 repo 인스턴스를 교체하면 읽는 모든 Composable이 재호출된다. 이 재호출은 Slot Table이 ‘같은 위치’를 유지해도 파라미터가 바뀌었기 때문에 발생한다.

CompositionLocal_AppRoot.kt
1package com.example.nodi
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.ProvidableCompositionLocal
5import androidx.compose.runtime.compositionLocalOf
6import androidx.compose.runtime.remember
7import androidx.compose.runtime.staticCompositionLocalOf
8
9interface UserRepositoryContract {
10    fun loadUserName(): String
11}
12
13class RealUserRepository : UserRepositoryContract {
14    override fun loadUserName(): String = "Kim"
15}
16
17val LocalUserRepo: ProvidableCompositionLocal<UserRepositoryContract> =
18    staticCompositionLocalOf { error("LocalUserRepo not provided") }
19
20@Composable
21fun AppRoot(content: @Composable () -> Unit) {
22    val repo = remember { RealUserRepository() }
23    androidx.compose.runtime.CompositionLocalProvider(LocalUserRepo provides repo) {
24        content()
25    }
26}
27
28@Composable
29fun Screen_UseLocal() {
30    val repo = LocalUserRepo.current
31    androidx.compose.material3.Text(text = "Hello, ${repo.loadUserName()}")
32}

LocalUserRepo를 제공하지 않으면 런타임에서 "LocalUserRepo not provided" 예외가 난다. 이 에러는 DI 컨테이너 미구성 오류와 성격이 비슷하다. 처음에 이 에러를 보고 20분 동안 “왜 Preview에서만 터지지?”를 헤맸던 적이 있다. 원인은 Preview에서 AppRoot를 감싸지 않았기 때문이었다. DI를 쓰면 테스트/프리뷰용 그래프를 명시적으로 구성하는 습관이 생긴다.

심화: Advanced 버전 만들기

실무 요구사항은 버튼 하나에도 기능이 붙는다. loading 표시, 연타 방지(debounce), 아이콘+텍스트 조합, 롱프레스, 접근성 라벨이 한 번에 들어온다. DI 없이 시작한 코드가 여기서 흔히 무너진다. 이유는 UI 컴포넌트가 UseCase/Repository를 직접 호출하며 타이밍 제어까지 떠안기 때문이다. 버튼은 이벤트를 방출하고, 타이밍/작업은 외부에서 관리되어야 테스트가 가능하다.

내 흑역사 하나는 debounce를 버튼 내부에 넣었다가 발생했다. 코드가 remember { mutableLongStateOf(0) }로 마지막 클릭 시간을 저장하고, onClick에서 SystemClock.uptimeMillis를 비교하는 방식이었다. 증상은 화면 회전 후 debounce가 초기화되어 연타가 다시 먹히는 것이었다. 원인은 debounce 상태가 ‘화면 위치’에만 묶이고, 작업 스코프와 분리되지 않았기 때문이다. 교정은 debounce를 UseCase 레벨로 올리고, UI는 disabled 상태만 받게 만드는 쪽이었다.

사례 1: UI는 이벤트만, 작업은 외부에서

loading과 debounce가 섞이면 UI 내부에서 coroutine을 돌리고 싶어진다. 하지만 UI 내부 coroutine은 재구성/취소 타이밍을 잘못 잡기 쉽다. 특히 LaunchedEffect 키를 잘못 주면 작업이 중복 실행된다. 버튼 컴포넌트는 ‘현재 상태’와 ‘클릭 이벤트’만 다루고, 실제 작업은 상위에서 수행한 뒤 상태를 내려주는 구조가 안정적이다.

AdvancedButton.kt
1package com.example.nodi
2
3import androidx.compose.foundation.combinedClickable
4import androidx.compose.foundation.interaction.MutableInteractionSource
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.Spacer
7import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.layout.width
9import androidx.compose.material3.CircularProgressIndicator
10import androidx.compose.material3.Icon
11import androidx.compose.material3.MaterialTheme
12import androidx.compose.material3.Surface
13import androidx.compose.material3.Text
14import androidx.compose.runtime.Composable
15import androidx.compose.runtime.remember
16import androidx.compose.ui.Modifier
17import androidx.compose.ui.graphics.vector.ImageVector
18import androidx.compose.ui.semantics.contentDescription
19import androidx.compose.ui.semantics.semantics
20import androidx.compose.ui.unit.dp
21
22@Composable
23fun AdvancedButton(
24    text: String,
25    loading: Boolean,
26    enabled: Boolean,
27    icon: ImageVector? = null,
28    a11yLabel: String = text,
29    onClick: () -> Unit,
30    onLongPress: (() -> Unit)? = null,
31    modifier: Modifier = Modifier,
32    debounceWindowMs: Long = 600L,
33    clickGate: ClickGate = remember { ClickGate(debounceWindowMs) }
34) {
35    val interaction = remember { MutableInteractionSource() }
36
37    Surface(
38        modifier = modifier.semantics { contentDescription = a11yLabel },
39        tonalElevation = 2.dp,
40        color = MaterialTheme.colorScheme.surface,
41        enabled = enabled && !loading,
42        onClick = {}
43    ) {
44        Row(
45            Modifier
46                .combinedClickable(
47                    interactionSource = interaction,
48                    indication = null,
49                    enabled = enabled && !loading,
50                    onClick = { if (clickGate.tryOpen()) onClick() },
51                    onLongClick = onLongPress
52                )
53                .padding(horizontal = 16.dp, vertical = 12.dp)
54        ) {
55            if (loading) {
56                CircularProgressIndicator(strokeWidth = 2.dp)
57                Spacer(Modifier.width(10.dp))
58            }
59            if (icon != null) {
60                Icon(imageVector = icon, contentDescription = null)
61                Spacer(Modifier.width(8.dp))
62            }
63            Text(text = text)
64        }
65    }
66}
67
68class ClickGate(private val windowMs: Long) {
69    private var lastClickAt: Long = 0L
70    fun tryOpen(now: Long = android.os.SystemClock.uptimeMillis()): Boolean {
71        if (now - lastClickAt < windowMs) return false
72        lastClickAt = now
73        return true
74    }
75}

여기서 의도적으로 ClickGate를 파라미터로 열어 두었다. 테스트에서 now를 주입하거나, 더 강한 정책(서버 응답 기반 잠금)을 적용하기 쉽다. remember로 기본 인스턴스를 제공하지만, 이건 ‘UI 편의 기본값’일 뿐이다. DI 프레임워크를 쓰면 ClickGate 같은 정책 객체도 스코프에 맞게 교체할 수 있다. Slot Table 관점에서는 clickGate가 remember 슬롯에 저장되어 재구성 때 재사용된다.

사례 2: 수동 DI 컨테이너를 만들어 한계 느끼기

Hilt 없이도 작은 컨테이너를 만들 수 있다. 문제는 스코프가 늘어날 때다. 앱 스코프, 로그인 스코프, 화면 스코프가 생기면 컨테이너가 중첩되고, 교체 타이밍이 복잡해진다. 이 복잡함이 DI 프레임워크의 존재 이유다.

ManualContainer.kt
1package com.example.nodi
2
3import kotlinx.coroutines.CoroutineDispatcher
4import kotlinx.coroutines.Dispatchers
5
6class ApiClient(val baseUrl: String)
7class UserService(private val api: ApiClient) {
8    fun fetchName(): String = "Kim"
9}
10class UserUseCase(
11    private val service: UserService,
12    private val io: CoroutineDispatcher
13) {
14    fun load(): String = service.fetchName()
15}
16
17class AppContainer {
18    private val api by lazy { ApiClient(baseUrl = "https://example.com") }
19    private val service by lazy { UserService(api) }
20
21    fun userUseCase(io: CoroutineDispatcher = Dispatchers.IO): UserUseCase {
22        return UserUseCase(service, io)
23    }
24}
25
26class SessionContainer(private val app: AppContainer, val userId: String) {
27    fun useCase(): UserUseCase = app.userUseCase()
28}

이 컨테이너는 동작한다. 하지만 요구사항이 늘면 문제가 드러난다. 예를 들어 userId가 바뀌면 SessionContainer를 교체해야 하고, 교체 시점에 UI 트리 전체가 어떤 Local을 읽는지 추적해야 한다. 한 번은 로그아웃 후에도 이전 userId로 API가 호출되는 버그를 잡느라 2시간을 썼다. 원인은 SessionContainer를 Local로 제공했는데, 일부 화면이 Local을 읽지 않고 전역 AppContainer를 직접 참조하고 있었기 때문이다. DI는 ‘참조 경로를 한 가지로 강제’해 이런 균열을 줄인다.

AdvancedButtonDemo.kt
1package com.example.nodi
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.material3.Text
5import androidx.compose.runtime.Composable
6import androidx.compose.runtime.getValue
7import androidx.compose.runtime.mutableStateOf
8import androidx.compose.runtime.remember
9import androidx.compose.runtime.setValue
10import androidx.compose.ui.tooling.preview.Preview
11
12@Composable
13fun AdvancedButtonDemo() {
14    var loading by remember { mutableStateOf(false) }
15    var clicks by remember { mutableStateOf(0) }
16
17    Column {
18        Text(text = "clicks=$clicks")
19        AdvancedButton(
20            text = "Submit",
21            loading = loading,
22            enabled = true,
23            a11yLabel = "submit-order",
24            onClick = {
25                loading = true
26                clicks++
27                loading = false
28            },
29            onLongPress = { clicks += 10 }
30        )
31    }
32}
33
34@Preview
35@Composable
36private fun AdvancedButtonDemoPreview() {
37    AdvancedButtonDemo()
38}

실행하면 Submit 버튼을 빠르게 연타해도 clicks가 매번 증가하지 않는다(600ms 게이트). 길게 누르면 10씩 증가한다. 이 동작을 테스트로 옮기려면 ClickGate의 now를 제어해야 한다. DI가 들어오면 ClickGate를 테스트 더블로 교체해 시간 의존성을 제거할 수 있다. UI 내부에 SystemClock이 박혀 있으면 테스트가 flaky해진다.

자주 하는 실수

1) Composable 안에서 Repository를 매 호출마다 생성

증상: 버튼 클릭이나 스크롤만 해도 네트워크 클라이언트/DB가 계속 생성되는 것처럼 보이고, Logcat에 init 로그가 폭증한다. 화면이 버벅이거나 GC가 자주 돈다.

원인: Composable은 이벤트마다 다시 호출된다. remember 없이 new를 하면 매 재구성마다 할당이 발생한다. remember를 넣어도 슬롯 위치에만 묶여 화면 제거/재진입에서 다시 생성된다.

해결: 의존성은 UI 바깥(Activity, ViewModel, 컨테이너)에서 만들고 Composable에는 파라미터로 전달한다. 최소한 최상단에서 한 번 생성해 하위로 내려준다.

2) 전역 싱글톤으로 모든 의존성을 고정

증상: 로그아웃 후에도 이전 사용자 데이터가 남거나, 다른 계정으로 전환해도 캐시가 섞인다. 테스트에서 Fake로 바꾸기 어려워 instrumentation 테스트만 남는다.

원인: 전역은 스코프가 ‘프로세스’로 고정된다. Compose 트리와 무관하게 살아남아, 화면 단위 교체가 불가능해진다. 멀티 모듈에서 참조 경로가 분기되면 더 위험하다.

해결: 앱 스코프와 세션 스코프를 분리한다. 전역을 쓰더라도 SessionContainer 같은 교체 가능한 레이어를 두고, UI는 오직 그 레이어만 참조하게 만든다.

3) CompositionLocal을 남발해 의존성 제공 지점이 숨겨짐

증상: Preview에서만 "not provided" 크래시가 나거나, 특정 화면에서만 NPE/IllegalStateException이 난다. 의존성이 어디서 주입되는지 찾느라 트리를 계속 위로 올라간다.

원인: Local은 암묵적 전달이라 호출부에서 의존성이 보이지 않는다. 제공 지점이 여러 곳이면 어떤 값이 적용되는지 추적이 어렵다. Local 값 교체는 그 값을 읽는 하위 전체를 invalidation 한다.

해결: Local은 테마/로거/세션처럼 ‘트리 전체에 자연스러운 컨텍스트’에만 제한한다. 화면 기능 의존성은 파라미터로 전달한다. Preview는 AppRoot를 반드시 감싼다.

4) remember를 DI 대용으로 사용

증상: 화면 회전/프로세스 재시작에서 상태나 정책이 초기화된다. debounce가 풀리거나, 로딩 상태가 예상과 다르게 리셋된다.

원인: remember는 Slot Table 위치 기반 캐시다. 컴포지션이 사라지면 같이 사라진다. 저장이 필요하면 rememberSaveable, 수명주기 스코프가 필요하면 ViewModel/컨테이너가 필요하다.

해결: UI 상태는 remember/rememberSaveable로, 비즈니스 정책/의존성은 상위 스코프로 올린다. ‘저장’과 ‘주입’을 같은 문제로 취급하지 않는다.

5) 불안정한 타입을 파라미터로 내려 리컴포지션 범위가 커짐

증상: 값이 바뀌지 않았는데도 하위 컴포넌트가 자주 재호출된다. Layout Inspector에서 Recomposition Counts가 예상보다 빠르게 증가한다.

원인: 리스트/맵/가변 객체를 그대로 파라미터로 내려주면, 컴파일러는 보수적으로 changed=true로 판단할 수 있다. 람다 캡처가 커지면 할당도 늘어난다.

해결: UI에는 불변 모델(@Immutable 데이터 클래스)이나 stable 홀더를 전달한다. 컬렉션은 immutable snapshot이나 copy를 사용하고, 이벤트는 필요한 최소만 캡처한다.

StableParamExample.kt
1package com.example.nodi
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.Immutable
5import androidx.compose.runtime.mutableStateOf
6import androidx.compose.runtime.remember
7import androidx.compose.runtime.getValue
8import androidx.compose.runtime.setValue
9import androidx.compose.material3.Text
10
11@Immutable
12data class UiUser(val name: String, val level: Int)
13
14@Composable
15fun StableParamExample() {
16    var user by remember { mutableStateOf(UiUser("Kim", 1)) }
17    Text(text = "${user.name} / ${user.level}")
18    // user를 갱신할 때 새 인스턴스로 교체하면 변경 추적이 명확해진다
19}

이 패턴은 ‘값이 바뀌면 새 인스턴스’라는 규칙을 강제한다. Compose의 changed 비교가 단순해지고, 하위 트리 스킵 가능성이 커진다. DI와 연결하면, UseCase/Repo가 반환하는 모델을 UI 전용 불변 모델로 변환하는 계층이 생기고 테스트가 쉬워진다.

성능 최적화 체크리스트

  • Composable 내부에서 new 키워드를 검색해 의존성 생성이 UI에 스며든 지점이 있는지 확인한다(IDE Find in Files).
  • remember에 들어간 객체가 ‘UI 상태’인지 ‘의존성/정책’인지 분류한다. 정책이면 상위 스코프로 올린다.
  • CompositionLocal 제공 지점을 한 군데(AppRoot)로 제한하고, 기능 의존성은 파라미터 주입으로 유지한다.
  • Local 값을 교체하는 코드(로그아웃/계정 전환)가 있을 때, 그 Local을 읽는 Composable 범위를 목록으로 만든다.
  • 불필요한 리컴포지션을 확인하려면 Layout Inspector의 Recomposition Counts를 켜고, 클릭/스크롤 시 증가하는 노드를 캡처한다.
  • 람다 파라미터가 큰 객체를 캡처하는지 확인한다. 캡처가 크면 rememberUpdatedState 또는 이벤트 분리로 줄인다.
  • @Immutable/@Stable 적용 후보를 찾는다. UI 모델, 상태 홀더, 이벤트 핸들러 묶음이 우선 대상이다.
  • Modifier 체인 순서가 hit test/semantics에 영향 주는 지점을 점검한다. padding과 clickable 순서를 바꾸며 테스트한다.
  • State 읽기 위치를 점검한다. 불필요하게 상위에서 읽으면 invalidation 범위가 커진다(상태는 필요한 가장 아래에서 읽는다).
  • 객체 생성/구독 중복을 로그로 계측한다. init 로그, Flow onStart 로그를 넣고 화면 재진입 시 누적 여부를 본다.
  • 테스트 더블 주입 경로를 확보한다. Repo/Clock/Dispatcher는 인터페이스로 추상화하고, 생성은 한 곳에서만 한다.
  • 프로세스 재시작에서 필요한 값은 rememberSaveable 또는 저장소(DataStore/DB)로 이동한다. remember만으로 버티려 하지 않는다.

자주 묻는 질문

DI 없이 Compose를 시작하면 가장 먼저 어디에서 문제가 터지나?

대개 ‘화면이 두 개 이상’이 되는 순간이다. 첫 화면에서는 전역 싱글톤(AppGraph)이나 Composable 내부 remember로도 버틴다. 두 번째 화면이 생기면 같은 Repository를 공유할지, 화면마다 분리할지 결정이 필요해진다. 이때 의존성 생성 위치가 UI 트리에 섞여 있으면, 네비게이션으로 화면이 제거될 때 객체가 같이 사라지거나(remember), 반대로 전역으로 남아 계정 전환에 실패한다(싱글톤). 해결 처방은 수동 DI라도 스코프를 명시하는 것이다. 예를 들어 AppContainer(앱 스코프)와 SessionContainer(로그인 스코프)를 나누고, Composable은 파라미터 또는 CompositionLocal로 SessionContainer만 참조하게 만든다. 학습 키워드는 ‘scope’, ‘object graph’, ‘CompositionLocal 제공 지점’이다.

remember로 Repository를 들고 있으면 DI가 필요 없지 않나?

remember는 Slot Table의 ‘현재 컴포지션 위치’에 값을 저장하는 캐시다. 화면이 트리에서 제거되면 슬롯이 사라지고 값도 같이 사라진다. 즉 remember는 화면 수명주기와 강하게 결합된다. Repository는 보통 화면보다 긴 수명(세션, 앱)을 갖거나, 테스트에서 교체 가능해야 한다. remember로 Repo를 만들면 화면 재진입마다 구독이 다시 생길 수 있고, 네트워크/DB 리소스가 반복 초기화된다. 반대로 전역으로 올리면 세션 교체가 막힌다. 처방은 역할 분리다. UI 상태(선택/토글/텍스트)는 remember, 저장이 필요하면 rememberSaveable, 비즈니스 의존성은 ViewModel/컨테이너/DI에서 만든다. 학습 키워드는 ‘Slot Table’, ‘remember lifecycle’, ‘ViewModel scope’이다.

CompositionLocal은 DI와 같은가?

CompositionLocal은 ‘트리 아래로 값을 전달하는 메커니즘’이고, DI는 ‘객체 그래프를 생성하고 스코프에 맞게 관리하는 시스템’이다. Local은 제공/소비만 있고, 생성 규칙(싱글톤/세션/팩토리), 교체 규칙, 테스트 모듈 같은 체계가 없다. Local을 남발하면 제공 지점이 숨겨져 Preview에서 not provided 크래시가 잦아진다. 반대로 Local을 AppRoot 한 곳에서만 제공하고, 테마/로거/세션처럼 컨텍스트 성격의 값만 담으면 강력하다. 처방은 Local을 “전역 대체”로 쓰지 말고, 컨테이너(예: SessionContainer) 하나만 Local로 주입한 뒤 나머지는 그 컨테이너의 명시적 프로퍼티로 접근하게 만드는 방식이다. 학습 키워드는 ‘staticCompositionLocalOf’, ‘provider boundary’, ‘preview graph’이다.

Compose Compiler가 바꾸는 changed 플래그와 DI가 무슨 상관인가?

컴파일러는 각 Composable에 Composer와 changed 비트마스크를 추가하고, 파라미터가 바뀌었는지 판단해 그룹 스킵 여부를 결정한다. 의존성을 파라미터로 받으면, 같은 인스턴스가 유지될 때 ‘변경 없음’으로 판정될 여지가 생긴다(타입 안정성에 따라). 반대로 Composable 내부에서 의존성을 생성하면, 그 의존성은 파라미터 비교 대상이 아니라 UI 코드 안의 실행 비용이 된다. 또한 의존성 생성이 State 읽기와 섞이면 invalidation 범위가 넓어져 스킵이 깨진다. 처방은 UI 파라미터를 안정적으로 유지하는 것이다. Repo/UseCase는 상위에서 한 번 만들고, UI에는 불변 모델(@Immutable)과 작은 람다만 내려준다. 학습 키워드는 ‘skipping’, ‘stability inference’, ‘@Immutable’이다.

DI가 성능에 직접 영향을 주나, 아니면 구조 문제만 해결하나?

DI 자체가 프레임마다 실행되면 성능 문제가 된다. 하지만 정상적인 DI는 앱 시작/화면 진입 같은 경계에서만 객체 그래프를 만든다. 성능 이득은 ‘불필요한 할당과 구독 중복’을 줄이는 쪽에서 나온다. 예를 들어 Composable 내부에서 ApiClient를 만들면 재구성마다 객체가 생기고, TLS 핸드셰이크 캐시나 인터셉터 목록이 중복될 수 있다. 반대로 DI로 싱글톤을 한 번 만들면 할당이 줄고, 로그/메트릭도 일관된다. 측정은 Android Studio Profiler에서 Allocation을 켜고, 버튼 연타/스크롤 중 할당이 증가하는지 본다. 또한 Layout Inspector Recomposition Counts로 UI 재호출 범위를 확인한다. 학습 키워드는 ‘allocation profiling’, ‘recomposition counts’, ‘object lifetime’이다.

ViewModel을 쓰면 DI가 필요 없어지나?

ViewModel은 수명주기 스코프를 제공한다. 화면 회전에도 살아남고, SavedStateHandle로 일부 상태를 복원할 수 있다. 하지만 ViewModel이 의존성을 어떻게 얻는지는 또 다른 문제다. ViewModel 내부에서 전역 싱글톤을 참조하면 테스트가 막히고, 멀티 계정에서 교체가 어렵다. ViewModel 생성자에 Repo/UseCase를 주입하면 테스트가 쉬워지지만, 그 주입을 누가 하느냐가 남는다. 작은 앱은 수동 팩토리로도 충분하다. 규모가 커지면 팩토리와 그래프 조립 코드가 폭증해 DI 프레임워크가 필요해진다. 처방은 ‘ViewModel은 상태/이벤트 조정자, 의존성은 생성자 주입’ 원칙을 잡는 것이다. 학습 키워드는 ‘ViewModelProvider.Factory’, ‘constructor injection’, ‘saved state’이다.

Hilt 같은 프레임워크를 도입하기 전, 수동 DI로 어디까지 버틸 수 있나?

모듈 수가 적고 스코프가 단순하면 수동 DI가 오래 간다. AppContainer 하나에 싱글톤을 넣고, 화면별로 필요한 UseCase를 팩토리 함수로 제공하는 방식은 유지보수 가능하다. 한계는 스코프가 늘어날 때다. 예를 들어 로그인/로그아웃으로 세션 그래프를 교체해야 하고, 기능별로 다른 구현(예: mock 서버, feature flag)이 필요해지면 컨테이너 중첩과 교체 규칙이 복잡해진다. 또한 테스트에서 Dispatcher/Clock/Repo를 조합해 주입하는 코드가 반복된다. 도입 시점 판단 기준은 ‘생성자 파라미터가 8개 이상인 클래스가 늘어나는가’, ‘팩토리/컨테이너 코드가 UI 네비게이션 코드보다 길어지는가’, ‘테스트에서 그래프 조립이 매번 복붙되는가’이다. 학습 키워드는 ‘manual DI container’, ‘scoping’, ‘test doubles’이다.

관련 글

6. Compose에서 DI 없이 시작하기: 왜 결국 필요해지는지 체감하는 흐름
Compose 기본2026.02.16

6. Compose에서 DI 없이 시작하기: 왜 결국 필요해지는지 체감하는 흐름

Compose 초보가 DI 없이 시작했다가 상태 보존·재구성·테스트에서 왜 한계가 오는지, Runtime/Slot Table 관점으로 원인을 추적한다. Hilt 없이도 설계 감각을 만든다. 150자 내외 구성이다. 150자 내외 구성이다. 150자 내외 구성이다.

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

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

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

5. Compose에서 DI 오버헤드 줄이기: 컴포저블 스코프와 객체 생성 최적화
Compose 기본2026.02.16

5. Compose에서 DI 오버헤드 줄이기: 컴포저블 스코프와 객체 생성 최적화

Jetpack Compose에서 DI 호출·객체 생성이 리컴포지션과 섞일 때 생기는 비용을 Slot Table, 안정성(@Stable) 관점에서 추적하고 최적화 패턴을 제시한다. 140~160자 내외 구성이다. 160자 채움용 문장 추가 없음. 150자대.