Android/Compose

[Android Compose] MutableState vs MutableStateFlow

참깨빵위에참깨빵_ 2024. 11. 25. 21:07
728x90
반응형

컴포즈로 앱을 만들면서 뷰모델을 구현하다 보면 상태 관리와 관련해서 2가지 선택지를 마주하곤 한다. MutableState와 MutableStateFlow가 그것이다. SharedFlow는 최신 상태를 자동으로 유지하지 않아서 이벤트 기반 작업에 자주 쓰이기 때문에 UI 상태 관련 내용을 다룰 이 포스팅에선 생략하고, MutableState와 MutableStateFlow의 차이를 확인한다.

 

MutableState

 

MutableState는 컴포즈에서 값을 읽고 쓸 수 있는 상태 홀더로, 값의 변경사항을 컴포즈가 자동 추적할 수 있게 한다.

값이 바뀌면 안드로이드 시스템은 이 상태에 의존하는 모든 컴포저블 함수들이 리컴포지션되게 한다. 그래서 유저 상호작용, 앱 상태의 변화에 따라 UI를 업데이트해야 하는 대화형 UI 제작 시 사용할 수 있다.

아래는 안드로이드 디벨로퍼에서 말하는 mutableState다.

 

https://developer.android.com/develop/ui/compose/state?hl=ko#state-in-composables

 

상태 및 Jetpack Compose  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. 상태 및 Jetpack Compose 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 앱의 상태는 시간이 지남에 따라

developer.android.com

mutableStateOf()는 관찰 가능한 MutableState<T>를 만드는데 이는 런타임 시 컴포즈에 통합되는 관찰 가능한 타입이다
interface MutableState<T> : State<T> {
    override var value: T
}
value가 바뀌면 value를 읽는 컴포저블 함수의 리컴포지션이 예약된다. 컴포저블에서 MutableState 객체를 선언하는 데는 3가지 방법이 있다. 선언은 동일한 것이며 서로 다른 용도의 상태를 사용하기 위한 구문 슈가로 제공된다. 작성 중인 컴포저블에서 가장 읽기 쉬운 코드를 생성하는 선언을 선택해야 한다...(중략)
val mutableState = remember { mutableStateOf(default) }

var value by remember { mutableStateOf(default) }

val (value, setValue) = remember { mutableStateOf(default) }

 

첫 번째와 같이 사용한다면 아래와 같이 쓸 수 있다.

 

@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }

    Button(onClick = { count.value++ }) {
        Text("${count.value}번 클릭함")
    }
}

 

MutableState<Int>로 선언된 count 변수가 클릭 시마다 1씩 커지고, 컴포즈는 이 값을 Text에 반영한다. 그래서 유저는 버튼을 누를 때마다 버튼의 숫자가 1씩 커지는 걸 볼 수 있다.

MutableState의 장점은 아래와 같다.

 

  • 사용하기 편함 : 반응형 데이터 흐름을 구성하는 데 필요한 보일러 플레이트 코드가 줄어들어서 UI 계층에서 상태 관리가 쉬워진다
  • 컴포즈와의 통합성 : MutableState는 컴포즈에 특화되도록 설계되서 리컴포지션을 자동 트리거하기 때문에 UI 상태 관리와 연관된 데이터를 유동적으로 처리할 수 있다
  • 간단하고 직관적 : by를 사용해서 상태를 일반 프로퍼티처럼 다룰 수 있어서 상대적으로 쉽게 사용할 수 있다
  • 단일 구독자 모델로 설계되서 오버헤드가 적고 간단한 앱이나 단일 상태 관리에 적합하다

 

