0. StateFlow
Android 개발에서 StateFlow는 UI 상태 관리의 표준으로 자리잡았다. Coroutine 기반의 반응형 프로그래밍을 지원하며, ViewModel에서 UI 상태를 관리하는 데 매우 유용하다.
StateFlow를 업데이트하는 두 가지 방식의 차이와 Race Condition, 그리고 StateFlow 설계철학에 대해 알아본다.
1. StateFlow의 두 가지 업데이트 방식
Kotlin의 MutableStateFlow를 업데이트하는 방법은 크게 두 가지이다.
1: .value를 통한 직접 할당
가장 직관적인 방법이다. 새로운 값을 직접 대입한다.
private val _itemPatches = MutableStateFlow<Map<Long, Patch>>(emptyMap())
fun addPatch(id: Long, patch: Patch) {
_itemPatches.value = _itemPatches.value + (id to patch)
}
2: .update()를 통한 함수형 업데이트
현재 값을 람다의 인자로 받아 새 값을 반환한다.
fun addPatch(id: Long, patch: Patch) {
_itemPatches.update { currentPatches ->
currentPatches + (id to patch)
}
}
얼핏 보면 비슷해 보이지만, 스레드 안전성에서 큰 차이가 있다.
2. 동작원리와 차이점
.value 사용하여 값을 업데이트 시 컴파일러가 보는 코드를 살펴보자

StateFlow.value의 getter는 내부의 atomic 상태를 읽기 때문에 thread-safe하지만, 별도의 락을 잡지 않는다.
setter는 updateState()를 통해 synchronized 블록에서 실행된다.
따라서 state.value = state.value + x는 atomic read → 계산 → synchronized write로 분리된 복합 연산이며,
중간 계산 단계는 보호되지 않아 동시 실행 시 업데이트 유실이 발생할 수 있다.
중요한 것은, READ–COMPUTE–WRITE가 하나의 원자적 동작으로 묶여 있냐의 차이이다.
지금 구조는 READ, COMPUTE 시점에는 동기화되지 않으며, WRITE 만 동기화되어 있다.
val counter = MutableStateFlow(0)
// 코루틴 A
fun incA() {
counter.value = counter.value + 1
}
// 코루틴 B
fun incB() {
counter.value = counter.value + 1
}
따라서 함수 incA, incB 를 동시에 호출하면 counter의 최종값은 2가 아닌 1로 나올 수도 있는 것이다.
.update { } 사용 시 살펴보자.

synchronized(lock) 진입 -> 현재 값 읽기 -> 람다 함수 실행 -> 새 값 쓰기 -> lock 해제
모든 과정이 하나의 synchronized 블록 안에서 실행된다.
Read, Compute, Write 단계가 하나의 동기화된 블록 내에서 원자적으로 실행된다. 따라서 중간에 다른 스레드가 끼어들 수 없다.
물론 ViewModel에서 단일 스레드(Dispatchers.Main)를 사용한다면 동시성 문제가 발생하지 않는다.
class ViewModel {
fun dispatch(action: Action) = viewModelScope.launch {
when (action) {
is PullToRefresh -> onPullToRefresh()
is UpdateCount -> updateCommentCount()
}
}
}
viewModelScope는 기본적으로 Dispatchers.Main 사용하고 있어 모든 dispatch() 호출이 순차 실행된다.
하지만 다른 Dispatcher를 사용하거나 코드가 변경되면 위험하다.
3. 그럼 동시성 문제가 있는데도 .value는 왜 존재할까?
StateFlow에서 .value는 분명 Read-Modify-Write 패턴에서 Race Condition에 취약한 API 이지만, Kotlin Coroutines는 왜 이 프로퍼티를 제거하지 않았을까? 그 이유는 StateFlow의 탄생 배경과 설계 목표에 있다.
StateFlow의 탄생 배경
StateFlow는 상태를 저장하고 관찰 가능한 Hot stream으로서, LiveData와 거의 유사한 역할을 한다.
공식 문서에서도 StateFlow를 observable data holder로 설명하며 LiveData와 비슷한 패턴으로 UI 상태 관리에 사용할 수 있다고 명시되어 있다. 그래서 많은 Android 개발자들이 LiveData를 대체해 ViewModel에서 StateFlow를 사용하는 패턴을 추천한다.
즉, StateFlow가 LiveData와 유사한 역할을 하도록 설계되었고, 그 역할을 완성하려면 현재 상태를 즉시 읽을 수 있는 API 가 필요했기 때문에 .value가 존재한다.
LiveData를 다시 짚어보자. LiveData 의 핵심은 단순한 이벤트 관찰이 아니라 상태 보관에 있다.
val state: LiveData<UiState>
val current = state.value
- LiveData는 항상 최신 값을 하나 들고 있다
- observe 하지 않아도 지금 상태를 바로 읽을 수 있다
그래서 LiveData는 단순 이벤트 스트림이 아니라 `상태 그 자체` 이다.
Flow에는 원래 이 개념이 없다
일반 Flow는 설계 철학이 다르다.
val flow = flow { emit(value) }
StateFlow is a state-holder observable flow
state-holder 는 지금 상태가 뭔지, 즉시 알 수 있어야 한다.
그래서, val stateFlow = MutableStateFlow(initial) val now = stateFlow.value
이 API는 부가 기능이 아니라 정체성 그 자체이다.
LiveData와의 연속성도 명확한 설계 의도
Android 팀 입장에서 보면, 수많은 ViewModel 코드가 이미 .value 에 의존하고 있었다.
그래서 StateFlow는 _state.value = newState 이 패턴을 의도적으로 허용했고 때문에 LiveData를 kotlinx-coroutines 으로 마이그레이션하기 위해서 필요했다.
StateFlow가 .value 프로퍼티를 제공하는 이유는 단순한 편의가 아니라,
LiveData와 동일한 `상태 보관(State Holder)` 개념을 유지하기 위한 설계 선택이다.
.value를 통해 현재 상태를 즉시 읽을 수 있어야만 StateFlow는 UI 상태 관리 도구로서 의미를 갖는다.
다만, 이전 상태를 기반으로 수정하는 경우에는 .update {}를 사용해야 하며, 이는 StateFlow가 제공하는 원자적 상태 갱신 API 이므로 적절하게 사용하면 된다.
참고 자료
'🐸 Android' 카테고리의 다른 글
| 서버에서 문자열로 넘어온 날짜가 Date로 잘 매핑되는 이유 (1) | 2025.06.14 |
|---|---|
| [Gradle] Android Gradle Plugin과 Gradle 기본 생성 파일 (0) | 2025.02.02 |
| [Gradle] Java Versions in Android Builds (0) | 2025.01.30 |
| [Android] Convention Plugin (0) | 2025.01.30 |
| FCM Notification 2 - background 에서 푸쉬 알람받기 (0) | 2023.12.12 |