해당 글의 경우 사내 신규 프로젝트를 MVI 아키텍처로 개발함에 있어 참고했던 영문자료를 번역한 글입니다! 원본은 맨아래의 레퍼런스 부분에 링크가 있습니다! 감사합니다!
Model View Intent는 Redux 순환 패턴에서 영감을 받은 아키텍처 프레젠테이션 패턴입니다. 이는 상태를 관리하고 사용자가 앱과 상호작용하기 위한 의도를 처리하는 반응형이고 예측 가능한 접근 방식을 제공합니다.

Model: 단일 상태와 비즈니스 모델을 나타냅니다. 데이터를 관리하고 사용자 의도에 응답하여 새로운 상태를 생성합니다. 모델은 상태가 불변성을 유지하도록 보장하며 사용자 상호작용을 처리합니다.
View: 사용자 인터페이스를 렌더링하고 현재 뷰 상태를 표시하는 역할을 담당합니다. MVI에서 뷰는 수동적이며 모델로부터 오는 뷰 상태 변화만을 관찰합니다. 뷰는 비즈니스 로직을 포함하지 않으며 오직 사용자에게 정보를 표시하는 데에만 집중합니다.
Intent: 상태 변화를 촉발하는 사용자 액션을 나타냅니다. 이는 버튼 클릭, 텍스트 입력 또는 기타 사용자 상호작용과 같은 액션일 수 있습니다. 인텐트는 뷰에서 모델로 전달되어 사용자가 원하는 액션을 알립니다.
MVI 패턴은 불변성을 강조하여 애플리케이션의 상태가 일관되고 예측 가능하게 유지되도록 합니다. MVI를 사용하면 개발자들은 테스트, 유지보수 및 확장이 더 쉬운 앱을 구축할 수 있습니다. 이 패턴은 관심사의 명확한 분리와 복잡한 상태 관리를 반응형이고 유지보수 가능한 방식으로 처리할 수 있는 능력 때문에 인기를 얻고 있습니다.
안드로이드 컨텍스트에서는 기존 MVVM을 MVI로 마이그레이션하는 것은 상태들을 하나로 그룹화함으로써 간단합니다. 이는 Compose와 잘 맞아 기능 상태를 관리하는데 적합합니다. 또한, 단일 상태를 디버깅하면 속성 변경을 모니터링하고 앱의 동작을 이해하는 데 도움이 됩니다.

