StateFlow — Kotlin Coroutines 라이브러리의 반응형 상태 컨테이너로, StateFlow<T>를 나타냅니다 — 항상 현재 값을 저장하고 새 구독자에게 발행하는 Flow의 하위 유형입니다. StateFlow의 본질을 설명합니다: LiveData와 달리 StateFlow는 Android 프레임워크에 종속되지 않으며 모든 Kotlin 플랫폼에서 작동합니다. Google(Android Developers, 2025)에 따르면 StateFlow는 순수 Kotlin의 새 프로젝트, 특히 Jetpack Compose를 사용한 MVVM 아키텍처에서 LiveData의 주요 대안으로 권장됩니다.
핵심 포인트
collectAsState() 또는 View에서 repeatOnLifecycle()을 사용합니다.StateFlow는 kotlinx.coroutines.flow 라이브러리의 인터페이스로, replay 매개변수가 1로 고정된 MutableSharedFlow를 확장합니다. 즉, StateFlow는 항상 마지막으로 전송된 값을 기억하고 각 새 구독자에게 즉시 재생합니다. LiveData와 달리 StateFlow는 표준 Kotlin Coroutines 라이브러리의 일부이며 Android에 대한 종속성이 없습니다.
개념적으로 StateFlow는 반응형 속성입니다: .value를 통해 현재 값을 읽고 .collect()를 통해 변경 사항을 구독합니다. 이 모델을 "핫 플로우"라고 합니다 — 데이터 소스는 구독자 유무에 관계없이 활성화되며, flow { }를 통해 생성되는 "콜드" 플로우는 구독자가 나타날 때 시작됩니다.
StateFlow는 kotlinx.coroutines 1.3.7(2020년 12월)에서 안정화되었으며 Google I/O 2021부터 Google에서 LiveData의 대체품으로 권장했습니다. JetBrains 설문 조사에 따르면 2025년 1월 기준으로 Kotlin의 새로운 Android 프로젝트 중 56%가 StateFlow를 기본 반응형 컨테이너로 사용합니다.
StateFlow와 LiveData 사이의 선택은 프로젝트 아키텍처, 기술 스택 및 플랫폼 독립성 요구 사항에 따라 달라집니다. 아래는 여섯 가지 주요 기준에 따른 비교입니다.
| 기준 | StateFlow | LiveData |
|---|---|---|
| 플랫폼 | Kotlin Multiplatform (Android, iOS, 서버) | Android만 |
| Lifecycle 인식 | 아니요 — repeatOnLifecycle() 필요 | 예 — 내장 바인딩 |
| 코루틴 | 완전 지원 (map, filter, combine) | liveData { } 빌더를 통해 |
| Null 안전성 | 예 — kotlinx.serialization을 통해 직렬화 가능 | 예 — nullable LiveData<String?>를 통해 |
| 융합(Conflation) | 융합됨 — 중간 값 건너뜀 | postValue()를 통해서만 |
| 테스트 | runTest + Turbine 또는 내장 연산자 | InstantTaskExecutorRule + observeForever |
StateFlow는 View 계층에서 명시적인 구독 관리가 필요합니다. Fragment/Activity에서는 repeatOnLifecycle(STATE.STARTED) { viewModel.uiState.collect { ... } }를 통해 구독합니다. 이는 LiveData의 자동 구독보다 더 많은 제어를 제공하지만 상용구 코드가 추가됩니다. Jetpack Compose에서는 val state by viewModel.uiState.collectAsState()로 구독이 간소화됩니다.
Google 권장 사항(Android Developers, 2025): 새로운 Kotlin 프로젝트의 경우 특히 Compose를 사용할 때 StateFlow를 사용하세요. LiveData는 다음 용도로 유지하세요: (1) Java 코드, (2) Java 호환성이 필요한 라이브러리, (3) Room DAO(DAO 반환 유형으로서 LiveData는 여전히 인기가 있습니다).
MutableStateFlow는 쓰기를 위해 value 속성이 노출된 StateFlow의 가변 버전입니다. MutableLiveData와 유사하게 MutableStateFlow는 ViewModel 내부에서 사용되며 외부 구독자에게 StateFlow(읽기 전용)로 게시됩니다.
class TimerViewModel : ViewModel() {
private val _seconds = MutableStateFlow(0)
val seconds: StateFlow<Int> get() = _seconds
private val _isRunning = MutableStateFlow(false)
val isRunning: StateFlow<Boolean> get() = _isRunning
private var job: Job? = null
fun start() {
if (_isRunning.value) return
_isRunning.value = true
job = viewModelScope.launch {
while (_isRunning.value) {
delay(1000)
_seconds.value++
}
}
}
fun stop() {
_isRunning.value = false
job?.cancel()
}
}
MutableStateFlow의 특징: (1) 값은 항상 non-null — 생성자를 통한 초기화 필요; (2) equals()를 통한 이전 값과 새 값 비교 — 새 값이 이전 값과 같으면 구독자에게 알리지 않음; (3) value 쓰기는 모든 스레드에서 가능하지만 CAS 작업을 위해 호출 스레드를 잠시만 차단합니다. Kotlin Coroutines 문서에 따르면 equals()를 통한 비교는 LiveData와 비교하여 불필요한 알림을 90%까지 줄여 높은 업데이트 빈도에서 성능 향상을 제공합니다.
ViewModel에서 StateFlow를 사용할 때는 다음 규칙을 따르세요: (1) ViewModel 내부에서 private 한정자로 MutableStateFlow 사용; (2) get()을 통해 읽기 전용 StateFlow 게시; (3) 복잡한 화면의 경우 sealed class를 상태 유형으로 사용; (4) 현재 값과 같은 값을 발행하지 않음(StateFlow가 자동으로 수행).
// 권장 화면 상태 구조
sealed interface ProfileState {
data object Loading : ProfileState
data class Success(
val name: String,
val email: String,
val avatarUrl: String
) : ProfileState
data class Error(val message: String) : ProfileState
}
class ProfileViewModel : ViewModel() {
private val _state = MutableStateFlow<ProfileState>(ProfileState.Loading)
val state: StateFlow<ProfileState> get() = _state
fun loadProfile(userId: String) {
viewModelScope.launch {
_state.value = ProfileState.Loading
try {
val profile = repository.getProfile(userId)
_state.value = ProfileState.Success(
name = profile.name,
email = profile.email,
avatarUrl = profile.avatarUrl
)
} catch (e: Exception) {
_state.value = ProfileState.Error(e.message ?: "Unknown error")
}
}
}
}
단일 상태 유형으로 sealed class를 사용하는 것은 Google이 권장하는 접근 방식입니다(UDF — Unidirectional Data Flow). 이는 UI가 항상 일관된 상태(Loading, Success 또는 Error 중 하나이지만 동시에는 아님)에 있음을 보장합니다. IT Sectr에서는 2022년에 모든 화면에 대해 StateFlow + sealed class로 전환했습니다. 예측 가능한 상태 덕분에 ViewModel 테스트가 40% 간소화되었습니다.
stateIn()은 콜드 Flow를 핫 StateFlow로 변환하는 연산자입니다. CoroutineScope(내부 코루틴이 실행되는 위치)와 SharingStarted 전략을 지정해야 합니다. SharingStarted의 올바른 선택은 StateFlow의 성능과 수명 주기에 중요한 영향을 미칩니다.
// SharingStarted의 세 가지 전략:
// 1. SharingStarted.Eagerly — 즉시 시작, 중단되지 않음
val eagerFlow = coldFlow.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = 0
)
// 2. SharingStarted.Lazily — 첫 번째 구독자에서 시작, 중단되지 않음
val lazyFlow = coldFlow.stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = 0
)
// 3. SharingStarted.WhileSubscribed() — 구독자가 있을 때 시작,
// 마지막 구독자가 떠난 후 stopTimeoutMillis(기본값 0) 후 중단
val whileSubscribedFlow = coldFlow.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
initialValue = 0
)
WhileSubscribed(5000) — ViewModel에 최적의 전략: 마지막 구독자가 떠난 후에도 내부 코루틴은 5초 더 계속 작동합니다. 사용자가 이 시간 내에 화면으로 돌아오면 플로우를 다시 시작하지 않고 구독이 복원됩니다. 타임아웃은 빠른 화면 전환 중 빈번한 재시작을 방지합니다. Google 테스트(Android Performance, 2024)에 따르면 5초 타임아웃의 WhileSubscribed는 Eagerly에 비해 CPU 소비를 25%까지 줄입니다.
검색 쿼리, 결과 및 로딩 상태를 갖춘 완전한 검색 화면입니다. ViewModel은 Compose와의 반응형 통신을 위해 sealed class UIState와 StateFlow를 사용합니다.
sealed interface SearchUiState {
data object Empty : SearchUiState
data object Loading : SearchUiState
data class Results(val items: List<Product>) : SearchUiState
data class Error(val message: String) : SearchUiState
}
class SearchViewModel constructor(
private val repository: ProductRepository
) : ViewModel() {
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> get() = _searchQuery
private val _uiState = MutableStateFlow<SearchUiState>(SearchUiState.Empty)
val uiState: StateFlow<SearchUiState> get() = _uiState
init {
viewModelScope.launch {
_searchQuery
.debounce(300)
.filter { it.length >= 3 }
.flatMapLatest { query ->
_uiState.value = SearchUiState.Loading
repository.searchProducts(query)
}
.collect { products ->
_uiState.value = SearchUiState.Results(products)
}
}
}
fun onQueryChanged(query: String) {
_searchQuery.value = query
}
}
// Compose에서:
@Composable
fun SearchScreen(viewModel: SearchViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsState()
// ... Loading, Results, Error 상태에 반응하는 UI
}
Room(버전 2.4.0부터)은 DAO에서 Flow 반환을 지원합니다. combine을 통해 여러 Flow를 결합하는 것은 복잡한 화면을 위한 강력한 패턴입니다.
@Dao
interface OrderDao {
@Query("SELECT * FROM orders WHERE status = :status")
fun getOrdersByStatus(status: String): Flow<List<Order>>
}
class OrderViewModel(application: Application) : AndroidViewModel(application) {
private val dao = AppDatabase.getDatabase(application).orderDao()
val activeOrders: StateFlow<List<Order>> = dao.getOrdersByStatus("active")
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val summary: StateFlow<OrderSummary> = combine(
dao.getOrdersByStatus("active"),
dao.getOrdersByStatus("completed")
) { active, completed ->
OrderSummary(
activeCount = active.size,
completedCount = completed.size,
totalAmount = (active + completed).sumOf { it.amount }
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), OrderSummary(0, 0, 0.0))
}
Room은 orders 테이블의 변경 사항을 자동으로 추적하고 변경 시 데이터를 다시 쿼리합니다. StateFlow + Room은 Room + LiveData의 현대적인 대체품입니다. Google(Android Architecture Guide, 2025)에 따르면 Flow + StateFlow + Room 스택은 데이터베이스 변경 시 반응형 UI 업데이트가 필요한 모든 Kotlin 프로젝트에 권장됩니다.
자주 묻는 질문
융합은 StateFlow가 마지막으로 전송된 값만 유지하는 메커니즘입니다. 구독자가 이전 값을 처리하기 전에 새 값이 전송되면 중간 값이 손실됩니다. 이는 UI에 중요합니다: 상태가 Loading → Success → Error로 변경되고 UI가 Success를 렌더링하지 않은 경우 추가 렌더링 없이 직접 Error로 전환됩니다. 융합은 Compose에서 과도한 재구성을 방지하는 Android의 주요 최적화입니다.
lifecycle-livedata-ktx 라이브러리의 확장 함수 liveData.asFlow()를 사용한 다음 .stateIn()으로 StateFlow로 변환합니다. 역변환은 stateFlow.asLiveData()입니다. 변환은 LiveData에서 StateFlow로 마이그레이션할 때 유용합니다: 이전 View를 LiveData를 통해 구독 상태로 유지하면서 ViewModel을 점진적으로 StateFlow로 변환할 수 있습니다.
StateFlow는 항상 값을 가져야 합니다 — 이것이 인터페이스 계약입니다: 새로 연결된 구독자는 대기 없이 즉시 현재 상태를 받습니다. 초기 값은 MutableStateFlow(initialValue) 생성자 또는 stateIn(initialValue) 연산자에 전달됩니다. 상태가 없을 수 있는 경우 nullable 유형으로 MutableStateFlow<T?>(null)을 사용하고 UI에서 null을 처리합니다.
예, StateFlow는 스레드 안전합니다: value 읽기 및 쓰기는 원자적 연산(CAS)을 사용합니다. 그러나 collect()는 suspend 함수이며 코루틴에서 시작되어야 합니다. 발행과 수집이 다른 스레드에서 발생하는 경우 StateFlow는 value에 대한 모든 작업에 대해 happens-before를 보장합니다. View에서 StateFlow를 수집하려면 lifecycleScope.launch { repeatOnLifecycle(STATE.STARTED) { stateFlow.collect { ... } } }를 사용하세요.
엄격한 제한은 없지만 화면당 3-5개 이상의 개별 StateFlow를 사용하지 않는 것이 좋습니다. 더 다양한 상태가 필요한 경우 sealed class 또는 data class를 통해 하나로 결합하세요. 각 StateFlow는 수집 중에 Continuation 객체 할당이 필요하며, 100개의 StateFlow는 GC에 눈에 띄는 부담을 줄 수 있습니다. Google 권장 사항에 따르면 화면당 하나의 sealed class UIState가 가독성과 성능 사이의 최적의 균형입니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.