MVI 도입배경
프로젝트에 Jetpack Compose를 도입하고 1년정도 적극 쓰면서 ‘상태’ 관리의 중요성을 머리가 아닌 몸으로 느껴버렸다. 상태 관리를 어떻게 하면 좋을까 고민하던 중 동료 개발자가 이전에 나에게 말해줬던 MVI가 떠올랐다.
“MVI 는 상태를 쉽게 관리해준다구 blah blah…”
Compose 도입 이전에는 그땐 상태 관리를 크게 중요하게 생각하지 않았다. 아니 어쩌면 관리가 엉망인데 잘 관리되고 있다고 믿고 있었나보다.
Compose를 쓰면서 UI가 갱신되지 않거나 Recomposition이 빈번하게 발생하는 것을 확인했다. 그러고 나서야 상태가 관리되지 않으면 앱의 품질이 떨어지고 퍼포먼스가 저하 될 수 있다는 것을 깨달았다.
앱의 상태는 시간의 흐름에 따라 다음과 같이 다양하게 변경될 수 있다.
- 네트워크 연결이 끊어졌을 때 표현되는 스낵바
- 사용자의 입력에 의해 작성되는 텍스트
- 특정 시간에 울리는 알람소리
일반적으로 이 분야에서 상태(state)라 함은 클래스 오브젝트의 어떠한 값 또는 데이터다. 예) ViewModel 객체에 선언되어있는 State
Compose의 업데이트는 상태의 변경에 의해서 이루어지며 이를 재구성(Recomposition)이라고 한다.
Compose를 도입한 이후, 개발자는 더 이상 View에 접근해서 업데이트 하는 수고를 덜었으며 그저 ‘상태 관리’에만 집중하면 된다.
MVI란 무엇인가?
MVC, MVP 또는 MVVM 처럼 MVI도 관심사를 나누고 해당 관심사의 앞글자를 따서 만든 패턴이다.
Model: UI에 반영될 상태를 의미한다. 그러므로 MVP 또는 MVVM 모델의 정의와는 다르다.
View: UI 그 자체다. View, Activity, Fragment, Compose 등이 될 수 있다.
Intent: 사용자 액션 및 시스템 이벤트에 따른 결과
일반적으로 이 Model, View, Intent의 상관관계는 다음과 같이 순수함수 형식으로 표현한다.
예를 들어보자면, 사용자가 View를 클릭해서 화면 목록을 갱신하고자 한다. 목록을 갱신하고자 하는 의도(Intent)가 결국 새로운 모델, 즉 상태를 업데이트 하게 되고 이것이 View(일반적으로 Compose)에 반영된다.
MVI는 단방향 흐름(Uni-directional flow) 구조다. 그렇기 때문에 다음 그림처럼 표현하기도 한다.
사용자의 액션이 새로운 Intent로 변경되고, 해당 Intent로 부터 새로운 Model을 만들어 View를 갱신하는 흐름을 보여준다.
MVI에서 Model은 상태를 표현하는 변경 불가한 데이터다. 앱의 상태는 단방향 흐름에서 Intent로부터 Model을 생성할 때만 새로운 Model 객체를 생성한다. 이러한 구조로 인해 우리는 예측가능하도록 상태를 설정할 수 있고 이로 인해 디버깅이 쉬워진다.
만약 당신이 MVVM을 사용해왔다면, MVI는 전혀 새로운 것이 아니며 이를 위해 전체를 변경할 필요는 없다. VM(View Model)은 상태가 관리되기 위해 좋은 장소이며 다이어그램으로 나타내자면 다음과 같다.
위 그림은 MVVM 계층 관점에서 MVI가 어느 계층에 속하는지 보여준다.
MVI 예제코드1
다음 예제코드는 Event 발생 -> State 변경 -> View 반영 순서로 MVI 패턴을 구현하고 있다.
Event는 Increment와 Decrement로 나뉘며 이는 각각 State.count 값을 1씩 증가 또는 감소 시킨다.
class ViewModel(){ private val _state = MutableStateFlow(State()) val state:StateFlow<State> = _state override suspend fun onEvent(event: Event) { when (event) { is Event.Increment -> { _state.value = _state.value.copy(count = _state.value.count + 1) } is Event.Decrement -> { _state.value = _state.value.copy(count = _state.value.count - 1) } } } }
하지만 이 코드는 스레드에 안전하지 않다. onEvent가 서로 다른 스레드에서 호출될 경우 동시성 이슈가 발생하기 때문이다.
실제 서로 다른 스레드에서 onEvent(Increment)와 onEvent(Decrement)를 10만번씩 호출해보면 0이 아닌 값이 나올 확률이 높다. (재현 가능한 테스트 코드)
들어오는 이벤트로부터 _state.update{…} 를 통해 상태를 변경하면 동시성 오류는 해결할 수 있지만, MVI패턴을 구현하긴 위한 근본적인 해결방법은 아니다.
MVI 예제코드2
동시성 오류를 회피하기 위해 다음과 같이 코드를 작성할 수 있다.
class ViewModel { private val events = Channel<Event>() private val _state = MutableStateFlow(State()) val state:StateFlow<State> = MutableStateFlow(State()) init { events.receiveAsFlow() .onEach(::updateState) .launchIn(viewModelScope) } override suspend fun onEvent(event: Event) { events.send(event) } private fun updateState(event: Event) { when (event) { is Event.Increment -> { _state.value = _state.value.copy(count = _state.value.count + 1) } is Event.Decrement -> { _state.value = _state.value.copy(count = _state.value.count - 1) } } } }
Channel을 도입하여 이벤트를 순차적으로 처리하게 끔 변경했다. 이로 인해 스레드 안정성이 보장된다.
하지만 이 예제코드에도 한가지 아쉬운 점이 존재하는데, updateState 함수 밖에서도 _state를 변경할 수 있다는 점이다.
MVI 예제코드3 (State Reducer)
Reducer = (State, Event) -> State
Reducer란 현재의 상태와 전달 받은 이벤트를 참고하여 새로운 상태를 만드는 것을 말한다. Reducer를 통해 예제코드2가 가지고 있던 문제점을 해결해보자.
class ViewModel { private val events = Channel<Event>() // State Reducer val state = events.receiveAsFlow() .runningFold(State(), ::reduceState) .stateIn(viewModelScope, SharingStarted.Eagerly, State()) override suspend fun onEvent(event: Event) { events.send(event) } private fun reduceState(current: State, event: Event): State { return when (event) { is Event.Increment -> current.copy(count = current.count + 1) is Event.Decrement -> current.copy(count = current.count - 1) } } }
이벤트 채널로부터 상태를 변경하기 때문에 이제 더 이상 외부에서 상태를 변경할 수 있는 요인은 없다. 상태 관리를 한곳에서 할 수 있게 되면서 Race Condition을 배제 시키고, 상태를 예측하기 쉽고, 디버깅을 수월하게 할 수 있게 되었다.
MVI 장단점 정리
지금까지 설명한 내용으로 장단점을 정리해보자면 다음과 같다.
장점
- 상태 관리가 쉽다
- 데이터가 단방향으로 흐른다.
- 스레드 안정성을 보장한다.
- 디버깅 및 테스트가 쉽다
단점
- 러닝커브가 가파르다
- 보일러플레이트 코드가 양산된다.
- 작은 변경도 Intent로 처리 해야한다.
- Intent, State, Side Effect 등 모든 상태에 대한 객체를 생성해야 하므로 파일 및 메모리 관리에 유의해야 한다.
실전 예제 – 사용자 목록 불러오기
sealed interface MainEvent{ object Loading:MainEvent // 프로그레스 바 표시 class Loaded(val users:List<User>): MainEvent // 유저 목록 불러온 결과 } data class MainState( val users: List<User> = emptyList(), val loading:Boolean = false, val error: String? = null ) @HiltViewModel class MainViewModel @Inject constructor( private val repository: MainRepository ) : ViewModel() { private val events = Channel<MainEvent>() val state: StateFlow<MainState> = events.receiveAsFlow() .runningFold(MainState(), ::reduceState) .stateIn(viewModelScope, SharingStarted.Eagerly, MainState()) private fun reduceState(current: MainState,event:MainEvent):MainState{ return when(event){ // 현재 상태와 들어온 이벤트를 참조하여 새로운 상태를 만들어 낸다. MainEvent.Loading -> { current.copy(loading = true) } is MainEvent.Loaded -> { current.copy(loading = false, users = event.users) } } } fun fetchUser() { // fetch 버튼을 클릭하면 순차적으로 이벤트를 발생시킨다. viewModelScope.launch { events.send(MainEvent.Loading) val users = repository.getUsers() events.send(MainEvent.Loaded(users = users)) } } }
Side Effects
실세계에서 View(Model(Intent())) 순수함수 구조로만 잘 순환하길 기대하지만 현실은 그렇지 못하다. 간혹 상태를 변경할 필요가 없는 이벤트가 필요할 수도 있기 때문이다. 예를 들면 Activity/Fragment 이동, Logging, Analytics, 토스트 노출 등이 그에 해당한다. 그렇기 때문에 MVI를 언급할 때 일반적으로 Side Effects(부수효과)라는 개념을 써서 이를 처리 한다. 위 실전 예제 코드를 예시로 사용자 수(users.size)를 토스트로 노출 하는 Side Effect를 만들어 보자.
@HiltViewModel class MainViewModel @Inject constructor( private val repository: MainRepository ) : ViewModel() { private val events = Channel<MainEvent>() val state: StateFlow<MainState> = events.receiveAsFlow() .runningFold(MainState(), ::reduceState) .stateIn(viewModelScope, SharingStarted.Eagerly, MainState()) private val _sideEffects = Channel<String>() // 사이드 이펙트 처리용 채널 val sideEffects = _sideEffects.receiveAsFlow() private fun reduceState(current: MainState,event:MainEvent):MainState{ return when(event){ MainEvent.Loading -> { current.copy(loading = true) } is MainEvent.Loaded -> { current.copy(loading = false, users = event.users) } } } fun fetchUser() { viewModelScope.launch { events.send(MainEvent.Loading) val users = repository.getUsers() events.send(MainEvent.Loaded(users = users)) _sideEffects.send("${users.size} user(s) loaded") // 사이드 이펙트 발생 } } }
16개의 댓글
성빈 · 2022년 12월 30일 3:10 오전
항상 양질의 게시글 감사합니다. 이번에도 잘 보았습니다.
Charlezz · 2023년 2월 9일 8:14 오후
읽어주셔서 감사합니다!
조시 · 2023년 1월 4일 10:29 오전
이렇게 좋은 글을 볼 수 있어 감사합니다. 다음에도 다른 글 올려주시면 감사히 읽어보도록 하겠습니다 🙂
Charlezz · 2023년 2월 9일 8:14 오후
읽어주셔서 감사합니다!
1209 · 2023년 1월 19일 6:08 오후
글 잘 읽었습니다.
Charlezz · 2023년 2월 9일 8:01 오후
읽어주셔서 감사합니다!
초보 Developer · 2023년 3월 24일 1:25 오전
안녕하세요 찰스님
덕분에 MVI에 대해서 잘 이해하게 되었습니다.
한가지 궁금한 점이 있는데 MVI 예제코드2에서 updateState 함수 밖에서도 _State를 변경할 수 있다고 말씀하셨는데 그 이유에 대해 알 수 있을까요..?
답변해주시면 정말 감사드립니다 🙂
Charlezz · 2023년 3월 24일 7:53 오후
_state에 접근하여 내부의 값을 변경가능하다는 것이 아쉽다는 점 이외에 본문에서 다른 의도는 없었습니다.
초보 Developer · 2023년 3월 25일 11:54 오후
아 이해했습니다~ 감사합니다 찰스님 🙂
huiung · 2023년 4월 25일 2:33 오후
안녕하세요 찰스님~ MVI관련 포스팅을 살펴보면서 궁금한 점이 한가지 생겨서 댓글 남기게 되었습니다.
예를 들어서, 갱신해야 될 데이터가 따로 처리 되어야 하는 케이스(각기 다른 Api를 호출하고, 각각의 데이터는 ConcatAdapter로 구성된 각각의 adapter에 데이터를 갱신함)가 있다면, 추가 적인channel과 stateFlow, render()함수를 별도로 하나씩 더 만들어서 state관리를 하는 것이 맞다고 생각하실까요? 이런 상황에서 어떻게 처리 할 수 있을지 궁금합니다!
Charlezz · 2023년 4월 26일 12:32 오전
하나의 스크린에 대한 상태를 단일로 관리하냐 다중으로 관리하냐 차이를 물어보시는 거죠?
정답은 없습니다. 단일이던 다중이던 장단점이 존재하니까요.
단일 상태관리가 여러가지 측면에서 접근하기 쉬운반면에 상태 변경 시(예: copy() 호출) 메모리를 많이 사용하게 되죠. 일반적인 경우에 크게 문제가 되지 않기 때문에 단일로 상태를 관리하시는 것을 추천합니다.
다중 상태관리는 상대적으로 관리가 어려운 반면에 메모리를 적게 사용하겠죠.
제가 쓴 이 포스팅에서 질문하셨기 때문에 최대한 그에 맞게 대답했지만, 이것도 상황별로 또 이야기가 달라질 수 있기 때문에 무엇이 맞고 틀리다라고 말씀드리기가 어렵습니다.
상태관리 측면에서 ConcatAdapter는 사용여부는 중요하지 않기 때문에 이에 대한 답은 생략하겠습니다.
huiung · 2023년 4월 26일 11:25 오전
답변 감사합니다!!
ㅎㅎ · 2023년 4월 28일 6:39 오후
안녕하세요 찰스님! 글 너무 잘 읽었습니다!
글을 읽다보니 궁금한 점이 하나 생겨서 댓글 남겨요.
MVI 예제코드1의
“들어오는 이벤트로부터 _state.update{…} 를 통해 상태를 변경하면 근본적인 해결방법은 아니다.” 에서 update를 사용한 해결방법이 왜 “근본”적이 아닌지 궁금합니다 … !
Charlezz · 2023년 5월 2일 12:26 오후
수정을 여러번 거치면서 일부 내용이 중간에 잘렸었네요.
다시 수정했습니다. 감사합니다.
goluckcy · 2023년 7월 13일 6:31 오후
안녕하세요 ! 글 잘 읽었습니다.
궁금한 점이 하나 생겨서 질문 드립니다.
Event나 SideEffect를 다루는 데에 Channel을 사용하였는데, 혹시 ViewModel Lifecycle에 맞춰서 따로 Close 해주지 않아도 문제가 없는지 궁금합니다 !
응애 · 2023년 8월 16일 3:47 오후
님 ui 에서 스테이트 옵저빙 할 때 어케 하심?
마지막 상태 항상 남아있는거 때문에 IdleState 이런걸로 항상 초기화 해주는데