본문 바로가기
Android/Architecture

[Android] MVI 패턴이란?

by 태크민 2025. 2. 28.

MVI란 무엇인가?

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

 

Model: UI에 반영될 상태를 의미한다. 그러므로 MVP 또는 MVVM 모델의 정의와는 다르다.

View: UI 그 자체이다. View, Activity, Fragment, Compose 등이 될 수 있다.

Intent: 사용자 액션 및 시스템 이벤트

 


MVI 왜 등장했을까? (MVVM과 비교)

MVVM의 문제점

1. 상태 관리의 복잡성

  • ViewModel에서 여러 개의 LiveData 또는 StateFlow를 사용하여 상태를 관리해야 한다.
  • 이로 인해 관리해야 하는 상태가 많아져 유지보수가 어려워진다.

2. 양방향 데이터 흐름으로 인한 상태 충돌 가능성

  • View와 ViewModel이 동시에 상태를 변경할 수 있어 예기치 않은 버그가 발생할 가능성이 있다.
  • 예를 들어, 사용자가 입력한 값이 UI에 반영되기도 전에 ViewModel에서 다른 값으로 업데이트되면, 어떤 값이 최종적으로 반영될지 예측하기 어려워진다
  • 특히, DataBinding을 사용할 경우 View에서도 값을 직접 바인딩 (양방향 데이터 바인딩) 할 수 있어, 유지보수 시 혼란이 발생할 수 있다.

3. 디버깅의 어려움

  • 데이터가 View와 ViewModel 사이에서 양방향으로 흐르기 때문에, 특정 상태가 변경되는 원인을 추적하기 어려울 수 있다.

4. UI 업데이트 로직의 분산

  • UI를 업데이트하는 코드가 ViewModel, XML(바인딩), BindingAdapter 등 여러 곳에 퍼져 있어 가독성이 떨어지고 유지보수가 어려워진다.

MVI의 등장

위와 같은 문제를 해결하기 위해 MVI(Model-View-Intent) 아키텍처가 등장했다.
MVI는 상태를 단일 소스로 관리하고, Unidirectional Data Flow(단방향 데이터 흐름)을 따르는 것에 초점을 맞춘다.

1. 단일 상태(State) 관리

  • MVI에서는 State라는 단 하나의 데이터 구조를 사용하여 모든 UI 상태를 관리한다.
  • 여러 개의 LiveData 또는 StateFlow를 관리할 필요 없이, 단일 State 객체만 추적하면 되므로 코드가 간결해지고 가독성이 높아진다.

2. 단방향 데이터 흐름 (Unidirectional Data Flow)

  • View->Intent->State->View 순으로 단방향으로 데이터가 흐르기 때문에 디버깅이 용이하다.
  • 단방향 데이터 흐름 덕분에 상태 충돌 문제를 방지할 수 있다. 여러 곳에서 동시에 상태를 변경하는 일이 없으므로, 상태 충돌 문제를 해결할 수 있다.
  • 뷰모델로의 직접적 호출이 아니라 인텐트에 기반하기 때문에 좀 더 느슨한 결합을 유지한다.

MVVM vs MVI: 데이터 흐름 비교

MVVM의 데이터 흐름(양방향)

1. View → ViewModel (UI 데이터 변경)
2. ViewModel → View (UI 데이터 변경)

데이터는 View와 ViewModel 사이에서 자유롭게 변경이 가능 (양방향).

 

문제점

  • 양방향 데이터 흐름: View와 ViewModel 간의 데이터 교환이 많아 디버깅이 어렵다.
  • 상태 충돌 가능성: View 와 ViewModel이 동시에 상태를 변경할 수 있다.

 

MVI의 데이터 흐름(단방향)

1. View → Intent (사용자 이벤트 전달)
2. Intent → State (ViewModel에서 상태 업데이트)
3. State → View (View에서 상태를 구독하여 렌더링)

데이터는 항상 한 방향으로만 흐름 (단방향)

 

개선

  • 데이터는 항상 한 방향으로만 흐름 (단방향).
  • 디버깅이 쉬워지고, 상태 충돌 가능성이 줄어듦.

Compose는 왜 MVVM보다 MVI에 적합할까?

