본문 바로가기

안드로이드

MVI란? 안드로이드에서 MVI는 무엇이고 어떻게 적용할까?

반응형

1. MVI(Model-View-Intent)란?

MVI는 말 그대로 Model - View - Intent를 통해 단방향 데이터 흐름(Unidirectional Data Flow)을 구현하는 아키텍처 패턴입니다.

  • Intent: 사용자 액션(이벤트) 혹은 시스템 이벤트.
    예) 버튼 클릭, 텍스트 입력, 스크롤, 외부 알림, 네트워크 응답 등 “무엇을 할 것인지” 의도를 표현합니다.
  • Model: UI가 그릴 상태(State).
    뷰에 표시되어야 할 “현재 상태”를 모두 포괄. 예) 로딩 여부, 보여줄 데이터 리스트, 에러 메시지 등.
  • View: 실제 UI.
    MVI에서는 View가 상태(Model)를 구독(subscribe)하고, 상태가 바뀌면 자동으로 화면을 갱신합니다.

흐름은 보통 다음과 같습니다:

  1. View에서 Intent(사용자 이벤트)를 발행
  2. 이 Intent가 ViewModel(혹은 MVI용으로 따로 만든 Store)에게 전달
  3. 로직(비즈니스/도메인/데이터 처리 등)을 수행 후, Model(상태)을 새로운 값으로 업데이트
  4. 업데이트된 Model(상태)은 View에서 구독 → 즉시 UI가 갱신
flowchart LR
    A[View] --> B[Intent]
    B --> C[ViewModel/Reducer]
    C --> D[Model(State)]
    D --> A

이처럼 단방향으로 흐르기 때문에, 상태가 어떻게 변했는지 추적하기 쉽고, UI가 언제 어떻게 갱신되는지 예측하기 쉬운 장점이 있습니다.


2. 안드로이드에서 MVI 구현 시 주 요소

안드로이드에서 MVI를 구현할 때는 다음 요소를 중심으로 구상할 수 있습니다.

2.1 Intent (또는 Action, Event)

Sealed Class나 Enum 등을 이용해 명시적인 이벤트를 정의합니다.
예를 들어, “버튼이 눌렸다”, “텍스트가 바뀌었다” 등의 이벤트를 하나의 타입으로 명시합니다.

sealed class HomeIntent {
    object LoadData : HomeIntent()
    data class SearchQueryChanged(val query: String) : HomeIntent()
    data class PodcastSelected(val podcastId: String) : HomeIntent()
}

이렇게 만들어두면, ViewModel(또는 MVI Store)에서 when(HomeIntent) 로 분기 처리하기 편리해집니다.

2.2 Model (또는 State)

UI에서 표시할 상태 전부를 data class 하나로 묶습니다.
필요하다면 로딩 상태, 에러, 데이터 목록 등을 모두 포함합니다.

data class HomeState(
    val isLoading: Boolean = false,
    val searchQuery: String = "",
    val podcastList: List<Podcast> = emptyList(),
    val errorMessage: String? = null
)

2.3 View

Compose 기준으로는 @Composable 함수가 View 역할을 합니다.
collectAsState() 등을 통해 StateFlow / LiveData를 구독하고, 상태가 변할 때마다 UI를 재구성합니다.
이벤트가 발생하면 viewModel.onIntent(...) 식으로 Intent를 전달합니다.

@Composable
fun HomeScreen(viewModel: HomeViewModel = hiltViewModel()) {
    val state by viewModel.uiState.collectAsState()

    // UI 렌더
    if (state.isLoading) {
        CircularProgressIndicator()
    } else {
        Column {
            TextField(
                value = state.searchQuery,
                onValueChange = { newValue ->
                    // Intent 발행
                    viewModel.onIntent(HomeIntent.SearchQueryChanged(newValue))
                }
            )

            LazyColumn {
                items(state.podcastList) { podcast ->
                    Text(
                        text = podcast.title,
                        modifier = Modifier.clickable {
                            // Intent 발행
                            viewModel.onIntent(HomeIntent.PodcastSelected(podcast.id))
                        }
                    )
                }
            }
        }

        state.errorMessage?.let {
            Text(text = "오류 발생: $it", color = Color.Red)
        }
    }
}

2.4 ViewModel (또는 Store)

Intent를 받아서(예: onIntent()) 상태를 업데이트하고, 그 결과를 StateFlow 혹은 LiveData로 방출합니다.
MVI 구현에서는 보통 “Reducer”라는 개념을 사용하여, 기존 상태와 이벤트를 받아 새로운 상태를 만들어 내는 과정을 명확히 분리하기도 합니다.

