토스트 · 네비게이션 · 스낵바처럼 한 번만 소비돼야 하는 신호.
상태로 두면 왜 안 되나
data class UiState(val toastMessage: String? = null)
화면을 회전한다 → UiState 를 다시 그린다 → 토스트가 또 뜬다 뒤로 갔다 온다 → 또 뜬다
상태는 "지금 화면이 어떤 모습이어야 하나" 다 토스트는 "방금 무슨 일이 있었나" 다 — 다른 것이다
SharedFlow로 보내기
private val _events = MutableSharedFlow<UiEvent>() // replay = 0 이 기본
val events: SharedFlow<UiEvent> = _events.asSharedFlow()
fun onSubmit() = viewModelScope.launch {
runCatching { repository.submit() }
.onSuccess { _events.emit(UiEvent.Navigate("home")) }
.onFailure { _events.emit(UiEvent.ShowToast("실패했습니다")) }
}
// 수집 — 화면이 보이는 동안에만
LaunchedEffect(Unit) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.events.collect { event ->
when (event) {
is UiEvent.ShowToast -> snackbarHost.showSnackbar(event.msg)
is UiEvent.Navigate -> navController.navigate(event.route)
}
}
}
}
SharedFlow vs Channel
SharedFlow (replay = 0)
- 구독자가 없으면 이벤트가 사라진다
- 화면이 백그라운드일 때 emit 하면 유실된다
Channel (receiveAsFlow)
- 구독자가 없으면 버퍼에 쌓였다가 나중에 전달된다
- 구독자가 여럿이면 하나만 받는다 (fan-out 아님)
유실되면 안 되는 이벤트(결제 완료 알림) → Channel 놓쳐도 되는 이벤트(스낵바) → SharedFlow
더 나은 대안 — 이벤트를 상태로 바꿔 표현
// "이동해라" 대신 "이동해야 하는 상태다" 로 모델링하고, 소비하면 지운다
data class UiState(val navigateTo: String? = null)
LaunchedEffect(state.navigateTo) {
state.navigateTo?.let { navController.navigate(it); viewModel.onNavigated() }
}
-
장점 — 구성 변경 · 프로세스 종료를 넘어 안전하다
-
단점 — 소비 처리(onNavigated) 를 잊으면 무한 반복한다
-
팀 규칙으로 하나를 정하는 편이 낫다
면접 함정
- ❌
StateFlow로 이벤트를 보낸다 →StateFlow는 최신 값을 replay하므로 재구독 시 다시 받는다. - ❌
collect를lifecycleScope.launch에 바로 건다 → 백그라운드에서도 수집돼 이벤트가 보이지 않는 화면에서 소비된다.
이벤트가 유실되는 순간을 직접 만들어 보기
-
① 제출 버튼을 누른다 (네트워크 2초)
-
② 사용자가 홈 버튼을 눌러 앱을 내린다
-
③ 2초 뒤 emit(ShowToast) 이 실행된다
-
SharedFlow(replay = 0) + repeatOnLifecycle(STARTED)
- 구독자가 없다 → emit 이 그냥 버려진다 → 사용자는 결과를 영영 모른다
-
Channel
- 버퍼에 남는다 → 앱을 다시 열면 그때 전달된다
Channel로 쓰는 형태
private val _events = Channel<UiEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()
fun onSubmit() = viewModelScope.launch {
val r = repository.submit()
_events.send(if (r.isSuccess) UiEvent.Navigate("home") else UiEvent.ShowToast("실패"))
}
receiveAsFlow() 는 단일 구독만 안전하다
- 구독자가 둘이면 이벤트가 나뉘어 간다 (한쪽만 받는다)
- 화면 하나가 받는 구조에서만 쓴다
화면 이동은 이벤트인가
- 이동을 ViewModel 이 명령한다 — → 이벤트 (Navigate)
- 이동을 UI 가 직접 한다 — → 이벤트가 필요 없다
후자가 더 단순하다
- Button(onClick = { navController.navigate("detail/${item.id}") })
ViewModel 이 이동을 결정해야 하는 경우에만 이벤트로 올린다
- (제출 성공 후 이동, 인증 만료 시 로그인 화면으로)