16. Compose에서 ViewModel 만들기: State 저장과 UI 업데이트 패턴
Compose에서 ViewModel로 상태를 저장하고 UI를 업데이트하는 패턴을 내부 동작(슬롯 테이블, recomposition, 안정성) 관점에서 설명하고 실습 코드로 검증한다. 140~160자 맞춤 문장 예시로 작성됨입니다? 수정 필요: 150자 내외.
Compose에서 ViewModel 만들기: State 저장과 UI 업데이트 패턴
Compose를 처음 쓰면 ViewModel에 값을 넣었는데 화면이 안 바뀌거나, 반대로 버튼 한 번 눌렀을 뿐인데 로그가 수십 번 찍히는 상황을 겪는다. Activity 재생성 후 상태가 사라지기도 하고, 리스트 스크롤만 했는데도 비싼 연산이 반복되기도 한다. 이 문제는 ‘상태를 어디에 두고, 누가 읽고, 누가 쓰는지’를 Compose Runtime이 어떻게 추적하는지와 직결된다. ViewModel 패턴을 내부 동작까지 연결해 이해하면, 같은 코드라도 리컴포지션 범위와 메모리 할당 지점을 예측할 수 있다.
핵심 개념
Compose에서 ViewModel을 쓰는 이유는 ‘수명’과 ‘관찰’ 때문이다. 수명은 화면 회전, 프로세스 재시작, 네비게이션 백스택에 따라 UI가 다시 만들어져도 상태를 유지하는 문제다. 관찰은 상태가 바뀌면 어느 Composable을 다시 호출해야 하는지 Runtime이 자동으로 결정하는 문제다. View 시스템에서는 findViewById로 잡아둔 View에 setText를 직접 호출했다. Compose는 그런 명령형 업데이트를 기본 경로로 두지 않고, 상태 읽기(read)와 쓰기(write)를 추적해 다시 그릴 범위를 계산한다.
용어를 ‘사전 정의’로 외우면 금방 막힌다. State는 값 그 자체가 아니라 ‘읽기 추적 가능한 컨테이너’다. Snapshot은 State 읽기/쓰기를 트랜잭션처럼 기록하는 런타임 메커니즘이다. Recomposition은 함수 재호출이지만, 모든 UI를 다시 그리는 행위가 아니라 Slot Table에 저장된 이전 호출 결과와 비교해 필요한 노드만 업데이트하는 과정이다. Stable/Immutable은 이 비교를 싸게 만들기 위한 힌트다.
처음에 나도 ViewModel에 var count = 0을 두고 버튼에서 count++ 했는데 UI가 그대로라서 멍해졌다. Logcat에는 클릭 로그가 찍히는데 Text는 안 바뀌었다. 원인은 Compose가 일반 var 변경을 ‘관찰’하지 못한다는 점이다. Compose가 추적하는 건 mutableStateOf 같은 Snapshot State, 또는 Flow/LiveData를 collect/observe로 연결한 스트림이다. 관찰 대상이 아니면 Recomposer는 다시 호출할 근거를 얻지 못한다.
또 한 번은 collectAsState로 Flow를 구독했는데, 화면이 열리자마자 네트워크 요청이 두 번 나갔다. 원인을 찾다가 Android Studio Logcat에 같은 태그가 연속으로 찍히는 걸 보고 3시간 삽질했다. Composable 본문에서 viewModel.load() 같은 사이드이펙트를 호출하면, 초기 composition과 이후 recomposition에서 반복 실행될 수 있다. Compose는 ‘함수 호출’이 UI 선언이기 때문에, 부수효과는 LaunchedEffect 같은 별도 채널로 분리해야 한다.
1package com.example.vmstate
2
3import androidx.compose.runtime.getValue
4import androidx.compose.runtime.mutableIntStateOf
5import androidx.compose.runtime.setValue
6import androidx.lifecycle.ViewModel
7
8class CounterViewModel : ViewModel() {
9 // Compose가 추적하는 Snapshot State
10 var count by mutableIntStateOf(0)
11 private set
12
13 fun inc() {
14 count++
15 }
16}이 코드를 실행하고 Text가 count를 읽는 Composable을 두면, inc()가 count를 변경하는 순간 Snapshot 시스템이 ‘이 State를 읽은 구독자’를 표시해 둔다. 다음 프레임에서 Recomposer가 해당 구독자 그룹만 invalidation 처리하고, Slot Table에서 그 그룹에 해당하는 호출 구간을 다시 실행한다. 일반 var였다면 읽기 추적이 없어서 invalidation 자체가 발생하지 않는다.
컴포넌트 해부
Compose에서 ‘ViewModel 만들기’는 단순히 클래스를 하나 추가하는 일이 아니다. UI 계층에서 ViewModel을 ‘어떻게 얻고’, ‘어떤 형태의 상태를 노출하고’, ‘이벤트를 어떤 방향으로 흘릴지’를 정하는 일이다. 특히 ViewModel을 Composable 안에서 직접 생성하면(예: CounterViewModel()) composition 수명과 충돌한다. recomposition마다 새 인스턴스가 만들어질 수 있고, 그 순간 상태는 초기화된다.
- viewModel(): ViewModelStoreOwner(보통 NavBackStackEntry/Activity)에서 같은 인스턴스를 재사용하기 위한 진입점
- key: 같은 owner 안에서도 서로 다른 인스턴스를 구분하기 위한 식별자
- factory: 생성자 파라미터가 필요한 ViewModel을 만들기 위한 주입 지점
- SavedStateHandle: 프로세스 종료/복원에서 최소 상태를 복원하기 위한 저장소
- state 노출 타입: mutableStateOf vs StateFlow vs LiveData 중 무엇을 UI에 연결할지 결정
- UIState 데이터 클래스: 여러 필드를 한 번에 스냅샷처럼 다루기 위한 묶음
- events: 클릭/스크롤 같은 사용자 입력을 ViewModel로 전달하는 함수 집합
- effects: 토스트/네비게이션 같은 1회성 결과를 분리하기 위한 채널(SharedFlow 등)
- collectAsStateWithLifecycle: 백그라운드에서 불필요한 수집을 막기 위한 lifecycle 결합
- remember: Composable 내부 객체(InteractionSource 등)의 재생성 비용을 막기 위한 캐시
- derivedStateOf: 입력 상태가 같으면 계산을 재사용해 recomposition 비용을 줄이는 도구
- @Stable/@Immutable: 파라미터 변경 감지 비용을 줄이기 위한 안정성 힌트
Surface 계층과 Content 계층을 분리해서 생각하면 ViewModel 패턴이 더 선명해진다. Surface는 ‘외부에서 주입받은 상태’로 무엇을 보여줄지 결정하는 얇은 껍질이다. Content는 순수 UI 함수에 가까워서 Preview, 테스트, 재사용이 쉽다. ViewModel을 Surface에서만 접근하면, Content는 상태와 이벤트만 받는 형태가 되고 recomposition 범위를 통제하기 쉬워진다.
Surface에 ViewModel 접근을 몰아두는 이유는 Slot Table 관점에서도 이득이다. ViewModel 인스턴스 자체는 Compose가 비교할 대상이 아니다. 하지만 Surface에서 uiState를 구독하면, Slot Table에 ‘구독한 위치’가 저장되고 그 아래 Content 서브트리 중 실제로 uiState를 읽는 범위만 다시 호출된다. 반대로 Content 깊은 곳 여기저기에서 collectAsState를 호출하면, 구독 지점이 분산되고 invalidation 범위가 예상보다 넓어진다.
Content 계층이 이벤트를 ViewModel로 직접 호출하지 않고 콜백을 받는 구조는 API 설계 의도와 닿아 있다. Compose는 함수 호출 그래프를 기반으로 변경 범위를 계산한다. Content가 ViewModel을 직접 참조하면, 파라미터 변경과 무관하게 ‘외부 전역’에 의존하는 함수가 늘고, 프리뷰/테스트에서 대체가 어려워진다. UI는 상태를 읽고 이벤트를 내보내는 역할로 제한하는 편이 런타임 추적과도 잘 맞는다.
1package com.example.vmstate
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.collectAsState
5import androidx.compose.runtime.getValue
6import androidx.lifecycle.ViewModel
7import kotlinx.coroutines.flow.MutableStateFlow
8import kotlinx.coroutines.flow.StateFlow
9
10// 구조 설명용 단순화된 UIState + VM
11
12data class CounterUiState(
13 val count: Int = 0,
14 val enabled: Boolean = true
15)
16
17class CounterVmSketch : ViewModel() {
18 private val _uiState = MutableStateFlow(CounterUiState())
19 val uiState: StateFlow<CounterUiState> = _uiState
20
21 fun onInc() {
22 val cur = _uiState.value
23 _uiState.value = cur.copy(count = cur.count + 1)
24 }
25}
26
27@Composable
28fun CounterRoute(vm: CounterVmSketch) {
29 val state by vm.uiState.collectAsState(initial = CounterUiState())
30 CounterScreen(
31 state = state,
32 onInc = vm::onInc
33 )
34}
35
36@Composable
37fun CounterScreen(
38 state: CounterUiState,
39 onInc: () -> Unit
40) {
41 // 실제 UI는 실습 섹션에서 구현
42}이 스케치는 ‘Surface(Route)에서 상태 수집 → Content(Screen)에 값 전달’ 흐름을 고정한다. 실행하면 CounterRoute에서 collectAsState가 구독을 만들고, StateFlow 값이 바뀔 때 CounterRoute가 invalidation 된다. 중요한 포인트는 CounterScreen이 state.count를 실제로 읽는 지점만 다시 호출 대상이 된다는 점이다. state 전체를 통째로 넘긴다고 해서 항상 전체가 다시 그려지는 건 아니지만, state를 읽는 위치가 넓으면 그만큼 리컴포지션 범위가 넓어진다.
1package com.example.vmstate
2
3import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Row
5import androidx.compose.foundation.layout.padding
6import androidx.compose.material3.Button
7import androidx.compose.material3.MaterialTheme
8import androidx.compose.material3.Surface
9import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
11import androidx.compose.ui.Modifier
12import androidx.compose.ui.unit.dp
13
14@Composable
15fun CounterScreen(
16 state: CounterUiState,
17 onInc: () -> Unit
18) {
19 Surface(color = MaterialTheme.colorScheme.background) {
20 Row(
21 modifier = Modifier.padding(16.dp),
22 horizontalArrangement = Arrangement.spacedBy(12.dp)
23 ) {
24 Text(text = "count=${state.count}")
25 Button(onClick = onInc, enabled = state.enabled) {
26 Text(text = "inc")
27 }
28 }
29 }
30}Surface는 배경/테마/클릭 리플 같은 ‘외피’를 담당하고, Row 이하 Content는 상태를 읽는 최소 범위를 가진다. enabled를 빼면 버튼은 항상 활성화되고, state.enabled가 바뀌어도 UI 변화가 없다. 즉, 상태를 만들어도 읽지 않으면 recomposition은 일어나도 화면 차이가 없다. 이런 종류의 버그는 Layout Inspector에서 recomposition count는 증가하는데 UI가 그대로인 형태로 나타난다.
내부 동작 원리
Compose 파이프라인은 Composition → Layout → Drawing 순서로 진행된다. Composition은 Composable 함수를 실행해 UI 트리를 만들고, Layout은 측정/배치, Drawing은 실제 픽셀 렌더링이다. ViewModel 상태 변경은 보통 Composition 단계의 재실행(recomposition)만 유발한다. Layout이나 Drawing까지 갈지는 ‘측정/그리기 결과가 달라졌는지’에 따라 달라진다. 예를 들어 Text의 문자열이 바뀌면 재측정이 필요할 수 있고, 색만 바뀌면 재그리기만 필요할 수 있다.
Compose Compiler는 @Composable 함수 호출을 그대로 두지 않고, 내부적으로 Composer를 파라미터로 받는 형태로 변환한다. 소스에서 보이는 CounterScreen(state, onInc) 호출은, 컴파일 후에는 ‘그룹 시작/종료’, ‘스킵 가능성 판단’, ‘변경 플래그 전달’ 같은 코드가 섞인다. 이 변환 덕분에 Runtime은 Slot Table에 각 호출의 위치를 기록하고, 다음 recomposition에서 같은 호출 위치로 다시 들어갈 수 있다.
Slot Table은 ‘호출 순서 기반’ 저장소다. 같은 Composable이 같은 순서로 호출되면 같은 슬롯에 상태와 노드 참조가 매핑된다. 그래서 조건문으로 Composable 호출을 넣었다 뺐다 하면 슬롯이 밀릴 수 있고, 키(key)나 안정적인 구조가 중요해진다. ViewModel을 Composable에서 직접 생성하면, 그 인스턴스가 remember 슬롯에 저장되지 않는 한 호출마다 새 객체가 만들어지고, 상태가 유지되지 않는다.
StateFlow를 collectAsState로 구독하면, collect가 Snapshot State로 브리지된다. Flow 값이 바뀌면 collectAsState 내부에서 Snapshot State에 write가 발생하고, Snapshot은 ‘이 값을 읽은 위치’를 알고 있으니 invalidation을 만든다. Recomposer는 프레임 경계에서 invalidation 목록을 처리하며, 해당 그룹만 재실행한다. 이때 파라미터 비교는 안정성(stability) 정보에 의존한다. data class는 equals로 비교 가능하지만, 내부에 mutable 컬렉션을 들고 있으면 equals가 의미를 잃고 변경 감지가 비싸진다.
Modifier 체이닝은 객체를 계속 붙이는 방식으로 보이지만, 핵심은 ‘순서가 의미를 가진다’는 점이다. padding 다음 clickable과 clickable 다음 padding은 hit-test 영역이 달라진다. Runtime은 Modifier를 노드에 적용할 때 체인 순서를 유지한 채로 요소를 합성한다. ViewModel 패턴과의 연결점은, InteractionSource나 semantics 같은 입력/접근성 요소도 상태처럼 recomposition과 연동된다는 점이다.
불필요한 recomposition은 대개 ‘읽기 범위가 넓다’ 또는 ‘불안정한 객체를 파라미터로 넘긴다’에서 시작한다. 예를 들어 List<Thing>을 매번 새로 만들어 넘기면, equals가 참이어도 참조가 바뀌는 순간 안정성 규칙에 따라 변경으로 간주될 수 있다. derivedStateOf로 계산 결과를 캐시하거나, UIState를 @Immutable로 만들고 내부를 불변으로 유지하면 비교 비용을 줄일 수 있다.
1package com.example.vmstate
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
13@Composable
14fun RecomposeProbe() {
15 var clicks by remember { mutableIntStateOf(0) }
16
17 Log.d("RecomposeProbe", "RecomposeProbe body clicks=$clicks")
18
19 Column {
20 Text(text = "clicks=$clicks")
21 Button(onClick = { clicks++ }) {
22 Text(text = "tap")
23 }
24 }
25}이 코드를 실행하면 버튼을 누를 때마다 Logcat에 RecomposeProbe body 로그가 다시 찍힌다. 로그가 찍히는 이유는 recomposition이 ‘함수 본문 재실행’이기 때문이다. Text만 바뀌어도 Column 블록 전체가 다시 호출되는 것처럼 보이지만, 실제로는 Slot Table에서 변경이 없는 하위 노드는 스킵될 수 있다. 다만 Log는 스킵 이전에 찍히므로, 로그만 보고 ‘전체가 다시 그려졌다’고 단정하면 디버깅이 꼬인다.
1package com.example.vmstate
2
3import androidx.compose.foundation.interaction.MutableInteractionSource
4import androidx.compose.foundation.layout.padding
5import androidx.compose.foundation.semantics.contentDescription
6import androidx.compose.foundation.semantics.semantics
7import androidx.compose.material3.Button
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.runtime.remember
11import androidx.compose.ui.Modifier
12import androidx.compose.ui.unit.dp
13
14@Composable
15fun AccessibleButton(
16 label: String,
17 onClick: () -> Unit
18) {
19 val interaction = remember { MutableInteractionSource() }
20
21 Button(
22 onClick = onClick,
23 interactionSource = interaction,
24 modifier = Modifier
25 .padding(8.dp)
26 .semantics { contentDescription = "action:$label" }
27 ) {
28 Text(text = label)
29 }
30}interactionSource를 remember로 고정하지 않으면 recomposition 때마다 새 인스턴스가 만들어질 수 있다. 그 결과 리플/프레스 상태가 중간에 끊기거나, 테스트에서 pointer input이 불안정하게 보일 수 있다. semantics는 접근성 트리로 전달되며, contentDescription이 바뀌면 해당 노드의 semantics가 업데이트된다. Modifier 체인 순서가 hit 영역과 semantics 적용 위치를 함께 결정한다.
실습하기
실습 목표는 3단계로 분리한다. 1단계는 ViewModel 없이 remember로만 상태를 유지해 ‘상태 읽기/쓰기 → recomposition’ 감각을 잡는다. 2단계는 ViewModel + StateFlow로 수명을 분리하고, 화면 회전에서도 값이 유지되는지 확인한다. 3단계는 UIState 분리, derivedStateOf, 이벤트 처리 위치를 조정해 불필요한 recomposition을 눈으로 확인한다.
1plugins {
2 id("com.android.application")
3 id("org.jetbrains.kotlin.android")
4}
5
6android {
7 compileSdk = 34
8 defaultConfig { minSdk = 24 }
9 buildFeatures { compose = true }
10 composeOptions { kotlinCompilerExtensionVersion = "1.5.14" }
11}
12
13dependencies {
14 implementation("androidx.activity:activity-compose:1.9.2")
15 implementation("androidx.compose.material3:material3:1.3.1")
16 implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6")
17 implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.6")
18}의존성은 lifecycle-viewmodel-compose와 lifecycle-runtime-compose가 핵심이다. 전자는 Composable에서 viewModel()을 안전하게 얻는 경로를 제공하고, 후자는 collectAsStateWithLifecycle로 백그라운드 수집을 막는다. 실행 환경에 따라 버전은 달라질 수 있지만, 실습에서는 ‘동작 확인’이 목적이라 최신 안정 버전 계열이면 충분하다.
1단계: remember로 상태와 recomposition 눈으로 확인
이 단계에서 화면에는 숫자와 버튼 하나가 보인다. 버튼을 누르면 숫자가 1씩 증가하고, Logcat에는 recomposition 로그가 클릭마다 1회 이상 찍힌다. 여기서 확인할 포인트는 ‘상태를 읽는 Composable이 다시 호출된다’는 사실이다.
remember를 제거하면 clicks가 0으로 계속 돌아간다. 이유는 recomposition이 함수 본문을 다시 실행하기 때문에 로컬 변수는 매번 초기화되기 때문이다. remember는 Slot Table의 해당 위치에 값을 저장하고, 같은 호출 위치로 돌아왔을 때 그 값을 다시 꺼낸다.
1package com.example.vmstate
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.foundation.layout.padding
9import androidx.compose.material3.Button
10import androidx.compose.material3.MaterialTheme
11import androidx.compose.material3.Surface
12import androidx.compose.material3.Text
13import androidx.compose.runtime.Composable
14import androidx.compose.runtime.getValue
15import androidx.compose.runtime.mutableIntStateOf
16import androidx.compose.runtime.remember
17import androidx.compose.runtime.setValue
18import androidx.compose.ui.Modifier
19import androidx.compose.ui.unit.dp
20
21class MainActivity : ComponentActivity() {
22 override fun onCreate(savedInstanceState: Bundle?) {
23 super.onCreate(savedInstanceState)
24 setContent {
25 MaterialTheme {
26 Surface {
27 RememberCounter()
28 }
29 }
30 }
31 }
32}
33
34@Composable
35fun RememberCounter() {
36 var clicks by remember { mutableIntStateOf(0) }
37 Log.d("RememberCounter", "body clicks=$clicks")
38
39 Column(modifier = Modifier.padding(16.dp)) {
40 Text(text = "clicks=$clicks")
41 Button(onClick = { clicks++ }) { Text("inc") }
42 }
43}실행 후 버튼을 연타하면 Text는 즉시 바뀌고, 로그는 클릭 수만큼 증가한다. 여기서 ‘UI 업데이트’는 setText 호출이 아니라, clicks를 읽는 Text 호출이 다시 실행되면서 새로운 문자열이 전달되는 방식으로 일어난다. Compose는 clicks 읽기를 추적했기 때문에 invalidation 범위를 계산할 수 있다.
2단계: ViewModel + StateFlow로 수명 분리
이 단계에서 화면은 동일해 보이지만, 기기 회전(또는 개발자 옵션의 ‘활동 유지 안 함’) 후에도 숫자가 유지되는지 확인한다. remember는 composition 수명에 묶이지만 ViewModel은 ViewModelStoreOwner 수명에 묶인다. 화면이 재구성되어도 같은 ViewModel 인스턴스를 재사용한다.
collectAsStateWithLifecycle을 쓰면 백그라운드에서 Flow 수집이 중단된다. 앱을 홈으로 보낸 뒤에도 수집이 계속되면, 타이머/네트워크가 불필요하게 돌고 배터리/데이터가 소모된다. UI는 보이지 않을 때 일을 멈추는 게 맞다.
1package com.example.vmstate
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.padding
5import androidx.compose.material3.Button
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.runtime.getValue
9import androidx.compose.ui.Modifier
10import androidx.compose.ui.unit.dp
11import androidx.lifecycle.ViewModel
12import androidx.lifecycle.viewmodel.compose.viewModel
13import androidx.lifecycle.compose.collectAsStateWithLifecycle
14import kotlinx.coroutines.flow.MutableStateFlow
15import kotlinx.coroutines.flow.StateFlow
16
17data class CounterUiState2(val count: Int = 0)
18
19class CounterViewModel2 : ViewModel() {
20 private val _ui = MutableStateFlow(CounterUiState2())
21 val ui: StateFlow<CounterUiState2> = _ui
22
23 fun inc() {
24 _ui.value = _ui.value.copy(count = _ui.value.count + 1)
25 }
26}
27
28@Composable
29fun CounterRoute2(vm: CounterViewModel2 = viewModel()) {
30 val state by vm.ui.collectAsStateWithLifecycle()
31 Column(modifier = Modifier.padding(16.dp)) {
32 Text(text = "count=${state.count}")
33 Button(onClick = vm::inc) { Text("inc") }
34 }
35}이 코드는 UI가 ViewModel의 StateFlow를 구독한다. inc() 호출 시 StateFlow에 새 값이 설정되고, collectAsStateWithLifecycle이 내부적으로 Snapshot State를 갱신한다. 그 write를 트리거로 Recomposer가 CounterRoute2가 속한 그룹을 다시 실행한다. 화면 회전 후에도 같은 ViewModel이 재사용되면 count는 유지된다.
3단계: UIState 분리 + derivedStateOf로 불필요한 연산 차단
이 단계에서 화면에는 count, 그리고 count가 특정 조건을 만족할 때만 활성화되는 버튼이 보인다. count가 바뀔 때마다 ‘비싼 계산’을 일부러 넣고, derivedStateOf로 계산 캐시가 걸리는지 로그로 확인한다. 로그가 매 클릭마다 찍히면 캐시가 안 걸린 상태다.
derivedStateOf는 입력이 바뀌지 않으면 계산 결과를 재사용한다. 중요한 점은 ‘입력 읽기 추적’이 함께 걸린다는 점이다. 계산 블록 안에서 읽은 State가 바뀔 때만 derivedState가 invalidation 된다. 그래서 계산 블록 안에서 불필요한 State를 읽으면 캐시가 쉽게 깨진다.
1package com.example.vmstate
2
3import android.util.Log
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.padding
7import androidx.compose.material3.Button
8import androidx.compose.material3.Text
9import androidx.compose.runtime.Composable
10import androidx.compose.runtime.derivedStateOf
11import androidx.compose.runtime.getValue
12import androidx.compose.runtime.remember
13import androidx.compose.ui.Modifier
14import androidx.compose.ui.unit.dp
15
16@Composable
17fun DerivedStateDemo(state: CounterUiState2, onInc: () -> Unit) {
18 val expensive by remember(state.count) {
19 derivedStateOf {
20 Log.d("DerivedStateDemo", "expensive calc for count=${state.count}")
21 // 일부러 비용이 큰 것처럼 보이게 문자열 생성
22 "enabled=${state.count % 3 != 0}"
23 }
24 }
25
26 Row(
27 modifier = Modifier.padding(16.dp),
28 horizontalArrangement = Arrangement.spacedBy(12.dp)
29 ) {
30 Text(text = "count=${state.count}")
31 Text(text = expensive)
32 Button(onClick = onInc) { Text("inc") }
33 }
34}실행하면 count가 바뀔 때만 expensive calc 로그가 찍힌다. derivedStateOf를 쓰지 않고 본문에서 바로 문자열을 만들면, recomposition마다 계산이 반복된다. 여기서는 count 변경이 recomposition을 일으키니 차이가 작아 보일 수 있지만, 실제 앱에서는 다른 상태 변화(테마, 인셋, 애니메이션 프레임)로 recomposition이 더 자주 발생한다. 그때 비싼 계산이 같이 돌면 프레임 드랍으로 이어진다.
심화: Advanced 버전 만들기
실무에서는 버튼 하나에도 요구사항이 덕지덕지 붙는다. 로딩 중 비활성화, 연타 방지(debounce), 아이콘+텍스트, 롱프레스, 접근성 라벨까지 들어간다. 이 기능을 ViewModel 상태와 연결할 때 핵심은 ‘UI는 상태를 읽고 이벤트를 내보낸다’ 원칙을 깨지 않는 것이다. 부수효과(네비게이션, 토스트)는 1회성이라 State로 표현하면 재구독 시 재발화 문제가 생긴다.
사례 1: 로딩 + 연타 방지 + 접근성 라벨을 UI에만 두기
연타 방지는 ViewModel에도 둘 수 있지만, UI 레벨에서 막는 편이 더 단순한 경우가 많다. 네트워크 요청 자체는 ViewModel이 담당하되, 클릭 입력을 몇 ms 막는 건 UI의 입력 정책이다. 이때 remember로 lastClickTime을 저장하면 recomposition에도 유지된다.
1package com.example.vmstate
2
3import android.os.SystemClock
4import androidx.compose.foundation.combinedClickable
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.Spacer
7import androidx.compose.foundation.layout.width
8import androidx.compose.foundation.progressSemantics
9import androidx.compose.foundation.semantics.contentDescription
10import androidx.compose.foundation.semantics.semantics
11import androidx.compose.material3.CircularProgressIndicator
12import androidx.compose.material3.Icon
13import androidx.compose.material3.MaterialTheme
14import androidx.compose.material3.Surface
15import androidx.compose.material3.Text
16import androidx.compose.runtime.Composable
17import androidx.compose.runtime.getValue
18import androidx.compose.runtime.mutableLongStateOf
19import androidx.compose.runtime.remember
20import androidx.compose.runtime.setValue
21import androidx.compose.ui.Alignment
22import androidx.compose.ui.Modifier
23import androidx.compose.ui.graphics.vector.ImageVector
24import androidx.compose.ui.unit.dp
25
26@Composable
27fun AdvancedButton(
28 text: String,
29 icon: ImageVector?,
30 loading: Boolean,
31 enabled: Boolean,
32 debounceMs: Long = 500L,
33 a11yLabel: String = text,
34 onClick: () -> Unit,
35 onLongClick: (() -> Unit)? = null,
36 modifier: Modifier = Modifier
37) {
38 var lastClickAt by remember { mutableLongStateOf(0L) }
39 val clickableEnabled = enabled && !loading
40
41 Surface(
42 color = if (clickableEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
43 contentColor = if (clickableEnabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
44 modifier = modifier
45 .semantics { contentDescription = a11yLabel }
46 .combinedClickable(
47 enabled = clickableEnabled,
48 onClick = {
49 val now = SystemClock.elapsedRealtime()
50 if (now - lastClickAt >= debounceMs) {
51 lastClickAt = now
52 onClick()
53 }
54 },
55 onLongClick = onLongClick
56 )
57 ) {
58 Row(verticalAlignment = Alignment.CenterVertically) {
59 if (loading) {
60 CircularProgressIndicator(
61 modifier = Modifier
62 .width(18.dp)
63 .progressSemantics(),
64 strokeWidth = 2.dp
65 )
66 Spacer(modifier = Modifier.width(8.dp))
67 } else if (icon != null) {
68 Icon(imageVector = icon, contentDescription = null)
69 Spacer(modifier = Modifier.width(8.dp))
70 }
71 Text(text = text)
72 }
73 }
74}이 코드를 실행하면 loading=true일 때 버튼이 눌리지 않고, 인디케이터가 보인다. 연타하면 debounceMs 동안 onClick이 추가로 호출되지 않는다. combinedClickable을 쓰는 이유는 onLongClick을 같은 입력 파이프라인에서 처리하기 위해서다. semantics의 contentDescription은 TalkBack에서 읽히며, 아이콘의 contentDescription을 null로 두면 중복 낭독을 피할 수 있다.
사례 2: ViewModel에서 1회성 effect를 분리하기
토스트나 네비게이션은 상태로 들고 있으면 화면 재구성/재구독 때 다시 발생할 수 있다. effect 스트림(SharedFlow)로 분리하면, 수집 시점에만 1회 처리한다. collectAsState는 상태에 적합하고, LaunchedEffect + collect는 effect에 적합하다.
1package com.example.vmstate
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.LaunchedEffect
5import androidx.compose.runtime.getValue
6import androidx.lifecycle.ViewModel
7import androidx.lifecycle.compose.collectAsStateWithLifecycle
8import androidx.lifecycle.viewmodel.compose.viewModel
9import kotlinx.coroutines.flow.MutableSharedFlow
10import kotlinx.coroutines.flow.MutableStateFlow
11import kotlinx.coroutines.flow.SharedFlow
12import kotlinx.coroutines.flow.StateFlow
13import kotlinx.coroutines.flow.asSharedFlow
14
15data class ActionUiState(
16 val loading: Boolean = false,
17 val enabled: Boolean = true,
18 val clicks: Int = 0
19)
20
21sealed interface ActionEffect {
22 data class Toast(val message: String) : ActionEffect
23}
24
25class ActionViewModel : ViewModel() {
26 private val _state = MutableStateFlow(ActionUiState())
27 val state: StateFlow<ActionUiState> = _state
28
29 private val _effect = MutableSharedFlow<ActionEffect>(extraBufferCapacity = 1)
30 val effect: SharedFlow<ActionEffect> = _effect.asSharedFlow()
31
32 fun onAction() {
33 val next = _state.value.clicks + 1
34 _state.value = _state.value.copy(clicks = next)
35 _effect.tryEmit(ActionEffect.Toast("clicked=$next"))
36 }
37}
38
39@Composable
40fun ActionRoute(
41 vm: ActionViewModel = viewModel(),
42 onToast: (String) -> Unit
43) {
44 val state by vm.state.collectAsStateWithLifecycle()
45
46 LaunchedEffect(vm) {
47 vm.effect.collect { e ->
48 when (e) {
49 is ActionEffect.Toast -> onToast(e.message)
50 }
51 }
52 }
53
54 // UI는 실습 앱에서 연결
55}처음에 나는 토스트 메시지를 uiState.message 같은 필드에 넣고, UI에서 message가 non-null이면 Toast를 띄우고 다시 null로 되돌리는 패턴을 썼다. 화면 회전 후 collect가 다시 시작되면서 같은 토스트가 또 뜨는 바람에 QA에서 ‘버튼 안 눌렀는데 토스트가 뜬다’ 리포트를 받았다. 원인은 상태(state)로 effect를 표현했기 때문이다. SharedFlow로 effect를 분리하면, 구독 타이밍에만 소비되고 Slot Table에 저장되는 UIState와 분리된다.
또 한 번은 LaunchedEffect(Unit) 안에서 vm.effect.collect를 붙여놨는데, 네비게이션으로 화면을 여러 번 들어갔다 나올 때 collect가 중복으로 붙어서 토스트가 두 번씩 떴다. Logcat에 같은 메시지가 2배로 찍혔고, 코루틴 덤프를 떠서 수집 코루틴이 여러 개 살아있는 걸 확인했다. 해결은 LaunchedEffect의 key를 ‘해당 화면의 수명’에 맞추는 일이다. 여기서는 vm을 키로 잡아, 같은 vm 인스턴스에 대해 collect가 하나만 유지되게 한다.
자주 하는 실수
1) ViewModel을 Composable에서 직접 new 해서 상태가 초기화됨
증상: 버튼을 누르면 숫자가 오르지만, 다른 상태 변화나 화면 갱신이 일어나는 순간 값이 0으로 튄다. 특히 애니메이션 프레임이나 부모 recomposition이 걸릴 때 더 잘 드러난다.
원인: CounterViewModel() 같은 생성은 Composable 호출마다 실행될 수 있다. Compose는 객체 생성 자체를 기억해주지 않는다. remember로 감싸지 않는 한 Slot Table에 저장되지 않는다.
해결: viewModel()을 사용해 ViewModelStoreOwner에 귀속된 인스턴스를 얻는다. 파라미터가 필요하면 factory를 제공한다. UI 깊은 곳이 아니라 Route 레벨에서 얻고 하위에는 상태/이벤트만 전달한다.
2) Composable 본문에서 load() 호출로 네트워크가 중복 실행됨
증상: 화면 진입 시 API 호출이 2번 이상 발생한다. 로그에 같은 요청이 연속으로 찍히고, 서버에서 중복 요청으로 rate limit에 걸리기도 한다.
원인: Composable 본문은 recomposition으로 여러 번 실행된다. 본문에서 vm.load()를 호출하면, 초기 composition 이후에도 상태 변화로 다시 실행될 때마다 load가 호출된다.
해결: LaunchedEffect(key)로 부수효과를 분리한다. key는 화면의 입력 파라미터(예: userId)에 맞춘다. load가 idempotent하지 않으면 ViewModel에서 중복 호출 방지도 같이 둔다.
3) UIState에 MutableList/MutableMap을 넣고 변경 감지가 깨짐
증상: 리스트 아이템을 추가/삭제했는데 UI가 갱신되지 않거나, 반대로 아무 변화가 없어도 리스트 전체가 자주 다시 그려진다. LazyColumn 스크롤이 끊기는 형태로도 나타난다.
원인: 불변으로 보이는 data class라도 내부가 mutable이면 안정성 추론이 깨진다. equals가 참이어도 내부 변경은 외부에서 감지되지 않거나, 참조 변경이 잦아 비교 비용이 커진다.
해결: UIState는 불변 컬렉션(List) + 새 인스턴스 copy로 갱신한다. 큰 리스트는 diff/페이징을 쓰고, 아이템은 stable key를 제공한다.
4) collectAsState를 여러 곳에서 호출해 구독이 분산됨
증상: 같은 StateFlow를 여러 Composable에서 collectAsState로 구독해, 값 하나 바뀌면 생각보다 넓은 영역이 invalidation 된다. 디버그 로그가 여러 컴포넌트에서 동시에 증가한다.
원인: 구독 지점이 늘면 Snapshot State write가 여러 슬롯을 invalidation 한다. 또한 각 구독은 코루틴 수집 비용이 추가된다.
해결: Route에서 한 번만 수집하고, 필요한 값만 하위로 전달한다. 하위에서 파생 값은 derivedStateOf로 만든다.
5) 1회성 이벤트를 state로 표현해 재발화됨
증상: 화면 회전이나 프로세스 복원 후에 토스트/네비게이션이 다시 실행된다. QA에서 “아무것도 안 했는데 이동했다” 같은 리포트가 나온다.
원인: state는 ‘현재 UI를 그리기 위한 값’이라 재구독 시 다시 읽힌다. 반면 effect는 ‘한 번 소비’가 목적이다. state로 effect를 흉내 내면 재구독 시점에 다시 처리된다.
해결: SharedFlow/Channel로 effect를 분리하고 LaunchedEffect에서 collect한다. UIState에는 화면을 그리는 값만 둔다.
1package com.example.vmstate
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.LaunchedEffect
5import kotlinx.coroutines.flow.Flow
6
7@Composable
8fun <T> CollectEffect(
9 effect: Flow<T>,
10 onEach: suspend (T) -> Unit
11) {
12 LaunchedEffect(effect) {
13 effect.collect { onEach(it) }
14 }
15}이 유틸은 effect 수집을 UI 코드에서 반복하지 않기 위한 형태다. key를 effect 인스턴스로 잡아, 같은 Flow에 대해 수집이 하나만 붙도록 한다. 화면별로 owner가 달라질 수 있으니, 실제 앱에서는 Route 레벨에서 effect를 합치는 구조가 디버깅이 쉽다.
성능 최적화 체크리스트
- Route 레벨에서 viewModel()을 호출하고, Screen/Component로는 상태와 이벤트만 전달한다
- UIState는 불변(data class)이며 내부에 Mutable 컬렉션을 넣지 않는다
- StateFlow 갱신은 copy로 새 인스턴스를 만든다(동일 참조 재사용 금지)
- Composable 본문에서 네트워크/DB 같은 부수효과를 호출하지 않고 LaunchedEffect로 이동한다
- Flow 수집은 collectAsStateWithLifecycle을 기본으로 하고, 백그라운드 수집을 차단한다
- 같은 Flow를 여러 위치에서 collect하지 않도록 구독 지점을 최소화한다
- 파생 값(필터/정렬/비싼 계산)은 derivedStateOf로 캐시하고, 입력 읽기 범위를 최소화한다
- Modifier 순서가 hit-test/semantics에 영향을 주는지 확인한다(padding vs clickable 순서)
- InteractionSource/ScrollState 등은 remember로 고정해 입력 상태가 끊기지 않게 한다
- LazyColumn 아이템에 안정적인 key를 제공하고, 아이템 모델은 가능한 stable하게 유지한다
- 1회성 이벤트는 SharedFlow/Channel로 분리하고 LaunchedEffect에서 소비한다
- Recomposition 로그는 ‘그려짐’이 아니라 ‘함수 재실행’임을 전제로 해석한다
자주 묻는 질문
왜 ViewModel의 var 값을 바꿨는데 Compose UI가 안 바뀌나?
Compose가 UI 업데이트 트리거로 삼는 것은 ‘관찰 가능한 상태의 write’이다. 일반 var 변경은 Snapshot 시스템에 기록되지 않으니, Runtime이 invalidation(다시 실행해야 할 그룹)을 만들 근거가 없다. 해결은 두 가지다. (1) Compose 전용이라면 mutableStateOf/mutableIntStateOf로 노출한다. (2) 아키텍처적으로는 StateFlow/LiveData로 노출하고, UI에서 collectAsStateWithLifecycle/observeAsState로 브리지한다. 학습 키워드는 snapshot, invalidation, recomposition, collectAsState이다.
remember가 없으면 뭐가 망가지는가? ViewModel이 있는데 remember가 또 필요한가?
remember는 ‘Composable 호출 위치에 귀속된 캐시’다. ViewModel은 화면 수명 동안 유지되는 상태 저장소지만, UI 내부에서만 의미가 있는 객체도 많다. 예를 들어 MutableInteractionSource, Animatable, ScrollState 같은 것은 ViewModel에 넣기엔 UI 세부 구현에 너무 가깝고, 화면이 사라지면 같이 사라지는 편이 맞다. remember가 없으면 recomposition 때마다 새 인스턴스가 만들어져 리플이 끊기거나 애니메이션이 리셋된다. 학습 키워드는 Slot Table, remember slot, interaction source, recomposition이다.
collectAsState와 collectAsStateWithLifecycle 차이는 무엇이고, 왜 lifecycle이 필요한가?
collectAsState는 Composable이 composition에 있는 동안 Flow를 수집한다. 하지만 화면이 백그라운드로 가도 composition이 유지되는 경우가 있고(예: 여러 화면을 쌓아둔 네비게이션 구조), 그때도 수집이 계속되면 불필요한 작업이 돈다. collectAsStateWithLifecycle은 Lifecycle이 STARTED 이상일 때만 수집하고, STOPPED면 자동 중단한다. 타이머/위치/네트워크처럼 지속 스트림에서 차이가 크다. 학습 키워드는 lifecycle-runtime-compose, repeatOnLifecycle, cold/hot flow, backpressure이다.
StateFlow를 UIState 하나로 묶으면 recomposition이 더 커지지 않나? 필드별로 Flow를 쪼개야 하나?
UIState 하나로 묶는다고 자동으로 ‘전부 다시 그려짐’이 되는 것은 아니다. Compose는 Slot Table 기반으로 ‘어디에서 그 값을 읽었는지’를 기준으로 invalidation 범위를 계산한다. 다만 state를 읽는 범위가 넓으면 그만큼 재호출되는 함수가 많아진다. 필드별 Flow 분리는 구독 지점을 늘려 코루틴 수집 비용과 구조 복잡도를 올릴 수 있다. 실무에서는 (1) Route에서 한 번만 수집, (2) 하위로 필요한 값만 전달, (3) 파생 계산은 derivedStateOf로 제한하는 조합이 가장 예측 가능하다. 학습 키워드는 read tracking, stability, derivedStateOf, recomposition scope이다.
@Stable, @Immutable은 왜 존재하고, ViewModel 패턴에 어떤 영향을 주나?
Compose는 파라미터 변경 여부를 판단해 스킵(재실행 생략)할지 결정한다. 안정적인 타입은 변경 감지가 싸고 예측 가능하다. @Immutable은 내부가 완전 불변임을, @Stable은 ‘같은 인스턴스의 공개 프로퍼티가 바뀌면 Compose가 알 수 있는 방식으로 바뀐다’는 힌트를 준다. ViewModel에서 UIState를 data class + 불변 컬렉션으로 유지하면, Compiler/Runtime이 변경 비교를 단순화한다. 반대로 mutable 컬렉션이나 커스텀 equals가 섞이면 변경 감지가 비싸지거나 깨진다. 학습 키워드는 stability inference, skippable, restartable, immutable collections이다.
왜 부수효과는 LaunchedEffect로 빼야 하나? 그냥 if문으로 처리하면 안 되나?
Composable 본문은 선언형 UI를 만들기 위한 ‘순수 함수에 가까운 실행’으로 취급된다. recomposition은 언제든 발생할 수 있고, 같은 프레임에서도 여러 번 실행될 수 있다. if(state.done) { nav() } 같은 코드는 state가 true인 동안 recomposition마다 반복 실행될 수 있다. LaunchedEffect는 key가 바뀔 때만 코루틴을 재시작하고, composition을 벗어나면 자동 취소된다. 그래서 네비게이션/토스트/로그인 리다이렉트 같은 1회성 작업을 안전하게 분리한다. 학습 키워드는 side effect, LaunchedEffect key, rememberCoroutineScope, cancellation이다.
Layout Inspector의 recomposition count를 믿어도 되나? 로그와 다르게 보일 때가 있다
recomposition count는 ‘해당 노드가 재구성 대상으로 표시된 횟수’에 가깝고, 실제로 하위가 스킵됐는지까지는 맥락이 필요하다. 반면 Log.d는 함수 본문이 실행될 때마다 찍히므로 스킵 이전/이후를 구분하지 못한다. 두 도구를 같이 써야 한다. (1) Layout Inspector에서 특정 Composable의 recomposition count가 비정상적으로 높으면, (2) 그 Composable이 읽는 State 범위를 줄이거나 파라미터를 stable하게 만들고, (3) 필요하면 androidx.compose.runtime:runtime-tracing을 켜서 trace에서 어떤 그룹이 invalidation 됐는지 확인한다. 학습 키워드는 skippable, trace, recomposition scope, snapshot reads이다.