개발/안드로이드

MVI의 이해

스몰스테핑 2024. 8. 9. 15:42

1. MVI란?

Model, View, Intent의 앞글자를 따와 만든 아키텍쳐 패턴을 일컫는다.

GUI 프로그래밍에서 주로 언급되는 패턴이며 세 가지의 키워드로 나누는 것도 일종의 관심사의 분리를 위한 것이다.

 

특히 UI와 연관된 것에 한정하여 관심사를 분리한 것이다.

  • Model: UI의 상태(State)
  • View: View, Compose 등
  • Intent: 의도, 사용자의 액션 또는 이벤트.

안드로이드에서의 Intent와는 다른 개념의 Intent이다.

이 Intent는 어떠한 의도를 나타내며, 사용자가 화면을 클릭하여 데이터를 로딩하거나 다른 화면으로 이동하는 그러한 의도를 말한다.

 

2. MVI는 순수함수

그래서 MVI의 가장 큰 특징은 순수함수 사이클 형태를 갖는다는 것이다.

view(model(intent()))

 

intent() 함수의 호출 결과가 model() 함수의 인수로 전달되고 model() 함수의 결과가 view()의 인수로 전달된다.

이를 다르게 표현하면 다음과 같다.

 

사용자가 화면에 나타난 버튼을 클릭하면 어떤 목록을 보여준다고 가정하자.

User가 버튼을 클릭하면 해당 버튼은 목록을 불러오는 의도를 갖는다.

이 Intent가 View에 반영된 Model을 만들어낸다.

 

이는 Compose의 단방향 데이터 흐름 철학과 같다. 그렇기에 MVI 패턴은 Compose와 궁합이 잘 맞는 편이다.

 

3. MVVM과 MVI

 

MVI는 사실 MVVM과 다른 새로운 패턴이 아닌 더 심화된 패턴이라고 보면 된다.

위 다이어그램은 MVVM의 세 가지 계층과 관점을 볼 수 있다.

그 MVVM 내 View Model과 View 계층을 보면 MVI를 확인할 수 있다. 그래서 MVVM 내에 MVI가 속한다고 볼 수 있다.

 

4. State Reducer

Reducer = (State, Event) -> State
  • MVI에서 상태는 불변해야 한다.
  • 새로운 이벤트는 기존 상태와 함께 새로운 상태를 만든다.
  • 상태 관리를 한 곳에서 할 수 있다.

MVI 에서는 상태관리에 집중하고 순수 함수 사이클을 지향하기 때문에 외부요소로부터 상태가 변경되지 않기 위해 상태를 불변하게 하는 것이 첫 번째 특징이다.

 

사용자의 조작, 시스템의 이벤트가 발생하면 어떠한 의도를 가지고 로직을 수행해서 새로운 상태를 만들게 된다. 이 상태를 만드는 로직의 집합이 State Reducer라고 한다. Reducer는 달리 말하자면, Transformer라고도 불린다.

 

또한, 상태를 한 곳에서 볼 수 있기 때문에 디버깅이 쉬워진다.

 

5. MVI 예제 코드

5-1. 기본 예제

sealed class Event {
    object Increment : Event()
    object Decrement : Event()
}

data class State(val counter: Int = 0)

class ViewModel {
    val state = MutableStateFlow(State())
    fun handleEvent(event: Event) {
        when (event) {
            is Increment -> state.update { it.copy(counter = it.counter + 1) }
            is Decrement -> state.update { it.copy(counter = it.counter - 1) }
        }
    }
}

 

단순히 Event에 따라서 Counter를 1씩 증감시키는 예제이다.

Intent에 해당하는 sealed class인 Event가 존재한다.

State 클래스는 MVI에서 Model에 해당하는 부분이다.

 

handleEvent는 Intent 함수를 호출하는 트리거가 된다.

이후, ViewModel이 가지고 있는 State를 바꾸게 된다.

 

현재 예제 코드는 update를 통해 상태를 변경하기 때문에 쓰레드에 안전하지만, 여전히 여러 쓰레드로부터 접근이 가능하다. 이벤트 처리 순서를 보장 받지 못하고 있기 때문에 방법을 강구해야한다.

 

5-2. Channl 사용

class ViewModel {
    private val events = Channel<Event>()
    val state = MutableStateFlow(State()) // 외부에서 상태 변경 가능
    
