
안드로이드 MVI 패턴은 모든 화면의 정답은 아니지만, 상태 전이가 자주 꼬이는 화면에서는 꽤 강합니다. 이번 글에서는 안드로이드 MVI 패턴이 언제 잘 맞는지, MVVM과 무엇이 다른지, 그리고 왜 어떤 화면에서는 더 안전하게 느껴지고 어떤 화면에서는 오히려 무거워지는지를 상태 흐름 기준으로 정리해보겠습니다.
결론부터 말하면 상태가 많고 이벤트 종류가 분명한 화면은 MVI가 잘 맞고, 단순 폼이나 CRUD 화면은 MVVM이 더 가볍게 끝나는 경우가 많습니다. 핵심은 패턴 이름이 아니라 상태 변경 경로를 얼마나 분명하게 관리해야 하느냐입니다.
안드로이드 MVI 패턴을 한 줄로 보면
안드로이드에서 말하는 MVI는 보통 이벤트를 받고, 현재 상태를 기준으로 새 상태를 만들고, UI는 그 상태만 그리는 흐름으로 이해하면 가장 쉽습니다. 공식 문서도 이름 자체를 MVI로 고정하지는 않지만, single source of truth와 unidirectional data flow를 계속 강조합니다. 결국 MVI가 매력적으로 느껴지는 이유도 이 원칙을 화면 단위에서 더 엄격하게 적용하기 때문입니다.
- 사용자가 이벤트를 보낸다
- ViewModel이 이벤트를 해석한다
- 리듀서나 상태 변경 함수가 새 UI 상태를 만든다
- UI는 그 상태를 그대로 그린다
- 네비게이션, 토스트 같은 부수효과는 별도 흐름으로 처리한다
여기서 중요한 것은 상태가 여기저기서 몰래 바뀌지 않는다는 점입니다. 값이 바뀌는 문이 적을수록 화면을 추적하기 쉬워지고, 버그가 났을 때도 어디서 틀어졌는지 찾기가 빨라집니다.
reducer 직관은 어렵지 않다
reducer라는 말이 거창하게 들리지만, 실제로는 현재 상태와 이벤트를 받아서 다음 상태를 계산하는 함수라고 보면 됩니다. 즉 if 문이 흩어져 있던 자리를 한곳으로 모아두는 감각에 가깝습니다.
data class SearchUiState(
val query: String = "",
val isLoading: Boolean = false,
val items: List<String> = emptyList(),
val errorMessage: String? = null
)
sealed interface SearchEvent {
data class QueryChanged(val value: String) : SearchEvent
data object SearchClicked : SearchEvent
data class SearchSucceeded(val items: List<String>) : SearchEvent
data class SearchFailed(val message: String) : SearchEvent
}
fun reduce(state: SearchUiState, event: SearchEvent): SearchUiState =
when (event) {
is SearchEvent.QueryChanged -> state.copy(query = event.value)
SearchEvent.SearchClicked -> state.copy(isLoading = true, errorMessage = null)
is SearchEvent.SearchSucceeded -> state.copy(
isLoading = false,
items = event.items,
errorMessage = null
)
is SearchEvent.SearchFailed -> state.copy(
isLoading = false,
errorMessage = event.message
)
}이 코드는 패턴을 멋있게 보이게 하려는 예시가 아닙니다. 검색 화면에서 isLoading, items, errorMessage가 어떤 이벤트를 통해 바뀌는지 한 자리에서 읽히게 만드는 예시입니다. MVI의 핵심 장점은 상태 전이의 위치가 눈에 보인다는 점입니다.
MVVM과 진짜 차이는 어디서 날까
MVVM과 MVI를 너무 극단적으로 나누면 오해가 생깁니다. 실제 안드로이드 코드에서는 둘 다 ViewModel을 state holder로 쓰고, 둘 다 ViewModel과 StateFlow를 함께 쓸 수 있습니다. 차이는 이름보다 상태를 얼마나 한 덩어리로 보고, 이벤트를 얼마나 엄격하게 통제하느냐에서 나는 경우가 많습니다.
- MVVM은 속성별로 상태를 나누거나, 메서드별로 상태를 갱신하는 식으로 비교적 자유롭게 설계되는 편이다
- MVI는 화면 상태를 하나의 모델로 두고 이벤트를 통해 상태 전이를 모으는 쪽에 더 가깝다
- MVVM은 잘 짜면 충분히 단방향 흐름을 가질 수 있고, MVI도 구현을 느슨하게 하면 그냥 이름만 MVI가 될 수 있다
그래서 실무에서는 ‘MVVM이냐 MVI냐’보다 이 화면에서 상태 변경 경로를 얼마나 좁혀야 하는가를 먼저 묻는 편이 낫습니다. 상태와 이벤트 분리 감각이 아직 흐리다면 StateFlow와 SharedFlow 차이를 같이 보면 이해가 훨씬 빨라집니다.
MVI가 더 안전하게 느껴지는 화면
MVI가 특히 빛나는 화면은 보통 상태 조합이 많고, 사용자가 빠르게 이벤트를 연속으로 일으키며, 로딩·성공·에러·빈 결과 같은 전이가 자주 엇갈리는 화면입니다. 예를 들면 검색, 필터링 목록, 장바구니, 결제 직전 확인 화면, 여러 입력 조건이 붙는 예약 화면이 그렇습니다.
- 로딩, 결과, 빈 상태, 에러를 동시에 다뤄야 한다
- 같은 화면에서 검색어 변경, 필터 변경, 재시도, 새로고침 같은 이벤트가 많다
- 이전 요청 결과가 뒤늦게 도착했을 때 화면이 꼬이기 쉽다
- 디버깅할 때 '어떤 이벤트 뒤에 이 상태가 나왔는지'를 추적해야 한다
이런 화면에서는 모든 전이를 한 흐름에 태워 두는 것이 심리적으로도 훨씬 안전합니다. Android UI layer 문서가 말하는 것처럼 UI는 상태를 관찰하고, 사용자 의도를 전달하고, 상태의 출처는 분리될수록 테스트와 유지보수가 쉬워집니다. 그래서 MVI는 복잡한 화면에서 실수할 수 있는 길을 줄여주는 패턴처럼 느껴집니다.
이벤트 처리에서 꼭 분리할 것
MVI를 쓴다고 해도 모든 것을 상태에 넣으면 오히려 더 복잡해집니다. 특히 토스트, 스낵바, 네비게이션 같은 부수효과를 상태에 넣어두면 재구독 시 다시 실행되는 문제가 생기기 쉽습니다. 공식 Compose 아키텍처 문서도 모든 입력을 event로 보고 ViewModel이 state를 업데이트하는 흐름을 권장하지만, 상태와 부수효과는 같은 상자가 아니라 다른 흐름으로 보는 편이 안전합니다.
sealed interface LoginEffect {
data object NavigateHome : LoginEffect
data class ShowToast(val message: String) : LoginEffect
}
class LoginViewModel : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState
private val _effect = MutableSharedFlow<LoginEffect>(
replay = 0,
extraBufferCapacity = 1
)
val effect: SharedFlow<LoginEffect> = _effect
}
상태는 StateFlow, 일회성 효과는 SharedFlow처럼 나누면 왜 많은 화면에서 MVI가 더 단단하게 느껴지는지 바로 체감됩니다. 이 포인트는 안드로이드 앱 구조 판단 기준에서 말한 데이터 흐름과도 그대로 연결됩니다.
반대로 언제 무거워질까
문제는 모든 화면이 그렇게 복잡하지 않다는 점입니다. 입력칸 두세 개 있는 설정 화면, 단순 상세 화면, 버튼 하나 누르면 끝나는 화면까지 이벤트와 상태와 effect를 다 분리하면 얻는 것보다 ceremony가 더 커질 수 있습니다.
- 상태 객체가 지나치게 커져서 오히려 읽기 어려워진다
- 이벤트 sealed class가 너무 잘게 쪼개져 파일 수만 늘어난다
- 단순 값 변경에도 reducer를 거치느라 코드가 장황해진다
- 팀 전체가 패턴에 익숙하지 않으면 작은 수정도 느려진다
이때는 MVI를 억지로 유지하기보다, MVVM 구조 안에서 필요한 부분만 단방향으로 엄격하게 만드는 편이 더 현실적입니다. 예를 들어 화면 상태를 하나의 UiState로 묶고, 이벤트성 값만 분리해도 이미 상당히 안정됩니다. 레이어를 어디까지 늘릴지 고민 중이라면 안드로이드 클린 아키텍처, 작은 앱에도 필요할까?도 함께 참고할 만합니다.
화면 단위로 고르는 체크리스트
- 이 화면은 상태 종류가 많고 전이 규칙이 복잡한가
- 사용자 이벤트가 여러 종류이며 순서에 따라 버그가 잘 나는가
- 디버깅할 때 상태가 왜 이렇게 됐는지 추적 가능성이 중요한가
- 토스트, 네비게이션, 스낵바 같은 부수효과가 자주 섞이는가
- 반대로 단순 폼이나 조회 화면처럼 상태 전이가 거의 없는가
앞의 네 질문에 yes가 많으면 MVI 쪽이 잘 맞을 가능성이 높고, 마지막 질문이 더 크게 다가오면 MVVM이 더 실용적일 수 있습니다. 결국 중요한 것은 패턴 통일감보다 화면 복잡도와 팀의 운영 비용입니다.
마무리
안드로이드 MVI 패턴은 멋있어서 쓰는 패턴이라기보다, 상태 전이와 이벤트 처리의 길을 좁혀서 화면을 더 안전하게 관리하고 싶을 때 힘을 발휘하는 패턴에 가깝습니다. 복잡한 검색 화면, 필터 화면, 주문 화면처럼 상태가 자주 엇갈리는 곳에서는 분명한 장점이 있습니다. 하지만 단순한 화면까지 같은 강도로 밀어붙이면 금방 무거워집니다.
그래서 실무에서는 MVVM과 MVI를 진영처럼 나누기보다, 이 화면이 단방향 상태 흐름을 얼마나 강하게 필요로 하는가를 먼저 보는 편이 더 낫습니다. 아키텍처는 패턴 이름보다 화면을 덜 꼬이게 만드는 쪽이 결국 오래 갑니다. 공식 기준은 Guide to app architecture, UI layer, Compose and architecture 문서를 같이 보면 더 선명해집니다.