
StateFlow와 SharedFlow 차이는 API 이름보다 먼저 역할로 이해해야 합니다. 안드로이드에서 이 둘을 아무 기준 없이 섞어 쓰기 시작하면, 스낵바가 다시 뜨고 네비게이션이 또 실행되는 식의 버그가 생각보다 쉽게 생깁니다.
결론부터 말하면 화면이 계속 보여줘야 하는 상태는 StateFlow가 더 잘 맞고, 한 번 처리하고 끝나야 하는 액션은 SharedFlow가 더 잘 맞습니다. 핵심은 값 자체보다 UI가 그 값을 어떻게 소비해야 하는가입니다.
state와 event는 왜 다를까
로그인 화면에 로딩 여부, 입력 에러, 홈으로 이동하라는 신호, 스낵바 표시 요청이 함께 있다고 해보겠습니다. 이 값들을 모두 같은 상자에 넣으면 구조가 흐려집니다.
- 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 ShowSnackbar(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.ShowSnackbar("로그인에 성공했습니다."))
_event.tryEmit(LoginEvent.NavigateToHome)
}
}여기서 중요한 포인트는 replay를 0으로 두는 기본 설계입니다. 일회성 액션은 새 collector에게 과거 값을 다시 들려줄 이유가 거의 없습니다.
StateFlow에 이벤트를 넣으면 왜 다시 실행될까
StateFlow는 최신 값을 유지하므로 collector가 다시 붙으면 마지막 값을 또 받습니다. 이 특성은 상태에는 장점이지만 이벤트에는 함정이 됩니다.
data class LoginUiState(
val isLoading: Boolean = false,
val navigateToHome: Boolean = false,
val snackbarMessage: String? = null
)- navigateToHome가 true로 남아 있으면 다시 이동할 수 있다
- snackbarMessage가 남아 있으면 스낵바가 다시 뜰 수 있다
- 이를 막으려고 null 또는 false로 되돌리는 코드가 늘어나며 상태 모델이 어색해진다
실무에서는 이 시점부터 상태 객체 안에 현재 UI 모습과 이미 소비했는지 여부가 섞입니다. 이때는 대개 상태와 액션을 분리한 쪽이 더 읽기 쉽고 버그도 적습니다.
실무에서는 이 값이 지금 화면의 모습인지, 아니면 지금 한 번 실행할 일인지 먼저 나누는 편이 좋습니다.
- 화면 상단 에러 문구가 보여야 한다 → 상태
- 저장 성공 후 이전 화면으로 돌아가야 한다 → 액션
- 스낵바를 지금 한 번 띄워야 한다 → 액션
- 로딩 스피너를 계속 보여줘야 한다 → 상태
이 기준으로 나누면 ViewModel도 단순해집니다. StateFlow UiState는 화면이 현재 어떤 모습이어야 하는지, SharedFlow UiEvent는 화면이 지금 한 번 수행해야 하는 일을 맡습니다. 핵심 질문은 다시 받으면 버그인가입니다.
SharedFlow를 쓴다고 자동으로 안전해지는 것은 아닙니다. replay 값을 잘못 설정하면 새 collector가 과거 액션을 다시 받을 수 있습니다.
- 새 구독자가 과거 값을 꼭 알아야 한다면 replay를 검토한다
- 새 구독자가 과거 액션을 다시 받으면 안 된다면 보통 replay 0이 더 자연스럽다
Compose에서는 수집 방식도 분리하는 편이 좋다
Compose에서는 지속 상태는 화면을 그리는 재료이므로 collectAsState 계열로 읽고, 일회성 액션은 부수효과 영역에서 별도로 collect하는 편이 자연스럽습니다.
@Composable
fun LoginRoute(
viewModel: LoginViewModel,
onNavigateHome: () -> Unit,
showSnackbar: (String) -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.event.collect { event ->
when (event) {
is LoginEvent.NavigateToHome -> onNavigateHome()
is LoginEvent.ShowSnackbar -> showSnackbar(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은 언제 써야 할까, ViewModel 경계를 먼저 정리하고 싶다면 ViewModel은 어디까지 상태를 가져야 할까도 함께 보면 흐름이 더 선명해집니다.
공식 기준은 Android Developers의 StateFlow and SharedFlow 문서, ViewModel 문서, Compose state 문서를 같이 보면 좋습니다.