여기서는 화면에 아이템 상호작용이 있는 리스트를 표시하기 위한 UserListViewModel에 MVI를 구현하는 예시를 보여줍니다. View는 Action을 처리하기 위해 이와 상호작용합니다. 안드로이드 특정 용어와의 혼동을 피하기 위해 Intent 대신 Action이라는 용어를 사용하고 있습니다.
이 구현은 두 개의 플로우를 노출합니다. 내부적으로, 첫 번째는 현재 상태를 얻기 위한 StateFlow 타입의 ViewState (콜드 플로우)이고, 두 번째는 Channel로 구현된 Event 핫 플로우입니다. 때로는 개발자들이 이를 사이드 이펙트라고 부르기도 합니다.
class UserListViewModel(...): ViewModel() {
private val _viewStateFlow = MutableStateFlow<ViewState>()
private val _eventChannel: Channel<Event> = Channel(BUFFERED)
val viewStateFlow: Flow<ViewState> = _viewStateFlow
val eventFlow: Flow<Event> = _eventChannel.receiveAsFlow()
fun process(action: Action) {
is Action.CheckConnectivity -> checkConnectivity()
is Action.Load -> load(...)
is Action.Reload -> load(...)
is Action.EmailClick -> emailClick(uuid = action.uuid)
is Action.PhoneClick -> phoneClick(uuid = action.uuid)
}
private fun checkConnectivity() { ... }
private fun load(...) { ... }
private fun emailClick(uuid: String) { ... }
private fun phoneClick(uuid: String) { ... }
}
봉인된 상태는 화면 상태를 나타내는 복잡한 접근 방식입니다. 이를 통해 다양한 상태를 보유하는 서브클래스의 폐쇄된 집합을 만들 수 있습니다.
sealed interface ViewState {
data object Loading : ViewState
data object Empty : ViewState
data class Content(
val users: List<UserState>,
) : ViewState
data class Error(
val message: String,
) : ViewState
}
봉인된 인터페이스는 더 복잡한 상태 구조를 가진 기능에 도움이 될 수 있으며, 다양한 상태를 처리할 때 더 나은 구성과 타입 안전성을 제공합니다. 그러나 봉인된 구조는 데이터를 더 접근하기 쉽게 만들고 과도한 데이터 가져오기를 방지하기 위해 캐시를 관리하는 좋은 하위 상태 전환이 필요합니다.
플랫(flat) 상태는 데이터 클래스를 사용하여 직관적으로 표현하는 방식입니다. 이는 균형 잡히고 계층적인 구조에서 화면의 현재 상태를 나타내는 데 필요한 모든 필드를 포함합니다.
data class ViewState(
val isLoaderVisible: Boolean = false,
val isEmptyVisible: Boolean = false,
val isUserListVisible: Boolean = false,
val users: List<UserState> = emptyList(),
val isErrorVisible: Boolean = false,
val errorMessage: String = "",
)
플랫 상태는 이해하고 업데이트하기 쉬우며, 특히 작은 기능이나 비교적 단순한 상태 요구사항을 가진 기능에 적합합니다. 봉인된 하위 상태를 확인하는 것과 달리, 행동 속성을 변경함으로써 뷰에 의해 조작되는 플래그를 관리할 수 있습니다. 그런 다음, 즉시 표시할 수 있는 콘텐츠 데이터를 유지하고 필요할 때 추가로 가져올 수 있습니다.
Model 컴포넌트, 여기서는 ViewModel은 로직, 유스 케이스 또는 레포지토리를 호출하여 액션을 처리하고 상태 변경이나 이벤트를 발생시키는 역할을 담당합니다. 위의 코드 스니펫은 액션 중 하나에 대한 부분적인 구현을 보여주며, 이미 100줄 이상의 코드가 있습니다. 더 많은 액션이 추가되면 UserListViewModel은 관리하고 유지보수하기 어려워집니다.
첫 번째 문서는 소스 코드입니다. ViewModel은 간결하고 명시적이여야합니다. 테스트 클래스도 마찬가지로 무거워질 수 있습니다.
class UserListViewModel(
private val getUserListRepository: GetUserListRepository,
private val getUserContactRepository: GetUserContactRepository,
private val connectivityMonitor: ConnectivityMonitor,
private val resources: Resources,
private val tracker: Tracker,
private val logger: Logger,
): ViewModel() {
...
fun process(action: Action) {
when (action) {
is Action.Load -> load()
...
}
}
private fun load() {
viewModelScope.launch {
_viewState.update { currentState ->
currentState.copy(
isLoaderVisible = true,
isContentVisible = false,
isEmptyVisible = false,
isErrorVisible = false,
)
}
when (val result = GetUserListRepository()) {
is Success -> {
tracker.trackEvent(CatalogFetched)
_viewState.update { currentState ->
currentState.copy(
isLoaderVisible = false,
isEmptyVisible = false,
isErrorVisible = false,
isUserListVisible = true,
users = result.users.map { it.toUserState() }
)
}
}
is Empty -> {
tracker.trackEvent(EmptyCatalogFetched)
logger.error(catalogResources.emptyCatalogMessage)
_viewState.update { currentState ->
currentState.copy(
isLoaderVisible = false,
isUserListVisible = false,
isEmptyVisible = true,
isErrorVisible = false,
)
}
}
is Failure -> {
tracker.trackEvent(CatalogFetchFailed)
logger.error(catalogResources.failedCatalogMessage(result.errorType))
_viewState.update { currentState ->
currentState.copy(
isLoaderVisible = false,
isUserListVisible = false,
isEmptyVisible = false,
isErrorVisible = true,
errorMessage = when(result.errorType) {
ErrorType.NoUser -> featureResources.noUserErrorMessage
ErrorType.ServerError -> featureResources.serverErrorMessage
else -> featureResources.genericErrorMessage
},
)
}
}
}
}
}
...
private fun UserDataModel.toUserState() = ...
}
ViewModel은 문제를 해결하기 위해서는 책임을 단계적으로 새로운 컴포넌트에 위임하여 ViewModel을 가볍게 만들어야 합니다.
먼저 Reducer 패턴을 소개합니다. 이는 원래 Redux 아키텍처에서 현재 상태와 액션을 입력으로 받아 새로운 상태를 반환하는 함수입니다. 리듀서는 사용자의 액션과 모델의 업데이트를 기반으로 상태를 업데이트 합니다.
여기서는 기대하는 요도는 조금 다릅니다. 리듀서는 변환을 위해 사용되며, 현재 상태와 변이를 입력받아 기대하는 변환을 얻습니다.
interface Reducer<Mutation, ViewState> {
operator fun invoke(mutation: Mutation, currentState: ViewState): ViewState
}
Mutation은 변환을 식별하는 내부 액션입니다. 이는 변환에 필요한 유스케이스에 의해 검색된 플래그나 모델을 나타내는 속성을 포함할 수 있습니다. 이는 개발자들이 뷰 상태의 일부를 업데이트하기 위해 일반적으로 사용하는 부분 상태와는 다릅니다.
sealed interface Mutation {
data object ShowLostConnection : Mutation
data object DismissLostConnection : Mutation
data object ShowLoader : Mutation
data class ShowContent(val users: List<UserDataModel>) : Mutation
data class ShowError(val exception: Exception) : Mutation
}
DefaultReducer는 뮤테이션에 따라 현재 뷰 상태를 다르게 변환합니다. 현재 상태를 새로운 상태로 복사하고 Mutation에서 모델을 매핑하여 상태를 축소합니다. 여기서 리듀서는 텍스트 형식을 지정하거나 drawable이나 색상을 가져오기 위해 안드로이드 Resources를 사용합니다.
class DefaultReducer(
private val resources: Resources,
) : Reducer<Mutation, ViewState> {
override fun invoke(mutation: Mutation, currentState: ViewState): ViewState =
when (mutation) {
Mutation.DismissLostConnection ->
currentState.mutateToDismissLostConnection()
is Mutation.ShowContent ->
currentState.mutateToShowContent(users = mutation.users)
is Mutation.ShowError ->
currentState.mutateToShowError(exception = mutation.exception)
Mutation.ShowLoader ->
currentState.mutateToShowLoader()
Mutation.ShowLostConnection ->
currentState.mutateToShowLostConnection()
}
private fun ViewState.mutateToShowContent(users: List<UserDataModel>) =
copy(
isLoaderVisible = false,
isUserListVisible = true,
userStates = userStates.toMutableList().apply {
addAll(users.map { it.toUserState() })
},
isErrorVisible = false,
)
private fun ViewState.mutateToShowError(exception: Exception) =
copy(
isLoaderVisible = false,
isUserListVisible = false,
isErrorVisible = true,
errorMessage = resources.getString(R.string.userlist_text_generic_error)
.format(exception.message),
)
private fun ItemModel.toItemState() = ...
...
}
ViewModel은 하나 또는 여러 개의 리듀서를 관리할 수 있습니다. 모범 사례로, 리듀서의 범위를 지정하는 것이 권장됩니다. 뮤테이션은 하나의 리듀서에 의해 처리되어야 하지만, 이를 제한하는 방법은 없습니다.
ViewModel은 이전에 변환을 수행했지만, 이제는 현재 상태를 업데이트하기 위해 뮤테이션과 함께 리듀서를 호출합니다.
class UserListViewModel(
private val getUserListRepository: GetUserListRepository,
private val getUserContactRepository: GetUserContactRepository,
private val connectivityMonitor: ConnectivityMonitor,
private val reducers: Collection<Reducer<Mutation, ViewState>>,
private val tracker: Tracker,
private val logger: Logger,
): ViewModel() {
...
fun process(action: Action) {
when (action) {
is Action.Load -> load()
...
}
}
private fun load() {
viewModelScope.launch {
handleMutation(Mutation.ShowLoader)
when (val result = getProductsUseCase()) {
is Success -> {
tracker.trackEvent(CatalogFetched)
handleMutation(Mutation.ShowContent(users = result.users))
}
is Empty -> {
tracker.trackEvent(EmptyCatalogFetched)
logger.error(catalogResources.emptyCatalogMessage)
handleMutation(Mutation.ShowEmpty)
}
is Failure -> {
tracker.trackEvent(CatalogFetchFailed)
logger.error(catalogResources.failedCatalogMessage(result.errorType))
handleMutation(Mutation.ShowError(result.errorType))
}
}
}
}
...
private fun handleMutation(mutation: Mutation) {
reducers.asIterable()
.forEach { reducer ->
_viewStateFlow.update { currentState ->
reducer(mutation, currentState)
}
}
}
}
변환을 위임한 후, 다음 중요한 단계는 로직을 분리하는 것입니다.
ActionProcessor는 Mutation과 Event의 쌍으로 구성된 플로우를 반환하기 위해 액션을 관리하는 새로운 컴포넌트입니다. 왜 플로우를 사용할까요? 프로세서는 하나 또는 여러 값을 발생시킬 수 있기 때문입니다. 이 값은 뮤테이션, 이벤트 또는 둘 다일 수 있습니다. 이것이 이들이 null 가능한 이유입니다.
interface ActionProcessor<Action, Mutation, Event> {
operator fun invoke(action: Action): Flow<Pair<Mutation?, Event?>>
}
위의 부분 구현인 DefaultActionProcessor는 주어진 리포지토리에서 사용자 목록을 가져오는 Load 액션의 예시를 제공합니다.
정상 경로를 따르면, 먼저 ShowLoader 뮤테이션을 발생시키고, 그 다음 UserDataModel 목록과 함께 ShowContent 뮤테이션을 발생시킵니다.
유스케이스나 리포지토리를 호출하는 것 외에도, 액션 프로세서는 트래커, 로거, 분석 등과 같은 다른 중간 컴포넌트들을 관리할 수 있습니다.
class DefaultActionProcessor(
private val getUserListRepository: GetUserListRepository,
private val connectivityMonitor: ConnectivityMonitor,
private val logger: Logger,
private val tracker: Tracker,
) : ActionProcessor<Action, Mutation, Event> {
override fun invoke(action: Action): Flow<Pair<Mutation?, Event?>> =
flow {
when (action) {
is Action.Load -> load()
...
}
}
private suspend fun FlowCollector<Pair<Mutation?, Event?>>.load() {
emit(Mutation.ShowLoader to null)
when (val result = getUserListRepository()) {
is Success -> {
tracker.trackEvent(CatalogFetched)
emit(Mutation.ShowContent(users = result.users) to null)
}
is Empty -> {
tracker.trackEvent(EmptyCatalogFetched)
logger.error("empty catalog")
emit(Mutation.ShowContent(users = result.users) to null)
}
is Failure -> {
tracker.trackEvent(CatalogFetchFailed)
logger.error("failed catalog ${result.errorType}")
emit(Mutation.ShowError(result.errorType)) to null)
}
}
}
...
}

