StateFlow — 본질, Android에서 StateFlow vs LiveData

저자: IT Sectr 게시일: 2026-02-20 읽는 시간: 9 분

StateFlow — Kotlin Coroutines 라이브러리의 반응형 상태 컨테이너로, StateFlow<T>를 나타냅니다 — 항상 현재 값을 저장하고 새 구독자에게 발행하는 Flow의 하위 유형입니다. StateFlow의 본질을 설명합니다: LiveData와 달리 StateFlow는 Android 프레임워크에 종속되지 않으며 모든 Kotlin 플랫폼에서 작동합니다. Google(Android Developers, 2025)에 따르면 StateFlow는 순수 Kotlin의 새 프로젝트, 특히 Jetpack Compose를 사용한 MVVM 아키텍처에서 LiveData의 주요 대안으로 권장됩니다.

핵심 포인트

  • StateFlow — kotlinx.coroutines.flow의 상태 홀더로, 항상 하나의 현재 값을 저장하고 구독 시 발행합니다.
  • MutableStateFlow — 변경 가능한 value 속성을 가진 가변 StateFlow로, ViewModel 내부에서 사용되고 StateFlow로 게시됩니다.
  • collect() — 변경 사항을 구독하기 위한 Flow의 터미널 연산자. UI의 경우 Compose에서 collectAsState() 또는 View에서 repeatOnLifecycle()을 사용합니다.
  • StateFlow vs LiveData: StateFlow는 Lifecycle에 의존하지 않으며 명시적인 구독 관리가 필요하지만 코루틴과 멀티플랫폼을 지원합니다.
  • stateIn() — 모든 Flow를 구성 가능한 SharingStarted 전략으로 StateFlow로 변환하는 연산자입니다.

Kotlin에서 StateFlow란?

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 vs LiveData: 주요 차이점

StateFlow와 LiveData 사이의 선택은 프로젝트 아키텍처, 기술 스택 및 플랫폼 독립성 요구 사항에 따라 달라집니다. 아래는 여섯 가지 주요 기준에 따른 비교입니다.

기준StateFlowLiveData
플랫폼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: 게시 및 구독

MutableStateFlow는 쓰기를 위해 value 속성이 노출된 StateFlow의 가변 버전입니다. MutableLiveData와 유사하게 MutableStateFlow는 ViewModel 내부에서 사용되며 외부 구독자에게 StateFlow(읽기 전용)로 게시됩니다.

kotlin
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: 모범 사례

ViewModel에서 StateFlow를 사용할 때는 다음 규칙을 따르세요: (1) ViewModel 내부에서 private 한정자로 MutableStateFlow 사용; (2) get()을 통해 읽기 전용 StateFlow 게시; (3) 복잡한 화면의 경우 sealed class를 상태 유형으로 사용; (4) 현재 값과 같은 값을 발행하지 않음(StateFlow가 자동으로 수행).

kotlin
// 권장 화면 상태 구조
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() 및 SharingStarted: 세 가지 전략

stateIn()은 콜드 Flow를 핫 StateFlow로 변환하는 연산자입니다. CoroutineScope(내부 코루틴이 실행되는 위치)와 SharingStarted 전략을 지정해야 합니다. SharingStarted의 올바른 선택은 StateFlow의 성능과 수명 주기에 중요한 영향을 미칩니다.

kotlin
// 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%까지 줄입니다.

코드 예제: Kotlin에서 StateFlow

예제 1: StateFlow 및 Compose를 사용한 ViewModel

검색 쿼리, 결과 및 로딩 상태를 갖춘 완전한 검색 화면입니다. ViewModel은 Compose와의 반응형 통신을 위해 sealed class UIState와 StateFlow를 사용합니다.

kotlin
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
}

예제 2: Room 및 combine을 사용한 StateFlow

Room(버전 2.4.0부터)은 DAO에서 Flow 반환을 지원합니다. combine을 통해 여러 Flow를 결합하는 것은 복잡한 화면을 위한 강력한 패턴입니다.

