MVVM Is Outdated: The Modern Android Stack Is MVI + GraphQL + Compose
MVVM had its time — just like Java did. But modern Android development has evolved. With Jetpack Compose, GraphQL, and unidirectional data…
blog.stackademic.com
MVVM은 한때 전성기가 있었다, 마치 자바처럼.
하지만 현대 안드로이드 개발은 진화했다.
JetPack Compose, GraphQL 그리고 방향을 바꾸지 않는 데이터 플로우
이제는 더 이상 불안정한 LiveData Chains이나 일관성없는 ViewModel 상태를 더 이상 사용할 필요가 없습니다.
MVI (Model-View-Intent) 의 등장이다
이 패턴은 상태를 예측 가능하고, 테스트 하기 쉽고, 확장 가능하게 만들어줍니다.
아직도 MVVM 에서 여러개의 MutableStateFlow와 수 많은 불리언 플래그를 이리저리 다루고 있다면 이제는 한단계 업그레이드 할 시간입니다.
이 게시물에는 나는 Hockey Hub 앱을 활용하여 MVI, Kotlin Flow그리고 Hilt 를사용하는법에대해 소개하고자합니다.
이 변화는 REST가 GraphQL로 대체된 흐름과 같습니다.
왜 MVI는 MVVM보다 나은가요?
MVVM은 종종 여러 MutableStateFlow에 로직이 분산되어, 변경 가능한 상태를 일관성 없이 노출합니다.
반면 MVI는 단일 진실의 원천 개념을 통해 이를 단순화 합니다.
즉 하나의 불변 UI상태와 하나의 이벤트 채널만 사용합니다.
다음과 같은 것을 보장합니다
- UI 비동기화나 잘못된 상태 조합이 발생하지 않습니다.
- ViewModel이 순수해지고 테스트하기 쉬워집니다.
- Compose에서 미리보기가 쉬워지고 리컴포지션 처리가 훨씬 더 간단해집니다.
ViewModel 에서 MVi 액션
여기 내 Hockey Hub 앱에서 가장 과소 평가된 선수 섹션에 대한 예시입니다.
sealed class TopUnderratedNhlersUiState: UiState {
data object Loading : TopUnderratedNhlersUiState()
data class Success(
val topNhler: TopNhler,
val topNhlerId: Int,
val isDescriptionExpanded: Boolean,
val isPrevButtonVisible: Boolean,
val isNextButtonVisible: Boolean
) : TopUnderratedNhlersUiState()
data class Error(val throwable: Throwable) : TopUnderratedNhlersUiState()
}
sealed class TopUnderratedNhlersIntent: UiIntent {
data class LoadPlayer(val topNhlerId: Int = 1) : TopUnderratedNhlersIntent()
data object NextPlayer : TopUnderratedNhlersIntent()
data object PreviousPlayer : TopUnderratedNhlersIntent()
data object ToggleDescription : TopUnderratedNhlersIntent()
data object OnBackPressed : BannedNumberIntent()
}
sealed class TopUnderratedNhlersEffect: UiEffect {
data object ShowInterstitialAd : TopUnderratedNhlersEffect()
}
이 ViewModel은 인텐트의 반응해 상태를 갱신하고, 사이드 이펙트는 분리된 불변 스트림으로 내보냅니다.
@HiltViewModel
class TopUnderratedNhlersViewModel @Inject constructor(
private val repository: TopUnderratedNhlersRepository
) : BaseMviViewModel<TopUnderratedNhlersIntent, TopUnderratedNhlersUiState, TopUnderratedNhlersEffect>() {
override fun initialState() = TopUnderratedNhlersUiState.Loading
override fun reduce(intent: TopUnderratedNhlersIntent) {
when (intent) {
is TopUnderratedNhlersIntent.LoadPlayer -> loadPlayer(intent.topNhlerId)
is TopUnderratedNhlersIntent.NextPlayer -> loadNextPlayer()
is TopUnderratedNhlersIntent.PreviousPlayer -> loadPreviousPlayer()
is TopUnderratedNhlersIntent.ToggleDescription -> toggleDescription()
is BannedNumberIntent.OnBackPressed -> onBackPress()
}
}
private fun loadPlayer(id: Int) = viewModelScope.launch {
setState { = TopUnderratedNhlersUiState.Loading }
try {
val (player, size) = coroutineScope {
val player = async { repository.getTopUnderratedNhler(id) }
val size = async { repository.getTopUnderratedNhlersSize() }
player.await() to size.await()
}
setState { TopUnderratedNhlersUiState.Success(
topNhler = player,
topNhlerId = id,
isDescriptionExpanded = false,
isPrevButtonVisible = id > 1,
isNextButtonVisible = id < size
)
} catch (e: Exception) {
setState { TopUnderratedNhlersUiState.Error(e) }
}
}
private fun toggleDescription() {
setState {
(this as? OriginalSixUiState.Success)?.copy(
isDescriptionExpanded = !isDescriptionExpanded
) ?: this
}
}
private fun onBackPress() {
sendEffect { BannedNumberEffect.ShowInterstitialAdAndNavigateBack }
}
.......
}
UI 상태가 수정가능하지않고, 직접적인 로직도 없습니다. 오직 Intent -> Reducer -> Render의 흐름만 존재합니다.
멍청한 컴포저블
MVI에서는 컴포저블이 오직 상태를 렌더링하고 인텐트를 발생시키는 역할만합니다.
Composable에서는 ViewModel에 대해 내부 로직이나 메서드를 전혀 알지 못하며,
비즈니스 로직을 수행하지 않습니다.
@Composable
fun TopUnderratedNhlersScreen(
viewModel: TopUnderratedNhlersViewModel = hiltViewModel(),
navController: NavController
) {
val ctx = LocalContext.current
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.reduce(TopUnderratedNhlersIntent.LoadPlayer())
}
LaunchedEffect(Unit) {
viewModel.effect.collect { effect ->
when (effect) {
OriginalSixEffect.ShowInterstitialAdAndNavigateBack -> {
InterstitialAdUtils(ctx).showInterstitialAd()
}
}
}
}
when (uiState) {
is TopUnderratedNhlersUiState.Loading -> LoadingScreen()
is TopUnderratedNhlersUiState.Error -> ErrorScreen(onRetry = {
viewModel.reduce(TopUnderratedNhlersIntent.LoadPlayer())
})
is TopUnderratedNhlersUiState.Success -> {
NhlMainScaffold(
navController = navController,
bottomBarType = BottomBarType.AD_BANNER,
topBar = {
BasicNavIconOnClickToolBar(
navIconOnClick = { viewModel.reduce(TopUnderratedNhlersIntent.OnBackPressed) },
navController = navController,
title = stringResource(R.string.underrated),
)
},
content = { padding ->
val successState = uiState as TopUnderratedNhlersUiState.Success
ShowTopUnderratedNhler(
padding = padding,
uiState = successState,
onToggleDescription = { viewModel.reduce(TopUnderratedNhlersIntent.ToggleDescription) },
canLoadPrev = successState.isPrevButtonVisible,
canLoadNext = successState.isNextButtonVisible,
onLoadPrev = { viewModel.reduce(TopUnderratedNhlersIntent.PreviousPlayer) },
onLoadNext = { viewModel.reduce(TopUnderratedNhlersIntent.NextPlayer) }
)
}
)
}
}
}
MVI는 테스팅을 쉽게 만듭니다.
MVI에서 Composable은 오직 상태를 렌더링하고 인텐트를 전달하는 역할만합니다.
ViewModel에 내부 로직이나 메서드 호출에 대해서는 알지못합니다.
이 덕분에 테스트가 매우 심플해집니다.
여러가지 UI State값을 주입하고, 올바른 UI가 그려지는지 검증만하면됩니다.
즉 ViewModel에 함수를 전혀 호출하지 않아도 UI 테스트 가 가능합니다.
@Test
fun `Given Success state, when ToggleDescription intent, then isDescriptionExpanded updates`() = runTest {
// Given
val currentPlayer = mockTopNhlPlayers[2]
coEvery { mockRepo.getTopUnderratedNhler(currentPlayer.id) } returns currentPlayer
coEvery { mockRepo.getTopUnderratedNhlersSize() } returns mockTopNhlPlayers.size
// When
viewModel.reduce(TopUnderratedNhlersIntent.LoadPlayer(currentPlayer.id))
testDispatcher.scheduler.advanceUntilIdle()
viewModel.reduce(TopUnderratedNhlersIntent.ToggleDescription)
testDispatcher.scheduler.advanceUntilIdle()
// Then
val state = viewModel.uiState.value as TopUnderratedNhlersUiState.Success
state.topNhler shouldBe currentPlayer
state.isDescriptionExpanded shouldBe true
}
이 스택이 좋은 이유
- UI 상태를 예측가능합니다. - 모든 리컴포지션은 이미 정의된 불변 상태를 반영합니다.
- 테스트 가능한 로직 - 안드로이드 생명주기를 신경쓰지않고 ViewModel 단위 테스트가 가능합니다.
- GraphQL 친화적인구조 - 단일 데이터 스트림이 MVP 플로우와 자연스럽게 매핑됩니다.
- Compose와에 시너지 - 리컴포지션이 상태 업데이트와 정확히 일치합니다.
스스로 도전해보세요
이 아키텍쳐는 Hockey Hub 앱에서 시작되었습니다. 여기 현대 안드로이드 앱 개발은 다음과 같습니다.
- 데이터 드리븐하고, 제트팩컴포즈 사용
- MVI와 코틀린 플로우
- Hilt를 통한 의존성 주입
- Mockk를 통한 테스팅
- 패스트레인을 통한 자동 처리
Google Play 에서 이용가능합니다
만약 이 접근방법이 마음에 드신다면, 깃허브 스타를 남기거나, 게시물을 공유하거나, 앱을 다운로드에서 MVI가 실제로 어떻게 동작하는지 직접 확인해보세요.
보너스 MVI 셋업 코드
핵심 아이디어
- _uiState는 private + mutable -> 기반 클래스만 상태를 변경할 수 있다
- uiState는 퍼블릭이지만 Read only -> UI나 다른 클래스가 안전하게 관찰만 할 수 있다
- setState {} 는 protected -> 서브클래스만 상태를 변경할 수 있으며, 그마저도 통제된, 불변 방식으로 업데이트 된다.
package com.brickyard.nhl.mvi
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.brickyard.nhl.mvi.contracts.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* Base class for all MVI ViewModels.
* Handles state, effects, and provides helpers for updating them.
*/
abstract class BaseMviViewModel<
I : UiIntent,
S : UiState,
E : UiEffect
> : ViewModel(), IntentHandler<I> {
/** StateFlow for UI State */
private val _uiState = MutableStateFlow(initialState())
val uiState = _uiState.asStateFlow()
/** SharedFlow for one-off UI Effects */
private val _effect = MutableSharedFlow<E>()
val effect = _effect.asSharedFlow()
/**
* Provide initial state for the ViewModel.
* Called once when the ViewModel is created.
*/
abstract fun initialState(): S
/**
* Helper to update UI state immutably.
* Always runs on the Main dispatcher via viewModelScope.
*/
protected fun setState(reducer: S.() -> S) {
_uiState.value = _uiState.value.reducer()
}
/**
* Helper to emit one-off UI effects (navigation, toasts, dialogs, etc.)
* Runs on the Main dispatcher since viewModelScope uses Dispatchers.Main.immediate by default.
*/
protected fun sendEffect(builder: () -> E) {
viewModelScope.launch {
_effect.emit(builder())
}
}
}
/** Generic interface for handling intents */
interface IntentHandler<I : UiIntent> {
fun reduce(intent: I)
}
/** Represents one-off UI Effects (navigation, toasts, ads, etc.) */
interface UiEffect
/** Represents the UI State */
interface UiState
/** Represents the UI Intent (user actions) */
interface UiIntent
설립자에 대한 메세지
안녕하세요.
끝까지 읽어주시고, 이 커뮤니티에일환이 되어주신것에 진심으로 감사드립니다.
저희 팀은 매달 350만명이 넘는 독자에게 이 콘텐츠를 자원봉사의 형태로 제공합니다.
어떠한 후원이나 자금도 받지않고, 오직 커뮤니티를 위해 이 일을 하고 있습니다.
혹시 이 프로젝트가 마음에 드신다면,
LinkedIn, TikTok, Instagram에서 저를 팔로우해주시거나
저희의 주간 뉴스레터를 구독해주시면 큰 힘이됩니다.
'영어 데일리' 카테고리의 다른 글
| 핫한 안드로이드 스킬 2025 (0) | 2025.11.18 |
|---|---|
| “Kotlin 2.2.0: 개발 워크플로우를 혁신적으로 바꿀 게임 체인저급 기능들 (0) | 2025.11.11 |
| 대부분의 안드로이드 면접은 이런 코틀린 질문으로 시작합니다. (0) | 2025.11.07 |
| 아무도 대답 할 수 없었던 안드로이드 인터뷰 질문들, 당신은 가능한가? (0) | 2025.10.31 |
| 코틀린의 장례식이 발표되었다. 애플의 안드로이드용 스위프트에 숨겨진 마스터플랜 (5) | 2025.07.07 |