[Architecture] 입문편. MVI란 무엇일까?

이승우·2023년 5월 25일
0

안드로이드 개발자는 확장 가능하고 유지 보수하기 쉬운 앱을 개발하기 위해 MVC, MVP, MVVM과 같은 아키텍쳐 패턴을 선택하여 구현해왔다. 그렇다면 기존에 존재하는 아키텍쳐 패턴이 있는데 왜 MVI가 나온것일까?

MVC, MVP, MVVM

MVVM을 많이 사용하고 있지만 이러한 디자인 패턴을 사용하면 관심사의 분리를 통해 테스트 코드의 작성을 용이하게 해주고 유지보수도 원활해진다. 따라서 대부분의 회사에서 아키텍쳐 패턴의 사용을 필수적이라고 볼 수 있다.

하지만, 이 아키텍쳐 패턴들도 장점만 존재하는 것은 아니다.

🤔 문제점

  • 다양한 곳으로부터 들어오는 입출력을 관리해야 하는 경우, 백그라운드 스레드를 사용하게 되는데 이는 동시성 문제와 같은 이슈를 유발할 수 있다.
  • 안드로이드는 상태의 집합이다. 화면에 나타나는 모든 정보나, 버튼 활성화 등 상태들로 구성되어 있다.
  • 이러한 상태들을 관리하기 힘들어지고, 의도하지 않는 방향으로 제어가 된다면 상태 문제가 발생한다.

🤩 MVI는 무엇일까?

MVI는 Model-View-Intent의 형태를 띄고 있다. Hannes Dorfmann이 처음 창시했는데, 행위가 단방향으로 이루어져 있으며(Uni-directinal), 그 방향이 사이클을 이루고 있는 (cycle flow)Cycle.js 프레임워크에 영감을 받아 안드로이드에서 사용할 수 있는 새로운 패턴으로 등장하게 되었다.

MVI는 기존의 아키텍쳐 패턴들과는 다르게 동작한다.

1) Model

  • Model은 (단일한)상태를 나타낸다.
  • 아키텍쳐의 다른 레이어와의 단방향 흐름을 보장하기 위해 변경이 불가능한 불변성을 보장해야 한다.

2) View

  • View를 나타내며 하나 이상의 Activity or Fragment로 구현된다.

3) Intent

  • Intent는 앱이나 사용자가 취하는 행위를 나타내기 위한 의도이다. View는 Intent를 받고 ViewModel은 Intent를 옵저빙하여 Model은 그에 따라 새로운 상태로 변환한다.

Models

다른 아키텍쳐 패턴에서 데이터베이스나 API 같은 백엔드와의 연결 고리 역할을 하는 것과 다르게 MVI에서 Model은 데이터를 가지고 있는 역할을 하면서 앱의 상태를 나타낸다.

앱의 상태란?
반응형 프로그래밍에서 사용자가 UI에 있는 버튼을 클릭하거나 변수의 값이 바뀔 때 앱이 변화할 수 있다. 앱이 변화할 때, 이는 새로운 상태로 전환된다고 할 수 있다. 새로운 상태라는 것은 간단하게 어떠한 행위로 인해 UI 에 프로그래스바가 보인다거나 다른 화면으로 전환되거나, 새로운 리스트의 데이터가 생겨 이전과는 다른 결과를 보여주는 것이다.

아래 예시를 확인해보자.

[영화의 정보를 보여주기 위한 Movie data class]

data class Movie(
  var voteCount: Int? = null,
  var id: Int? = null,
  var video: Boolean? = null,
  var voteAverage: Float? = null,
  var title: String? = null,
  var popularity: Float? = null,
  var posterPath: String? = null,
  var originalLanguage: String? = null,
  var originalTitle: String? = null,
  var genreIds: List<Int>? = null,
  var backdropPath: String? = null,
  var adult: Boolean? = null,
  var overview: String? = null,
  var releaseDate: String? = null
)

ViewModel or Presenter을 통해 아래처럼 영화 리스트를 조회할 수 있다.

class MainPresenter(private var view: MainView?) {    
  override fun onViewCreated() {
    view.showLoading()
    loadMovieList { movieList ->
      movieList.let {
        this.onQuerySuccess(movieList)
      }
    }
  }
  
  override fun onQuerySuccess(data: List<Movie>) {
    view.hideLoading()
    view.displayMovieList(data)
  }
}

이런 접근법이 나쁘지 않지만, 몇가지 문제점이 존재한다.

  1. Mutlple inputs : MVP와 MVVM에서 Presenter와 ViewModel은 많은 수의 입출력을 관리해야 하는 경우가 많다. 이 경우에는 백그라운드 스레드를 사용할 수 있는데 이는 동시성과 같은 큰 문제로 이어질 수 있다.

  2. Multiple States : MVP와 MVVM에서 비즈니스 로직과 View는 언제든 다른 상태를 가질 수 있다. 개발자는 Observable과 Observer 콜백의 상태를 동기화 시킨다. 하지만, 행위의 충돌을 야기할 수 있다.