class HomeViewModel(
    private val repository: PodcastRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(HomeState())
    val uiState: StateFlow<HomeState> = _uiState.asStateFlow()

    fun onIntent(intent: HomeIntent) {
        when (intent) {
            is HomeIntent.LoadData -> loadData()
            is HomeIntent.SearchQueryChanged -> updateSearchQuery(intent.query)
            is HomeIntent.PodcastSelected -> handlePodcastSelection(intent.podcastId)
        }
    }

    private fun loadData() {
        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(isLoading = true)
            runCatching {
                repository.getPodcasts()
            }.onSuccess { podcasts ->
                _uiState.value = _uiState.value.copy(
                    isLoading = false,
                    podcastList = podcasts,
                    errorMessage = null
                )
            }.onFailure { throwable ->
                _uiState.value = _uiState.value.copy(
                    isLoading = false,
                    errorMessage = throwable.localizedMessage
                )
            }
        }
    }

    private fun updateSearchQuery(query: String) {
        // 단순 상태 갱신
        _uiState.value = _uiState.value.copy(searchQuery = query)
    }

    private fun handlePodcastSelection(podcastId: String) {
        // 선택된 팟캐스트 처리 (화면 이동, 재생 등)
        // ...
    }
}

3. 전통적인(완벽한) MVI와의 차이

실무에서는 엄격한 MVI 대신, 위와 같이 MVVM+단방향 흐름 또는 “MVI Lite” 형태로 구현하는 경우가 많습니다.
특히 Compose UI를 사용할 때는 StateFlow를 이용해 단일 상태를 관리하고, 이벤트를 ViewModel로 전달하여 상태를 갱신하는 방식이 보편적이죠.

엄격한 MVI에서 흔히 등장하는 개념:

  1. Intent: 입력 이벤트
  2. Action: Intent를 좀 더 세부 동작으로 변환한 것
  3. Result: 비즈니스 로직 결과
  4. Reducer: (현재 상태 + Result) → 새 상태
  5. State: UI가 관찰하는 최종 상태

즉, 각 단계별로 별도 클래스를 두어 기능을 명확히 분리하지만, 규모가 작은 앱에서는 오히려 복잡도가 높아질 수 있습니다.
상황에 맞추어 적절한 복잡도를 선택하면 됩니다.


4. 안드로이드에서 MVI 구현 라이브러리

  • Orbit MVI: Kotlin Coroutines/Flow 기반의 MVI 프레임워크. Intent, State, Reducer 등의 개념을 체계적으로 제공.
  • Mvi-Kotlin: Badoo에서 만든 MVI 라이브러리로, Rx 기반으로 되어 있으나 기본 개념은 동일.
  • ReduxKotlin: Redux 개념을 Kotlin Multiplatform으로 구현한 라이브러리.

Compose가 보편화되면서, 위 라이브러리들을 참고하거나 또는 라이브러리 없이 직접 MVI를 구성하는 사례도 많이 볼 수 있습니다.


5. 정리

MVI의 핵심은 “사용자/시스템 이벤트 → 상태 업데이트 → UI 반영”의 단방향 흐름을 유지하는 것입니다.
안드로이드에서 보편적으로는 ViewModel을 사용해 StateFlow(또는 LiveData)로 단일 상태를 관리하고, Sealed Class로 Intent(이벤트)를 정의해 처리하는 형태가 많습니다.
규모가 커질수록 Reducer나 Result 같은 개념을 세분화해 각 단계를 명확히 분리할 수 있습니다.
Jetpack Compose와 함께 사용할 때 UI 재컴포지션과 단방향 상태 관리를 쉽게 결합할 수 있어, 점점 더 많이 도입되는 추세입니다.

  • 작은 프로젝트:
    ViewModel + StateFlow + Sealed Intents + 단일 State data class로 충분히 간단히 MVI-like 구조를 만들 수 있습니다.
  • 큰 프로젝트:
    Reducer와 Effect(화면에 팝업 알림이나 메시지 표시 등 일시적 이벤트)를 분리해, 로직/테스트/가독성을 높일 수 있습니다.

마무리

안드로이드에서 MVI를 구현할 때는 “모든 것을 다 지키는 정통(?) MVI”가 아니라도, 단방향 상태 관리를 구현할 수 있다면 사실상 MVI의 이점을 충분히 누릴 수 있습니다.

  • Compose 기반 UI → 상태(State) 만 바꾸면 UI가 알아서 반영
  • ViewModel이벤트(Intent)를 받아서 비즈니스 로직 수행 후 상태 업데이트
  • 단방향 흐름으로 인해 유지보수가 쉽고, 각 단계를 추적하기 편함
반응형