액션 프로세서로 로직을 이동한 후, ViewModel은 이제 액션 프로세서가 발생시키는 뮤테이션과 이벤트를 수집하는 역할을 합니다. 리듀서는 뮤테이션을 처리하고, 이벤트는 다시 View로 발생됩니다.
모든 것이 새로운 컴포넌트들에 위임되었습니다. ViewModel은 이제 액션 프로세서를 호출하고 리듀서를 통해 수집된 뮤테이션을 처리하는 간단한 책임만을 가지게 되었습니다.
class UserListViewModel(
private val actionProcessors: Collection<ActionProcessor<Action, Mutation, Event>>,
private val reducers: Collection<Reducer<Mutation, ViewState>>,
private val initialState: ViewState = ViewState(),
): ViewModel() {
...
fun process(action: Action) {
viewModelScope.launch {
actionProcessors
.map { actionProcessor -> actionProcessor(action) }
.merge()
.collect { value ->
mutation?.let(::handleMutation)
event?.let(eventChannel::trySend)
}
}
}
private fun handleMutation(mutation: Mutation) {
reducers
.asIterable()
.forEach { reducer ->
_viewStateFlow.update { currentState ->
reducer(mutation, currentState)
}
}
}
}
컴포넌트들이 함께 또는 개별적으로 사용될 수 있다는 점을 인식하는 것이 중요합니다. 거의 완성되었지만, 이러한 컴포넌트들을 쉽게 구현할 수 있는 재사용 가능한 패턴이 필요합니다.
중복을 줄이고 공유 코드를 재사용하기 위해 기본 클래스를 사용하는 것이 매우 일반적인 방법이었습니다. 하지만 단일 클래스 상속만 가능하다는 제약 때문에, 이와 관련없는 다른 것들도 처리할 수 있는 일반적인 기본 ViewModel을 만들게 되고, 이는 단일 책임 원칙을 위반하게 됩니다.
ViewModel에서 액션 처리와 뷰 상태 축소 책임을 분리하기 위해 새로운 일반 Model클래스를 만들 수 있습니다.
class Model<ViewState, Action, Mutation, Event>(
private val actionProcessors: Collection<ActionProcessor<Action, Mutation, Event>>,
private val reducers: Collection<Reducer<Mutation, ViewState>>,
private val coroutineScope: CoroutineScope,
private val viewMutableStateFlow: MutableStateFlow<ViewState>,
private val eventChannel: Channel<Event>,
) {
val viewStateFlow: Flow<ViewState> = viewMutableStateFlow
val eventFlow: Flow<ViewState> = eventChannel.receiveAsFlow()
fun process(action: Action) {
coroutineScope.launch {
actionProcessors
.map { actionProcessor -> actionProcessor(action) }
.merge()
.collect { value ->
mutation?.let(::handleMutation)
event?.let(eventChannel::trySend)
}
}
}
private fun handleMutation(mutation: Mutation) {
reducers
.asIterable()
.forEach { reducer ->
viewMutableStateFlow.update { currentState ->
reducer.invoke(mutation, currentState)
}
}
}
}
Model 클래스는 코루틴 호출을 실행하기 위한 CoroutineScope와 Viewstate의 MutableStateFlow 및 EventChannel이 필요합니다.
왜 마지막 두 매개변수를 주입할까요? ViewModel처럼 이들을 비공개 프로퍼티로 가지지 않는 이유는 무엇일까요? 우리는 프로퍼티 위임을 통해 인스턴스를 얻기 위해 ReadOnlyProperty로서 ModelProperty를 생성합니다. 이는 읽기 전용이므로, 우리가 업데이트하는 프로퍼티들을 주입해야합니다.
class ModelProperty<ViewState, Action, Mutation, Event>(
private val viewModel: ViewModel,
private val actionProcessors: Collection<ActionProcessor<Action, Mutation, Event>>,
private val reducers: Collection<Reducer<Mutation, ViewState>>,
private val viewMutableStateFlow: MutableStateFlow<ViewState>,
private val eventChannel: Channel<Event>,
) : ReadOnlyProperty<Any, Model<ViewState, Action, Mutation, Event>> {
override fun getValue(thisRef: Any, property: KProperty<*>) =
Model(
actionProcessors = actionProcessors,
reducers = reducers,
coroutineScope = viewModel.viewModelScope,
viewMutableStateFlow = viewMutableStateFlow,
eventChannel = eventChannel,
)
}
inline fun <reified ViewState, reified Action, reified Mutation, reified Event> ViewModel.model(
private val actionProcessor: Collection<ActionProcessor<Action, Mutation, Event>>,
private val reducers: Collection<Reducer<Mutation, ViewState>>,
private val initialState: ViewState,
) =
ModelProperty(
viewModel = this,
actionProcessors = actionProcessors,
reducers = reducers,
viewMutableStateFlow = MutableStateFlow(initialState),
eventChannel = Channel(Channel.BUFFERED),
)
마지막으로 위임을 통해 UserListViewModel의 새로운 구현이 더 가벼워졌습니다.
class UserListViewModel(
private val actionProcessors: Collection<ActionProcessor<Action, Mutation, Event>>,
private val reducers: Collection<Reducer<Mutation, ViewState>>,
private val initialState: ViewState = ViewState(),
): ViewModel() {
private val model by model(actionProcessors, reducers, initialState)
internal val viewStateFlow: Flow<ViewState> get() = model.viewStateFlow
internal val eventFlow: Flow<Event> get() = model.eventFlow
fun process(action: Action) = model.process(action)
}