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 예제코드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") // 사이드 이펙트 발생
        }
    }

}

후원하기

카테고리: Composeetc

3개의 댓글

성빈 · 2022년 12월 30일 3:10 오전

항상 양질의 게시글 감사합니다. 이번에도 잘 보았습니다.

조시 · 2023년 1월 4일 10:29 오전

이렇게 좋은 글을 볼 수 있어 감사합니다. 다음에도 다른 글 올려주시면 감사히 읽어보도록 하겠습니다 🙂

1209 · 2023년 1월 19일 6:08 오후

글 잘 읽었습니다.

답글 남기기

Avatar placeholder

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다.