| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- 습관만들기
- WebView
- 카카오 알고리즘
- 안드로이드
- Android ProgressBar
- Android Navigation
- 안드로이드 갤러리 접근
- scope function
- Android Jetpack
- MVP Architecture
- 영어공부
- Java
- 코틀린 코루틴
- Android ViewPager2
- 프로그래머스 알고리즘
- coroutine
- Kotlin
- 영어독립365
- 66챌린지
- 알고리즘 자바
- Android 12
- Kotlin FCM
- OkHttp Interceptor
- Android Interceptor
- Android WebView
- 안드로이드 카카오 로그인
- DataBinding
- Android
- Android 12 대응
- android recyclerview
- Today
- Total
주맨의 개발노트
[Android] MVVM에서 MVI로 - 사용자 액션과 상태 변화를 명시적으로 다루기 본문
이전에 Compose 기반 프로젝트를 진행하면서 MVVM + UiState/SideEffect 구조를 사용한 적이 있었습니다.
당시에도 Compose를 상태 기반으로 사용하는 데 큰 문제는 없었습니다.
화면은 ViewModel이 노출하는 UiState를 구독해서 렌더링했고, Toast나 Navigation처럼 한 번만 소비되어야 하는 이벤트는 SideEffect로 분리했습니다.
사용자의 입력은 View에서 ViewModel로 전달되고, ViewModel은 상태를 변경한 뒤 다시 View가 그 상태를 바라보는 구조였습니다.
즉, MVI를 사용하지 않더라도 UDF(Unidirectional Data Flow)는 충분히 지킬 수 있었습니다. 또한 화면 상태의 원천도 ViewModel의 uiState 하나로 두었기 때문에 SSOT(Single Source of Truth) 관점에서도 나쁘지 않았습니다.
그런데 프로젝트를 진행하면서 한 가지 아쉬움이 남았습니다.
화면에서 발생하는 사용자 액션이 많아질수록 ViewModel의 함수가 빠르게 늘어났고, 상태 변화가 여러 함수 내부로 흩어지기 시작했습니다.
처음 작성한 개발자에게는 자연스러운 흐름이었지만, 코드 리뷰를 하거나 초기 피처 개발자가 아닌 사람이 이후에 코드를 수정할 때는 이야기가 달라졌습니다.
Screen과 ViewModel만 보고는 이 화면에서 어떤 사용자 액션이 발생할 수 있는지, 그 액션이 어떤 상태 변화를 만들고, 어떤 SideEffect를 발생시키는지 추적하는 데 시간이 꽤 걸렸습니다.
이 경험이 새로운 프로젝트에서 MVI 도입을 고민하게 된 출발점이었습니다.
MVVM + UiState/SideEffect도 충분히 좋은 구조
먼저 분명히 하고 싶은 점이 있습니다.
MVVM + UiState/SideEffect 구조가 잘못된 구조라고 생각하지 않습니다. 오히려 Compose 환경에서 꽤 실용적인 구조라고 생각합니다.
data class LoginUiState(
val email: String = "",
val password: String = "",
val isLoading: Boolean = false,
)
sealed interface LoginSideEffect {
data object MoveToHomeScreen : LoginSideEffect
data class ShowToast(val message: String) : LoginSideEffect
}
ViewModel은 UiState를 변경하고, Screen은 상태를 구독해서 UI를 렌더링합니다.
Navigation이나 Toast처럼 상태로 표현하기 애매한 일회성 동작은 SideEffect로 분리합니다.
UiState를 변경합니다.
UiState를 보고 다시 화면을 그립니다.
SideEffect는 Toast, Navigation 같은 일회성 동작을 처리합니다.
이 구조만으로도 UDF와 SSOT를 지킬 수 있었습니다.
그래서 저는 “상태 기반 함수인 Composable을 위해 MVVM보다 MVI가 필요하다”거나, “UDF를 지키기 위해 MVVM보다 MVI가 필요하다”는 말만으로는 MVI 도입의 충분한 이유가 되지 않는다고 생각합니다.
제가 느꼈던 문제는 상태 기반 함수나 UDF 자체가 아니었습니다. 핵심은 화면이 복잡해졌을 때 사용자 액션과 상태 전이의 흐름을 코드만 보고 빠르게 파악하기 어려워진다는 점이었습니다.
문제는 상태 전이의 분산
MVVM 구조에서 화면이 단순할 때는 ViewModel에 다음과 같은 함수들이 있어도 크게 불편하지 않습니다.
fun onEmailChanged(email: String)
fun onPasswordChanged(password: String)
fun onLoginClicked()
하지만 화면이 복잡해지면 함수가 계속 늘어나게 됩니다.
fun onRefreshClicked()
fun onItemClicked(id: Long)
fun onSearchKeywordChanged(keyword: String)
fun onCategorySelected(category: Category)
fun onSortChanged(sort: Sort)
fun onDeleteClicked(id: Long)
fun onDeleteConfirmed()
fun onRetryClicked()
각 함수는 보통 내부에서 _uiState.update { ... }를 호출합니다.
또는 어떤 함수가 다른 함수를 호출하고, 그 안에서 다시 상태가 바뀌기도 합니다. 이 방식은 코드를 처음 작성할 때는 빠를 수 있습니다.
하지만 시간이 지나면 다음 질문에 답하기 어려워집니다.
- 이 화면에서 발생 가능한 사용자 액션은 무엇인가?
- 특정 상태는 어떤 액션에 의해 변경되는가?
- 로딩 상태는 어디서 켜지고 어디서 꺼지는가?
- Navigation은 어떤 액션의 결과로 발생하는가?
- 이 ViewModel의 상태 변화는 몇 군데에서 일어나는가?
이전 프로젝트에서 코드 리뷰를 하거나 다른 개발자가 기존 피처를 수정할 때 이런 추적 비용이 꽤 크게 느껴졌습니다.
함수 이름을 따라가고, 내부 호출을 따라가고, 상태 변경 지점을 검색해야 했습니다.
새로운 프로젝트에서도 팀원들과 함께 기능을 만들고 리뷰하고 유지보수해야 했기 때문에, 단순히 “개인이 작성하기 편한 구조”보다 나중에 누가 봐도 흐름을 파악하기 쉬운 구조가 더 중요하다고 생각했습니다.
MVI에서 기대했던 것
MVI를 고민하면서 가장 기대했던 것은 두 가지였습니다.
사용자 액션을 하나의 타입 계층으로 명시해서, 화면에서 발생 가능한 입력 이벤트를 한눈에 파악할 수 있게 만드는 것입니다.
상태 변경을 일정한 규칙 아래에 두고, 현재 상태를 기반으로 새로운 상태를 만드는 흐름을 명확하게 만드는 것입니다.
사용자 액션을 ViewModel의 함수 목록으로 흩어두는 대신 하나의 타입 계층으로 정의하면, 화면에서 발생 가능한 액션을 한눈에 볼 수 있습니다.
sealed interface LoginIntent {
data class EmailChanged(val email: String) : LoginIntent
data class PasswordChanged(val password: String) : LoginIntent
data object LoginWithEmailClicked : LoginIntent
data object GoogleLoginButtonClicked : LoginIntent
data object SignupClicked : LoginIntent
}
이렇게 정의되어 있으면 LoginScreen에서 어떤 사용자 액션이 ViewModel로 전달될 수 있는지 훨씬 명확해집니다.
ViewModel의 여러 public 함수를 뒤지는 대신 LoginIntent만 봐도 화면의 입력 이벤트 목록을 파악할 수 있습니다.
Orbit MVI를 선택한 이유
MVI 구조를 직접 만들 수도 있었습니다.
하지만 직접 설계한다는 것은 생각보다 고려해야 할 것이 많았습니다.
- State와 SideEffect를 어떻게 보관할지
- Intent 처리 스코프를 어떻게 만들지
- 예외 처리는 어떻게 할지
- Compose에서 상태와 SideEffect를 어떻게 수집할지
- ViewModel 생명주기와는 어떻게 맞출지
그래서 직접 MVI 기반 코드를 모두 만드는 대신 Orbit MVI를 도입했습니다.
Orbit MVI는 Android ViewModel과 자연스럽게 결합할 수 있고, ContainerHost를 통해 State와 SideEffect를 관리할 수 있습니다.
intent, reduce, postSideEffect 같은 API도 직관적이었습니다. 이미 널리 사용되는 라이브러리이기 때문에 안정성 측면에서도 직접 구현보다 낫다고 판단했습니다.
private fun onEmailChanged(email: String) = intent {
reduce {
state.copy(email = email)
}
}
private fun onLoginSucceeded() = intent {
postSideEffect(LoginSideEffect.MoveToHomeScreen)
}
이 자체만으로도 기존에 StateFlow와 SharedFlow를 직접 다루던 보일러플레이트를 줄일 수 있었습니다.
하지만 실제로 구조를 설계하다 보니 한 가지 아쉬운 점이 보였습니다.
Orbit MVI만으로는 부족했던 점
Orbit MVI는 reduce API를 통해 현재 State를 기반으로 새로운 State를 만들 수 있게 해줍니다. 이 점은 좋았습니다.
하지만 사용 패턴을 그대로 가져가면 결국 각 Intent 처리 함수 내부에서 reduce를 호출하는 구조가 되기 쉬웠습니다.
private fun onEmailChanged(email: String) = intent {
reduce {
state.copy(email = email)
}
}
private fun onPasswordChanged(password: String) = intent {
reduce {
state.copy(password = password)
}
}
private fun onLoginFailed() = intent {
reduce {
state.copy(isLoading = false)
}
postSideEffect(LoginSideEffect.ShowLoginFailedToast)
}
이렇게 되면 Intent 덕분에 사용자 액션 목록은 명확해집니다. 하지만 상태 변경은 여전히 여러 함수 내부에 분산됩니다.
이전 프로젝트에서 아쉬웠던 부분은 단순히 “사용자 액션을 알기 어렵다”가 아니었습니다.
더 정확히는 어떤 상태 변화가 어디서 발생하는지 추적하기 어렵다는 점이었습니다.
Orbit의 reduce를 각 함수에서 직접 호출하는 방식만으로는 이 문제가 다시 발생할 수 있다고 느꼈습니다.
그래서 Orbit MVI를 그대로 사용하는 것에서 한 단계 더 나아가, 프로젝트에 맞는 규칙이 필요하다고 판단했습니다.
Mutation을 도입한 이유
그래서 Mutation이라는 개념을 추가했습니다.
제가 정의한 Mutation은 State를 어떻게 바꿀 것인지 설명하는 명시적인 상태 변경 단위입니다.
Intent가 사용자 액션을 표현한다면, Mutation은 그 액션 또는 비즈니스 로직의 결과로 발생한 상태 변경의 의미를 표현합니다.
sealed interface HomeIntent : NeveraIntent {
data class RecentIngredientTabClick(val tab: IngredientFilterTab) : HomeIntent
data object AddIngredientClick : HomeIntent
data class LoadMoreIngredients(val tab: IngredientFilterTab) : HomeIntent
data class UpdateNicknameClick(val nickname: String) : HomeIntent
data object CreateWishClick : HomeIntent
data class CreateWishConfirmed(val name: String, val goalAmount: Long) : HomeIntent
data object WishEditClick : HomeIntent
data class UpdateWishConfirmed(
val id: Long,
val name: String,
val goalAmount: Long,
) : HomeIntent
data object NotificationIconClicked : HomeIntent
}
이 목록만 봐도 Home 화면에서 사용자가 할 수 있는 행동들이 드러납니다.
반면 Mutation은 상태 변경의 의미를 표현합니다.
sealed interface HomeMutation : NeveraMutation {
data object Loading : HomeMutation
data object LoadComplete : HomeMutation
data class SetRecentIngredientFilterTab(val tab: IngredientFilterTab) : HomeMutation
data class ShowProfile(val profile: HomeProfileUiModel) : HomeMutation
data class ShowWish(val wish: HomeWishUiModel) : HomeMutation
data object ShowEmptyWish : HomeMutation
data class ShowSavings(val savings: HomeSavingsUiModel) : HomeMutation
data class ShowRescuedIngredients(
val ingredients: List<IngredientUiModel>,
val hasMore: Boolean,
) : HomeMutation
data object LoadingMoreRescuedIngredients : HomeMutation
data class AppendRescuedIngredients(
val ingredients: List<IngredientUiModel>,
val hasMore: Boolean,
) : HomeMutation
data class BadgeUpdated(val hasUnread: Boolean) : HomeMutation
}
여기서 중요한 점은 Mutation이 단순히 state.copy(...)를 감싸기 위한 타입이 아니라는 점입니다.
Mutation은 상태 변경의 의도를 명시적인 이름으로 남깁니다.
state.copy(isLoading = true)만 보면 단순히 로딩 값이 바뀐다는 사실만 보입니다. 반면 HomeMutation.Loading은 지금 화면이 로딩 상태로 진입한다는 의미를 드러냅니다.
state.copy(wish = null)만 보면 null이 무엇을 의미하는지 바로 알기 어렵습니다. 반면 HomeMutation.ShowEmptyWish는 사용자의 위시가 없는 상태를 표현하기 위한 변경이라는 의미를 더 쉽게 전달합니다.
core:mvi로 프로젝트 규칙 만들기
이 구조를 특정 feature 안에서만 사용하면 큰 의미가 없다고 생각했습니다.
프로젝트 전체에서 팀원들이 같은 패턴을 사용해야 코드 리뷰와 유지보수에서 효과가 생기기 때문입니다.
그래서 core:mvi 모듈을 만들었습니다.
이 프로젝트는 Multi-Module과 Clean Architecture 구조를 사용하고 있었고, Presentation Layer의 피처들은 feature:* 모듈로 나뉘어 있었습니다.
interface NeveraState
interface NeveraSideEffect
interface NeveraIntent
interface NeveraMutation
core:mvi는 Orbit MVI에 의존하지만, feature 모듈은 프로젝트에서 추상화한 NeveraState, NeveraSideEffect, NeveraIntent, NeveraMutation을 사용하도록 했습니다.
abstract class NeveraViewModel<
STATE : NeveraState,
SIDE_EFFECT : NeveraSideEffect,
INTENT : NeveraIntent,
MUTATION : NeveraMutation,
>(initialState: STATE) : ViewModel(), ContainerHost<STATE, SIDE_EFFECT> {
override val container = container<STATE, SIDE_EFFECT>(
initialState = initialState,
buildSettings = {
exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Timber.e(throwable)
}
},
)
abstract fun handleIntent(action: INTENT)
protected abstract suspend fun Syntax<STATE, SIDE_EFFECT>.applyMutation(
mutation: MUTATION,
)
}
이 추상 클래스가 강제하는 흐름은 단순합니다.
Intent
View는 handleIntent(intent)로 사용자 액션을 전달합니다.
ViewModel
Intent를 해석하고 필요한 UseCase를 호출한 뒤, 그 결과를 화면 상태나 SideEffect로 연결합니다.
Mutation
상태를 바꿔야 하면 변경의 의미를 담은 Mutation을 만듭니다.
applyMutation
한 곳에서 Mutation을 실제 State 변경으로 변환합니다.
즉, Orbit MVI를 사용하되 프로젝트 차원에서는 다음과 같은 규칙을 세웠습니다.
- 사용자 액션은
Intent로 표현합니다. - 화면 상태는
UiState로 표현합니다. - 일회성 이벤트는
SideEffect로 표현합니다. - 상태 변경의 의미는
Mutation으로 표현합니다. - Orbit의
reduce는 원칙적으로applyMutation에서 사용합니다.
실제 ViewModel에서의 흐름
예시로 HomeViewModel을 보면 이 구조가 어떻게 동작하는지 알 수 있습니다.
먼저 handleIntent는 화면에서 들어오는 사용자 액션의 단일 진입점입니다.
override fun handleIntent(intent: HomeIntent) {
when (intent) {
is HomeIntent.RecentIngredientTabClick -> onRecentIngredientTabClick(intent.tab)
HomeIntent.AddIngredientClick -> onAddIngredientClick()
is HomeIntent.LoadMoreIngredients -> loadMoreIngredients(intent.tab)
is HomeIntent.UpdateNicknameClick -> onConfirmNickname(intent.nickname)
HomeIntent.CreateWishClick -> onGreetingCreateWishClick()
is HomeIntent.CreateWishConfirmed -> {
onCreateWishConfirmed(intent.name, intent.goalAmount)
}
HomeIntent.WishEditClick -> onWishEditClick()
is HomeIntent.UpdateWishConfirmed -> {
onUpdateWishConfirmed(intent.id, intent.name, intent.goalAmount)
}
HomeIntent.NotificationIconClicked -> onNotificationIconClick()
}
}
이 코드만 봐도 Home 화면의 액션이 어떤 처리 함수로 연결되는지 알 수 있습니다.
Intent 처리 함수는 사용자 액션을 해석하고, 필요한 UseCase를 호출한 뒤 그 결과를 상태 변화나 SideEffect로 연결하는 데 집중합니다.
private fun onRecentIngredientTabClick(tab: IngredientFilterTab) = intent {
applyMutation(HomeMutation.SetRecentIngredientFilterTab(tab))
}
private fun onAddIngredientClick() = intent {
postSideEffect(HomeSideEffect.ShowCaptureModeBottomSheet)
}
private fun onNotificationIconClick() = intent {
markAllNotificationsAsRead()
postSideEffect(HomeSideEffect.NavigateToNotification)
}
상태 변경은 applyMutation에서 처리됩니다.
override suspend fun Syntax<HomeUiState, HomeSideEffect>.applyMutation(
mutation: HomeMutation,
) {
when (mutation) {
HomeMutation.Loading -> reduce { state.copy(isLoading = true) }
HomeMutation.LoadComplete -> reduce { state.copy(isLoading = false) }
is HomeMutation.SetRecentIngredientFilterTab -> reduce {
state.copy(ingredientFilterTab = mutation.tab)
}
is HomeMutation.ShowProfile -> reduce {
state.copy(profile = mutation.profile)
}
is HomeMutation.ShowWish -> reduce {
state.copy(wish = mutation.wish)
}
HomeMutation.ShowEmptyWish -> reduce {
state.copy(wish = null)
}
is HomeMutation.BadgeUpdated -> reduce {
state.copy(hasUnreadNotification = mutation.hasUnread)
}
}
}
이렇게 하면 상태 변경을 보고 싶을 때 여러 함수를 전부 따라가지 않아도 됩니다.
우선 HomeMutation을 보고 어떤 상태 변화가 존재하는지 확인하고, applyMutation을 보면 각 Mutation이 실제 State를 어떻게 바꾸는지 알 수 있습니다.
이 구조에서 Intent와 Mutation은 역할이 다릅니다.
Intent는 사용자가 무엇을 했는지를 의미하고, Mutation은 그 결과로 화면 상태가 어떻게 바뀌어야 하는지를 의미합니다.
보일러플레이트 문제와 AI-Agent Skill
물론 이 구조가 장점만 있는 것은 아닙니다.
Intent, UiState, SideEffect, Mutation, ViewModel, Screen, Content, Navigation을 나누면 파일이 많아집니다.
특히 사용자 액션이 많지 않은 단순 화면에서는 구조가 과하게 느껴질 수 있습니다.
실제로 feature 모듈 하나를 만들 때 기본적으로 생성해야 할 파일이 많습니다. 이 부분은 MVI 구조의 명확한 비용입니다.
다만 이 프로젝트에서는 이 비용을 AI-Agent Skill로 줄였습니다.
create-feature-module Skill을 만들어 feature 모듈의 기본 구조를 자동으로 생성하도록 했습니다. 모듈명을 전달하면 다음 파일들을 한 번에 만듭니다.
build.gradle.ktsAndroidManifest.xml{Name}Intent.kt{Name}UiState.kt{Name}SideEffect.kt{Name}Mutation.kt{Name}ViewModel.kt{Name}Content.kt{Name}Screen.kt{Name}Navigation.kt- 기본 테스트 파일
또한 settings.gradle.kts와 app/build.gradle.kts 등록까지 자동화했습니다.
이 자동화 덕분에 팀원들이 직접 반복 파일을 만드는 비용이 거의 사라졌습니다.
더 중요한 것은 정합성이 높아졌다는 점입니다. 사람이 매번 손으로 만들면 패키지 구조, 클래스명, 상속 구조, handleIntent, applyMutation 구현 형태가 조금씩 달라질 수 있습니다.
Skill을 사용하면 기본 구조가 거의 동일하게 생성되기 때문에 프로젝트 전체의 통일성이 높아졌습니다.
보일러플레이트를 없앤 것은 아닙니다.
대신 보일러플레이트를 직접 작성하는 비용과 패턴이 어긋날 가능성을 줄였습니다. 이 차이가 꽤 컸습니다.
도입 후 좋았던 점
도입 후 가장 좋았던 점은 코드 리뷰 경험이었습니다.
이전에는 화면을 직접 실행해보거나 ViewModel 내부 함수를 따라가야만 흐름이 보이는 경우가 많았습니다.
지금은 Contract처럼 Intent, Mutation, SideEffect, UiState가 나뉘어 있으니 코드만 보고도 화면의 구조를 파악하기 쉬웠습니다.
리뷰할 때는 보통 다음 순서로 코드를 확인했습니다.
UiState를 보고 화면이 어떤 상태를 가지는지 확인합니다.Intent를 보고 사용자가 어떤 액션을 할 수 있는지 확인합니다.Mutation을 보고 화면 상태가 어떤 의미 단위로 바뀌는지 확인합니다.applyMutation을 보고 각 Mutation이 실제 State를 어떻게 변경하는지 확인합니다.SideEffect를 보고 Navigation, Toast, BottomSheet 같은 일회성 동작을 확인합니다.
이 흐름은 신규 피처 리뷰뿐 아니라 기존 피처를 수정할 때도 도움이 됐습니다.
어떤 상태를 수정해야 할 때 applyMutation부터 보면 상태 변경 지점이 모여 있고, 어떤 액션을 추가해야 할 때 Intent부터 보면 기존 액션 목록과 비교할 수 있었습니다.
동료들에게도 비슷한 피드백을 받았습니다.
MVI를 도입하면 보일러플레이트가 늘어날 수 있다는 걱정은 있었지만, AI-Agent Skill로 기본 구조를 생성하니 직접 반복 코드를 작성하는 부담은 크지 않았습니다.
오히려 파일과 타입이 명확히 나뉘어 있어서 피처의 의도를 파악하기 쉽다는 피드백이 있었습니다.
아쉬웠던 점
아쉬운 점도 분명히 있었습니다.
사용자 액션이 거의 없는 단순 화면에서도 여러 파일이 생깁니다. 간단한 설정 화면이나 정적인 정보 화면이라면 Intent, Mutation, SideEffect를 모두 나누는 것이 과하게 느껴질 수 있습니다.
SampleIntent.kt
SampleUiState.kt
SampleSideEffect.kt
SampleMutation.kt
SampleViewModel.kt
SampleScreen.kt
SampleContent.kt
SampleNavigation.kt
이 구조는 분명 가볍지는 않습니다.
하지만 팀 프로젝트에서는 일관성도 중요한 가치였습니다.
어떤 화면은 MVVM, 어떤 화면은 Orbit MVI, 어떤 화면은 직접 만든 이벤트 구조를 사용하면 처음에는 빠를 수 있어도 시간이 지나면서 유지보수 비용이 커질 수 있습니다.
우리 프로젝트에서는 Presentation Layer에서 표준화된 패턴을 따르는 것이 더 중요하다고 판단했습니다.
그리고 파일 생성 비용은 Skill로 낮췄기 때문에, 트레이드오프 관점에서 core:mvi를 사용하는 것이 충분히 납득 가능한 선택이었습니다.
파일과 타입이 늘어납니다. 단순 화면에서는 구조가 다소 무겁게 느껴질 수 있습니다.
화면의 의도, 사용자 액션, 상태 변화 흐름이 코드에 명시적으로 남습니다.
즉, 이 구조의 비용은 “파일이 많아진다”는 점이고, 얻은 이점은 “화면의 의도와 상태 변화 흐름이 명시적으로 남는다”는 점이었습니다.
우리 프로젝트에서는 후자가 더 컸습니다.
정리
이번에 Orbit MVI와 core:mvi를 도입하면서 다시 느낀 점은, 아키텍처 선택의 핵심은 특정 패턴 이름이 아니라 문제를 어디까지 명시적으로 드러낼 것인가라는 점이었습니다.
MVVM + UiState/SideEffect만으로도 UDF와 SSOT는 충분히 지킬 수 있습니다.
MVI가 무조건 더 좋은 구조라고 말하고 싶지는 않습니다.
다만 화면이 복잡해지고, 사용자 액션이 많아지고, 팀원들과 함께 리뷰하고 유지보수해야 하는 상황에서는 이야기가 달라집니다.
이때는 사용자 액션을 Intent로 명시하고, 상태 변경 의미를 Mutation으로 표현하고, 실제 상태 변경을 applyMutation 한 곳에 모으는 구조가 큰 도움이 됐습니다.
Orbit MVI는 State와 SideEffect를 다루는 기반을 제공해주었고, core:mvi는 그 위에 우리 프로젝트의 규칙을 얹는 역할을 했습니다.
결국 core:mvi는 단순히 공통 코드를 모아둔 모듈이 아니었습니다.
우리 프로젝트에서 화면 상태와 사용자 액션, 상태 변화, 일회성 이벤트를 어떤 방식으로 다룰지 정한 약속에 가까웠습니다.
그리고 이 약속이 코드로 고정되었을 때, 리뷰와 유지보수에서 얻는 이점은 생각보다 컸습니다.
- MVVM + UiState/SideEffect만으로도 UDF와 SSOT는 충분히 지킬 수 있었습니다.
- MVI 도입 이유는 Compose 때문이 아니라, 복잡한 화면에서 사용자 액션과 상태 전이를 더 명확히 남기기 위해서였습니다.
- Orbit MVI는 State와 SideEffect를 관리하는 기반을 제공해주었습니다.
- Mutation은 상태 변경의 의미를 코드에 명시적으로 남기기 위한 장치였습니다.
- core:mvi는 공통 모듈이라기보다, Presentation Layer에서 상태와 이벤트를 다루는 프로젝트의 약속에 가까웠습니다.
'안드로이드' 카테고리의 다른 글
| [CD] Firebase App Distribution으로 테스트 앱 배포 자동화하기 (0) | 2026.06.16 |
|---|---|
| [Jetpack Compose] val인데 왜 Unstable일까? Compose Stability 이해하기 (0) | 2026.06.10 |
| Foreground Service Short Service로 게시글 업로드 구현하기 (0) | 2025.12.24 |
| [Android DI] 왜 Hilt를 사용해야할까? (0) | 2025.11.13 |
| Dynamic Link - 파이어베이스 프로젝트 셋팅 (0) | 2023.03.20 |