|

StateFlow와 SharedFlow 차이: 안드로이드에서 상태와 이벤트를 왜 나눠야 할까

StateFlow와 SharedFlow 차이를 설명하는 안드로이드 대표 이미지
화면 상태와 일회성 이벤트를 왜 다른 흐름으로 다뤄야 하는지 정리한다

StateFlow와 SharedFlow 차이는 API 모양보다 먼저 역할로 이해해야 합니다. 안드로이드에서 이 둘을 같은 용도로 쓰기 시작하면, 토스트가 두 번 뜨고 네비게이션이 다시 실행되는 식의 버그가 쉽게 생깁니다.

결론부터 말하면 화면이 계속 보여줘야 하는 상태는 StateFlow가 더 잘 맞고, 한 번 처리하고 끝나야 하는 이벤트는 SharedFlow가 더 잘 맞습니다. 핵심은 값의 종류가 아니라 UI가 그 값을 어떻게 소비해야 하는가입니다.


StateFlow와 SharedFlow 차이를 이해하려면 먼저 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의 최신 값 유지 특성이 장점이 됩니다.


SharedFlow는 one-time event를 흘려보낼 때 더 자연스럽다

반대로 토스트, 스낵바, 네비게이션은 다시 구독한 쪽이 과거 값을 또 받을 필요가 없는 경우가 많습니다. 로그인 성공 후 홈으로 이동하라는 신호는 한 번 실행되면 끝나는 일이기 때문입니다.

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를 잘못 주면 같은 문제를 다시 만든다

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`는 토스트나 네비게이션 같은 부수효과로 분리됩니다. 코드만 봐도 어떤 값이 상태이고 어떤 값이 이벤트인지 드러납니다.


실무 판단 체크리스트: 다시 받으면 버그인가

  1. 새 collector가 붙었을 때 최신 값을 즉시 받아야 하는가
  2. 이 값은 지금 화면의 모습을 설명하는가
  3. 한 번 처리한 뒤에는 다시 전달되면 안 되는가
  4. 재구독이나 화면 재진입 때 다시 실행되면 버그인가

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 문서를 같이 보면 좋습니다.

함께보면 좋은 글