안드로이드 아키텍처 용어 사전
MVVMSharedFlow

일회성 이벤트

토스트·네비게이션처럼 한 번만 소비돼야 하는 신호. 상태가 아니라 별도 스트림으로 보낸다.

토스트 · 네비게이션 · 스낵바처럼 한 번만 소비돼야 하는 신호.

상태로 두면 왜 안 되나

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하므로 재구독 시 다시 받는다.
  • collectlifecycleScope.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 이 이동을 결정해야 하는 경우에만 이벤트로 올린다

  • (제출 성공 후 이동, 인증 만료 시 로그인 화면으로)

함께 보면 좋은 용어

노트에서 맥락과 함께 보기 — MVVM·단방향 데이터 흐름(UDF)