MVI

박재원·2022년 4월 1일
post-thumbnail

MVVM은 많이 해봤으니 MVI도 한번 구현해봅시다.

  • MVVM(생략) vs MVI
    MVVM(Model–View–ViewModel)은 이미 많이 사용되는 아키텍처로, 뷰와 상태를 ViewModel을 통해 연결하는 구조다.
    하지만 복잡한 비즈니스 로직이나 상태 동기화 문제가 커질수록 상태 일관성을 유지하기가 어려워진다.
    이에 반해 MVI(Model–View–Intent)단방향 데이터 흐름(Unidirectional Data Flow)을 기반으로 하여,
    모든 상태 변화를 명확한 Intent → Result → State의 순서로 제어한다는 점이 핵심이다.
    즉, MVI는 MVVM을 완전히 대체하기보다는,
    “상태 충돌이나 중복 이벤트 처리 등 복잡한 상태 관리 문제를 함수형 패러다임으로 해결하기 위한 진화된 형태”라고 볼 수 있다.

MVI 주요 구성요소

1. Intent(사용자의 의도)

Intent는 사용자가 수행하고자 하는 액션을 표현한다.
즉, “무엇을 하길 원하는가”를 명시적으로 정의한다.

sealed class MviIntent {
    object LoadData : MviIntent()
    data class UpdateData(val newData: String) : MviIntent()
}

ViewModel에서는 이 Intent를 수신해 실제 로직을 수행한다.

fun processIntent(intent: MviIntent) {
    when (intent) {
        is MviIntent.LoadData -> loadData()
        is MviIntent.UpdateData -> updateData(intent.newData)
    }
}

2. Result & Reducer (결과와 상태 변환)

Intent가 처리되면, 그 결과를 MviResult 형태로 표현한다.
이 Result는 ViewModel 내부에서 Reducer라는 순수 함수에 의해 새로운 ViewState로 변환된다.

Reducer는 다음과 같은 특징을 가진다:

  • 순수 함수(Pure Function): 같은 입력에 대해 항상 같은 결과를 반환한다.

  • 부수 효과(Side Effect) 없음: 네트워크 호출, 로그 출력 등 외부 상태를 변경하지 않는다.

// state
typealias Reducer<State, Result> = (State, Result) -> State

val myReducer: Reducer<MviViewState, MviResult> = { _, result ->
    when (result) {
        is MviResult.Loading -> MviViewState.Loading
        is MviResult.Loaded -> MviViewState.Loaded(result.data)
        is MviResult.Error -> MviViewState.Error(result.message)
    }
}

이렇게 하면 상태 변화는 항상 예측 가능하며, Thread-safe하고 디버깅이 용이하다.

3. ViewState (현재 상태)

ViewState는 UI가 그려질 수 있는 단일 상태(Single Source of Truth)를 표현한다.
이는 화면이 어떤 데이터를 표시해야 하는지를 완전하게 정의한다.

sealed class MviViewState {
    object Idle : MviViewState()
    object Loading : MviViewState()
    data class Loaded(val data: List<Item>) : MviViewState()
    data class Error(val message: String) : MviViewState()
}

private val _state = MutableStateFlow<MviViewState>(MviViewState.Idle)
val state: StateFlow<MviViewState> get() = _state

View는 오직 state를 구독하고, 상태가 변경될 때만 UI를 다시 렌더링한다.

4. SideEffect (일회성 이벤트)

많은 개발자가 혼동하는 부분이 바로 SideEffect이다.
“State로 다 처리 가능한데 왜 SideEffect가 필요한가?”라는 질문이 자주 나온다.

State는 UI를 그리기 위한 순수한 상태를 표현해야 한다.
즉, 상태가 바뀔 때마다 UI는 재구성(Compose Recomposition)된다.

그러나 Snackbar, Toast, Navigation, Dialog 표시와 같은 일회성 이벤트는 상태 기반으로 처리하면 문제가 생긴다.
예를 들어 Error 상태에서 팝업을 띄우면, UI가 재구성될 때마다 계속 팝업이 다시 뜨게 된다.

이러한 문제를 해결하기 위해 MVI에서는 SideEffect 채널을 별도로 둔다

private val _sideEffect = Channel<MviSideEffect>(Channel.BUFFERED)
val sideEffect = _sideEffect.receiveAsFlow()

private fun loadData() {
    viewModelScope.launch {
        when (val result = repository.loadData()) {
            is MviResult.Error -> {
                _sideEffect.send(MviSideEffect.ShowToast(result.message))
            }
            is MviResult.Loaded -> {
                _state.emit(MviViewState.Loaded(result.data))
            }
        }
    }
}
  • State → 지속적으로 반영되어야 하는 UI 상태
  • SideEffect → 일회성으로 소비되어야 하는 이벤트
    이 둘을 분리함으로써, UI의 예측 가능성과 안정성이 높아진다.

결론

  • 장점
    1. 상태 충돌이 없다.(일관된 상태 관리)
    2. 모든 상태 변화가 단방향으로 흐르기 때문에, 예측 가능한 상태 전이가 가능하다.
    3. StateFlow와 Reducer의 조합으로 멀티스레드 환경에서도 안전하게 상태를 관리할 수 있다.
    1. SideEffect를 통해 “1회성 이벤트”를 명확하게 구분함으로써, UI 중복 문제를 방지한다.
    2. 개인적인 생각, MVVM을 사용할때 DataBinding을 주로(거의)사용하여 개발할때, XML 혹은 Fragment, Activity 두곳에 UI 처리가 있어 코드 파편화같은 느낌이 있었는데, MVI는 불변 상태 기반으로 UI를 갱신하기 때문에 XML Binding보다 명확하고 유지보수가 쉽다.
  • 단점
    1. Intent, Result, State, SideEffect 등의 구조가 필수로 생기므로, 초기 설정이 다소 복잡하다.
    1. 간단한 화면에는 MVVM이 더 효율적일 수 있다.(이런 단점이있다는데, 나는 개인적으로 크게 단점이라고 느껴지지 않는다. )
    2. 러닝커브 - 단방향 데이터 플로우와 Reducer 기반 사고에 익숙해지기까지 시간이 필요하다.
profile
Android developer.

0개의 댓글