이러한 이슈를 해결하기 위해 모델이 데이터가 아닌 상태를 나타내도록 한다. 위의 Movie는 아래처럼 표현할 수 있다.

sealed class MovieState {
  object LoadingState : MovieState()
  data class DataState(val data: List<Movie>) : MovieState()
  data class ErrorState(val data: String) : MovieState()
  data class ConfirmationState(val movie: Movie) : MovieState()
  object FinishState : MovieState()
}

이렇게 Model을 만들었을 경우, 더 이상 상태를 View 또는 Presenter나 ViewModel과 같이 여러 곳에서 관리할 필요가 없다. Model이 그 자체로 언제 프로그래스 바를 표시해야할지 아이템 리스트를 표시해야 할지를 알려주게 된다.

그렇다면 코드를 아래처럼 개선해볼 수 있다.

class MainPresenter {
  
  private val compositeDisposable = CompositeDisposable()
  private lateinit var view: MainView

  fun bind(view: MainView) {
    this.view = view
    compositeDisposable.add(observeMovieDeleteIntent())
    compositeDisposable.add(observeMovieDisplay())
  }
  
  fun unbind() {
    if (!compositeDisposable.isDisposed) {
      compositeDisposable.dispose()
    }
  }
  
  private fun observeMovieDisplay() = loadMovieList()
      .observeOn(AndroidSchedulers.mainThread())
      .doOnSubscribe { view.render(MovieState.LoadingState) }
      .doOnNext { view.render(it) }
      .subscribe()
}

Presenter는 이제 View의 상태를 위한 하나의 아웃풋(Model)을 갖는다. 이것은 앱의 현재 상태를 전달받는 View의 render()라는 함수로 전달되어 뷰를 표현한다.

MVI에서 모델의 또 다른 특징은 Single source of truth로서 비즈니스 로직을 유지하기 위해 모델은 불변성(Immutable)을 지니고 있어야 한다는 것이다. 이 방식은 여러 클래스에서 모델이 수정되지 않음을 보장하기 위해서인데, 앱의 전체 라이프 사이클 동안 단일 상태를 유지할 수 있게 된다. (불변성을 가지고 있다는 의미는 모델을 수정할 수 없기 때문에 내부의 Property는 바꿀 수 없고 모델 자체를 copy()해서 새로 생성하는 방법이 필요하다.)

-> Presenter(ViewModel)에서 영화 리스트를 추가하거나 제거하는 행위가 이뤄질 때 비즈니스 로직에서는 각각 다른 상태를 가지고 있는 모델을 새로 생성하고 View에서는 유저 액션(영화 리스트를 더하거나 제거한 행위)를 옵저빙하고 UI를 렌더링하는 역할을 한다.

모델의 불변성과 위와 같은 레이어 간의 순환 구조 덕분에 다음과 같은 이점을 얻을 수 있다.

  1. 단일 상태(Single State) : 불변성을 가지고 있는 데이터 구조는 한 곳에서만 데이터를 관리할 수 있다는 이점을 가지고 있기 때문에 관리하기가 매우 용이하며 앱의 모든 레이어에 단일 상태를 보장할 수 있다.
  2. 스레드 안전성(Thread Safety) : RxJava나 LiveData, Coroutines 같은 비동기 라이브러리와 사용할 때 특히 유용하다. 모델의 내부를 수정할 수 있는 방법이 없기 때문에 모델은 항상 한 곳에서 다시 만들어지고 유지된다. 이런 점은 다른 스레드에서 모델을 수정하여 일어나는 충돌을 방지한다.

Views and Intents

MVP와 같이 MVI는 일반적으로 View의 interface를 정의한다. MVP의 경우에는 일반적으로 다른 input, output을 정의하기 위해 많은 메소드를 사용해야 하곤 한다. 하지만, MVI에서 View는 화면을 렌더링하기 위한 상태를 허용하는 하나의 render() 함수를 가지고 있는 편이며, View는 유저의 행동에 반응하기 위해 감지할 수 있는 intent() 라는 함수를 사용하게 된다.

MVI에서의 Intent는 android.content.Intent class가 아닌 앱의 상태를 변화시킬 액션을 의미한다.

일반적으로 MVI는 아래와 같이 적용할 수 있다.

