14. Compose에서 ViewModel 역할: UI State와 비즈니스 로직을 분리하는 이유
Jetpack Compose에서 ViewModel이 왜 필요한지, 상태(State) 소유와 이벤트 처리, recomposition·Slot Table 관점에서 UI/로직 분리를 이해한다. 성능 함정도 함께 다룬다. 2025-03-01 기준 작성. (150자)
Compose에서 ViewModel 역할: UI State와 비즈니스 로직을 분리하는 이유
Compose를 처음 붙이면 화면은 뜨는데, 버튼을 누를 때마다 네트워크 요청이 다시 나가거나 리스트 스크롤이 튀고, 화면 회전 한 번에 입력값이 초기화된다. 디버그 로그로는 Composable이 여러 번 호출되는 게 보이는데 어디서 상태를 들고 있어야 하는지 감이 안 잡는다. 이 혼란을 끊는 경계가 ViewModel이다. ViewModel은 “UI가 그릴 상태를 소유하고, 이벤트를 받아 상태를 갱신하는 곳”으로 두고, Composable은 그 상태를 읽어 그리기만 한다.
핵심 개념
Compose의 UI는 ‘현재 상태를 입력받아 화면을 그리는 함수’에 가깝다. 함수는 언제든 다시 호출될 수 있고, 실제로도 자주 호출된다. 초보가 가장 먼저 부딪히는 문제는 “호출이 다시 되면 안 되는 일(네트워크, DB, 로그아웃 등)이 다시 실행된다”는 점이다. ViewModel은 이 불안정한 호출 모델 위에 ‘안정적인 상태 소유자’를 세운다.
용어를 기능 맥락으로 정의한다. - UI State: 화면이 그려지기 위해 필요한 값의 묶음이다. 로딩 여부, 에러, 리스트, 선택된 아이템 같은 값이 들어간다. - Event/Intent: 사용자의 입력(클릭, 스크롤, 텍스트 변경)이나 시스템 신호를 의미한다. UI는 이벤트를 ViewModel로 전달한다. - State holder: 상태를 저장하고 갱신 규칙을 가진 객체다. ViewModel이 대표적이다. - UDF(단방향 데이터 흐름): UI는 State를 읽고, Event를 올리고, ViewModel이 State를 내린다. 이 방향성을 지키면 재현 가능한 버그가 된다.
왜 Composable 내부에 mutableStateOf로 다 들고 있으면 안 되나. 첫째, Composable은 recomposition으로 여러 번 호출되며, remember를 빼먹는 순간 상태가 매 호출마다 초기화된다. 둘째, remember로 지켜도 프로세스 죽음(process death)이나 구성 변경에서 복구가 약하다. 셋째, ‘화면 로직’과 ‘비즈니스 규칙’이 섞이면 테스트가 UI 프레임워크에 종속된다. ViewModel은 Lifecycle과 분리된 테스트 가능한 경계를 제공한다.
Compose Runtime 관점에서 ViewModel은 ‘recomposition을 트리거하는 상태의 공급자’다. UI가 StateFlow/LiveData를 collectAsState로 읽으면, Runtime은 그 읽기(read)를 추적한다. 이후 ViewModel이 새 상태를 emit하면, 해당 상태를 읽었던 Composable 범위가 invalidation 되고 다음 프레임에 재호출된다. 전체 UI가 아니라 “읽은 곳”만 다시 호출되는 이유는 Slot Table에 호출 순서와 이전 값이 저장되기 때문이다.
1package com.example.vmrole
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
11
12@Composable
13fun CounterWithoutViewModel() {
14 var count by remember { mutableIntStateOf(0) }
15
16 Column {
17 Text(text = "count=$count")
18 Button(onClick = { count++ }) {
19 Text("+1")
20 }
21 }
22}이 코드는 ‘상태가 화면 안에만 존재해도 되는’ 가장 단순한 예다. 실행하면 버튼을 누를 때마다 숫자가 증가한다. 하지만 count가 “서버 동기화가 필요한 값”이거나 “다른 화면에서도 공유되는 값”으로 바뀌는 순간, 상태의 소유 위치가 문제가 된다. ViewModel은 그 순간부터 필요해진다.
컴포넌트 해부
Compose에서 ViewModel을 쓰는 패턴은 보통 ‘Screen Composable + ViewModel + UI State + Event’ 조합으로 굳어진다. 여기서 ‘컴포넌트’는 Button 같은 위젯이 아니라, 화면 단위 컴포넌트(Screen)다. Screen은 상태를 주입받아 그리기만 하고, ViewModel은 상태를 만들고 갱신한다.
Screen 함수의 파라미터를 왜 이렇게 나누는지부터가 핵심이다. UI를 테스트 가능하게 만들려면 ViewModel을 직접 참조하지 않는 순수 함수 형태가 필요하다. 그래서 ‘Route(또는 Container)’는 ViewModel을 알고, ‘Screen(또는 Content)’는 ViewModel을 모른다.
- Route/Container: ViewModel을 얻고 상태를 collect한 뒤 Screen에 전달한다. 왜 필요한가: DI/Hilt, preview, 테스트에서 대체 가능하게 만든다.
- Screen/Content: UiState와 이벤트 핸들러만 받는다. 왜 필요한가: Compose Preview와 단위 테스트에서 ViewModel 없이 실행된다.
- UiState: data class로 묶는다. 왜 필요한가: recomposition 범위를 예측 가능하게 하고, 상태 스냅샷을 로그로 남기기 쉽다.
- onEvent: (Event) -> Unit 형태로 둔다. 왜 필요한가: UI가 비즈니스 규칙을 모르게 한다.
- StateFlow/MutableStateFlow: 상태 스트림을 둔다. 왜 필요한가: 비동기 작업과 상태 갱신을 직렬화하기 쉽다.
- SavedStateHandle: 복구해야 할 입력(검색어, 선택 id)을 저장한다. 왜 필요한가: 프로세스 죽음 후 복구 지점을 제공한다.
- Dispatcher/Clock 주입: debounce나 타임아웃에 필요하다. 왜 필요한가: 테스트에서 시간을 제어한다.
- Repository 인터페이스: 네트워크/DB를 숨긴다. 왜 필요한가: UI/VM이 구현체에 묶이지 않는다.
- @Immutable/@Stable: 안정성 힌트를 준다. 왜 필요한가: 불필요한 recomposition을 줄이는 근거가 된다.
- collectAsStateWithLifecycle: lifecycle-aware collect. 왜 필요한가: 백그라운드에서 불필요한 collect를 막는다.
Surface 계층과 Content 계층을 분리해서 생각해야 한다. Surface는 ‘상태를 어디서 가져오나’와 ‘수명은 어디까지인가’를 다룬다. Content는 ‘어떻게 그리나’를 다룬다. ViewModel은 Surface에 속하고, Composable UI는 Content에 속한다. 이 경계가 흐려지면 SideEffect가 UI 트리에 퍼지고, recomposition 때마다 같은 일이 반복된다.
처음에 나도 이 경계를 무시했다가, 클릭 한 번에 API가 3번 호출되는 걸 3시간 동안 잡았다. Logcat에 "fetch() called"가 연속으로 찍혔고, 원인은 Composable 본문에서 repository.fetch()를 직접 호출한 것이었다. recomposition은 버그가 아니라 정상 동작인데, ‘정상 동작이 위험해지는 코드 위치’가 존재한다.
1package com.example.vmrole
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.Immutable
5import androidx.compose.runtime.collectAsState
6import androidx.compose.runtime.getValue
7import androidx.lifecycle.ViewModel
8import kotlinx.coroutines.flow.MutableStateFlow
9import kotlinx.coroutines.flow.StateFlow
10import kotlinx.coroutines.flow.update
11
12@Immutable
13data class ProfileUiState(
14 val loading: Boolean = false,
15 val name: String = "",
16 val error: String? = null
17)
18
19sealed interface ProfileEvent {
20 data object Refresh : ProfileEvent
21 data class NameChanged(val value: String) : ProfileEvent
22}
23
24class ProfileViewModel : ViewModel() {
25 private val _uiState = MutableStateFlow(ProfileUiState())
26 val uiState: StateFlow<ProfileUiState> = _uiState
27
28 fun onEvent(event: ProfileEvent) {
29 when (event) {
30 ProfileEvent.Refresh -> _uiState.update { it.copy(loading = true, error = null) }
31 is ProfileEvent.NameChanged -> _uiState.update { it.copy(name = event.value) }
32 }
33 }
34}
35
36@Composable
37fun ProfileRoute(viewModel: ProfileViewModel) {
38 val state by viewModel.uiState.collectAsState()
39 ProfileScreen(state = state, onEvent = viewModel::onEvent)
40}
41
42@Composable
43fun ProfileScreen(state: ProfileUiState, onEvent: (ProfileEvent) -> Unit) {
44 // UI는 state를 읽고 이벤트만 올린다.
45}이 구조를 실행하면 UI는 ViewModel의 상태 변경에만 반응한다. 중요한 포인트는 ProfileScreen이 ViewModel을 모른다는 점이다. Preview에서 ProfileScreen(state=..., onEvent={}) 형태로 바로 띄울 수 있고, 테스트에서는 가짜 onEvent를 주입해 “클릭 시 어떤 이벤트가 올라가는지”만 검증할 수 있다.
1package com.example.vmrole
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.padding
5import androidx.compose.material3.Button
6import androidx.compose.material3.CircularProgressIndicator
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 ProfileScreen(state: ProfileUiState, onEvent: (ProfileEvent) -> Unit) {
16 Surface(color = MaterialTheme.colorScheme.background) {
17 Column(modifier = Modifier.padding(16.dp)) {
18 if (state.loading) {
19 CircularProgressIndicator()
20 } else {
21 Text(text = "Hello, ${state.name.ifBlank { "anonymous" }}")
22 }
23
24 state.error?.let { Text(text = "error=$it") }
25
26 Button(onClick = { onEvent(ProfileEvent.Refresh) }) {
27 Text(text = "Refresh")
28 }
29 }
30 }
31}여기서 Surface는 ‘배경/테마/클릭 리플 같은 외피’를 담당하고, Column 이하가 Content다. state.loading이 true로 바뀌면 ProgressIndicator가 보이고, false면 텍스트가 보인다. UI는 “왜 로딩이 시작됐는지”를 모른다. 그 규칙은 ViewModel에 있어야 재현 가능해진다.
내부 동작 원리
Compose 실행은 Composition → Layout → Drawing 3단계로 흐른다. ViewModel은 이 중 Composition 단계에만 영향을 준다. 상태가 바뀌면 어떤 Composable을 다시 호출할지 결정하는 단계가 Composition이고, 그 결과로 Layout(측정/배치)과 Drawing(그리기)이 이어진다. 상태 변경이 Layout까지 영향을 주는지는 ‘측정에 사용된 값이 바뀌었는지’에 달렸다.
StateFlow를 collectAsState로 읽는 순간, Runtime은 현재 실행 중인 Composable 그룹에 “이 State를 읽었다”는 의존성을 기록한다. 이 기록이 Slot Table과 연결된다. Slot Table은 ‘호출 순서 기반’으로 노드를 저장하는데, 같은 위치에서 같은 Composable이 다시 호출되면 이전 슬롯을 재사용한다. 그래서 “어떤 상태를 어디서 읽었는지”가 곧 recomposition 범위를 만든다.
Compose Compiler는 @Composable 함수를 직접 호출하는 대신, 내부적으로 추가 파라미터(Composer, changed flags)를 붙인 형태로 변환한다. 코드 레벨에서는 안 보이지만, 개념적으로는 이런 형태다: ProfileScreen(state, onEvent, composer, changed). changed 플래그는 파라미터가 이전 호출과 달라졌는지 추적한다. 안정성(stability) 추론이 가능한 타입이면 더 공격적으로 스킵할 수 있고, 불안정 타입이면 보수적으로 다시 호출한다.
@Immutable/@Stable은 ‘절대 다시 그리지 않는다’가 아니다. “같은 인스턴스면 내부 값이 바뀌지 않는다” 같은 계약을 컴파일러/런타임이 믿을 근거를 제공한다. UiState를 data class로 두고 copy로 새 인스턴스를 만들면, 변경 여부가 인스턴스 비교로 드러난다. 반대로 MutableList를 state에 넣고 내부를 mutate하면, UI는 바뀐 걸 모르거나(참조 동일) 예상보다 넓게 다시 그려진다.
remember가 왜 필요한지도 ViewModel과 연결된다. remember는 Slot Table의 특정 위치에 값을 저장해 recomposition 사이에서 유지한다. ViewModel은 구성 변경에서도 유지되는 별도의 저장소이고, remember는 ‘같은 Composition 생명주기’ 안에서만 유지된다. 그래서 입력 폼의 임시 텍스트는 rememberSaveable, 서버에서 온 데이터는 ViewModel 같은 식으로 수명을 나눠야 한다.
내가 처음 Compose 디버깅할 때 가장 도움 됐던 건 “읽는 위치를 바꾸면 recomposition 범위가 바뀐다”는 실험이었다. 리스트 전체가 깜빡이는 것처럼 보여도, 실제로는 상위에서 state 전체를 읽어서 하위까지 전파된 경우가 많다. Layout Inspector의 recomposition count는 ‘호출 횟수’에 가깝고, 실제 그리기 비용은 Layout/Drawing까지 갔는지 따로 봐야 한다.
1package com.example.vmrole
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 RecomposeTraceDemo() {
15 var parent by remember { mutableIntStateOf(0) }
16 var child by remember { mutableIntStateOf(0) }
17
18 Log.d("Recompose", "RecomposeTraceDemo called parent=$parent child=$child")
19
20 Column {
21 ParentReadsBoth(parent = parent, child = child)
22 Button(onClick = { parent++ }) { Text("parent++") }
23 Button(onClick = { child++ }) { Text("child++") }
24 }
25}
26
27@Composable
28private fun ParentReadsBoth(parent: Int, child: Int) {
29 Log.d("Recompose", "ParentReadsBoth called")
30 Text(text = "parent=$parent, child=$child")
31}이 코드를 실행하고 버튼을 번갈아 누르면 Logcat에 호출 로그가 쌓인다. parent++를 누르면 RecomposeTraceDemo와 ParentReadsBoth가 다시 호출된다. child++도 동일하다. 여기서 핵심은 “상위가 두 값을 모두 읽고 전달하면, 둘 중 하나만 바뀌어도 상위가 다시 호출된다”는 점이다. ViewModel을 쓰더라도 같은 실수를 하면 화면 전체가 자주 다시 호출된다.
1package com.example.vmrole
2
3import androidx.compose.foundation.interaction.MutableInteractionSource
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.remember
9import androidx.compose.ui.Modifier
10import androidx.compose.ui.semantics.contentDescription
11import androidx.compose.ui.semantics.semantics
12import androidx.compose.ui.unit.dp
13
14@Composable
15fun ModifierOrderAndSemanticsDemo(
16 enabled: Boolean,
17 label: String,
18 onClick: () -> Unit
19) {
20 val interaction = remember { MutableInteractionSource() }
21
22 Button(
23 enabled = enabled,
24 interactionSource = interaction,
25 onClick = onClick,
26 modifier = Modifier
27 .padding(12.dp)
28 .semantics { contentDescription = "action:$label" }
29 ) {
30 Text(text = label)
31 }
32}Modifier는 체이닝 순서가 의미를 가진다. padding이 먼저면 터치 영역과 레이아웃이 먼저 확장되고, semantics가 뒤면 확장된 영역에 접근성 라벨이 붙는다. interactionSource를 remember로 고정하지 않으면, recomposition마다 새 인스턴스가 만들어지고 pressed/hover 상태가 튀는 현상이 생긴다. “UI는 상태만 그린다”는 원칙에는 interaction도 포함된다.
한 문단 요약: ViewModel은 UI가 그릴 상태의 단일 소유자이고, Compose Runtime은 그 상태를 ‘읽은 위치’만 다시 호출한다. 상태를 어디서 읽고, 어떤 타입으로 표현하느냐가 recomposition 범위와 버그 재현성을 결정한다.
실습하기
실습 목표는 세 가지다. (1) ViewModel 없이 생기는 문제를 재현한다. (2) ViewModel로 상태를 올려 recomposition을 ‘예측 가능한 호출’로 바꾼다. (3) 상태 표현을 다듬어 불필요한 invalidation을 줄인다. 화면에서 무엇이 보이는지와 Logcat에서 무엇이 찍히는지를 같이 확인한다.
1plugins {
2 id("com.android.application")
3 id("org.jetbrains.kotlin.android")
4}
5
6android {
7 namespace = "com.example.vmrole"
8 compileSdk = 34
9
10 defaultConfig {
11 applicationId = "com.example.vmrole"
12 minSdk = 24
13 targetSdk = 34
14 }
15
16 buildFeatures { compose = true }
17 composeOptions { kotlinCompilerExtensionVersion = "1.5.14" }
18}
19
20dependencies {
21 implementation(platform("androidx.compose:compose-bom:2024.10.00"))
22 implementation("androidx.activity:activity-compose:1.9.2")
23 implementation("androidx.compose.material3:material3")
24 implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6")
25 implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.6")
26}버전은 프로젝트 환경에 맞춰 조정한다. lifecycle-runtime-compose의 collectAsStateWithLifecycle을 쓰면 백그라운드에서 flow collect가 멈춘다. 실습에서 눈으로 확인할 포인트는 ‘화면을 홈으로 내렸다가 다시 올렸을 때 로그가 계속 찍히는지’다. lifecycle-aware collect가 없으면 백그라운드에서도 계속 동작하는 경우가 생긴다.
1단계: ViewModel 없이 SideEffect가 폭발하는 위치 만들기
네트워크 대신 로그를 찍는 가짜 fetch를 Composable 본문에 둔다. 실행하면 버튼을 누르지 않아도 로그가 찍히고, 화면의 작은 상태 변화에도 fetch 로그가 반복된다. 이 반복은 버그가 아니라 recomposition이 정상적으로 발생한 결과다.
확인 방법은 Logcat 필터를 "FakeRepo"로 두는 것이다. 텍스트 입력 한 글자마다 fetch가 호출되면, “Composable 본문에서 비즈니스 로직을 실행하면 안 된다”는 규칙이 몸으로 들어온다.
1package com.example.vmrole
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.OutlinedTextField
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
16class MainActivity : ComponentActivity() {
17 override fun onCreate(savedInstanceState: Bundle?) {
18 super.onCreate(savedInstanceState)
19 setContent { BadScreen() }
20 }
21}
22
23private object FakeRepo {
24 fun fetch(query: String): String {
25 Log.d("FakeRepo", "fetch called query=$query")
26 return "result for $query"
27 }
28}
29
30@Composable
31fun BadScreen() {
32 var query by remember { mutableStateOf("") }
33
34 val result = FakeRepo.fetch(query)
35
36 Column {
37 OutlinedTextField(value = query, onValueChange = { query = it }, label = { Text("query") })
38 Text(text = result)
39 }
40}이 상태에서 한 글자 입력할 때마다 fetch 로그가 찍힌다. 이유는 OutlinedTextField가 query를 바꾸고, query를 읽는 BadScreen이 invalidation 되며, BadScreen 본문이 다시 실행되기 때문이다. Slot Table은 BadScreen의 위치를 유지하지만, 본문 실행 자체는 다시 된다. “UI 그리기”와 “데이터 로딩”을 섞으면 이 문제가 항상 따라온다.
2단계: ViewModel로 상태와 로딩 규칙을 이동시키기
fetch를 ViewModel로 옮기고, UI는 query 변경 이벤트만 전달한다. 실행하면 입력할 때마다 fetch 로그가 여전히 찍힐 수 있다. 하지만 그 호출 위치가 ViewModel로 이동했기 때문에, 이제 debounce/취소/캐시 같은 규칙을 UI와 분리해서 넣을 수 있다.
화면에서 보이는 변화는 동일해 보이지만, 디버깅 관점이 바뀐다. “왜 fetch가 여러 번 호출됐나”를 UI 트리 대신 ViewModel의 이벤트 처리에서 추적한다. 이 지점부터 테스트 코드로 재현 가능해진다.
1package com.example.vmrole
2
3import android.util.Log
4import androidx.compose.foundation.layout.Column
5import androidx.compose.material3.OutlinedTextField
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.runtime.getValue
9import androidx.lifecycle.ViewModel
10import androidx.lifecycle.viewmodel.compose.viewModel
11import kotlinx.coroutines.flow.MutableStateFlow
12import kotlinx.coroutines.flow.StateFlow
13import kotlinx.coroutines.flow.update
14
15data class SearchUiState(
16 val query: String = "",
17 val result: String = ""
18)
19
20class SearchViewModel : ViewModel() {
21 private val _uiState = MutableStateFlow(SearchUiState())
22 val uiState: StateFlow<SearchUiState> = _uiState
23
24 fun onQueryChanged(value: String) {
25 Log.d("SearchVM", "onQueryChanged=$value")
26 val result = FakeRepo.fetch(value)
27 _uiState.update { it.copy(query = value, result = result) }
28 }
29}
30
31@Composable
32fun GoodRoute(vm: SearchViewModel = viewModel()) {
33 val state by vm.uiState.collectAsStateWithLifecycleCompat()
34 GoodScreen(state = state, onQueryChanged = vm::onQueryChanged)
35}
36
37@Composable
38fun GoodScreen(state: SearchUiState, onQueryChanged: (String) -> Unit) {
39 Column {
40 OutlinedTextField(value = state.query, onValueChange = onQueryChanged, label = { Text("query") })
41 Text(text = state.result)
42 }
43}collectAsStateWithLifecycleCompat는 바로 다음 코드에서 정의한다. 실습에서는 라이프사이클 의존성을 최소화하려고 확장 함수 형태로 감싼다. 실행 후 Logcat에서 SearchVM 로그와 FakeRepo 로그를 분리해서 보면, UI 호출과 데이터 호출이 분리된 게 눈에 들어온다.
1package com.example.vmrole
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.State
5import androidx.compose.runtime.collectAsState
6import androidx.compose.runtime.remember
7import androidx.lifecycle.compose.collectAsStateWithLifecycle
8import kotlinx.coroutines.flow.StateFlow
9
10@Composable
11fun <T> StateFlow<T>.collectAsStateWithLifecycleCompat(): State<T> {
12 // lifecycle-runtime-compose가 있으면 이 경로가 사용된다.
13 return try {
14 this.collectAsStateWithLifecycle()
15 } catch (t: Throwable) {
16 // 의존성이 없거나 테스트 환경이면 기본 collect로 폴백한다.
17 // remember는 여기서 큰 의미는 없지만, 호출 위치를 고정해 디버그를 단순화한다.
18 remember { this }.collectAsState()
19 }
20}이 폴백은 교육용 스케치다. 실제 앱에서는 try/catch로 분기하지 말고 의존성을 명확히 둔다. 여기서 확인할 포인트는 collect가 Composable의 ‘읽기’로 기록된다는 점이다. state.result를 Text에서 읽으면, result가 바뀔 때 그 Text가 포함된 그룹이 다시 호출된다.
3단계: 상태 표현을 다듬어 recomposition 범위를 줄이기
상태를 한 덩어리로 내려도 되지만, 읽는 위치를 조절하면 호출 범위를 줄일 수 있다. 예를 들어 query는 TextField만 필요하고 result는 Text만 필요하다. 큰 화면에서는 이 분리가 체감된다. 실행하면 입력할 때 TextField만 자주 호출되고, 다른 영역은 덜 호출되도록 만들 수 있다.
확인 방법은 각 Composable에 Log.d를 넣는 것이다. Layout Inspector의 recomposition count도 같이 보면 ‘호출은 줄었는데 레이아웃은 여전히 도는지’ 같은 차이가 보인다.
1package com.example.vmrole
2
3import android.util.Log
4import androidx.compose.foundation.layout.Column
5import androidx.compose.material3.OutlinedTextField
6import androidx.compose.material3.Text
7import androidx.compose.runtime.Composable
8import androidx.compose.runtime.collectAsState
9import androidx.compose.runtime.getValue
10import kotlinx.coroutines.flow.StateFlow
11
12@Composable
13fun SplitReadScreen(uiState: StateFlow<SearchUiState>, onQueryChanged: (String) -> Unit) {
14 val state by uiState.collectAsState()
15
16 Column {
17 QueryField(query = state.query, onQueryChanged = onQueryChanged)
18 ResultText(result = state.result)
19 }
20}
21
22@Composable
23private fun QueryField(query: String, onQueryChanged: (String) -> Unit) {
24 Log.d("Recompose", "QueryField called")
25 OutlinedTextField(value = query, onValueChange = onQueryChanged, label = { Text("query") })
26}
27
28@Composable
29private fun ResultText(result: String) {
30 Log.d("Recompose", "ResultText called")
31 Text(text = result)
32}심화: Advanced 버전 만들기
실무에서는 ViewModel이 단순히 StateFlow를 내보내는 수준에서 끝나지 않는다. 로딩 중 중복 클릭 방지, 짧은 시간 연속 입력 debounce, 접근성 라벨, 롱프레스 같은 요구가 붙는다. 이 요구를 UI 안에서 처리하면 recomposition과 결합되면서 재현이 어려워진다. 규칙은 ViewModel(또는 use-case)로 올리고, UI는 표현만 담당하는 편이 디버그 비용이 낮다.
사례 1: loading + debounce + 취소를 이벤트 처리로 묶기
버튼을 연타하면 API가 연속 호출되고, 응답 순서가 뒤집혀 오래된 결과가 화면을 덮는 문제가 자주 나온다. 이 문제는 UI에서 막기 어렵다. UI는 클릭 이벤트만 올리고, ViewModel이 “현재 로딩 중이면 무시” 또는 “이전 작업 취소” 규칙을 가져야 한다.
내가 겪었던 실제 증상은 이랬다. QA가 ‘저장 버튼을 두 번 누르면 토스트가 두 번 뜬다’고 했고, 서버 로그에는 같은 요청이 2~4회 찍혔다. UI에서 enabled=false로 막아놨다고 생각했지만, recomposition 타이밍 때문에 enabled가 false로 내려오기 전에 두 번째 클릭이 들어갔다. 해결은 UI가 아니라 ViewModel에서 debounce/guard를 둔 것이었다.
1package com.example.vmrole
2
3import androidx.compose.runtime.Immutable
4import androidx.lifecycle.ViewModel
5import androidx.lifecycle.viewModelScope
6import kotlinx.coroutines.Job
7import kotlinx.coroutines.delay
8import kotlinx.coroutines.flow.MutableStateFlow
9import kotlinx.coroutines.flow.StateFlow
10import kotlinx.coroutines.flow.update
11import kotlinx.coroutines.launch
12
13@Immutable
14data class AdvancedButtonUiState(
15 val loading: Boolean = false,
16 val message: String = "idle"
17)
18
19class AdvancedButtonViewModel : ViewModel() {
20 private val _uiState = MutableStateFlow(AdvancedButtonUiState())
21 val uiState: StateFlow<AdvancedButtonUiState> = _uiState
22
23 private var inFlight: Job? = null
24 private var lastClickAtMs: Long = 0L
25
26 fun onPrimaryClick(nowMs: Long = System.currentTimeMillis()) {
27 if (_uiState.value.loading) return
28 if (nowMs - lastClickAtMs < 600) return
29 lastClickAtMs = nowMs
30
31 inFlight?.cancel()
32 inFlight = viewModelScope.launch {
33 _uiState.update { it.copy(loading = true, message = "loading...") }
34 delay(800)
35 _uiState.update { it.copy(loading = false, message = "done at $nowMs") }
36 }
37 }
38}이 코드를 실행하면 600ms 이내 연타는 무시되고, 로딩 중에는 클릭이 먹지 않는다. 중요한 점은 ‘무시 규칙’이 UI에 없다는 것이다. UI는 loading을 그릴 뿐이다. Compose Runtime은 loading/message를 읽은 곳만 다시 호출한다. 버튼 자체는 loading이 바뀌면 다시 호출되지만, 화면 전체를 다시 만들 필요는 없다.
사례 2: 컴포넌트 슬롯 설계(icon+text)와 접근성 라벨
AdvancedButton은 icon+text 조합을 자주 요구한다. Slot API로 content를 받으면 유연하지만, 접근성 라벨을 content에서 추론하기 어렵다. 그래서 label 파라미터를 별도로 받는 설계가 필요해진다. 이 label은 semantics로 들어가고, 테스트에서도 찾기 쉬워진다.
롱프레스는 pointerInput으로 구현할 수 있지만, UI가 직접 비즈니스 로직을 실행하면 또 같은 문제가 생긴다. UI는 onLongPress 이벤트만 올리고, ViewModel이 상태를 바꿔 표현이 달라지게 만드는 편이 안전하다.
1package com.example.vmrole
2
3import androidx.compose.foundation.combinedClickable
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.Spacer
7import androidx.compose.foundation.layout.width
8import androidx.compose.material3.CircularProgressIndicator
9import androidx.compose.material3.MaterialTheme
10import androidx.compose.material3.Surface
11import androidx.compose.material3.Text
12import androidx.compose.runtime.Composable
13import androidx.compose.ui.Alignment
14import androidx.compose.ui.Modifier
15import androidx.compose.ui.semantics.contentDescription
16import androidx.compose.ui.semantics.semantics
17import androidx.compose.ui.unit.dp
18
19@Composable
20fun AdvancedButton(
21 label: String,
22 loading: Boolean,
23 enabled: Boolean,
24 modifier: Modifier = Modifier,
25 leading: (@Composable () -> Unit)? = null,
26 onClick: () -> Unit,
27 onLongPress: (() -> Unit)? = null
28) {
29 Surface(
30 color = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
31 contentColor = if (enabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
32 modifier = modifier
33 .semantics { contentDescription = "button:$label" }
34 .combinedClickable(
35 enabled = enabled && !loading,
36 onClick = onClick,
37 onLongClick = onLongPress
38 )
39 ) {
40 Row(
41 horizontalArrangement = Arrangement.Center,
42 verticalAlignment = Alignment.CenterVertically,
43 modifier = Modifier
44 ) {
45 if (loading) {
46 CircularProgressIndicator(strokeWidth = 2.dp)
47 Spacer(Modifier.width(8.dp))
48 } else if (leading != null) {
49 leading()
50 Spacer(Modifier.width(8.dp))
51 }
52 Text(text = label)
53 }
54 }
55}combinedClickable을 쓰면 클릭과 롱클릭이 같은 입력 파이프라인에서 처리된다. semantics를 Surface에 붙이면 접근성 트리에서 이 노드를 버튼으로 인식한다. loading일 때 enabled를 꺼서 UI 입력을 막지만, 연타 방지는 ViewModel에도 남겨둔다. UI만 믿으면 recomposition 타이밍 때문에 ‘아주 짧은 창’에서 입력이 새는 경우가 실제로 생긴다.
1package com.example.vmrole
2
3import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.padding
5import androidx.compose.material3.Icon
6import androidx.compose.material3.MaterialTheme
7import androidx.compose.material3.Text
8import androidx.compose.runtime.Composable
9import androidx.compose.runtime.getValue
10import androidx.compose.ui.Modifier
11import androidx.compose.ui.graphics.vector.ImageVector
12import androidx.compose.ui.unit.dp
13import androidx.lifecycle.viewmodel.compose.viewModel
14
15@Composable
16fun AdvancedButtonRoute(vm: AdvancedButtonViewModel = viewModel()) {
17 val state by vm.uiState.collectAsStateWithLifecycleCompat()
18
19 Column(modifier = Modifier.padding(16.dp)) {
20 AdvancedButton(
21 label = "Save",
22 loading = state.loading,
23 enabled = true,
24 leading = { SimpleVectorIconPlaceholder() },
25 onClick = { vm.onPrimaryClick() },
26 onLongPress = { vm.onPrimaryClick() }
27 )
28 Text(text = state.message, color = MaterialTheme.colorScheme.onBackground)
29 }
30}
31
32@Composable
33private fun SimpleVectorIconPlaceholder(image: ImageVector? = null) {
34 // 실습 단순화를 위한 자리표시자
35 Icon(imageVector = image ?: androidx.compose.material.icons.Icons.Default.Favorite, contentDescription = null)
36}처음에 내가 여기서 또 한 번 삽질했다. Icon을 넣으려고 material-icons-extended 의존성을 빼먹어서 빌드가 깨졌고, 에러 메시지는 "Unresolved reference: icons"였다. 해결은 dependencies에 implementation("androidx.compose.material:material-icons-extended")를 추가하거나, 아이콘을 아예 제거하는 것이었다. 이런 류의 문제를 줄이려면 UI 컴포넌트는 아이콘을 ‘슬롯’으로 받고, 아이콘 제공은 앱 레이어에서 책임지는 편이 낫다.
또 다른 흑역사는 StateFlow 대신 MutableState를 ViewModel에 직접 넣고, UI에서 그 MutableState를 여러 군데서 읽게 만든 것이다. 화면이 커지자 어느 Composable이 어떤 state를 읽는지 추적이 안 됐고, recomposition 범위가 예측 불가능해졌다. StateFlow + UiState 단일 모델로 바꾸고, 이벤트를 sealed interface로 제한하니 로그만으로도 흐름이 보이기 시작했다.
자주 하는 실수
1) Composable 본문에서 repository 호출하기
증상: 텍스트 입력 한 글자마다 네트워크/DB 호출 로그가 반복된다. 화면 회전이나 다크모드 전환 같은 구성 변경에서도 호출이 재발한다.
원인: recomposition은 Composable 본문을 다시 실행한다. Slot Table은 UI 구조를 기억하지만, 본문 실행 자체를 막지 않는다. SideEffect가 본문에 있으면 정상 동작이 곧 반복 실행이 된다.
해결: 호출은 ViewModel로 이동하고, UI는 이벤트만 올린다. Composable에서 꼭 실행해야 한다면 LaunchedEffect(key) 같은 SideEffect API로 키 기반 실행을 제한한다.
2) UiState에 MutableList/MutableMap을 넣고 내부 mutate하기
증상: 리스트를 추가/삭제했는데 UI가 안 바뀌거나, 반대로 화면 전체가 과하게 다시 호출된다. 어떤 기기에서는 되고 어떤 기기에서는 안 되는 것처럼 보이기도 한다.
원인: 참조는 그대로인데 내부만 바뀌면 안정성 추론이 깨진다. Compose는 “무엇이 바뀌었는지”를 보수적으로 판단하거나, 아예 변경을 감지 못 한다. 특히 StateFlow의 값이 같은 인스턴스로 유지되면 emit 자체가 안 일어날 수도 있다.
해결: 불변 컬렉션(List)로 두고 copy로 새 인스턴스를 만든다. 큰 리스트는 paging이나 diff 전략을 쓰고, item 단위 key를 안정적으로 준다.
3) Screen이 ViewModel을 직접 생성하거나 Context를 오래 잡기
증상: 화면 이동 후에도 작업이 계속 돌아가거나, 메모리 릭 경고가 뜬다. LeakCanary에서 Activity가 ViewModel에 의해 참조된다고 나온다.
원인: ViewModel은 Activity/Fragment 스코프에 붙어서 오래 산다. ViewModel 안에 Context/뷰 참조를 넣으면 수명 차이 때문에 릭이 된다. Composable에서 ViewModel()을 직접 new 하면 스코프가 깨진다.
해결: viewModel() 또는 DI(Hilt)의 스코프를 사용한다. ViewModel에는 Application context가 꼭 필요할 때만 AndroidViewModel 또는 주입으로 넣고, Activity context는 넣지 않는다.
4) collectAsState를 화면 최상단에서 한 번만 하고 state 전체를 하위로 다 전달하기
증상: 작은 값 하나만 바뀌어도 화면 전체가 다시 호출되는 로그가 보인다. 스크롤 중 프레임 드랍이 생기고, Layout Inspector에서 recomposition count가 빠르게 증가한다.
원인: 상위가 state 전체를 읽고, 그 값을 파라미터로 하위에 전파하면 상위가 invalidation의 중심이 된다. changed flags는 파라미터 변경을 감지해도, 상위 호출 자체는 피하기 어렵다.
해결: 읽는 위치를 내린다. 하위 컴포넌트가 필요한 값만 받게 쪼개고, derivedStateOf로 계산 비용이 큰 파생 값을 캐시한다. 리스트는 item 내부에서 필요한 state만 읽게 한다.
5) 이벤트를 람다 여러 개로 흩뿌리기(onClickX, onClickY, onChangeZ...)
증상: 파라미터가 폭발하고, 어느 이벤트가 어떤 상태를 바꾸는지 추적이 어렵다. 화면이 커질수록 Preview를 만들기도 힘들어진다.
원인: 이벤트가 모델링되지 않으면, UI와 ViewModel 사이 계약이 느슨해진다. 람다 캡처가 늘어나면서 불필요한 객체 할당과 안정성 저하도 생긴다.
해결: sealed interface Event로 통합하고 onEvent 하나로 받는다. 이벤트는 데이터만 담고, 규칙은 ViewModel에서 처리한다.
1package com.example.vmrole
2
3sealed interface SettingsEvent {
4 data object ToggleDarkMode : SettingsEvent
5 data class ChangeFontScale(val scale: Float) : SettingsEvent
6 data object Logout : SettingsEvent
7}
8
9fun reduceSettings(state: Map<String, Any>, event: SettingsEvent): Map<String, Any> {
10 return when (event) {
11 SettingsEvent.ToggleDarkMode -> state + ("dark" to !((state["dark"] as? Boolean) ?: false))
12 is SettingsEvent.ChangeFontScale -> state + ("fontScale" to event.scale)
13 SettingsEvent.Logout -> state + ("loggedIn" to false)
14 }
15}이 스케치는 ‘이벤트를 데이터로 만든다’는 감각을 주기 위한 코드다. 실제 앱에서는 Map 대신 data class state를 쓰는 편이 낫다. 중요한 건 UI가 onEvent(SettingsEvent.Logout)만 호출하고, 로그아웃 처리(토큰 삭제, 캐시 삭제, 네비게이션)는 ViewModel/UseCase에서 일어난다는 점이다.
성능 최적화 체크리스트
- UiState를 data class로 두고, 변경은 copy로 새 인스턴스를 만든다(내부 mutate 금지).
- StateFlow는 ViewModel 내부에서만 Mutable로 두고 외부에는 StateFlow로 노출한다.
- Composable 본문에서 네트워크/DB/파일 I/O를 호출하지 않는다(LaunchedEffect 또는 ViewModel로 이동).
- collectAsState는 lifecycle-aware(collectAsStateWithLifecycle)로 사용해 백그라운드 collect를 막는다.
- 상태를 읽는 위치를 가능한 한 하위로 내린다(상위가 state 전체를 읽지 않게 분리).
- 파생 값 계산은 derivedStateOf로 캐시하고, 키가 바뀔 때만 재계산되게 만든다.
- 리스트는 안정적인 key를 제공하고, item content에서 필요한 값만 읽게 한다.
- @Immutable/@Stable 적용은 ‘계약이 맞는 타입’에만 한다(가변 필드가 있으면 붙이지 않는다).
- 람다 파라미터는 필요 이상으로 캡처하지 않게 하고, 큰 객체를 캡처하면 remember로 고정한다.
- InteractionSource, ScrollState 같은 상태 객체는 remember로 유지해 입력/스크롤이 튀지 않게 한다.
- ViewModel에 Activity/Fragment Context를 저장하지 않는다(필요 시 Application context/주입 사용).
- 디버깅 시 Logcat에 recomposition 로그를 심고, Layout Inspector의 recomposition count와 함께 본다.
자주 묻는 질문
왜 Compose는 화면을 그렇게 자주 다시 호출하나? ViewModel이 있으면 호출이 줄어드나?
Compose는 ‘상태를 그리는 함수’를 기본 모델로 둔다. 상태가 바뀌면 그 함수를 다시 호출해 새 트리를 만든다. 호출이 잦은 이유는 프레임워크가 “언제 다시 그려야 하는지”를 개발자가 직접 invalidation 하지 않아도 되게 만들기 위해서다. ViewModel은 호출을 줄이는 장치가 아니라, 호출이 반복돼도 안전한 구조를 만드는 장치다. ViewModel이 소유한 StateFlow를 UI가 읽으면, Runtime은 그 읽기를 추적해 필요한 그룹만 invalidation 한다. 학습 키워드는 recomposition, snapshot state, Slot Table, invalidation 범위다. 호출 수를 줄이려면 ‘상태를 읽는 위치’와 ‘안정성(Immutable/Stable)’을 다듬어야 한다.
UiState를 하나로 크게 묶는 게 좋은가, 여러 Flow로 쪼개는 게 좋은가?
정답은 화면 규모와 변경 패턴에 달렸다. UiState 하나로 묶으면 디버깅과 로깅이 쉽고, 상태 스냅샷을 남기기 좋다. 대신 상위에서 UiState 전체를 읽고 하위에 전파하면 작은 변경에도 상위가 다시 호출될 수 있다. 여러 Flow로 쪼개면 읽는 범위를 더 좁힐 수 있지만, 상태 일관성(loading과 data가 엇갈리는 순간)을 맞추기 어렵고 combine 비용이 생긴다. 실전 처방은 “기본은 단일 UiState + 이벤트”로 시작하고, 병목이 보일 때만 특정 하위 컴포넌트 근처에서 필요한 조각을 별도로 collect하는 방식이다. 키워드는 state hoisting, split reads, derivedStateOf, combine이다.
collectAsState vs collectAsStateWithLifecycle 차이가 실제로 체감되나?
체감되는 케이스가 있다. 예를 들어 검색 화면에서 query를 입력할 때마다 ViewModel이 debounce 없이 서버를 치는 구조라면, 앱을 홈으로 내렸을 때도 Flow collect가 살아있으면 네트워크가 계속 나갈 수 있다. collectAsStateWithLifecycle은 STARTED 이상에서만 collect하고, STOPPED로 내려가면 수집을 중단한다. 로그로 확인하려면 Flow의 onEach에 Log.d를 넣고 홈/복귀를 반복하면 된다. 백그라운드에서 불필요한 작업이 줄어들면 배터리와 데이터 사용량이 줄고, ‘왜 백그라운드에서 계속 도나’ 같은 버그 리포트가 사라진다. 키워드는 lifecycle-runtime-compose, repeatOnLifecycle, cold/hot flow다.
ViewModel에서 Compose mutableStateOf를 써도 되나? StateFlow가 더 나은가?
ViewModel에서 mutableStateOf를 쓰는 것도 가능하다. lifecycle-viewmodel-compose는 Compose와 ViewModel을 연결하는 용도라서, Compose state를 ViewModel에 둔다고 즉시 틀리진 않는다. 다만 팀 규모가 커지면 StateFlow가 더 보편적인 장점이 있다. 첫째, 코루틴 연산자(map, debounce, combine)로 비동기 파이프라인을 만들기 쉽다. 둘째, UI가 Compose가 아닌 다른 소비자(테스트, 서비스, 다른 UI 툴킷)로 확장될 때도 재사용이 된다. 셋째, 상태 갱신을 update로 직렬화하기 쉽다. 실전 처방은 UI 전용의 아주 작은 상태는 Compose state로도 충분하지만, 네트워크/DB와 결합된 화면 상태는 StateFlow + UiState가 디버그와 테스트에 유리하다. 키워드는 snapshot state vs Flow, hot stream, reducer 패턴이다.
@Immutable/@Stable을 붙이면 recomposition이 줄어드는 이유가 뭔가?
Compose Compiler는 파라미터가 ‘안정적인 타입’인지에 따라 changed flags를 더 정확히 만들 수 있다. 안정적인 타입이면 “같은 인스턴스면 내부 값이 안 바뀐다” 같은 계약을 믿고 스킵 조건을 강화한다. 반대로 불안정 타입(가변 필드, 커스텀 getter, mutable collection 포함)이면 보수적으로 다시 호출할 가능성이 커진다. 다만 애너테이션은 마법이 아니라 계약이다. 내부에 var나 MutableList가 있는데 @Immutable을 붙이면, 컴파일러가 스킵해버려 UI가 갱신되지 않는 종류의 버그로 이어질 수 있다. 실전 처방은 UiState는 data class + val + 불변 컬렉션으로 만들고, 안정성 추론이 깨지는 타입은 분리하거나 래핑한다. 키워드는 stability inference, skippable, restartable, changed flags다.
remember와 ViewModel의 역할이 겹치는 것처럼 보인다. 둘 중 하나만 쓰면 안 되나?
둘은 저장 수명이 다르다. remember는 Slot Table에 저장되는 값이라 ‘같은 Composition이 유지되는 동안’에만 살아있다. 화면이 네비게이션으로 제거되거나 프로세스가 죽으면 사라진다. ViewModel은 Activity/Fragment 스코프에 붙어 구성 변경에서도 유지되고, 프로세스 죽음은 SavedStateHandle로 일부 복구할 수 있다. 그래서 입력창의 포커스, 애니메이션 진행도, 스크롤 위치 같은 UI 순간 상태는 remember/rememberSaveable이 맞고, 서버에서 받은 데이터, 사용자 세션, 비즈니스 규칙에 따른 로딩/에러는 ViewModel이 맞다. 실전 처방은 “UI 순간 상태는 UI에, 도메인 상태는 VM에”로 나누고, 경계가 애매하면 ‘프로세스 죽음 후에도 복구돼야 하는가’ 질문으로 결정한다. 키워드는 state lifetime, saveable, SavedStateHandle, process death다.
불필요한 recomposition이 실제 성능에 미치는 영향은 어떻게 측정하나?
recomposition 횟수 자체가 곧 프레임 드랍은 아니다. 호출은 늘어도 Layout/Drawing이 거의 없으면 비용이 작을 수 있다. 측정은 세 층으로 한다. (1) Layout Inspector에서 recomposition count로 ‘어디가 자주 호출되는지’ 위치를 찾는다. (2) Log.d로 특정 Composable 호출 빈도를 확인하고, 상태 읽기 위치를 바꿔 범위가 줄어드는지 본다. (3) 실제 프레임 성능은 Android Studio Profiler의 System Trace(또는 Perfetto)에서 Choreographer 프레임 타임과 Compose 관련 트레이스(가능하면 androidx.tracing)를 본다. 실전 처방은 자주 호출되는 상위를 쪼개고, 안정성 문제를 해결하고, 리스트 아이템에서 불필요한 객체 할당(람다 캡처, 새 Modifier 생성)을 줄이는 것이다. 키워드는 Layout Inspector, System Trace, Perfetto, allocation tracking이다.