1. MVI(Model-View-Intent)란?
MVI는 말 그대로 Model - View - Intent를 통해 단방향 데이터 흐름(Unidirectional Data Flow)을 구현하는 아키텍처 패턴입니다.
- Intent: 사용자 액션(이벤트) 혹은 시스템 이벤트.
예) 버튼 클릭, 텍스트 입력, 스크롤, 외부 알림, 네트워크 응답 등 “무엇을 할 것인지” 의도를 표현합니다. - Model: UI가 그릴 상태(State).
뷰에 표시되어야 할 “현재 상태”를 모두 포괄. 예) 로딩 여부, 보여줄 데이터 리스트, 에러 메시지 등. - View: 실제 UI.
MVI에서는 View가 상태(Model)를 구독(subscribe)하고, 상태가 바뀌면 자동으로 화면을 갱신합니다.
흐름은 보통 다음과 같습니다:
- View에서 Intent(사용자 이벤트)를 발행
- 이 Intent가 ViewModel(혹은 MVI용으로 따로 만든 Store)에게 전달
- 로직(비즈니스/도메인/데이터 처리 등)을 수행 후, Model(상태)을 새로운 값으로 업데이트
- 업데이트된 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에서 흔히 등장하는 개념:
- Intent: 입력 이벤트
- Action: Intent를 좀 더 세부 동작으로 변환한 것
- Result: 비즈니스 로직 결과
- Reducer: (현재 상태 + Result) → 새 상태
- 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)를 받아서 비즈니스 로직 수행 후 상태 업데이트
- 단방향 흐름으로 인해 유지보수가 쉽고, 각 단계를 추적하기 편함
'안드로이드' 카테고리의 다른 글
Android에서의 객체 전달 방법 비교 Serializable vs Parcelable, Parcel (0) | 2025.01.25 |
---|---|
안드로이드 jetcaster 예제 분석 - Flow와 Combine을 이용한 흐름 제어 (0) | 2025.01.19 |
Dagger의 @Binds vs @Provides, @Qualifier vs @Named, @Lazy<T> vs @Provider<T> (0) | 2022.08.29 |
컴포즈 부수효과 (0) | 2022.07.30 |
Compose의 Stateful과 Stateless 개념 (0) | 2022.06.27 |