class MainActivity : MainView {
  
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
  }
    
  //1 MainView의 버튼에 대한 Observable을 생성한다.
  override fun displayMoviesIntent() = button.clicks()
    
  //2 View에 올바른 메소드를 연결시키기 위해 ViewState를 1대1 매핑한다.
  override fun render(state: MovieState) {
    when(state) {
      is MovieState.DataState -> renderDataState(state)
      is MovieState.LoadingState -> renderLoadingState()
      is MovieState.ErrorState -> renderErrorState(state)
    }
  }
    
  //3 View에 Model data를 랜더링하는 과정이다. 
  // data는 날씨 데이터, 영화 리스트, 에러 등등 모든 것이 될 수 있다.
  private fun renderDataState(dataState: MovieState.DataState) {
      //Render movie list
  }
    
  //4 뷰에 로딩상태를 보여주는 과정이다.
  private fun renderLoadingState() {
      //Render progress bar on screen
  }
	
  //5 뷰에 에러 메세지를 보여주는 과정이다.
  private fun renderErrorState(errorState: MovieState.ErrorState) {
      //Display error mesage
  }
}

위 코드를 통해 Presenter, ViewModel에서 하나의 render() 함수가 여러 개의 상태를 받을 때 어떻게 대응되는지 그리고 intent(Event)가 Button Click을 트리거하는지에 대해 알 수 있다. 위의 코드는 로딩 상태, 에러 상태, 데이터 상태를 처리하여 UI를 보여줄 수 있다.

State Reducers

Model이 mutable 하다면 앱의 상태를 쉽게 바꿀 수 있다. 기본 데이터를 추가, 삭제, 업데이트 하기 위해서는 아래와 같은 메소드를 호출한다.

model.insert(items)

하지만, MVI에서 Model은 Immutable 하다. 그렇기 때문에 앱의 상태를 바꾸기 위해서는 매번 모델을 재생성해야 한다. 새로운 데이터를 표시하기 위해서는 새로운 모델을 만들어야 한다는 것이다. 만약, 이전 상태에 대한 정보가 필요하다면 어떻게 할 수 있을까?

여기서 State Reducer라는 개념이 등장한다. 컨셉은 Reactive Programming의 Reducer function에서 가져왔다. Reducer는 각각의 요소를 축약된 컴포넌트로 merge하는 단계들을 제공한다. 이미 많은 표준 라이브러리들이 불변 데이터 구조를 위해 비슷한 메소드를 구현해놨다.

ex) 코틀린의 List에는 reduce() 메소드가 있다. reduce() 메소드는 List의 첫번째 값부터 인수로 전달되는 연산을 적용하여 그 값을 누적한다.

val myList = listOf(1, 2, 3, 4, 5)

// accumulator : 누적된 총 값, 
// currentValue : iterator 를 통해 들어온 현재 위치의 value
var result = myList.reduce { accumulator, currentValue ->
  println("accumulator = $accumulator, currentValue = $currentValue")
  accumulator + currentValue }
println(result)

// result
accumulator = 1, currentValue = 2
accumulator = 3, currentValue = 3
accumulator = 6, currentValue = 4
accumulator = 10, currentValue = 5
15

위 코드는 List의 값들을 순환하여 각각의 값을 더해값에 누적한다.

Reducer function은 두가지 컴포넌트로 이루어져 있다.

  • 축적된 값 : 첫번째 인자로 각각의 값을 순환하여 축적된 값이다.
  • 현재 값 : 두번째 인자로 순환하는 중 지니고 있는 현재의 값이다.

State Reducer와 MVI는 무슨 관계를 가지고 있을까?

Tying it all together

State Reducers는 Reducer function과 비슷하게 동작한다. 하지만, Reducer function이 변경 상태를 유지하는 것과 다르게 State Reducers는 이전 상태를 바탕으로 새로운 상태를 만든다는 점에서 차이가 난다.

아래와 같이 만들어볼 수 있을 것 같다.

  1. 앱의 새로운 상태는 나타내는 PartialState라는 새로운 상태를 만든다.
  2. 시작점과 같은 앱의 이전 상태가 필요한 경우 새로운 Intents가 있을 때 완료된 상태로부터 새로운 PartialState를 만든다.
  3. reduce() 함수에서 이전 상태와 PartialState를 사용하여 화면에 표시할 새로운 상태로 병합한다.
  4. RxJava의 scan()을 사용하여 reduce() 함수가 앱의 초기 상태를 적용하고 새 상태를 반환한다.

MVI 장점과 단점

MVI는 유지 관리가 용이하고 확장 가능한 앱을 만들수 있도록 도와준다.

[장점]

  • 데이터가 단방향으로 순환한다.
  • View의 생명주기 동안 일관성 있는 단일 상태를 갖는다.
  • 불변성을 갖는 Model은 멀티 스레드 안정성(Thread Safety)과 안정적인 동작을 제공한다.

[단점]

  • 다른 아키텍쳐 패턴보다 러닝커브가 높으며, 반응형 프로그래밍과 멀티 스레드 방식에 대한 지식이 필요하다.

Ref

profile
Android Developer

0개의 댓글