Develop

Kotlin StateFlow vs SharedFlow 완벽 가이드 — 실전 사용법과 차이점

Kotlin StateFlow와 SharedFlow의 차이를 내부 동작 원리부터 Android ViewModel 활용 패턴까지 실전 예제와 함께 정리하였다.

★★★★★적극 추천
KotlinStateFlowSharedFlowCoroutinesAndroid
Kotlin StateFlow vs SharedFlow 완벽 가이드 — 실전 사용법과 차이점
  • ·StateFlow는 항상 초기값이 필요하며 최신 값 하나만 보관한다
  • ·SharedFlow는 초기값이 없고 replay 캐시 크기를 설정할 수 있다
  • ·StateFlow는 동일한 값이 연속으로 emit되면 두 번째는 무시된다
  • ·Android에서 Flow를 UI에 구독할 때 repeatOnLifecycle(STARTED)를 써야 백그라운드 시 자동 취소된다
ViewModel에서 UI 상태를 전달할 때 처음에는 LiveData를 사용했고, Coroutines로 전환하면서 StateFlow와 SharedFlow 중 무엇을 쓸지 한동안 헷갈렸다. 로딩 상태 같은 UI 상태는 StateFlow, 토스트 메시지처럼 한 번만 소비하는 이벤트는 SharedFlow가 맞다는 것을 직접 버그를 겪으면서 정리하게 됐다.

StateFlow와 SharedFlow의 핵심 차이

StateFlow 내부 동작 — 최신 상태 보관과 중복 필터링

StateFlow는 Hot Flow의 일종으로 항상 최신 값 하나를 메모리에 보관한다. 새 구독자가 생기면 현재 보관 중인 최신 값을 즉시 받는다는 점에서 LiveData와 유사하다. 값이 변경될 때만 emit되며 이전 값과 동일한 값을 연속으로 emit하면 두 번째 emit은 무시된다. 이 중복 필터링은 내부적으로 equals 비교로 이루어지므로, data class는 자동으로 처리되지만 일반 클래스는 equals를 직접 구현해야 한다. ViewModel에서 MutableStateFlow를 private으로 선언하고 외부에는 StateFlow로만 노출하는 패턴이 표준이다. 값 변경은 value 프로퍼티에 직접 대입하거나 update 확장 함수를 쓰는데, update는 내부적으로 compareAndSet을 사용해 멀티스레드 환경에서도 안전하게 상태를 갱신한다.

private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()

fun loadData() {
    _uiState.update { it.copy(isLoading = true) }
}

SharedFlow 실전 활용 — 일회성 이벤트 처리 패턴

SharedFlow는 초기값이 없고 emit된 값을 replay 설정에 따라 캐시할 수 있다. replay = 0이면 구독 시점 이후에 emit되는 값만 수신하므로, 이미 화면에 표시된 토스트가 화면 복귀 시 다시 나타나는 문제를 방지한다. 일회성 이벤트 채널로 자주 사용되며 ViewModel에서 MutableSharedFlow()로 선언하고 viewModelScope.launch { _event.emit(Event.ShowToast(message)) } 형태로 이벤트를 발행한다. 구독 측에서는 repeatOnLifecycle(STARTED) 블록 안에서 collect해야 백그라운드 진입 시 수집이 일시 중단되고 복귀 시 재개된다. SharedFlow와 Channel 중 어느 것을 써야 하는지 논쟁이 많은데, 복수의 구독자가 동일 이벤트를 수신해야 한다면 SharedFlow, 단일 소비자가 보장된 경우라면 Channel이 더 명확하다.

Android UI 레이어에서 StateFlow 구독하기

StateFlow를 Jetpack Compose에서 collectAsStateWithLifecycle로 안전하게 구독하는 방법

Jetpack Compose에서 StateFlow를 구독할 때 collectAsState()만 사용하면 앱이 백그라운드로 가도 Flow 수집이 계속 유지된다. 이는 불필요한 리소스 소비로 이어진다. androidx.lifecycle:lifecycle-runtime-compose 라이브러리가 제공하는 collectAsStateWithLifecycle()을 사용하면 Lifecycle.State.STARTED 미만일 때 자동으로 구독을 일시 중단해 배터리와 메모리를 절약한다. 내부적으로 DisposableEffect와 repeatOnLifecycle을 결합한 구현이며 별도 설정 없이 함수 호출만으로 Lifecycle-aware 구독이 완성된다. Fragment나 Activity에서는 lifecycleScope.launch { repeatOnLifecycle(STARTED) { viewModel.uiState.collect { } } } 패턴을 사용한다. 이 두 패턴을 팀 전체가 일관되게 지키지 않으면 백그라운드 크래시나 메모리 누수가 산발적으로 발생해 원인 추적이 어렵다.

// Compose
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

// Fragment
viewLifecycleOwner.lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { state -> render(state) }
    }
}

StateFlow와 SharedFlow 선택 기준 정리

StateFlow vs SharedFlow — Android 앱에서 언제 무엇을 써야 하는가

결론부터 말하면 UI 상태를 표현할 때는 StateFlow, 이벤트를 전달할 때는 SharedFlow를 쓴다. UI 상태란 화면이 어떻게 보여야 하는지를 기술하는 데이터다. 로딩 여부, 에러 메시지 표시 여부, 리스트 아이템 목록이 이에 해당한다. 반면 내비게이션 이동, 토스트 표시, 다이얼로그 트리거처럼 한 번 발생하고 끝나는 부수효과는 이벤트이며 SharedFlow로 전달하는 것이 자연스럽다. 만약 이벤트를 StateFlow로 처리하면 화면 회전 등으로 구독이 재시작될 때 이전 이벤트가 재실행되는 문제가 생긴다. 실제 프로젝트에서 이 둘을 혼동해 토스트가 화면 복귀 때마다 재표시되는 버그를 경험했고, SharedFlow로 전환한 뒤 해결됐다.

자주 묻는 질문

StateFlow를 LiveData로 변환하는 방법이 있나요?+

flow.asLiveData() 확장 함수를 사용하면 StateFlow를 LiveData로 변환할 수 있습니다. LiveData에서 Flow로 점진 마이그레이션 중일 때 유용합니다.

여러 StateFlow를 하나로 합치려면 어떻게 하나요?+

combine 연산자를 사용하면 여러 Flow를 하나로 합칠 수 있습니다. combine(flow1, flow2) { a, b -> UiState(a, b) }.stateIn(scope, SharingStarted.Lazily, UiState()) 형태로 사용합니다.

SharedFlow에서 emit과 tryEmit의 차이는 무엇인가요?+

emit은 suspend 함수로 버퍼가 가득 차면 대기하고, tryEmit은 일반 함수로 즉시 반환하며 성공 여부를 Boolean으로 알려줍니다. 코루틴 밖에서 이벤트를 발행해야 할 때 tryEmit을 씁니다.

관련 글