Compose는 선언형 UI 프레임워크로, 상태 변경 시 UI가 자동으로 업데이트된다. 또한, 단일 방향 데이터 흐름(UDF)을 지향하므로, 명확하고 예측 가능 상태 관리를 제공하는 MVI가 MVVM보다 더 적합한 패턴이다.

 


MVI는 순수함수형태다.

MVI의 가장 큰 특징은 오직 함수의 입력만이 함수의 결과에 영향을 주는 순수함수 사이클 형태를 갖는다는 것이다.

Model, View, Intent의 상관관계는 다음과 같이 순수함수 형식으로 표현된다.

 

예를 들어보자면, 사용자가 View를 클릭해서 화면 목록을 갱신하고자 한다. 목록을 갱신하고자 하는 의도(Intent)가 결국 새로운 모델, 즉 상태를 업데이트 하게 되고 이것이 View(일반적으로 Compose)에 반영된다.

즉,상태를 변경하려면 Intent(Action)을 발생시켜야하며, 이는 불변성을 유지하여 상태 변경의 예측 가능성을 높인다.

 

MVI는 단방향 흐름(Uni-directional flow) 구조다. 데이터 흐름이 단방향으로 흐르기 때문에 추적이 쉽다.

그렇기 때문에 다음 그림처럼 표현하기도 한다.

 

사용자의 액션이 새로운 Intent로 변경되고, 해당 Intent로 부터 새로운 Model을 만들어 View를 갱신하는 흐름을 보여준다.

 

만약 당신이 MVVM을 사용해왔다면, MVI는 전혀 새로운 것이 아니며 이를 위해 전체를 변경할 필요는 없다. VM(View Model)은 상태가 관리되기 위해 좋은 장소이며 다이어그램으로 나타내자면 다음과 같다.

 

위 그림은 MVVM 계층 관점에서 MVI가 어느 계층에 속하는지 보여준다.

 


MVI는 불변의 상태를 갖는다.

MVI는 순수함수의 형태를 갖고 있기 때문에, 외부의 영향으로 상태가 바뀌지 않고 새 이벤트가 발생하면 기존 상태와 더불어 새 상태를 만든다.

즉, MVI에서 Model은 상태를 표현하는 변경 불가한 데이터다. 앱의 상태는 단방향 흐름에서 Intent로부터 Model을 생성할 때만 새로운 Model 객체를 생성한다. 이러한 구조로 인해 우리는 예측가능하도록 상태를 설정할 수 있고 이로 인해 디버깅이 쉬워진다.

 

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

Reducer = (State, Event) -> State

 

상태 변경은 순수 함수인 Reducer를 통해 이루어진다. 리듀서는 이전 상태와 액션을 받아 새로운 상태를 반환하며, 부작용 없이 상태를 관리한다.

 

정리하자면, 아래와 같다.

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

만약 불변이 아닌, Mutable Model을 사용하면 생기는 문제

안드로이드에서 mutable Model을 사용하면 다음과 같은 문제가 발생할 수 있다:

  • Thread-Safety 문제: mutable Model은 변경 가능한 상태를 가지고 있기 때문에, 멀티 스레딩 환경에서 동시에 접근하면 예기치 않은 결과가 발생할 수 있다.
  • 예측 불가능한 동작: mutable Model을 사용하면 객체의 상태가 언제든지 변경될 수 있기 때문에, 이전에 예측한 동작과 달라질 수 있다.
  • 테스트 어려움: mutable Model을 사용하면 테스트하기 어려울 수 있습니다. 왜냐하면 테스트 케이스에서 모델의 상태를 일관되게 유지하기 어렵다.


MVI로 상태 불변성 유지 하기

기본 예제

다음 예제코드는 Event 발생 -> State 변경 -> View 반영 순서로 MVI 패턴을 구현하고 있다.

Event는 Increment와 Decrement로 나뉘며 이는 각각 State.count 값을 1씩 증가 또는 감소 시킨다.

class ViewModel(){

    private val _state = MutableStateFlow(State())
    val state:StateFlow<State> = _state

