|

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

안드로이드 UI state와 event 차이를 설명하는 대표 이미지
화면 상태와 일회성 액션을 왜 다른 흐름으로 다뤄야 하는지 정리한다

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의 최신 값 유지 특성이 장점이 됩니다.


SharedFlow는 일회성 액션에 더 자연스럽다

반대로 토스트, 스낵바, 네비게이션은 다시 구독한 쪽이 과거 값을 또 받을 필요가 없는 경우가 많습니다. 이미 한 번 처리했으면 끝나야 하기 때문입니다.

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 모습과 이미 소비했는지 여부가 섞입니다. 이때는 대개 상태와 액션을 분리한 쪽이 더 읽기 쉽고 버그도 적습니다.


navigation과 snackbar를 안전하게 모델링하려면

실무에서는 이 값이 지금 화면의 모습인지, 아니면 지금 한 번 실행할 일인지 먼저 나누는 편이 좋습니다.

  • 화면 상단 에러 문구가 보여야 한다 → 상태
  • 저장 성공 후 이전 화면으로 돌아가야 한다 → 액션
  • 스낵바를 지금 한 번 띄워야 한다 → 액션
  • 로딩 스피너를 계속 보여줘야 한다 → 상태

이 기준으로 나누면 ViewModel도 단순해집니다. StateFlow UiState는 화면이 현재 어떤 모습이어야 하는지, SharedFlow UiEvent는 화면이 지금 한 번 수행해야 하는 일을 맡습니다. 핵심 질문은 다시 받으면 버그인가입니다.


SharedFlow에서도 replay를 잘못 주면 같은 문제를 만든다

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


실무 판단 체크리스트

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

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

함께보면 좋은 글