MVI 패턴은 어떤 문제를 해결할까?

kimdohun0104
9 min readApr 19, 2020

MVI 패턴에 대한 관심이 커져 관련 글, 발표, 적용 사례를 많이 찾아보았습니다. 한국에서는 MVVM, MVP 패턴 이외에는 적용 사례를 찾기 힘들었지만, 일본이나 미국의 많은 기업은 이미 관련 기술을 연구, 도입한 것을 발견할 수 있었습니다. 유명한 사례를 나열해 보자면 Spotify-Mobius, Airbnb-MvRx 가 있었습니다.

저 또한 학습을 위해서 개인적으로 MVI 기반 라이브러리를 작성해보았습니다. 직접 라이브러리와 샘플 프로젝트를 작성하며 MVI로 얻을 수 있는 이점을 뚜렷하게 느꼈습니다. 하지만 고려해야할 부분도 그만큼 많았던 것 같습니다. 이 글에선 MVI 패턴이 해결하는 문제와 고려할 점에 대해서 알아보겠습니다.

MVI의 이유

MVVM 패턴으로 해결하지 못하는 문제는 무엇일까요? 위에서 언급한 Spotify, Airbnb, 그 외의 다른 기업들이 공통으로 언급하는 내용이 있습니다. 바로 상태 문제부수 효과입니다. 두 용어가 의미하는 바를 살펴보겠습니다.

상태 문제

프로그래밍은 상태 제어와 깊은 연관성이 있습니다. 화면에 나타나는 모든 정보, 프로그래스바 상태, 버튼 활성화 등 무수한 상태들로 구성되어 있습니다. 하지만 상태를 관리하기 힘들어지고 의도하지 않은 방향으로 제어가 된다면, 우리는 이것을 상태 문제라고 합니다.

간단한 상태 문제의 예로, 서버의 응답으로 리스트를 성공적으로 출력했지만 프로그래스바가 계속 보이는 상황이 있습니다. 단순히 실수일 수도 있겠지만, 정말 복잡한 상태를 가지는 화면이라면 단순해보이는 상태 변경도 놓치기 쉽습니다.

상태 문제 예시

부수 효과

안드로이드는 무수한 부수 효과들로 이루어져 있습니다. 서버 호출, 데이터베이스 접근 등, 우리는 어떤 결과를 얻을지 예상할 수 없으며, 그에 따라 상태 변경에 어려움을 겪습니다.

대표적으로 위 두 가지 문제 때문에 많은 기업이 MVI를 채택하기 시작했습니다. 분명 MVP 혹은 MVVM으로 상태를 제어하는 데 한계가 뚜렷하기 때문입니다. 그렇다면 MVI 패턴은 위 문제를 해결하기 위해 어떤 방식으로 접근하는 것일까요?

MVI가 선택한 방법

MVI의 특징 중에서도 제가 가장 중요하다고 여기는 부분은 pure cycleside effect cycle입니다. 정해진 용어는 없지만, 의미만큼은 무엇보다 잘 표현한다고 생각합니다. 하나씩 자세하게 다뤄보도록 하겠습니다.

Pure cycle

MVI에서 상태는 불변합니다. 그렇기 때문에 View를 업데이트하기 위해서는 intent를 사용해 이벤트를 발생시키고, 이벤트에 따라 새로운 상태를 생성해 적용하게 됩니다. 한 마디로 이벤트가 상태를 변경하기 위한 유일한 방법입니다.

Pure cycle

새로운 상태를 View에 업데이트하는 프로세스를 그림으로 표현해보았습니다. 글로 설명해 드리자면, intent()로 Increase 이벤트를 발생시키면 기존 count 값에 1을 더해 새로운 상태를 생성합니다. 새로운 상태를 화면에 render() 합니다. 사용자가 또 다른 이벤트를 발생시킨다면 같은 순환을 거치게 됩니다.

중요한 점은 상태는 불변성을 가지기 때문에 우리는 예상 가능한 값을 얻을 수 있습니다. 개발자의 실수나 부수 효과가 발생하지 않는다는 것이죠. 이는 함수형 프로그래밍이 가지는 이점을 그대로 반영했습니다.

// 함수 x()의 인자로 y를 넘기면 항상 결과는 z이다
x(y) == z
// 똑같은 event에 대한 결과는 항상 state이다
intent(event) == state

이것이 바로 제가 pure cycle 라 부르는 이유입니다. 마치 순수 함수처럼 인자가 같다면 항상 같은 값을 반환하기 때문입니다.

pure cycle은 상태 관리에서 가장 중요한 역할을 수행하며 개발자의 실수를 줄여줍니다. 예상 가능한 값을 반환하기 때문에 테스트 멋진 테스트 코드를 작성할 수 있습니다.

Pure cycle과 로깅

Pure cycle의 이점에 관해서 더 소개하자면, 뛰어난 로깅을 지원해 디버깅을 쉽게 진행할 수 있습니다. 이전 상태와 새로운 상태의 흐름이 선명하기 때문에 라이브러리에서 질 높은 로깅을 진행할 수 있습니다. 아래는 제가 직접 구현한 라이브러리의 로그입니다. 발생한 이벤트, 전/현재 상태를 쉽게 확인할 수 있습니다.

Event [com.dohun.kindamvi.main.MainEvent$Increase@c3cb293]
From [MainState(count=0)]
Next [MainState(count=1)]

Side effect cycle

