-
MVI 디자인 패턴Design Pattern 2025. 5. 18. 22:54
안녕하세요~ 오늘은 디자인 패턴 중 MVI 패턴에 대해 알아보겠습니다.
MVI 패턴이란?
MVI 는 Model + View + Intent 를 포함하는 아키텍처 패턴을 의미합니다. GUI 프로그래밍에서 주로 언급되는 패턴이고, 3개의 키워드로 나누는 것은 관심사 분리를 위한 것입니다. Model 은 UI의 상태를, View 는 UI 를 의미합니다. 안드로이드 뷰, 컴포즈가 이에 해당합니다. Intent 는 사용자가 화면을 클릭해서 데이터를 로딩하거나 다른 화면으로 이동하는 의도를 의미합니다. 안드로이드 컴포넌트를 실행하는 인텐트와는 다른 개념입니다.
MVI 는 순수함수 사이클을 갖습니다. Intent 함수의 호출 결과가 model 함수의 파라미터로 전달되고, model 함수의 호출 결과가 view 의 인수로 전달됩니다.

MVI 의 단방향 데이터 흐름 예를 들어 사용자(user)가 버튼을 클릭하면 어떤 데이터를 불러오는 의도(intent)를 가질 때, 모델을 만들고 이를 뷰에 반영합니다. 이때 데이터는 단방향으로 흐르게 되는데 컴포즈의 단방향 데이터 흐름과 유사한 개념입니다.
MVVM 과 MVI 패턴

MVVM과 MVI MVI 패턴은 MVVM 패턴과 완전히 다른게 아니라 한 단계 심화된 개념이라고 볼 수 있습니다. 위에 보시는 MVVM 패턴 내에서 MVI 가 속한다고 볼 수 있습니다. 이때, Intent 나 Model 은 MVVM 의 ViewModel 에 속합니다.
State Reducer 의미

MVI 에서는 인텐트 함수 호출의 결과로 새로운 모델을 만들게 됩니다. 뷰에다가 보여줄 새로운 상태를 만들어야 하는데, MVI 에서는 UI 상태 관리에 집중하고 순수 함수 사이클을 지향하기 때문에 외부 요소로부터 상태가 변경되지 않기 위해서 상태를 불변하게 생성하는게 특징입니다. 사용자의 UI 조작(클릭,터치)이나 시스템 이벤트가 발생하면 인텐트(의도)를 가지고 로직을 수행해서 새로운 상태를 만들게 됩니다. 이때, State Reducer 는 새로운 상태를 만드는 로직 집합입니다. Reducer 는 변환기로서 기존 상태를 새로운 상태로 변환합니다. 이는 상태를 한 곳에서 관리하여 디버깅이 용이해집니다.
MVI 예제 코드
// Intent 에 해당 sealed class Event { object Increment: Event() object Decrement: Event() } data class State(val counter: Int = 0) // Model 에 해당 class ViewModel { val state = MutableStateFlow(State()) fun handleEvent(event: Event) { // intent 를 호출하는 트리거 when(event) { is Increment -> state.update {it.copy(counter = it.counter + 1)} is Decrement -> state.update {it.copy(counter = it.counter -1)} } } }Event 에 따라서 카운트를 1씩 증감시키는 예제입니다. 인텐트 함수의 호출로 모델을 변경하고 최종적으로 뷰에 반영합니다. update 메소드를 통해 상태 변경을 하고, handleEvent 는 여러 쓰레드에서 접근이 가능합니다. 이때, 이벤트 처리 순서를 보장받지 못합니다. 이를 위해 채널을 도입할 수 있습니다.
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)} } } }채널을 도입하여 이벤트를 순차적으로 처리할 수 있습니다. MVI 는 순수 함수를 지향하여 오직 함수의 입력만이 결과에 영향을 줄 수 있습니다. 하지만 위에서는 handleEvent 를 호출하지 않아도 다른 어딘가에서 state 에 접근하여 바꿀 수 있다는 한계가 있습니다.
class ViewModel { private val events = Channel<Event>() val state = events.receiveAsFLow() .runningFold(State(), ::reduceState) .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) } } }코틀린의 runningFold 메소드는 주어진 이벤트와 상태를 통해서 새로운 상태를 만들어내는 state reducer 입니다. event 가 채널로 들어오면 reduceState 메소드가 호출되고, 이벤트에 따라 새로운 상태를 반환합니다. 상태가 변경이 되면 상태를 콜렉팅하는 안드로이드 뷰나 컴포즈가 렌더링됩니다. 하지만, 매번 뷰모델을 만들 때마다 보일러 플레이트 코드를 만들게 됩니다.
Orbit 라이브러리
Orbit 은 MVI 구현을 도와주고 보일러 플레이트를 줄여주는 라이브러리입니다. 코틀린 멀티플랫폼도 지원을 합니다. MVI 에 대한 기본적인 이해가 있으면 Orbit 을 사용할 수 있습니다.
MVI 패턴의 장단점
MVI 패턴의 장점은 상태 관리가 쉽고, 단뱡향 데이터 흐름을 가져서 데이터나 이벤트를 예측하기가 쉬워지고 디버깅이 수월합니다. 또한, 스레드 안정성을 보장하고 테스트가 쉽습니다. MVI 패턴의 단점은 러닝 커브가 가파릅니다. MVI 을 이해하기 위해서는 알아야 할 배경 지식이 많습니다. 보일러플레이트 코드가 많지만, 이는 orbit 같은 라이브러리로 어느정도 해소할 수 있습니다. 이벤트가 발생할 때마다 새로운 상태를 만드는 것은 객체를 생성하기 위해 새로운 메모리를 할당한다는 것을 의미합니다. 짧은 시간 내에 반복적으로 메모리를 할당하고 접근하면 성능이 떨어지고, 시스템에 무리를 줘 소켓이나 파일같이 스트림을 다루는 영역에서는 MVI 구현이 앱의 성능을 저하시킬 수 있습니다.
Side Effect 란?
MVI 순수함수 사이클이 모든 경우에 딱 들어맞지는 않기에 어떠한 문제가 생길 수 있는데, 이를 해결하기 위한 개념이 Side Effect 입니다. 이는 함수의 외부 요소 또는 상태를 변경하는 것을 의미합니다.

side effect 앱을 만들 때 인텐트가 새로운 UI 의 상태를 만들어서 뷰에 보여주는 경우가 필요하지 않는 경우가 있습니다. 예를 들어 다이얼로그를 노출하거나, 새로운 액티비티 띄우거나, 구글 애널리틱스로 로그를 전송하거나, 토스트 메세지를 일시적 노출하는 것처럼 일회성 이벤트 같은 경우는 UI 뷰에 영향을 크게 미치지 않습니다. 이처럼 실제 보여주는 화면의 상태와는 중요하지 않는 내용을 처리할 때 side effect 를 사용합니다. 이때, MVI 라이브러리인 orbit 은 side effect 을 헨들링하도록 설계가 되어있습니다.
오늘은 디자인 패턴 중 MVI 패턴에 대해 알아보았습니다. MVVM 패턴에 비교적 익숙한 저로선 MVI 패턴은 많이 생소하고 어려운 개념이네요. 꾸준히 반복 학습하면서 점차 익숙해져갔으면 좋겠습니다.