[번역] 안드로이드를 위한 MVI (Model-View-Intent) 아키텍쳐 튜토리얼: 시작하기
MVI를 시작하며 개념적인 부분을 프리뷰 하는데는 도움이 될 수 있을것 같아 공유합니다. 이 글에서는 Presenter 를 사용한 MVP 기반의 MVI를 설명하고 있지만 ViewModel을 사용하거나 때로는 RxJava를 사용하지 않는 MVI의 형태도 존재합니다. 추후에 이런 내용에 대해서도 다뤄보겠습니다!
전통적인 명령형 프로그램을 사용하는 안드로이드 개발자는 확장가능하고 유지 보수하기 쉬운 앱을 개발하기 위해 MVC, MVP 그리고 MVVM과 같은 아키텍쳐 패턴을 선택할 수 있습니다. 이번 튜토리얼에서는 매우 다른 아키텍쳐 패턴에 대해 알아보려고 합니다. MVI는 reactive-programming으로 안드로이드 앱 개발을 하는 데 사용됩니다.
이 튜토리얼에는 이런 것들이 포함됩니다.
- MVI는 무엇이고 어떻게 동작하는가
- MVI 아키택처 패턴의 레이어
- 단방향 안드로이드 앱은 어떻게 동작하는가
- MVI와 함께 testablity를 향상시키는 방법 (*본문에 언급되지 않습니다)
- MVI를 사용하는 것이 다른 아키텍처 패턴에 비해 갖는 장점과 단점
MVI는 무엇인가요?
MVI는 Model-View-Intent의 약자입니다. MVI는 Cycle.js프레임워크의 단방향성과 Cycle Nature에서 영감을 받은 안드로이드를 위한 최신 아키텍처 패턴 중 하나입니다. MVI는 먼 친척인 MVC, MVP, MVVM과 매우 다른 방식으로 작동합니다. MVI의 각 컴포넌트의 역할은 다음과 같습니다.
Model — 모델은 상태를 나타냅니다. MVI의 모델은 아키텍쳐의 다른 레이어와의 단방향 데이터 흐름을 보장하기 위해 변경이 불가능해야 합니다.
View — View를 나타내며 하나 이상의 Activity나 Fragment로 구현됩니다.
Intent — 사용자 또는 앱내 발생하는 Action을 나타냅니다. 모든 Action에 대해 View는 Intent를 수신합니다. Presenter는 Intent를 관찰하고 Model은 새로운 상태로 변환합니다.
각 레이어에 대해 더 자세히 알아보겠습니다.
Models
다른 아키텍처 패턴에서 모델은 데이터베이스나 API 같은 Backend 와의 연결고리 역할을 하는 계층으로 구현됩니다. 하지만 MVI에서 모델은 앱의 상태를 나타냅니다.
앱의 상태는 무엇인가요?
reactive-programing에서 앱은 버튼 클릭이나 변수 값의 변화 등에 반응합니다. 앱이 그런 변경 사항에 반응하면 새로운 상태로 전환됩니다. 새로운 상태는 프로그래스바나 새 영화 목록과 같은 UI 변경이 있을 수 있습니다.
Model이 MVI에서 어떻게 동작하는지 묘사하기 위해서 TMDB API를 이용하여 영화 순위 같은 것을 검색하길 원한다고 가정해봅시다. MVP 패턴으로 만들어진 앱에서 모델은 다음과 같은 클래스가 될 것입니다.
이 경우 Presenter는 위 모델을 사용하여 다음과 같은 코드로 동영상 목록을 표시합니다.
이런 접근법이 나쁘진 않지만 MVI로 해결할 수 있는 몇 가지 이슈가 있습니다.
- Multiple inputs: MVP와 MVVM에서, Presenter와 ViewModel은 많은 수의 입출력을 관리해야 하는 경우가 많습니다. 이건 많은 백그라운드 테스크가 있는 큰 앱에서는 문제를 일으킬 수 있습니다.
- Multiple states: MVP와 MVVM에서, 비즈니스로직과 View는 언제든 다른 상태를 가질 수 있습니다. 개발자는 자주 Observable 과 Observer 콜백의 상태를 동기화시킵니다. 하지만 이건 행위의 충돌을 야기할 수 있습니다.
이런 이슈를 해결하기 위해서 모델이 데이터가 아닌 상태를 나타내도록 합니다. 위에 사용된 예제의 경우 상태를 나타내는 데이터를 아래와 같이 표현할 수 있습니다.
이런 식으로 Model을 만들었을 경우 더이상 상태를 View 또는 Presenter나 ViewModel과 같이 여러 곳에서 관리할 필요가 없습니다. Model이 그 자체로 어느때 프로그래스 바를 표시해야 할지 아이템 리스트를 표시해야 할지를 가르키게 됩니다.
Presenter는 다음과 같이 바뀔 수 있습니다.
Presenter는 이제 View의 Model(상태)이라는 하나의 아웃풋만을 갖습니다. 이것은 앱의 현재 상태를 전달받는 View의 render()라는 함수로 전달되어 그리기가 수행됩니다.
MVI에서 모델의 또 다른 특징은 변경이 불가능하여 비즈니스 로직을 순수한 코드로 유지할 수 있다는 것 입니다. 이렇게 하면 여러 위치에서 모델이 수정되지 않고 앱의 전체 생명 주기 동안 단일 상태를 유지합니다.
아래 다이어그램은 다른 계층 간의 상호작용을 보여줍니다.
이 다이어그램에서 주목할 점을 눈치채셨나요? 바로 “순환 구조”입니다!
모델의 불변성과 레이어 간의 순환구조 덕분에 다음과 같은 이점을 얻을 수 있습니다.
- 단일 상태: 불변 데이터 구조는 매우 다루기 쉽고 한곳에서 관리되기 때문에 앱 내 모든 레이어 간 하나의 단일 상태를 보장할 수 있습니다.
- Thread Safety: 이 부분은 RxJava나 LiveData와 같은 라이브러리를 사용하는 Reactive 앱에 특히 유용합니다. 어떤 함수도 모델을 수정할 수 없기 때문에 모델은 항상 한 곳에서 다시 만들어지고 유지됩니다. 이런 점은 다른 쓰레드에서 모델을 수정하여 일어나는 충돌을 방지합니다.
위 예제 코드는 하나의 예시입니다. 모델과 프레젠터를 다르게 설계할 수 있지만, 전제는 항상 같습니다.
다음으로 View와 Intents에 대해서 살펴보겠습니다.
Views And Intents
MVP와 같이 MVI는 일반적으로 Fragment나 Activity에서 구현되는 View의 Contract interface를 정의합니다. MVI의 Views는 랜더링할 하나의 상태를 허용하는 하나의 render() 함수를 구현하는것이 일반적입니다. MVP가 일반적으로 자세한 메소드 이름을 사용하여 다른 입력과 출력을 정의하는 반면 MVI의 Views는 intent() 함수를 Observable 하여 유저 반응에 응답합니다.
MVI의 Intent는 android.content.Intent를 의미하지 않습니다. MVI의 Intents는 앱의 상태를 변화시킬 액션을 의미합니다.
MVI의 View가 어떻게 구현되는지 다음 코드를 봐주세요.
각 세션을 차례대로 살펴봅시다.
- displayMovieIntent: UI 이벤트를 적합한 intents에 바인드 합니다. 이 경우엔 intents에 버튼 클릭 이벤트가 바인드 되었습니다. MainView에 정의되었고 MainView를 구현한 MainActivity에서 override 되었습니다. 위 코드에선 RxBinding을 사용하여 버튼 클릭 리스너를 RxJava Observables로 변환했습니다.
- render: ViewState를 View의 알맞은 메소드와 맵핑합니다. 역시 MainView에 정의되어 있습니다.
- renderDataState: Model의 데이터를 View에 그립니다. 데이터는 날씨, 영화 또는 에러에 관한 내용일 수 있습니다. 일반적으로 내부 메소드로 정의되며 state에 기반하여 화면을 갱신합니다.
- renderLoadingState: 로딩화면을 View에 그립니다.
- renderErrorState: 에러 메시지를 View에 그립니다.
이 예제는 View의 render()가 Presenter로부터 어떻게 State를 수신하는지와 버튼 클릭으로 Intent가 발생하는것을 보여줍니다. 그 결과는 에러메시지 출력이나 로딩화면과 같은 UI 변화로 보여지게 됩니다.
State Reducers
Model이 mutable하다면 앱의 상태를 쉽게 바꿀 수 있습니다. 기본 데이터를 추가, 삭제, 업데이트 하기 위해서는 다음과 같은 메소드를 호출합니다.
myModel.insert(items)
하지만 MVI에서 Model은 Immutable 합니다. 때문에 앱의 상태를 바꾸기 위해서는 매번 모델을 재 생성해야 합니다. 새로운 데이터를 화면에 표시하기 위해서는 새로운 모델을 만들어야 한다는 말이죠. 이 경우 만약 이전 상태에 대한 정보가 필요하다면 어떻게 할 수 있을까요?
정답은? State Reducers 입니다.
State Reducers의 컨셉은 Reactive programming의 Reducer functions에서 가져왔습니다. Reducer functions는 각각의 요소를 축약된 컴포넌트로 merge하는 단계들을 제공합니다.
Reducer functions은 개발자에게 익숙한 툴이고 이미 많은 표준 라이브러리들이 불변 데이터 구조를 위해 비슷한 메소드를 구현해놨습니다. 예를 들어 Kotlin의 List에는 reduce() 메소드가 있습니다. reduce() 메소드는 List의 첫번째 값부터 인수로 전달되는 연산을 적용하여 그 값을 누적합니다.
위 코드를 실행하면 아래와 같은 결과가 출력됩니다.
accumulator = 1, currentValue = 2
accumulator = 3, currentValue = 3
accumulator = 6, currentValue = 4
accumulator = 10, currentValue = 5
15
위 코드는 myList의 값들을 순환하며 각각의 값을 더해 현재 값에 누적합니다.
Reducer functions은 두가지 컴포넌트로 이루어져 있습니다.
- 축척된 값: 첫번째 인자로 각각의 값을 순환하며 축척된 값입니다.
- 현재 값: 두번째 인자로 순환하는 중 지나고 있는 현재의 값입니다.
이것이 State Reducers 나 MVI와 어떤 관련이 있을까요?
(*본문에서 State Reducers 에 대한 설명과 예제가 부족합니다. 더 알아보고 싶다면 아래 코드를 참고해주세요)
Tying It All Together
State Reducers는 Reducer functions와 비슷하게 동작합니다. 하지만 Reducer functions가 변경 상태를 유지하는 것과 다르게 State Reducers는 이전 상태를 바탕으로 새로운 상태를 만드다는 점에서 차이가 납니다.
그 과정은 다음과 같습니다.
- 앱의 새로운 상태를 나타내는 PartialState라는 새로운 상태를 만듭니다.
- 시작점과 같은 앱의 이전 상태가 필요한 새로운 Intents가 있을때 완료된 상태로부터 새로운 PartialState를 만듭니다.
- reduce() 함수에서 이전 상태와 PartialState를 사용하여 화면에 표시할 새로운 상태로 병합합니다.
- RxJava의 scan()을 사용하여 reduce() 함수가 앱의 초기 상태를 적용하고 새 상태를 반환합니다.
앱의 두개의 상태를 병합하는 방법으로 reducer function을 구현하는것은 각각 개발자에 달려있습니다. 보통은 scan()이나 merge() 연산자가 이런 작업에 도움이 됩니다.
MVI: 장점과 단점
Model-View-Intent는 유지 관리가 용이하고 확장 가능한 앱을 만들수 있도록 도와줍니다.
주요 장점은 다음과 같습니다.
- 데이터가 단방향으로 순환합니다.
- View의 생명주기 동안 일관성 있는 상태를 갖습니다.
- 불변 Model은 큰 앱에서 멀티 스레드 안정성과 안정적인 동작을 제공합니다.
다른 Android 아키텍처 패턴에 비하여 MVI가 갖는 한 가지 단점은 학습 곡선이 약간 더 높다는 점입니다. MVI를 제대로 이해하기 위해서는 멀티쓰레드와 RxJava와 같은 중/고급 주제에 대한 상당한 지식이 필요합니다. 이해 비하여 MVC또는 MVP와 같은 아키텍처 패턴이 새로운 Android 개발자에게 쉬울 수 있습니다.