kotlin
@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에서 융합(conflation)이란?

융합은 StateFlow가 마지막으로 전송된 값만 유지하는 메커니즘입니다. 구독자가 이전 값을 처리하기 전에 새 값이 전송되면 중간 값이 손실됩니다. 이는 UI에 중요합니다: 상태가 Loading → Success → Error로 변경되고 UI가 Success를 렌더링하지 않은 경우 추가 렌더링 없이 직접 Error로 전환됩니다. 융합은 Compose에서 과도한 재구성을 방지하는 Android의 주요 최적화입니다.

LiveData를 StateFlow로 변환하는 방법은?

lifecycle-livedata-ktx 라이브러리의 확장 함수 liveData.asFlow()를 사용한 다음 .stateIn()으로 StateFlow로 변환합니다. 역변환은 stateFlow.asLiveData()입니다. 변환은 LiveData에서 StateFlow로 마이그레이션할 때 유용합니다: 이전 View를 LiveData를 통해 구독 상태로 유지하면서 ViewModel을 점진적으로 StateFlow로 변환할 수 있습니다.

StateFlow에 초기 값이 필요한 이유는?

StateFlow는 항상 값을 가져야 합니다 — 이것이 인터페이스 계약입니다: 새로 연결된 구독자는 대기 없이 즉시 현재 상태를 받습니다. 초기 값은 MutableStateFlow(initialValue) 생성자 또는 stateIn(initialValue) 연산자에 전달됩니다. 상태가 없을 수 있는 경우 nullable 유형으로 MutableStateFlow<T?>(null)을 사용하고 UI에서 null을 처리합니다.

StateFlow는 스레드 안전한가요?

예, StateFlow는 스레드 안전합니다: value 읽기 및 쓰기는 원자적 연산(CAS)을 사용합니다. 그러나 collect()는 suspend 함수이며 코루틴에서 시작되어야 합니다. 발행과 수집이 다른 스레드에서 발생하는 경우 StateFlow는 value에 대한 모든 작업에 대해 happens-before를 보장합니다. View에서 StateFlow를 수집하려면 lifecycleScope.launch { repeatOnLifecycle(STATE.STARTED) { stateFlow.collect { ... } } }를 사용하세요.

하나의 ViewModel에 몇 개의 StateFlow를 저장할 수 있나요?

엄격한 제한은 없지만 화면당 3-5개 이상의 개별 StateFlow를 사용하지 않는 것이 좋습니다. 더 다양한 상태가 필요한 경우 sealed class 또는 data class를 통해 하나로 결합하세요. 각 StateFlow는 수집 중에 Continuation 객체 할당이 필요하며, 100개의 StateFlow는 GC에 눈에 띄는 부담을 줄 수 있습니다. Google 권장 사항에 따르면 화면당 하나의 sealed class UIState가 가독성과 성능 사이의 최적의 균형입니다.

요약

  • StateFlow — Kotlin Coroutines의 핫 반응형 컨테이너(replay=1), 항상 마지막 값을 유지합니다.
  • StateFlow vs LiveData: StateFlow는 Lifecycle에 독립적이며 코루틴과 멀티플랫폼을 지원합니다. LiveData는 자동 구독입니다.
  • MutableStateFlow를 private set으로 사용하고 읽기 전용 StateFlow 게시 — ViewModel의 표준 패턴입니다.
  • UIState로서의 Sealed class — 복잡한 화면 상태 관리를 위한 Google 권장 UDF 접근 방식입니다.
  • stateIn()WhileSubscribed(5000) — ViewModel을 위해 콜드 Flow를 StateFlow로 변환하는 최적의 전략입니다.
  • Room은 DAO에서 Flow를 반환 — StateFlow + combine + Room이 Room + LiveData를 대체합니다.
  • Google은 새로운 Kotlin 프로젝트, 특히 Jetpack Compose와 함께 StateFlow를 권장합니다.

턴키 방식의 모바일 애플리케이션을 개발해 드립니다

IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.

프로젝트 논의

더 읽어보기