이전에 MVI 패턴에 대해서 정리한 경험이 있지만, 당시에는 잘 몰랐고 무언가 투박했던 것 같다. 이에 다시 정리를 해보면서 프로젝트에서 적용했던 인사이트를 업로드하고자 한다.
https://superohinsung.tistory.com/148
[Android] MVI 패턴이란?
MVI 패턴이란 Android MVI 패턴은 Model-View-Intent의 약자로, 안드로이드 앱 개발에서 사용되는 아키텍처 패턴 중 하나입니다. MVI 패턴은 기존의 MVP, MVVM 등 다른 패턴과는 다르게 데이터 흐름이 단방향
superohinsung.tistory.com
Model-View-Intent (MVI) 패턴은 단순한 아키텍처 패턴을 넘어서 반응형 프로그래밍(Reactive Programming)과 함수형 프로그래밍(Functional Programming) 철학을 Android 개발에 적용한 진화된 접근 방식이다. 이 패턴은 Redux, Cycle.js 등 웹 개발에서 검증된 단방향 데이터 흐름 개념을 모바일 환경에 맞게 발전시킨 것으로, Android의 복잡한 생명주기와 비동기 작업을 우아하게 다룰 수 있는 해법을 제시한다.
기존 MVVM 패턴에서 자주 발생하는 상태 중복(State Overlapping) 문제를 근본적으로 해결하는 것이 MVI의 핵심 목표이다. MVVM에서는 여러 LiveData 객체가 각각 다른 UI 상태를 관리하다 보니, 로딩 상태와 에러 상태가 동시에 표시되는 등의 일관성 문제가 발생할 수 있다. 하지만 MVI는 Single Immutable State를 통해 이러한 문제를 원천 차단한다.
아래 코드 예시 모두 프로젝트에서 진행한 코드이다.
MVI의 핵심 구성 요소
UiState - 불변성과 State Machine
data class MyPageUiState(
val nickname: String = "",
val email: String = "",
val quizCount: Int = 0,
val quizBookCount: Int = 0,
val quizSolvings: Map<String, Int> = emptyMap(),
val joinDateStr: String = "",
val isLoading: Boolean = false,
val errorMessage: String? = null
) : BaseContract.UiState
이 구조에서 주목할 점은 완전한 불변성(Complete Immutability)이다. 모든 속성이 val로 선언되어 있고, 상태 변경은 오직 copy() 함수를 통해서만 가능하다. 이는 State Machine 개념을 자연스럽게 구현하게 해준다.
State Machine으로서의 UiState
MVI에서 UiState는 단순한 데이터 홀더가 아닌 Finite State Machine의 역할을 한다. 각 상태는 명확하게 정의된 전환 규칙을 가지며, 예측 가능한 방식으로만 변경된다.
// 상태 전환 예시
val loadingState = currentState.copy(isLoading = true, errorMessage = null)
val successState = currentState.copy(
isLoading = false,
nickname = newData.nickname,
errorMessage = null
)
val errorState = currentState.copy(isLoading = false, errorMessage = error)
이러한 접근 방식은 Time Travel Debugging을 가능하게 하며, 각 상태 변화를 추적하고 재현할 수 있게 해준다.
Intent - 타입 안전한 액션 시스템
sealed class MyPageIntent : BaseContract.ViewIntent {
data object LoadUserInfo : MyPageIntent()
data class SuccessLoadUserInfo(val data: UserInfo) : MyPageIntent()
data class FailLoadUserInfo(val errorMessage: String) : MyPageIntent()
data object ClickUpdateUserInfo : MyPageIntent()
data object ClickLogout : MyPageIntent()
// ... 더 많은 Intent들
}
Intent 시스템은 Command Pattern과 타입 안전성을 결합한 강력한 구조이다. Sealed class를 사용함으로써 컴파일 타임에 모든 가능한 액션이 처리되는지 확인할 수 있으며, 새로운 Intent 추가 시 when 절에서 누락된 케이스를 즉시 발견할 수 있다.
Intent의 세분화 전략
여기서 흥미로운 점은 Intent의 세분화 수준이다. LoadUserInfo, SuccessLoadUserInfo, FailLoadUserInfo가 모두 별도의 Intent로 정의되어 있는데, 이는 비동기 작업의 생명주기를 명시적으로 모델링한 것이다. 이러한 접근 방식은 다음과 같은 이점을 제공한다.
- 명확한 책임 분리: 각 Intent가 하나의 명확한 의미를 가진다.
- 테스트 용이성: 각 상태 전환을 독립적으로 테스트할 수 있다.
- 디버깅 편의성: 로그를 통해 정확한 실행 흐름를 추적할 수 있다.
SideEffect - 일회성 이벤트의 우아한 처리
sealed class MyPageEffect : BaseContract.ViewEffect {
data object NavigateToMyCreatedQuizBooks : MyPageEffect()
data class NavigateToUpdateUserInfo(val step: Int) : MyPageEffect()
data object ShowUserInfoDialog : MyPageEffect()
data class ShowError(val message: String) : MyPageEffect()
}
SideEffect는 MVI 패턴에서 가장 논쟁이 많은 부분 중 하나이다. 상태가 아닌 이벤트를 어떻게 처리할 것인가는 오랫동안 MVI 커뮤니티의 핵심 이슈였다. 보여준 코드는 이 문제를 별도의 이벤트 스트림으로 해결하는 접근 방식이다.
Navigation과 Dialog 처리
전통적인 MVI에서는 "모든 것이 상태"라는 철학을 고수했지만, 실제 Android 개발에서는 네비게이션, 토스트, 다이얼로그 같은 일회성 이벤트가 불가피하다. 이를 상태에 포함시키면 다음과 같은 문제가 발생한다.
// 문제가 있는 접근법
data class UiState(
val shouldNavigate: Boolean = false,
val shouldShowDialog: Boolean = false
)
이 경우 네비게이션 후 상태를 다시 false로 되돌려야 하는 추가 작업이 필요하며, 이는 복원 시 의도치 않은 이벤트가 재실행될 수 있는데, 이는 MVVM 스러운 접근이다. 결국엔 SideEffect를 별도로 관리하는 것이 더 깔끔한 해결책이다.
BaseViewModel: Redux 패턴의 Android 구현
이번 프로젝트에서 BaseViewModel은 Redux 패턴을 Android 환경에 맞게 훌륭하게 구현한 경우이다. 이는 같은 팀원이 작업을 진행하였다.
abstract class BaseViewModel<State : BaseContract.UiState, Intent : BaseContract.ViewIntent, Effect : BaseContract.ViewEffect>(
initialState: State,
) : ViewModel() {
private val _state: MutableStateFlow<State> = MutableStateFlow(initialState)
val state: StateFlow<State> get() = _state.asStateFlow()
private val intent: MutableSharedFlow<Intent> = MutableSharedFlow()
private val _effect: MutableSharedFlow<Effect> = MutableSharedFlow()
val effect: SharedFlow<Effect> get() = _effect.asSharedFlow()
}
Flow 타입 선택의 분석
StateFlow vs SharedFlow vs Channel
코드에서 각기 다른 Flow 타입을 선택한 것은 매우 정교한 설계라고 생각한다.
StateFlow for State:
- Conflation: 중간 값들이 손실되어도 최신 상태만 중요
- Initial Value: 항상 현재 상태를 즉시 제공
- distinctUntilChanged: 동일한 상태의 중복 방출을 자동으로 방지
- Memory Optimization: 최신 값만 유지하여 메모리 효율적이다.
SharedFlow for Intent & Effect:
- Hot Stream: 구독자 여부와 관계없이 이벤트 처리 가능
- Multiple Observers: 여러 곳에서 동일한 이벤트 수신 가능
- No Initial Value: 이벤트는 발생 시점에만 의미가 있다.
- Replay Buffer: 필요에 따라 최근 이벤트 재전송 가능
StateFlow의 내부 동작 원리
StateFlow는 본질적으로 distinctUntilChanged()가 적용된 특수한 SharedFlow이다.
// StateFlow의 내부 구현 개념
val shared = MutableSharedFlow(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
shared.tryEmit(initialValue)
val state = shared.distinctUntilChanged()
이는 성능 최적화에 매우 중요하다. 동일한 상태 객체를 반복해서 방출하지 않음으로써 불필요한 리컴포지션을 방지한다.
Reduce 함수: 순수 함수형 상태 관리
override fun reduce(currentState: MyPageUiState, intent: MyPageIntent): MyPageUiState {
return when (intent) {
is MyPageIntent.LoadUserInfo -> currentState.copy(isLoading = true, errorMessage = null)
is MyPageIntent.SuccessLoadUserInfo -> currentState.copy(
nickname = intent.data.nickname,
quizCount = intent.data.quizCount,
quizBookCount = intent.data.quizBookCount,
quizSolvings = intent.data.quizCountByDate,
joinDateStr = intent.data.joinDateStr,
isLoading = false,
errorMessage = null
)
else -> currentState
}
}
Reduce 함수는 MVI의 심장부라고 할 수 있다. 이는 다음과 같은 순수 함수(Pure Function) 특성을 만족한다.
순수 함수의 이점
- 참조 투명성(Referential Transparency): 같은 입력에 대해 항상 같은 출력을 보장합니다
- 부작용 없음(No Side Effects): 외부 상태를 변경하지 않습니다
- 테스트 용이성: 입력과 출력만 확인하면 되는 단순한 테스트
- 병렬 처리 안전성: 여러 스레드에서 동시 실행 가능
@Test
fun `reduce LoadUserInfo should set loading true`() {
// Given
val currentState = MyPageUiState()
val intent = MyPageIntent.LoadUserInfo
// When
val newState = viewModel.reduce(currentState, intent)
// Then
assertThat(newState.isLoading).isTrue()
assertThat(newState.errorMessage).isNull()
}
이런 테스트는 빠르고, 안정적이며, 이해하기 쉽다.
단방향 데이터 흐름 분석
데이터 흐름의 세부 단계
제공된 코드에서 구현된 데이터 흐름을 단계별로 분석해보자.
- User Interaction: 사용자가 UI 요소와 상호작용
- Intent Creation: 상호작용이 특정 Intent로 변환
- Intent Processing: ViewModel이 Intent를 수신하고 처리
- State Reduction: Reduce 함수가 새로운 상태 생성
- State Emission: StateFlow를 통해 새 상태 방출
- UI Recomposition: Compose가 상태 변화를 감지하고 UI 업데이트
- Side Effect Handling: 필요시 일회성 이벤트 처리
// 실제 데이터 흐름 예시
@Composable
fun MyPageScreen(
state: MyPageUiState,
onClick: (MyPageIntent) -> Unit = {}
) {
// 6. UI Recomposition
MyPageUserName(state.nickname)
MyPageMenu { menuId ->
when (menuId) {
// 1-2. User Interaction -> Intent Creation
0 -> onClick(MyPageIntent.ClickUpdateUserInfo)
1 -> onClick(MyPageIntent.ClickLogout)
}
}
}
비동기 작업과 상태 관리
private suspend fun getUserInfo() {
getUserInfoUseCase().collect {
when (it) {
is Resource.Success -> {
sendIntent(MyPageIntent.SuccessLoadUserInfo(it.data))
}
is Resource.Failure -> {
sendIntent(MyPageIntent.FailLoadUserInfo(it.errorMessage))
}
}
}
}
이 패턴은 비동기 작업의 결과를 다시 Intent로 변환하여 단방향 흐름을 유지한다. 이는 콜백이나 직접적인 상태 변경보다 훨씬 추적하기 쉽고 테스트하기 용이한 구조이다.
성능 최적화와 메모리 관리
StateFlow와 성능 최적화
MVI 패턴에서 가장 중요한 성능 고려사항 중 하나는 불필요한 상태 업데이트 방지이다. 코드에서 StateFlow를 사용한 것은 이런 측면을 고려한 것이다.
distinctUntilChanged의 자동 적용
StateFlow는 내부적으로 distinctUntilChanged()를 적용하여 동일한 객체의 중복 방출을 방지한다.
// 이런 상황에서 중복 방출이 방지됩니다
val state1 = MyPageUiState(nickname = "test")
val state2 = MyPageUiState(nickname = "test")
// state1 == state2 이므로 두 번째 방출은 무시됩니다
하지만 주의할 점은 data class의 equals 구현에 의존한다는 것이다. 모든 프로퍼티가 같아야만 동일한 객체로 인식된다.
대용량 리스트 최적화
제공된 코드에서 quizSolvings: Map<String, Int>와 같은 컬렉션을 다룰 때는 특별한 주의가 필요하다. Compose에서는 컬렉션이 항상 unstable로 간주되어 리컴포지션이 빈번하게 발생할 수 있습니다.
// 최적화 전략 1: Immutable Collections 사용
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
data class MyPageUiState(
val quizSolvings: ImmutableMap<String, Int> = persistentMapOf(),
// ...
)
// 최적화 전략 2: Stable 래퍼 클래스
@Stable
data class QuizSolvingData(
val data: Map<String, Int>
)
메모리 누수 방지
MVI 패턴에서 메모리 누수를 방지하기 위한 핵심 포인트들이다.
CoroutineScope 관리
class MyPageViewModel : BaseViewModel() {
init {
viewModelScope.launch {
intent.collect { intent ->
// viewModelScope 사용으로 ViewModel 소멸 시 자동 취소
handleIntent(intent)
}
}
}
}
viewModelScope를 사용하면 ViewModel이 소멸될 때 모든 코루틴이 자동으로 취소된다.
Flow 구독 관리
View에서 Flow를 구독할 때는 lifecycleScope와 repeatOnLifecycle을 함께 사용해야 한다.
@Composable
fun MyPageScreen(viewModel: MyPageViewModel) {
val state by viewModel.state.collectAsState()
LaunchedEffect(Unit) {
viewModel.effect.collect { effect ->
when (effect) {
is MyPageEffect.ShowError -> {
// Handle effect
}
}
}
}
}
테스트 전략: MVI의 가장 큰 장점
Reduce 함수 테스트
MVI 패턴의 가장 큰 장점 중 하나는 테스트 용이성이다. 특히 reduce 함수는 순수 함수이므로 테스트가 매우 간단하다.
class MyPageViewModelTest {
@Test
fun `when LoadUserInfo intent, should set loading true and clear error`() {
// Given
val initialState = MyPageUiState(
isLoading = false,
errorMessage = "Previous error"
)
val intent = MyPageIntent.LoadUserInfo
// When
val result = viewModel.reduce(initialState, intent)
// Then
assertThat(result.isLoading).isTrue()
assertThat(result.errorMessage).isNull()
}
@Test
fun `when SuccessLoadUserInfo intent, should update user data and set loading false`() {
// Given
val userData = UserInfo(
nickname = "TestUser",
quizCount = 100,
quizBookCount = 10
)
val initialState = MyPageUiState(isLoading = true)
val intent = MyPageIntent.SuccessLoadUserInfo(userData)
// When
val result = viewModel.reduce(initialState, intent)
// Then
assertThat(result.isLoading).isFalse()
assertThat(result.nickname).isEqualTo("TestUser")
assertThat(result.quizCount).isEqualTo(100)
assertThat(result.quizBookCount).isEqualTo(10)
}
}
통합 테스트와 상태 흐름 검증
더 복잡한 시나리오를 위한 통합 테스트 예시:
@Test
fun `complete user info loading flow`() = runTest {
// Given
val mockUserInfo = UserInfo(nickname = "Test")
whenever(getUserInfoUseCase()).thenReturn(flowOf(Resource.Success(mockUserInfo)))
val states = mutableListOf<MyPageUiState>()
val job = launch {
viewModel.state.collect { states.add(it) }
}
// When
viewModel.sendIntent(MyPageIntent.LoadUserInfo)
// Then
assertThat(states).hasSize(3)
assertThat(states[0].isLoading).isFalse() // Initial state
assertThat(states[39].isLoading).isTrue() // Loading state
assertThat(states[40].isLoading).isFalse() // Success state
assertThat(states[40].nickname).isEqualTo("Test")
job.cancel()
}
이런 테스트는 전체 데이터 흐름을 검증하면서도 빠르고 안정적이다.
Jetpack Compose와의 시너지
State Hoisting과 MVI의 결합
Jetpack Compose의 State Hoisting 개념은 MVI와 완벽하게 조화를 이룬다.
@Composable
fun MyPageScreen(
state: MyPageUiState,
onIntent: (MyPageIntent) -> Unit
) {
// Stateless Composable - 완벽한 State Hoisting
Column {
MyPageUserName(
nickname = state.nickname,
onEditClick = { onIntent(MyPageIntent.ClickUpdateNickname) }
)
MyPageStats(
quizCount = state.quizCount,
quizBookCount = state.quizBookCount
)
if (state.isLoading) {
CircularProgressIndicator()
}
}
}
리컴포지션 최적화
MVI와 Compose를 함께 사용할 때 리컴포지션 최적화는 매우 중요하다.
@Composable
fun OptimizedMyPageScreen(
state: MyPageUiState,
onIntent: (MyPageIntent) -> Unit
) {
// 상태의 일부만 변경되어도 전체가 리컴포지션되는 것을 방지
val nickname by remember(state.nickname) { mutableStateOf(state.nickname) }
val isLoading by remember(state.isLoading) { mutableStateOf(state.isLoading) }
Column {
// 개별 상태를 직접 전달하여 불필요한 리컴포지션 방지
UserNameSection(
nickname = nickname,
onEditClick = { onIntent(MyPageIntent.ClickUpdateNickname) }
)
if (isLoading) {
LoadingSection()
}
}
}
마무리: MVI 패턴의 미래와 권장사항
- 명확한 책임 분리: UiState, Intent, Effect가 각각의 역할을 명확히 수행
- 적절한 Flow 타입 선택: StateFlow, SharedFlow의 특성을 정확히 이해하고 활용
- 순수 함수형 설계: Reduce 함수의 부작용 없는 구현
- 실용적인 비동기 처리: UseCase와의 깔끔한 통합
적용 권장 시나리오
MVI 패턴이 특히 유용한 경우:
- 복잡한 UI 상태를 가진 화면 (폼, 다단계 프로세스)
- 실시간 데이터 업데이트가 빈번한 앱 (채팅, 피드)
- 높은 품질과 안정성이 요구되는 프로젝트
- Jetpack Compose 기반 프로젝트
- 팀 규모가 크고 코드 일관성이 중요한 프로젝트
기존 MVVM이 더 적합한 경우:
- 단순한 CRUD 화면
- 프로토타입이나 MVP 개발
- 팀의 러닝 커브를 고려해야 하는 상황
- 레거시 코드가 많은 프로젝트.
'Android > Study' 카테고리의 다른 글
| [Android] Retrofit 네트워크 Mapper와 Utility 아키텍처 설계를 어떻게 하였을까? (0) | 2025.08.25 |
|---|---|
| [Android] DataStore를 왜 쓸까? feat. 내부 데이터 저장하기 (0) | 2025.08.24 |
| [Android] Compose Navigation은 어떻게 하면 좋을까? (0) | 2025.08.19 |
| [Android] Orbit으로 MVI 구현기 (0) | 2025.08.19 |
| [Android] Orbit Multiplatform ViewModel, ComposeUI, Test 탐구하기 (0) | 2025.07.05 |