    init {
        events.receiveAsFlow().onEach(::updateState).launchIn(viewModelScope)
    }

    fun handleEvent(event: Event) { events.trySend(event) }
    
    private fun updateState(event: Event) {
        when (event) {
            is Increment -> state.update { it.copy(counter = it.counter + 1) }
            is Decrement -> state.update { it.copy(counter = it.counter - 1) }
        }
    }
}

 

여기에 Channel을 도입하여 이벤트를 순차적으로 처리한다.

하지만 MVI는 순수 함수를 지향한다고 했지만, 지금은 handleEvent를 호출하지 않아도 어딘가에서 state에 접근하여 상태를 직접 바꿀 수 있는 상태이다.

 

순수 함수는 결국, 오직 함수의 입력만이 함수의 결과에 영향을 주어야한다.

 

지금은 그렇지 못한 상태이므로 방법을 강구해야한다.

 

5-3. runningFold 사용

class ViewModel {
    private val events = Channel<Event>()
    val state = events.receiveAsFlow()
    .runningFold(State(), ::reduceState) // State Reducer
    .stateIn(viewModelScope, Eagerly, State())
    
    fun handleEvent(event: Event) { events.trySend(event) }

    private fun reduceState(currentState: State, event: Event): State {
        return when (event) {
            is Increment -> currentState.copy(counter = currentState.counter + 1)
            is Decrement -> currentState.copy(counter = currentState.counter - 1)
        }
    }
}

 

Kotlin의 runningFold라는 메서드를 사용한 방법이다.

이 running Fold는 주어진 Event와 상태를 통해 새로운 상태를 만들어내는 간단한 State Reducer이다.

 

매번 ViewModel을 만들때마다 기본적인 로직을 포함하는 보일러 플레이트를 감소시키기 위해 잘 만들어지고 잘 알려진 외부 라이브러리를 사용하는 것도 좋다.

 

5-4. Orbit 라이브러리 사용

Orbit (MVI 라이브러리) 로고

 

https://orbit-mvi.org/

 

Orbit Multiplatform | Orbit

Logo

orbit-mvi.org

 

MVI 구현을 도와주고, 보일러 플레이트 감소를 시킨다. 또한, Orbit은 kotlin multi platform을 지원한다.

유명한 MVI 라이브러리이며, MVI에 대한 기본적인 이해만 있다면 Orbit 사용 자체는 어렵지 않다.

 

 

사용법에 대해서는 들어가자마자 문서를 통해 정리되어있으므로 해당 문서를 참고하는 것이 좋다.

 

6. MVI 장점

  • 상태 관리가 쉽다
  • 단방향 데이터 흐름 (Unidirectional Data Flow)
  • 스레드 안정성 보장
  • 디버깅이 쉽다
  • 테스트가 쉽다

 

7. MVI 단점

  • 배우기 어렵다 (원리를 이해하기 위해 알아야할 사전 지식이 많다)
  • 보일러플레이트 코드가 많다 (Orbit 같은 라이브러리로 해결 가능)
  • 파일 및 메모리 관리가 어렵다

 

8. Side Effect

Side Effect란, 함수 외부의 요소 또는 상태를 변경하는 것을 일컫는다.

 

 

아까 본 MVI 다이어그램에 Side Effect 개념을 붙이면 이렇게 표현 가능하다.

 

애플리케이션을 만들다 보면 Intent가 새로운 UI 상태를 만들어서 View에 보여주는데, 이렇게까지 할 필요 없는 경우가 존재한다. 예를 들어, 다이얼로그 노출, 새로운 액티비티 호출, 구글 애널리틱스로 로그 전송, 토스트로 메시지 일시 노출 등..

이러한 경우는 UI, View에 영향을 미치지 않을 수 있기 때문에 그러한 경우 Side Effect라는 개념을 사용한다.

 

실제 지금 보여지는 화면의 상태와는 크게 중요하지 않는 내용들을 처리할 때 사용한다고 보면 된다.

MVI 라이브러리인 Orbit은 기본적으로 Side Effect를 핸들링하도록 설계가 되어 있다.

 

  • 안드로이드에서 순수함수로만 앱을 구성하기는 어렵다.
  • Side Effect는 일반적으로 네비게이션, 로깅, 분석, 토스트 노출 등 일회성 이벤트를 처리할 때 필요하다.
  • Side Effect 처리 결과는 선택적으로 UI 상태를 변경할 수 있다.