이 MutableState의 단점은 아래와 같다.

 

  • 쓰레드 세이프하지 않다
  • MutableStateFlow에 비해 읽기 전용으로 쉽게 바꿀 수 없다
  • 단일 구독자 모델이기 때문에 하나의 구독자만 상태를 관찰할 수 있어서 상태를 여러 컴포넌트, 모듈에서 공유하기 어렵다
  • 컴포즈 전용이라 다른 UI 프레임워크나 자바 기반의 안드로이드 뷰에서 쓸 수 없다
  • 상태 Flow를 emit하거나 collect할 수 없어서 반응형 상태 관리가 필요할 때 사용하기 어렵다

 

정리하면 MutableState는 혼자 만드는 소규모 앱이거나, 간단한 화면이라 복잡한 UI 상태 관리가 불필요한 경우에 사용을 고려할 수 있다.

단일 구독자 모델은 어떤 데이터, 상태에 오직 구독자 하나만 접근해서 변화를 감지할 수 있는 패턴을 말한다. 그래서 상태가 바뀌면 변경사항은 그 유일한 구독자에게만 전달된다.

반대로 복수 구독자 모델(=멀티플 구독자 모델)은 여러 구독자가 한 데이터를 동시에 구독해서 변경사항을 구독자들이 동시에 받을 수 있는 패턴을 말한다. 

 

아래는 뷰모델과 MutableState을 사용한 예시다.

 

import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel

class TestViewModel: ViewModel() {

    private val _intState: MutableState<Int> = mutableStateOf(0)
    val intState: State<Int>
        get() = _intState

    fun increment() {
        _intState.value += 1
    }

}
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.ViewModelProvider
import com.example.composepractice.ui.theme.ComposePracticeTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            ComposePracticeTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    val testViewModel = ViewModelProvider(this)[TestViewModel::class.java]
                    Column(modifier = Modifier.padding(innerPadding)) {
                        IntStateTestScreen1(testViewModel = testViewModel)
                        IntStateTestScreen2(testViewModel = testViewModel)
                    }
                }
            }
        }
    }
}

@Composable
fun IntStateTestScreen1(testViewModel: TestViewModel) {
    val intStateFromViewModel = testViewModel.intState.value
    Column {
        Text(text = "화면 1에서 intState : $intStateFromViewModel")
        Button(
            onClick = {
                testViewModel.increment()
            }
        ) {
            Text(text = "1 더하기!")
        }
    }
}

@Composable
fun IntStateTestScreen2(testViewModel: TestViewModel) {
    val intStateFromViewModel2 = testViewModel.intState.value
    Column {
        Text(text = "화면 2에서 intState : $intStateFromViewModel2")
        Button(
            onClick = {
                testViewModel.increment()
            }
        ) {
            Text(text = "1 더하기!")
        }
    }
}

 

이걸 실행하면 각각 다른 화면에서 버튼을 눌러도 두 화면의 숫자가 모두 1씩 증가한다. 두 화면에서 모두 같은 상태를 공유하고 있기 때문이다.

 

 

(Mutable)StateFlow

 

MutableState가 단일 구독자 모델이란 건 알겠다. 그럼 StateFlow는 어떤 장단점이 있는가? 장점은 아래와 같다.

 

  • 멀티플 구독자 모델 : 여러 구독자가 하나의 상태를 동시 관찰할 수 있어서 여러 컴포넌트가 상태를 공유해야 할 때 적합하다
  • 레거시 뷰 시스템과 통합 가능 : XML을 사용하던 예전 뷰 시스템이나 뷰모델에서 백그라운드 처리를 구현하는 등 다양한 환경에서 사용할 수 있다
  • Flow를 통한 비동기 상태 관리 : 콜드 스트림이라 구독한 순간부터 데이터가 방출되서 불필요한 데이터를 만들지 않고, 배압을 자동 조절하며 데이터 생성 / 소비가 같은 코루틴에서 이뤄지는 Flow의 특성을 활용해서 데이터를 다양하게 emit, collect할 수 있다. filter, map 따위의 컬렉션 함수도 쓸 수 있고 cancel도 지원된다
  • 최신 상태 유지 : StateFlow는 collect 시 내부적으로 값이 같으면 방출하지 않는 특성 때문에 별 구현 없이도 최신 상태를 유지한다. 구독자가 중간에 추가돼도 최신 상태를 유지할 수 있다

 