    override suspend fun handleEvent(event: Event) {
        when (event) {
            is Event.Increment -> {
                _state.value = _state.value.copy(count = _state.value.count + 1)
            }
            is Event.Decrement -> {
                _state.value = _state.value.copy(count = _state.value.count - 1)
            }
        }
    }
}

 

하지만 이 코드는 스레드에 안전하지 않다. handleEvent가 서로 다른 스레드에서 호출될 경우 동시성 이슈가 발생하기 때문이다.

실제 서로 다른 스레드에서 handleEvent(Increment)와 handleEvent(Decrement)를 10만번씩 호출해보면 0이 아닌 값이 나올 확률이 높다. 

 

따라서 위의 코드를 update{..}를 통해 상태를 변경하면 동시성 오류를 해결할 수 있다.

class ViewModel(){

    private val _state = MutableStateFlow(State())
    val state:StateFlow<State> = _state

    override suspend fun handleEvent(event: Event) {
        when (event) {
            is Event.Increment -> {
                _state.update { it.copy(counter = it.counter + 1) }
            }
            is Event.Decrement -> {
                _state.update { it.copy(counter = it.counter - 1) }
            }
        }
    }
}

 

하지만, 이벤트(Event)가 발생한 순서대로 상태가 변경된다는  보장은 없다. 

즉, 연속적으로 이벤트가 들어왔을 때 이벤트 처리 순서를 보장 못한다는 문제가 있다.

 

Channel 사용

동시성 오류를 회피하고, 이벤트를 순차적으로 처리하기 위해 Channel을 사용할 수 있다.

다음은 Channel을 사용한 예제 코드이다.

class ViewModel {

    private val events = Channel<Event>()

    private val _state = MutableStateFlow(State())

    val state:StateFlow<State> = MutableStateFlow(State())

    init {
        events.receiveAsFlow()
            .onEach(::updateState)
            .launchIn(viewModelScope)
    }

    override suspend fun handleEvent(event: Event) {
        events.send(event)
    }

    private fun updateState(event: Event) {
        when (event) {
            is Event.Increment -> {
                _state.value = _state.value.copy(count = _state.value.count + 1)
            }
            is Event.Decrement -> {
                _state.value = _state.value.copy(count = _state.value.count - 1)
            }
        }
    }
}

 

Channel을 도입하여 이벤트를 순차적으로 처리하게 끔 변경했다. 이로 인해 스레드 안정성이 보장된다.

하지만 MVI의 순수 함수형태를 위배하는 문제가 발생하는데, updateState 함수 밖에서도 _state를 변경할 수 있는 상태가 된다.

 

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

 

State Reducer 사용

Reducer란 현재의 상태와 전달 받은 이벤트를 참고하여 새로운 상태를 만드는 것을 말한다.

kotlin에서 제공하는 state reducer가 runningFold인데, runningFold가 하는 역할은 누적된 값을 계산하면서 현재 값과 새로운 값을 결합해나가는 것이다.

 

Reducer를 통해 위에서 가지고 있던 문제점을 해결해보자.

class ViewModel {

    private val events = Channel<Event>()

    // State Reducer
    val state = events.receiveAsFlow()
        .runningFold(State(), ::reduceState)
        .stateIn(viewModelScope, SharingStarted.Eagerly, State())

    override suspend fun handleEvent(event: Event) {
        events.send(event)
    }

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

 

이벤트 채널로부터 상태를 변경하기 때문에 이제 더 이상 외부에서 상태를 변경할 수 있는 요인은 없다. 상태 관리를 한곳에서 할 수 있게 되면서 Race Condition을 배제 시키고, 상태를 예측하기 쉽고, 디버깅을 수월하게 할 수 있게 되었다.

 


MVI 장단점 정리

지금까지 설명한 내용으로 장단점을 정리해보자면 다음과 같다.

장점

  • 상태 관리가 쉽다
  • 데이터가 단방향으로 흐른다.
  • 스레드 안정성을 보장한다.
  • 디버깅 및 테스트가 쉽다

단점

