is mutableStateOf “NOT” thread-safety?
Jetpack Compose를 사용할 때, Compose를 위한 UI 상태 객체를 다루기 위해 주로 사용하고 있는 mutableStateOf
에 대한 개인적인 궁금증을 해결해보았습니다.
먼저, 현재 센디는 Jetpack Compose로의 점진적인 migration을 진행중이며, ViewModel에 UI 상태 객체를 저장하고 Compose에서 getter를 통해 참조하여 사용할 수 있도록 구성하였습니다.
@Composable
fun MainScreen(
viewModel: MainViewModel = MainViewModel()
) {
MainContent(state = viewModel.state)
}
class MainViewModel: ViewModel() {
var state: MainUIState by mutableStateOf(MainUIState.Default)
private set
}
이러한 방식으로 ViewModel에 정의된 상태 객체를 Compose에서 잘 사용해오고 있었지만, 문득 멀티스레드 환경에서 메인 스레드가 아닌 다른 스레드에서 상태 객체의 값을 변경한다면 어떤 일이 일어날지 궁금해졌습니다.
스레드 안전한 경우
물론, Composable 함수 내에서 정의된 mutableStateOf
는 스레드 안정성을 가질 것입니다. 왜냐하면, Composable 함수 자체가 항상 메인 스레드에서 호출될 것이기 때문입니다.
그리고, ViewModel에서 정의된 mutableStateOf
도 코루틴 스코프에 특별히 Dispatcher를 지정하거나, 새롭게 스레드를 생성하여 그 내부에서 상태 값을 변경하는 경우가 아니라면 항상 메인 스레드에서 동작하기 때문에 스레드 안전성을 가질 것입니다.
하지만, 코루틴 스코프에서 특정 Dispatcher(IO, Default)를 지정한다거나 새롭게 스레드를 생성하여 상태값을 변경하면 이야기가 달라집니다. 더 이상 mutableStateOf
는 스레드 안전하지 않은 상태가 되어버립니다.
스레드 안전하지 않은 경우
간단한 예제와 함께 알아보면 더 빠를 것 같습니다.
mutableStateOf
로 정의된 counter 변수에 1을 더하는 작업을 여러 방법을 통해 시도해보려고 합니다.
먼저, counter 변수를 보여줄 텍스트와 버튼을 UI에 추가합니다.
// MainActivity.kt
class MainActivity: ComponentActivity() {
private var counter by mutableIntStateOf(0)
override fun onCreate(savedInstanceState: Bundle?) {
...
MainScreen(
counter = counter,
onClick = ::onClick,
)
}
}
@Composable
private fun MainScreen(
counter: Int,
onClick: () -> Unit,
) {
Column {
Text(text = "$counter")
Button(onClick = onClick) {
Text("Add Counter")
}
}
}
그리고, 버튼을 클릭했을 때 동작의 명세는 다음과 같이 정했습니다.
- 현재 스레드에서 바로 1을 더합니다.
- 새로운 스레드를 생성하여 1을 더합니다.
- lifecycleScope에서 바로 launch 하여 1을 더합니다.
- lifecycleScope에서 Main Dispatcher를 명시하여 1을 더합니다.
- lifecycleScope에서 IO Dispatcher를 명시하여 1을 더합니다.
- lifecycleScope에서 Default Dispatcher를 명시하여 1을 더합니다.
- lifecycleScope에서 Unconfined Dispatcher를 명시하여 1을 더합니다.
private fun onClick() {
Log.d("MainActivity.kt", "[Main Thread]: ${Thread.currentThread().id}")
// normally update
counter++
// new thread
thread(name = "New Thread") {
Log.d("MainActivity.kt", "[New Thread]: ${Thread.currentThread().id}")
counter++
}
// No Dispatcher (Main.immediate)
lifecycleScope.launch {
Log.d("MainActivity.kt", "[Main.immediate Dispatcher]: ${Thread.currentThread().id}")
counter++
}
// Main Dispatcher
lifecycleScope.launch(Dispatchers.Main) {
Log.d("MainActivity.kt", "[Main Dispatcher]: ${Thread.currentThread().id}")
counter++
}
// IO Dispatcher
lifecycleScope.launch(Dispatchers.IO) {
Log.d("MainActivity.kt", "[IO Dispatcher]: ${Thread.currentThread().id}")
counter++
}
// Default Dispatcher
lifecycleScope.launch(Dispatchers.Default) {
Log.d("MainActivity.kt", "[Default Dispatcher]: ${Thread.currentThread().id}")
counter++
}
// Unconfined Dispatcher
lifecycleScope.launch(Dispatchers.Unconfined) {
Log.d("MainActivity.kt", "[Unconfined Dispatcher]: ${Thread.currentThread().id}")
counter++
}
}
버튼을 딱 한 번 클릭하였을 때, 로그 상으로는 총 네 개의 스레드 id가 기록됩니다. 그렇다는 뜻은 counter 변수에 네 개의 스레드가 접근한다는 뜻이기도 합니다.
그리고 우리는 counter를 더하는 코드가 총 7번 호출되기 때문에 버튼을 클릭하면 계속해서 7을 더해나갈 것이라 예상할 것입니다.
아니나 다를까, 단 몇 번의 버튼 클릭 만으로도 예상했던 7의 배수가 깨집니다.
스레드 안전하지 않은 대표적인 예시인 Race Condition이 발생한 것을 확인할 수 있었습니다. mutableStateOf
는 스레드 안전하지 않은 것이죠.
mutableStateOf 내부는 synchronized 하다.
mutableStateOf
는 내부적으로 SnapshotMutableStateImpl
을 상속받도록 되어있습니다.
해당 클래스의 value
프로퍼티를 확인해보면, setter에서 StateRecord 클래스의 확장함수인 withCurrent
함수 내에서 상태가 이전 상태와 같은지 확인 후 다른 경우에 overwrite 하도록 처리하고 있습니다.
이 과정에서 내부적으로 kotlin의 synchronized
를 사용하여 데이터를 write 하도록 되어있습니다.
그 말인 즉슨, synchronized
블록을 통해 Snapshot 시스템이 Compose의 상태 변경 작업에서 일관성을 유지할 수 있도록 해당 상태 값을 read 또는 write 할 때 특정 영역을 보호해준다는 뜻입니다.
하지만, 여기서 사용되는 synchronized
는 조금은 제한적입니다.
Race Condition을 방지하지 못합니다.
여기서의 synchronized
는 Snapshot 시스템 내에서의 상태 일관성을 보장하지만, 상태를 읽고-수정하고-쓰는(read-modify-write) 작업들이 원자적으로 처리되지는 않습니다.
예를 들면 위에서 다루었던 예제에서와 같이 count++
과 같은 작업에서는 여전히 Race Condition을 방지하지 못합니다.
단일 스레드 환경을 가정하고 동작합니다.
Compose의 기본적인 설계는 단일 스레드, 즉 메인 스레드에서 상태가 변경된다는 가정을 따르고 있습니다. 이 가정을 개발자가 임의로 깨버린다면, synchronized
블록 만으로는 스레드 안전하지 않은 상황이 발생하는 것이죠.
이와 같은 이유로 mutableStateOf
는 단일 스레드에서의 변경에 대해서는 안전하게 사용할 수 있지만, 멀티스레드 환경에서는 안전하지 않습니다.
StateFlow is thread-safety
mutableStateOf
가 멀티스레드 환경에서 스레드 안전하지 않다면, 멀티스레드 환경에서의 Jetpack Compose 상태 관리로 가장 적합한 대안은 StateFlow
일 것입니다.
StateFlow
interface의 주석 중 일부를 확인해보면 다음과 같이 언급하고 있습니다.
Concurrency
All methods of state flow are “thread-safe” and can be safely invoked from concurrent coroutines without external synchronization.
즉, StateFlow
는 외부에서 동기화 처리를 따로 하지 않아도 여러 코루틴에서 스레드 안전하게 값을 읽고 쓸 수 있도록 설계되었습니다. 그리고 이는 StateFlow
내부 구현 방식과 이어집니다.
StateFlow
의 구현체인 StateFlowImpl
클래스를 확인해보면, _state
프로퍼티를 초기화할 때, AtomicReference를 사용하여 상태를 원자적으로 처리할 수 있도록 하고 있습니다. 그래서 이를 통해 스레드 안전할 수 있는 것이죠.
그럼 StateFlow
를 활용하여 이전에 작성했던 예제를 수정해보겠습니다.
(이번에는 counter를 더하는 경우의 수를 조금 줄여보았습니다.)
// MainActivity.kt
class MainActivity: ComponentActivity() {
// counter를 StateFlow로 수정
private val _flowOfCounter = MutableStateFlow(0)
private val flowOfCounter = _counter.asStateFlow()
override fun onCreate(savedInstanceState: Bundle?) {
...
val counter by flowOfCounter.collectAsStateWithLifecycle()
MainScreen(
counter = counter,
onClick = ::onClick,
)
}
}
@Composable
private fun MainScreen(
counter: Int,
onClick: () -> Unit,
) {
Column {
Text(text = "$counter")
Button(onClick = onClick) {
Text("Add Counter")
}
}
}
// onClick
fun onClick() {
// new thread
thread(name = "New Thread") {
Log.d("MainActivity.kt", "[New Thread]: ${Thread.currentThread().id}")
_flowOfCounter.value++
}
// IO Dispatcher
lifecycleScope.launch(Dispatchers.IO) {
Log.d("MainActivity.kt", "[IO Dispatcher]: ${Thread.currentThread().id}")
_flowOfCounter.value++
}
// Default Dispatcher
lifecycleScope.launch(Dispatchers.Default) {
Log.d("MainActivity.kt", "[Default Dispatcher]: ${Thread.currentThread().id}")
_flowOfCounter.value++
}
// Unconfined Dispatcher
lifecycleScope.launch(Dispatchers.Unconfined) {
Log.d("MainActivity.kt", "[Unconfined Dispatcher]: ${Thread.currentThread().id}")
_flowOfCounter.value++
}
}
버튼을 단 두 번 클릭하였을 때의 결과입니다. 각각의 클릭마다 모두 다른 네 개의 스레드에서 StateFlow의 값을 변경하려 하였다는 것을 확인할 수 있습니다.
그런데, 예상치 못한 일이 벌어졌습니다.
Race Condition
이전에 mutableStateOf
에서도 발생했던 스레드 안전하지 않을 때 가장 대표적인 문제인 Race Condition이 발생한 것을 확인할 수 있었습니다.
// 제가 작성한 코드
_flowOfCounter.value++
// 그걸 풀어서 작성했을 때
_flowOfCounter.value = _flowOfCounter.value + 1
풀어서 다시 작성해본 코드를 보면, 우리는 단순히 StateFlow
의 value 값을 불러와 1을 더한 뒤 다시 value에 set하는 과정을 거쳤습니다.
하지만, 여러 코루틴에서 동시에 increment 함수를 호출하게 되면, 다음과 같은 문제가 발생할 수 있습니다.
- 여러 코루틴이 동시에 _flowOfCounter.value 값을 읽고, 동일한 값을 가져오게 됩니다.
- 그 후 각각의 코루틴이 1을 더하고, _flowOfCounter.value 값에 저장하게 됩니다.
- 결과적으로 여러 코루틴의 작업 결과물 중, 단 하나의 결과물만 업데이트 됩니다.
이는 상태 변경 작업이 원자적으로 처리되지 않았기 때문에 발생하는 결과입니다.
결론적으로, StateFlow
는 값 자체의 읽기와 쓰기에 대한 스레드 안정성을 보장하지만, 상태를 읽고 수정한 뒤 다시 쓰는 작업(read-modify-write)은 원자적이지 않기 때문에 Race Condition이 발생할 수 있습니다.
update
StateFlow
는 이 문제를 해결하기 위해 원자적으로 값을 변경할 수 있도록 update
함수를 제공합니다.
이 update
함수는 무한루프를 돌면서 계속 값을 compareAndSet
메소드를 통해 비교하다가, 값이 변경되는 순간에 조건문이 만족하여 return 되도록 구현되어 있습니다.
이 과정은 지금까지 이야기해왔던 원자성을 완벽하게 보장하고 있습니다. 왜냐하면 값이 변경될 때까지 계속 함수를 끝내지 않고 있다가, 변경되었을 때 값을 갱신하고 함수를 끝내기 때문입니다.
그럼 어떻게 해야 하는가?
지금까지 mutableStateOf
와 StateFlow
를 비교하며 각각의 장점과 한계점들을 살펴보았습니다.
요약해보자면, mutableStateOf
는 Compose의 단일 스레드 환경에서 빠르고 간단한 상태 관리를 제공하지만, 멀티스레드 환경에서는 Race Condition과 같은 문제를 야기할 수 있습니다.
반면에 StateFlow
는 스레드 안전하며, 멀티스레드 환경에서의 안전한 상태 관리를 가능하게 해줍니다.
그렇다면 우리는 두 가지의 경우를 나누어 선택할 수 있게 되었습니다.
단일 스레드 + 단순한 UI 상태 관리
mutableStateOf
는 단일 스레드에서는 정상적으로 값을 보장하기 때문에 단순한 UI 상태를 처리하는 경우에는 적합할 것입니다.
멀티 스레드
멀티 스레드 환경이라면 상태를 안전하게 처리하기 위해 반드시 StateFlow를 사용하는 것이 좋습니다.
그리고, 상태를 변경할 때에는 반드시 update 함수를 사용하여 원자적으로 처리할 수 있도록 하여 Race Condition을 방지해야 합니다.
아키텍쳐 설계의 측면
아키텍쳐를 설계하는 측면에서도 살펴볼 필요가 있습니다.
요즘에는 Jetpack Compose와 MVI 패턴을 함께 사용하는 경우가 많습니다. (저희도 현재 그렇게 하고 있습니다. 😎)
그래서 ViewModel이 기존 MVVM과는 다르게 Model의 개념으로 바뀐 것이죠.
ViewModel이 UI 상태를 저장하고, 그 상태를 갱신하거나 생산하는 역할을 합니다. View는 어떻게 상태를 변경하는지는 알지 못하고, Model에서 제공하는 상태를 가져와서 사용할 뿐입니다.
그렇다는 뜻은, 아키텍쳐를 MVI 패턴으로 정의하고 프로젝트를 작성한다면, ViewModel은 UI 종속적인 컴포넌트를 가지지 못한다는 의미일 것입니다.
다시 말해, Compose에 종속적인 mutableStateOf
를 사용하게 되면 ViewModel와 View의 결합도가 높아진다는 의미입니다.
그렇기 때문에, MVI 패턴을 선택하거나 ViewModel에서 최대한 Compose의 의존성을 제거하도록 설계하기 위해서는 StateFlow
를 사용하여 응집도를 떨어뜨리는 것이 적합하다고 생각됩니다.
(어떤 방법이든 완전히 둘을 분리할 수는 없겠지만요.)
결론
글을 작성하면서 많은 생각을 하게 되었습니다. 지금 제가 개발해왔던 제품이 안정적이지 않은 것이었나? 괜히 걱정되고 무서워지기도 했습니다.
하지만, 실제로는 mutableStateOf
도 안정적으로 동작할 가능성이 크고, StateFlow
도 마찬가지일 것입니다.
그래도 이렇게 세부적으로 알고있다면 추후에 많은 데이터를 다루거나 제품이 복잡해졌을 때 더 세세하게 다룰 수 있겠다는 생각도 들었습니다.
그리고, 안드로이드 공식 문서에서 Repository 레이어에서 Dispatcher를 주입받아 사용하는 것을 권장하는데, 테스트의 용이성 때문으로 알고있었지만, 이제는 뭔가 합리적인 의심을 하게 되었습니다.
ViewModel에서도 단일 스레드로 동작하게 하는 것이 좋아보입니다.
저의 글이 좀 더 안정적으로 Compose 상태를 관리하는데 도움이 되셨으면 좋겠습니다. 긴 글 읽어주셔서 감사합니다.