단점은 아래와 같다.

 

  • 초기값 설정 필요 : StateFlow는 반드시 초기값을 설정해야 해서 귀찮을 수 있다
  • 코루틴에 대한 러닝 커브 : StateFlow와 코루틴은 바늘과 실 같은 존재다. 반드시 코루틴을 알아야 해서 StateFlow만 알면 다룰 수 없다
  • 컴포즈와 같이 사용 시 추가 작업 : 컴포즈에서 StateFlow를 사용하려면 collectAsState()를 추가로 사용해서 StateFlow 상태를 연결해야 한다. 또한 컴포저블이 활성 상태일 때만 StateFlow를 구독하고 비활성화 상태면 구독을 취소하기 위해, 컴포즈 생명주기와 StateFlow의 collect는 동기화돼야 한다. 조금 번거롭다
  • MutableState에 비해 상대적으로 높은 오버헤드 : 여러 구독자와 상태 emit을 관리해야 해서 MutableState에 비해 오버헤드가 좀 더 높을 수 있다. 그래서 간단한 상태 관리에 StateFlow를 쓰는 건 조금 고민될 수 있다.

 

위와 같은 특징들로 인해 StateFlow는 여러 구독자가 하나의 상태를 바라보고 있어야 하고 비동기적인 상태 관리가 필요한 UI, 컴포저블 함수 밖에서 상태를 공유해야 할 때 사용을 고려할 수 있다.

조금 전의 MutableState 예시를 StateFlow를 사용하도록 바꿔서 사용 예시를 확인해 본다.

 

import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow

class TestViewModel: ViewModel() {

    private val _state = MutableStateFlow(0)
    val state: StateFlow<Int> get() = _state

    fun increment() {
        _state.value += 1
    }

}
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.ViewModelProvider
import com.example.composepractice.ui.theme.ComposePracticeTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            ComposePracticeTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    val testViewModel = ViewModelProvider(this)[TestViewModel::class.java]
                    Column(modifier = Modifier.padding(innerPadding)) {
                        IntStateTestScreen1(testViewModel = testViewModel)
                        IntStateTestScreen2(testViewModel = testViewModel)
                    }
                }
            }
        }
    }
}

@Composable
fun IntStateTestScreen1(testViewModel: TestViewModel) {
    val intStateFromViewModel by testViewModel.state.collectAsState()
    Column {
        Text(text = "화면 1에서 intState : $intStateFromViewModel")
        Button(
            onClick = {
                testViewModel.increment()
            }
        ) {
            Text(text = "1 더하기!")
        }
    }
}

@Composable
fun IntStateTestScreen2(testViewModel: TestViewModel) {
    val intStateFromViewModel2 by testViewModel.state.collectAsState()
    Column {
        Text(text = "화면 2에서 intState : $intStateFromViewModel2")
        Button(
            onClick = {
                testViewModel.increment()
            }
        ) {
            Text(text = "1 더하기!")
        }
    }
}

 

수정한 후 앱을 실행하면 MutableState와 동일하게 작동한다. 왜냐면 사용하는 게 MutableState냐 아니냐의 차이만 있을 뿐이고 두 코드는 모두 같은 상태를 공유하기 때문이다. 둘의 사용법이 서로 다른 걸 보기 위한 예시 코드라고 이해하면 된다.

이런 특징이 있고 사용법이 다르니 만드는 화면의 특성에 따라 구현을 다르게 가져가는 게 일반적이지 않을까 생각되지만 팀 내 컨벤션이 있다면 그걸 따르면서 일관성을 가져가는 게 가장 중요하다. 화면을 만들 때 이건 이래서 이거 써야지 정도로만 생각하고 이유도 떠올릴 수 있는 정도라면 좋을 듯하다.

반응형