
StateFlow와 SharedFlow 차이는 API 모양보다 먼저 역할로 이해해야 합니다. 안드로이드에서 이 둘을 같은 용도로 쓰기 시작하면, 토스트가 두 번 뜨고 네비게이션이 다시 실행되는 식의 버그가 쉽게 생깁니다.
결론부터 말하면 화면이 계속 보여줘야 하는 상태는 StateFlow가 더 잘 맞고, 한 번 처리하고 끝나야 하는 이벤트는 SharedFlow가 더 잘 맞습니다. 핵심은 값의 종류가 아니라 UI가 그 값을 어떻게 소비해야 하는가입니다.
예를 들어 로그인 화면에 로딩 여부, 입력 에러, 홈으로 이동하라는 신호, “로그인에 성공했습니다” 토스트 요청이 함께 있다고 해보겠습니다. 이 값들을 모두 한 상자에 넣으면 설계가 흐려집니다.
- 상태(state): 지금 화면이 무엇을 보여줘야 하는가
- 이벤트(event): 지금 한 번 무엇을 실행해야 하는가
로딩 여부와 입력 에러는 현재 화면 상태에 가깝습니다. 반면 네비게이션과 토스트는 한 번 처리되면 끝나는 사건에 가깝습니다. 이 차이를 먼저 나누지 않으면 나중에 이벤트 재실행 버그가 생깁니다.
StateFlow는 최신 상태를 계속 들고 있어야 할 때 강하다
StateFlow는 항상 현재 값을 하나 들고 있고, 나중에 다시 구독한 쪽도 최신 값을 바로 받을 수 있습니다. 그래서 query, 로딩 여부, 결과 목록, 빈 결과 여부처럼 화면을 다시 그릴 때 즉시 필요한 값과 잘 맞습니다.
data class SearchUiState(
val query: String = "",
val isLoading: Boolean = false,
val items: List<String> = emptyList(),
val errorMessage: String? = null
)
class SearchViewModel : ViewModel() {
private val _uiState = MutableStateFlow(SearchUiState())
val uiState: StateFlow<SearchUiState> = _uiState
fun onQueryChanged(newQuery: String) {
_uiState.value = _uiState.value.copy(query = newQuery)
}
fun onSearchResult(items: List<String>) {
_uiState.value = _uiState.value.copy(
isLoading = false,
items = items,
errorMessage = null
)
}
}핵심은 새 collector도 지금 상태를 바로 알아야 한다는 점입니다. 상태는 늦게 합류한 쪽에도 현재값이 필요하므로 StateFlow의 최신 값 유지 특성이 장점이 됩니다.
반대로 토스트, 스낵바, 네비게이션은 다시 구독한 쪽이 과거 값을 또 받을 필요가 없는 경우가 많습니다. 로그인 성공 후 홈으로 이동하라는 신호는 한 번 실행되면 끝나는 일이기 때문입니다.
sealed interface LoginEvent {
data object NavigateToHome : LoginEvent
data class ShowToast(val message: String) : LoginEvent
}
class LoginViewModel : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState
private val _event = MutableSharedFlow<LoginEvent>(
replay = 0,
extraBufferCapacity = 1
)
val event: SharedFlow<LoginEvent> = _event
fun onLoginSuccess() {
_uiState.value = _uiState.value.copy(isLoading = false)
_event.tryEmit(LoginEvent.ShowToast("로그인에 성공했습니다."))
_event.tryEmit(LoginEvent.NavigateToHome)
}
}여기서 중요한 포인트는 `replay = 0`입니다. 일회성 이벤트는 보통 새 collector에게 과거 값을 다시 들려줄 이유가 없습니다.
one-time event를 StateFlow에 넣으면 왜 토스트가 또 뜰까
StateFlow는 최신 값을 유지하므로 collector가 다시 붙으면 마지막 값을 또 받습니다. 이 특성은 상태에는 장점이지만 이벤트에는 함정이 됩니다.
data class LoginUiState(
val isLoading: Boolean = false,
val navigateToHome: Boolean = false,
val toastMessage: String? = null
)- navigateToHome가 true로 남아 있으면 화면 재구독 시 또 이동할 수 있다
- toastMessage가 남아 있으면 토스트가 다시 뜰 수 있다
- 이를 막으려고 null 또는 false로 되돌리는 코드가 늘어나며 상태 모델이 지저분해진다
실무에서는 이 시점부터 상태 객체 안에 “현재 UI 모습”과 “이미 소비했는지”가 섞입니다. 이때는 대개 상태와 이벤트를 분리하는 편이 더 읽기 쉽고 안전합니다.
SharedFlow를 쓴다고 자동으로 안전해지는 것은 아닙니다. replay 값을 잘못 주면 새 collector가 과거 이벤트를 다시 받을 수 있습니다. 즉, SharedFlow에서도 중요한 것은 API 이름이 아니라 replay 의도입니다.
- 새 구독자가 과거 값을 꼭 알아야 한다면 replay를 검토한다
- 새 구독자가 과거 이벤트를 다시 받으면 안 된다면 보통 replay 0이 더 자연스럽다
Compose에서는 상태와 이벤트 수집 방식도 분리하는 편이 좋다
Compose에서는 지속 상태는 화면을 그리는 재료이므로 `collectAsState()` 계열로 읽고, 일회성 이벤트는 부수효과 영역에서 별도로 collect하는 편이 자연스럽습니다.
@Composable
fun LoginRoute(
viewModel: LoginViewModel,
onNavigateHome: () -> Unit,
showToast: (String) -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.event.collect { event ->
when (event) {
is LoginEvent.NavigateToHome -> onNavigateHome()
is LoginEvent.ShowToast -> showToast(event.message)
}
}
}
LoginScreen(
isLoading = uiState.isLoading,
email = uiState.email,
password = uiState.password,
errorMessage = uiState.errorMessage,
onLoginClick = viewModel::login
)
}이 패턴의 장점은 분명합니다. `uiState`는 재구성과 화면 복원에 안전하고, `event`는 토스트나 네비게이션 같은 부수효과로 분리됩니다. 코드만 봐도 어떤 값이 상태이고 어떤 값이 이벤트인지 드러납니다.
실무 판단 체크리스트: 다시 받으면 버그인가
- 새 collector가 붙었을 때 최신 값을 즉시 받아야 하는가
- 이 값은 지금 화면의 모습을 설명하는가
- 한 번 처리한 뒤에는 다시 전달되면 안 되는가
- 재구독이나 화면 재진입 때 다시 실행되면 버그인가
1번과 2번이 yes라면 StateFlow 쪽이 더 자연스럽고, 3번과 4번이 yes라면 SharedFlow 쪽이 더 자연스럽습니다. 특히 다시 받으면 버그인가?라는 질문이 실무에서는 가장 강력한 기준입니다.
마무리
StateFlow와 SharedFlow 차이는 결국 값의 생명주기 차이입니다. StateFlow는 지금 화면이 어떤 상태인지 계속 설명해야 할 때 강하고, SharedFlow는 토스트·스낵바·네비게이션처럼 한 번 처리하고 끝나야 하는 신호를 흘려보낼 때 더 잘 맞습니다.
안드로이드 상태 관리를 더 단단하게 잡고 싶다면 화면 상태는 어디에 두는 게 맞을까, process death까지 이어서 보고 싶다면 SavedStateHandle은 언제 써야 할까, Compose 쪽 상태 감각을 먼저 다지고 싶다면 remember와 rememberSaveable은 언제 다르게 써야 할까도 함께 보면 흐름이 더 선명해집니다.
공식 기준은 Android Developers의 StateFlow and SharedFlow 문서, ViewModel 문서, Compose state 문서를 같이 보면 좋습니다.