본문 바로가기

안드로이드/코루틴

[안드로이드 Coroutine Flow] State Flow 원자성 보장


728x90

안드로이드의 State Flow는 Kotlin Coroutine의 일부로, 상태 관리를 위해 만들어진 Flow입니다. 상태가 없는 Flow에 상태를 보유할 수 있는 기능을 추가해서 개발자가 상태를 보다 쉽고 편리하게 관리할 수 있게 해 줍니다. 이번 포스팅에서는 이러한 State Flow를 사용할 때 발생할 수 있는 문제와 원자성을 보장하기 위한 해결방법에 대해 알아보겠습니다.

State_Flow_원자성 보장예제
다중 스레드에서 체크 상태를 변경했을 때, 원자성이 보장되지 않은 경우 체크박스 상태가 이상해진다

1. StateFlow 사용방법

안드로이드에서 UI에 상태를 유지하고 변경할 때, StateFlow를 많이 사용합니다. 예를 들어, 아래와 같이 data class로 생성된 상태 홀더를 StateFlow로 관리할 수 있습니다.

data class UiState(
    val name: String = "",
    val checked: Boolean = false
)

@HiltViewModel
class UiExampleViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
) : ViewModel() {
    private val _uiState = MutableStateFlow(UiState())
    val uiState: StateFlow<CheckUiState> = _uiState
}


그리고 상태값을 변경할 때 아래와 같이 data class의 copy 함수를 통해 변경하고자 하는 프로퍼티만 편하게 변경할 수 있습니다. 상태를 관리할 때 data class를 사용하면 한 곳에서 관련된 데이터들을 확인할 수 있고, 코드 가독성이 좋아집니다. 또한, 아래 코드 예제처럼 하나 이상의 프로퍼티를 쉽게 수정할 수 있어서 UI 상태를 간단하게 업데이트할 수 있게 됩니다.

_uiState.value = _uiState.value.copy(
    name = "my name",
    checked = _uiState.value.checked.not(),
)

_uiState.value = _uiState.value.copy(
    name = "brother's name"
)

2. StateFlow의 value를 통해 업데이트할 때의 문제점

위의 예제만 보면 문제가 없는 것처럼 보이지만, 치명적인 문제가 한 가지 있습니다. 바로 다중 스레드에서 uiState의 값을 변경할 때 문제가 발생합니다. 아래 코드처럼 StateFlow의 값이 경신되기 전에 다른 스레드에서 값을 업데이트하면 원자성을 보장할 수 없게 됩니다.

viewModelScope.launch(Dispatchers.IO) {
    _uiState.value = _uiState.value.copy(
        name = "my name"
    )
}

viewModelScope.launch(Dispatchers.Default) {
    _uiState.value = _uiState.value.copy(
        checked = _uiState.value.checked2.not(),
    )
}


버튼을 눌렀을 때, 위의 코드가 실행되게 한다면 name은 "my name"이 되고 checked는 토글로 작동되는 것을 예상할 수 있습니다. 그러나 실제로 10~20번 버튼을 눌러보면 원자성이 보장되지 않는 결과를 확인할 수 있습니다. 즉, 위 예제의 launch 코드가 운이 좋아서 잘 작동했던 것뿐입니다.

3. StateFlow의 원자성을 보장하는 방법

다중 스레드에서 동시에 상태를 업데이트해도 원자성을 보장하기 위해서는 몇 가지 방법이 있는데, 여기서는 가장 쉬운 방법에 대해 설명하겠습니다. 바로, 코틀린 코루틴 1.5.1에 추가된 update() 함수를 사용하는 것입니다. 아래는 MutableStateFlow의 update 함수 내부 모습입니다.

/**
 * Updates the [MutableStateFlow.value] atomically using the specified [function] of its value.
 *
 * [function] may be evaluated multiple times, if [value] is being concurrently updated.
 */
public inline fun <T> MutableStateFlow<T>.update(function: (T) -> T) {
    while (true) {
        val prevValue = value
        val nextValue = function(prevValue)
        if (compareAndSet(prevValue, nextValue)) {
            return
        }
    }
}


이전 상태값과 현재 상태값을 비교해서 다른 스레드에서 수정이 됐다면 다시 루프를 수행하는 구조로 되어있습니다. compareAndSet 함수의 내부 모습을 확인해 보면 synchronized를 이용해서 원자성을 보장하고 있습니다.

아래 코드는 아까 작성했던 예제 코드를 update() 함수를 사용해서 리팩토링한 모습입니다. 아래와 같이 MutableStateFlow의 update() 함수를 사용하면 여러 스레드에서 동시에 상태값을 수정하는 상황에서도 안전하게 상태값을 수정할 수 있으며, 크리티컬 섹션 문제도 해결할 수 있습니다.

viewModelScope.launch(Dispatchers.IO) {
    _uiState.update { state ->
        state.copy(
            text = "my name"
        )
    }
}

viewModelScope.launch(Dispatchers.Default) {
    _uiState.update { state ->
        state.copy(
            checked = state.checked.not()
        )
    }
}

4. 결론

안드로이드에서 StateFlow를 사용할 때 동시 작업으로 값을 업데이트하는 경우, 원자성이 보장되지 않는 문제와 이를 해결하기 위한 방법에 대해 알아보았습니다. MutableStateFlow의 update() 함수가 생기기 전에는 개발자가 mutex를 생성해서 관리해주어야 했지만, 코틀린 코루틴 1.5.1 이상 버전에서는 위의 예제처럼 update() 함수를 통해 편하고 안전하게 상태값을 업데이트할 수 있습니다.