불행히도 pure cycle만을 이용하여 안드로이드 앱을 완성할 수 없을 것입니다. 데이터베이스, 서버 등 부수 효과에 의존해야 하는 상황이 불가피할 것입니다. MVI는 우아한 방법으로 부수 효과를 제어합니다. Side effect cycle을 이해하기 위해선 먼저 그림을 참고하는 것이 좋을 것 같습니다.

Side effect cycle

기존 순환에 새롭게 Side effect가 추가된 것을 확인할 수 있습니다. 추가로 설명해 드리자면, intent()를 사용해 새로운 상태를 생성함과 동시에 부수 효과를 실행(dispatch)합니다. 실행이 끝났다면 결과를 바로 render() 하는 것이 아닌, Event로써 결과를 전달합니다.

여기서 가장 중요한 부분은 부수 효과의 결과를 Event로서 전달한다는 것입니다. 이 의미는 side effect도 순수한 관점에서 바라볼 수 있다는 것입니다. 부수 효과의 결과를 성공적으로 전달 받았다면, 그 데이터를 특정한 이벤트를 통해 상태에 적용할 수 있습니다. 그렇다면 pure cycle에서 다룬 것처럼 예측 가능한 결과를 얻을 수 있습니다.

실제 코드를 살펴보며 개념을 정리해보겠습니다. 예제 코드는 제가 개발한 Kinda MVI 라이브러리를 사용했습니다. 아래는 Github API를 사용하여 데이터를 불러오는 예제입니다.

API 요청의 결과에 따라 다른 Event를 결과로 반환한다

단일 이벤트 핸들링

안드로이드에선 많은 단일 이벤트가 발생합니다. 대표적으로 toast, snackbar, navigation 등이 존재합니다. MVI의 관점에선 완벽한 정답이 없는 부분입니다. 단일 이벤트에 대한 많은 접근과 제가 선택한 방법을 알아보겠습니다.

결국 상태이다!

toast로 출력되는 메시지도 결국 상태라는 접근입니다. 다음과 같은 코드를 상상해볼 수 있습니다.

whenEvent<LoginEvent.ShowToast> {
next(copy(toastMessage = "Message"))
}

그런데 한 가지 문제가 있습니다. 만약 toastMessage 상태를 공백으로 다시 변경하지 않는다면, 다른 이벤트가 발생할 때마다 toast를 띄울 것입니다. 이 문제를 해결하기 위해 다음과 같은 방법을 고안했습니다.

whenEvent<LoginEvent.ShowToast> {
next(copy(toastMessage = "Message"))
next(copy(toastMessage = ""))
}

이젠 계속해서 toast가 띄워지는 상황이 발생하지 않습니다. 하지만 toastMessage를 공백으로 변경하는 과정에서 새로운 상태가 생성되어 render() 과정이 한 번 더 실행됩니다.

일종의 부수 효과이다!

이 주장도 설득력있습니다. toast 메세지는 분명 pure cycle이라고 볼 수 없으니까요.

하지만 그렇다고 해서 side effect로 처리하는 것도 이상합니다. 저희는 단일 이벤트를 발생시킴으로서 어떤 결과를 원하는 것도 아니고, side effect를 처리하는 로직이 방대해질 수 있기 때문입니다.

단일 이벤트 해결 방법

저는 단일 이벤트를 일종의 상태로 보기로 했습니다. 하지만 위에서 말했던 문제점을 Event 객체를 통해서 해결했습니다. 이를 통해서 이미 발생된 이벤트인지 판별할 수 있게 되었습니다. 게다가 테스트도 쉽게 가능하고요!

// Event.kt
class Event<T>(private val data: T? = null) {
private val isPending = AtomicBoolean(true)

fun getData(): T? {
if (isPending.getAndSet(false)) {
return data
}
return null
}
}
// 이벤트 발생 in ViewModel
next(copy(toastEvent = Event("Hello world")))
// render in Activity
state.toastEvent.getData()?.let { message ->
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}

자주 발생하는 render

View에서는 상태가 새로 생성될 때를 감지하여 render()를 수행하기 때문에 자주 상태가 변경된다면 view가 많은 작업을 처리하게 될 수 있습니다.

자칫 잘못하면 잘못된 UX를 제공하는 경우가 생길 수 있기 때문에 각별히 주의해야합니다.

ListAdapter

이것은 RecyclerView 한정으로 사용할 수 있는 방법입니다. ListAdapter는 RecyclerView.Adapter를 상속하며, 내부적으로 DiffUtill을 사용하여 데이터의 변경이 있는 부분만 내부적으로 notify 합니다. 그렇기 때문에 깜빡임 현상을 해결하고 자연스러운 사용자 경험을 제공할 수 있습니다. Kinda MVI의 예제에서 확인할 수 있습니다.

독립된 상태로 관리하기

Fragment 등을 사용하여 각각의 화면을 따로 render하는 것도 방법일 것 같습니다. 자주 변경되는 부분을 파악하고 분리하는 것은 성능 개선에 많은 도움이 될 것입니다.

마무리

순수 영역과 부수 효과를 다루는 방법이 인상적인 MVI 패턴에 대해 알아보았습니다. 왜 많은 사람이 이 패턴에 관심을 가지는지 알 수 있었습니다. 저 또한 아직 몇몇 예제를 작성해본 것이 전부이지만, MVI의 매력에 푹 빠진 상태입니다.

열심히 저만의 MVI기반 상태 관리 라이브러리를 만들어가고 있습니다. 많은 관심 부탁드립니다.

추가로 직접 라이브러리를 제작해 보는 것이 MVI를 이해하는 데 큰 도움이 되었습니다. 여러분도 관심이 있다면 도전해보는 것도 좋겠네요.

--

--