  • 러닝커브가 가파르다 (원리를 이해하기 위해 알아야할 사전 지식이 많다)
  • 보일러플레이트 코드가 양산된다. ( https://orbit-mvi.org/ 같은 라이브러리로 해결 가능)
  • Intent, State, Side Effect 등 모든 상태에 대한 객체를 생성해야 하므로 파일 및 메모리 관리에 유의해야 한다.


Side Effects

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

 

실세계에서 View(Model(Intent())) 순수함수 구조로만 잘 순환하길 기대하지만 현실은 그렇지 못하다. 간혹 상태를 변경할 필요가 없는 이벤트가 필요할 수도 있기 때문이다.

예를 들면 Activity/Fragment 이동, Logging, Analytics, 토스트 노출 등이 그에 해당한다.

이러한 경우는 UI, View에 영향을 미치지 않을 수 있기 때문에, MVI를 언급할 때 일반적으로 Side Effects(부수효과)라는 개념을 써서 이를 처리 한다. 

 

앞서 사용했던

예제 코드를 예시로 사용자 수(users.size)를 토스트로 노출 하는 Side Effect를 만들어 보자.

@HiltViewModel
class MainViewModel @Inject constructor(
    private val repository: MainRepository
) : ViewModel() {

    private val events = Channel<MainEvent>()

    val state: StateFlow<MainState> = events.receiveAsFlow()
        .runningFold(MainState(), ::reduceState)
        .stateIn(viewModelScope, SharingStarted.Eagerly, MainState())

    private val _sideEffects = Channel<String>() // 사이드 이펙트 처리용 채널

    val sideEffects = _sideEffects.receiveAsFlow()

    private fun reduceState(current: MainState,event:MainEvent):MainState{
        return when(event){
            MainEvent.Loading -> {
                current.copy(loading = true)
            }
            is MainEvent.Loaded -> {
                current.copy(loading = false, users = event.users)
            }
        }
    }

    fun fetchUser() {
        viewModelScope.launch {
            events.send(MainEvent.Loading)
            val users = repository.getUsers()
            events.send(MainEvent.Loaded(users = users))
            _sideEffects.send("${users.size} user(s) loaded") // 사이드 이펙트 발생
        }
    }

}

 

정리하자면, 아래와 같다.

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

 

 

끝.


참고자료

https://charlezz.com/?p=46365

 

Android 프로젝트에 MVI 도입하기 | 찰스의 안드로이드

MVI 도입배경 프로젝트에 Jetpack Compose를 도입하고 1년정도 적극 쓰면서 '상태' 관리의 중요성을 머리가 아닌 몸으로 느껴버렸다. 상태 관리를 어떻게 하면 좋을까 고민하던 중 동료 개발자가 이전

charlezz.com

https://small-stepping.tistory.com/1137

 

MVI의 이해

1. MVI란?Model, View, Intent의 앞글자를 따와 만든 아키텍쳐 패턴을 일컫는다.GUI 프로그래밍에서 주로 언급되는 패턴이며 세 가지의 키워드로 나누는 것도 일종의 관심사의 분리를 위한 것이다. 특

small-stepping.tistory.com

https://kimmandooo.tistory.com/170

 

MVI - Model, View, Intent 입문하기

이제 컴포즈의 흐름을 피해갈 수 없다. 제대로 입문하기 위해 MVI 패턴먼저 공부하고, 시작하려고 한다.# MVI?MVI는 Model-View-Intent로, Model은 UI의 상태, View는 UI, Intent는 사용자와의 상호작용이나 다

kimmandooo.tistory.com

https://haeti.palms.blog/mvi

 

[Android] MVI 패턴에 대한 고찰

MVI 패턴에 대해 정리하고, 다른 선언형 UI 분야에서는 어떻게 상태를 관리하는지 알아보았습니다.

haeti.palms.blog

https://velog.io/@moonliam_/Android-MVI-Pattern-1

 

[Android] MVI Pattern - (1)

Compose의 복잡한 State 관리를 좀 더 쉽게

velog.io

https://velog.io/@freesky/MVI-pattern

 

MVI 패턴

MVVM은 View 와 ViewModel 간의 양방향 데이터 흐름을 허용합니다.View에서 발생한 이벤트는 ViewModel로 전달되고, ViewModel에서 LiveData나 StateFlow 를 통해 상태를 업데이트하여 View 로 다시 전달됩니다.이

velog.io