Android Development

MVI Pattern in Android

photo by Kelly Sikkema on Unsplash

요즘 선언형 UI가 점점 많이 사용되면서 안드로이드에서도 Jetpack Compose가 많이 사용되고 있다. 이와 함께 적용하기 좋은 아키텍처 패턴인 MVI(Model – View – Intent) 패턴도 MVVM 다음으로 대세가 되어가는 중이다. 이번 포스팅을 통해 MVI 패턴에 대해 알아보고 어떻게 Jetpack Compose와 함께 사용하는지 정리해보려 한다.

아키텍처 패턴 (MVx)

먼저 MVI 패턴에 대해 알아보기 전에 아키텍처 패턴이 뭔지 한 번 더 생각해보고 시작하려 한다. 아키텍처 패턴은 뭘 위해서 있는 것인지 한 번 생각해보자.

이해가 쉽고 최적화 된 간단한 코드를 작성하고 싶어

우리를 포함한 개발자들이 하는 말이다. 이는 실제 개발을 하며 마주하게 되는 여러 상황들을 좀 더 잘 헤쳐나갈 수 있도록 도와주는 유지보수성, 확장성 등이 잘 고려되고 잘 짜여진 코드를 만들고 싶다는 말을 쉽게 표현한 것이다. 아키텍처 패턴은 이걸 달성해주기 위해서 존재하는 것이다.

이를 달성하는 방법 중 하나로 모듈화 프로그래밍이 있다. 이는 기능들을 독립적인 교환 가능한 모듈로 분리하는 것이다. 이를 달성하기 위한 여러 시행착오들이 있어왔으며, 수년에 걸쳐서 진화해 온 아키텍처 패턴들이 있다. 아키텍처 패턴은 앞의 목표들을 달성하기 위해 정해진 규칙이 있는 blueprint라고 생각할 수 있다. 안드로이드에서 그동안 널리 쓰여왔고, 지금도 많이 사용되는 아키텍처 패턴들은 다음과 같은 네 가지가 있다.

  • MVC (Model – View – Controller)
  • MVP (Model – View – Presenter)
  • MVVM (Model – View – ViewModel)
  • MVI (Model – View – Intent)

앞의 세 가지는 많이 보았을 것이고, MVI는 비교적 최근에 생겨서 약간 생소할 수 있는데 이제 이에 대해 자세히 알아보자.

MVI (Model – View – Intent) 패턴

MVI는 Model-View-Intent로 반응형 아키텍처 패턴이라고 할 수 있다.

  • Model : Intent를 기반으로 View에 데이터(State)를 제공하는 비즈니스 로직 레이어 / 또는 State 자체
  • View : UI Layer로, Model이 emit하는 State를 렌더링
  • Intent : 유저 인터랙션을 기반으로 트리거되는 작업

기본 개념은 어렵지 않다. model은 은 상태(state)를 나타내며, view는 이러한 상태를 보여준다. intent는 사용자 또는 앱 자체가 동작을 수행하려는 의도이다. 안드로이드의 Intent를 지칭하는 것이 아니니 헷갈리면 안 된다. 각 역할이 명료하게 분리되어 있고, 흐름이 단방향으로 순환되는 형태인 걸 볼 수 있는데 이 부분이 핵심이다.

이해를 좀 더 돕기 위해 ViewModel을 이용하여 예시를 간단하게 표현해본 그림이다. View는 ViewModel의 State를 화면에 렌더링해주는 역할을 한다. 또한 유저 인터랙션이 발생하면 Intent를 발생시킬 수 있다. Intent는 정의하여 사용할 수도 있고, 복잡하지 않은 화면인 경우에는 ViewModel의 함수를 직접 호출 하는 경우도 있다. 하지만 변경 가능성이 어느 정도 있는 화면이라면(없을 것이라 보장하는 화면은 없을 거다..), MVI 아키텍처 구조의 목적에 부합하게 Intent를 따로 정의하여 사용하는 것을 추천한다. ViewModel은 Intent 발생에 따른 비즈니스 로직을 수행하고, 결과를 기반으로 새로운 State를 업데이트한다. 이렇게 State가 업데이트 되면, View는 새로운 State에 따라 화면을 업데이트한다.

  • 유저 인터랙션은 정의된 Intent로 전달됨 (정의되지 않은 동작은 처리되지 않고, 사용 가능한 Intent들이 모여서 정의되어 있어 명료)
  • 미리 정의해 둔 State들 중 한 가지 상태만을 보유
  • 기능이 큰 화면이어서 뷰 담당자와 비즈니스 로직 담당자가 따로 있는 경우 자신의 관심 영역에 대한 수정만을 통해 기능 추가/변경 하기가 쉬움
  • State와 Intent 정의 시 sealed class, data class를 사용

왜 MVI를 써야 할까?

명확한 상태 관리가 없는 경우에는 앱의 성장, 기능 변경 및 추가 발생 시 비즈니스 로직과 뷰 렌더링을 변경하기가 약간 혼란스러울 수 있다. 프로젝트 시작부터 모든 기능을 명확하고 완벽하게 정의하는 경우는 거의 없다. 그렇기에 변경은 항상 발생한다고 보면 되는데, 앱 코드베이스가 확장 가능할수록 새로운 아이디어와 업데이트를 수용할 수 있는 유연성이 높아진다. MVI는 이런 측면에서 적합한 아키텍처라고 할 수 있다.

2017년 경부터 모바일 개발에는 선언형 UI가 생겨났고, 이제 완벽하게 자리잡고 장점을 인정받아 많은 지원도 되며 명령형 UI로 구현된 앱들도 넘어가는 추세이다(선언형 UI : Android Jetpack Compose, SwiftUI, Flutter / 명령형 UI : xml). 안드로이드의 경우를 예로 들면, Compose와 Coroutines, StateFlow를 함께 사용하면 MVI 아키텍처를 적용하기 굉장히 좋게 되어 있다. 선언형 UI를 사용한다면 MVI를 적용하지 않을 이유가 없다.

MVI 패턴의 장단점

장점과 함께 단점도 분명히 존재할 것이다. 간단하게 다음과 같이 정리해볼 수 있다.

장점

  • Separation of Concerns : 앱의 State, 유저 입력, UI 렌더링을 명확히 분리하여 코드를 체계적이고 쉽게 추론할 수 있도록 해 줌
  • Testability : View에서 입력과 상태를 분리함으로써 비즈니스 로직과 State 관리를 위한 Unit Test를 작성하는 것이 훨씬 쉬워짐
  • 반응형 프로그래밍 : State 변경 시 View가 자동으로 업데이트 되는 형태
  • 유지보수성 (Maintainability) : 관심사의 분리(Separation of Concerns)를 통해 State, 입력, UI의 변화가 서로 다른 요소에게 영향을 미치지 않음. 복잡한 앱에서 MVI를 적용 시 유지보수성을 더 향상시킬 수 있음

단점

  • 보일러 플레이트 : MVVM과 같은 다른 경우보다 더 많은 보일러 플레이트 코드를 필요로 하여, 개발 프로세스가 지루하게 느껴질 수 있음
  • Learning Curve : 반응형 프로그래밍 개념에 익숙치 않은 경우라면, 처음 이해하고 구현하려면 조금 어렵게 느껴질 수 있음. 하지만 MVVM 등에서도 Flow, LiveData, Binding 등을 사용용했보았다면 금방 이해 가능
  • 복잡성 : 단순한 앱에서는 오히려 복잡해보일 수 있음

MVI 예시 1 – LiveData와 함께 사용

sealed class MainState {
    object Loading : MainState()
    data class DisplayCount(val count: Int) : MainState()
}
sealed class MainIntent {
    object IncreaseCount : MainIntent()
}
class MainActivity : AppCompatActivity() {
    private val viewModel: MainViewModel by viewModels()
    private val binding: ActivityMainBinding by lazy {
                DataBindingUtil.setContentView(this, R.layout.activity_main)
        }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding.viewModel = viewModel
        binding.lifecycleOwner = this

        binding.increaseCountButton.setOnClickListener {
             viewModel.handleIntent(MainIntent.IncreaseCount)
        }

        viewModel.state.observe(this, Observer { state ->
            when (state) {
                is MainState.Loading -> {
                    binding.indicator.visibility = View.VISIBLE
                    binding.countTextView.visibility = View.GONE
                }
                is MainState.DisplayCount -> {
                    binding.indicator.visibility = View.GONE
                    binding.countTextView.visibility = View.VISIBLE
                    binding.countTextView.text = "count = ${state.count}"
                }
        })
    }
}
class MainViewModel : ViewModel() {
    private val _state = MutableLiveData<MainState>(MainState.Loading)
    val state: LiveData<MainState> = _state

    private var count = 0

    fun handleIntent(intent: MainIntent) {
        when (intent) {
            is MainIntent.IncreaseCount -> {
                _state.postValue(++count)
            }
        }
    }
}

버튼을 클릭하면 카운트를 증가시켜서 TextView의 숫자가 증가하는 간단한 예시이다. 자세한 예시들은 Coroutines, Jetpack Compose를 함께 사용하는 다음 예시들에서 살펴볼 예정이어서 여기에는 간단하게만 작성하였다. Coroutines를 사용하지 않는다면, ViewModel의 State는 LiveData나 Rx를 사용하여 View가 업데이트를 받을 수 있도록 구현하면 된다. Coroutines만 사용하는 경우에도 flow를 통해 동일하게 구현하면 된다.

MVI 예시 2 – Coroutines, Jetpack Compose와 함께 사용

class MainActivity : ComponentActivity() {
    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MVIExampleTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colors.background
                ) {
                    MainScreen(viewModel)
                }
            }
        }
    }
}

Jetpack Compose를 사용할 것이므로 Activity는 간단하게 구현했다.

sealed class MainState {
    object Loading: MainState()
    data class Success(val data: String): MainState()
    data class Error(val errorMessage: String): MainState()
}

ViewModel emit하는 State, UI가 보고 렌더링 할 State이다. 화면의 상태라고 생각하면 된다. 단순하게 데이터를 로딩하여 보여주는 화면이라고 가정하였다. 이러한 화면을 위해 State는 Loading, Success, Error 세 가지로 정의했다.

sealed class MainIntent {
    object RefreshData : MainIntent()
}

MVI에서 I(Intent)에 해당하는 MainIntent이다. 단순 데이터 로딩 후 보여주는 화면이므로 Intent는 데이터 새로고침 정도만 떠올라서 RefreshData라는 것 하나만 정의했다. 실제 프로젝트라면 추후에 기능이 늘어나면서 새로운 Intent가 필요하면 여기에 추가할 수 있도록 처음부터 State와 마찬가지로 sealed class로 선언하였다.

class MainViewModel : ViewModel() {
    private val _mainState = MutableStateFlow<MainState>(MainState.Loading)
    val mainState: StateFlow<MainState>
        get() = _mainState

    private val mainIntent = Channel<MainIntent>()

    init {
        handleIntent()
        viewModelScope.launch(Dispatchers.IO) { // for test
            delay(1500)
            _mainState.value = MainState.Success("성공")
        }
    }

    fun sendIntent(intent: MainIntent) = viewModelScope.launch(Dispatchers.IO) {
        mainIntent.send(intent)
    }

    private fun handleIntent() {
        viewModelScope.launch(Dispatchers.IO) {
            mainIntent.consumeAsFlow().collect { intent ->
                when (intent) {
                    is MainIntent.RefreshData -> refresh()
                }
            }
        }
    }

    private fun refresh() {
        _mainState.value = MainState.Loading
        viewModelScope.launch(Dispatchers.IO) {
            delay(1000)
            _mainState.value = MainState.Success("성공!!")
        }
    }
}

Intent를 받아서 비즈니스 로직을 수행하고 결과에 따라 State를 업데이트 하는 역할을 수행하는 ViewModel이다. StateFlow를 사용하여 State를 전달하고, Channel을 통해 Intent를 받아올 수 있도록 구현했다. handleIntent()에서 Intent를 받아 각 동작에 해당하는 함수를 호출하여 동작을 수행한다. Channel은 직접 노출하여도 되지만, private으로 선언하고 sendIntent() 함수를 public으로 제공하도록 만들어보았다. 동작 결과에 따라 State 변경이 발생하면 MutableStateFlow를 통해 값을 업데이트 해주면 된다.

@Composable
fun MainScreen(viewModel: MainViewModel) {
    val dataState = viewModel.mainState.collectAsState()

    when (val state = dataState.value) {
        is MainState.Loading -> LoadingScreen()
        is MainState.Success -> SuccessResultScreen(viewModel, state.data)
        is MainState.Error -> ErrorScreen(state.errorMessage)
    }
}

@Composable
fun LoadingScreen() {
    Box {
        CircularProgressIndicator(modifier = Modifier.align(Center))
    }
}

@Composable
fun SuccessResultScreen(viewModel: MainViewModel, result: String) {
    Column {
        Text(text = "success result: $result")
        Button(onClick = {
            viewModel.sendIntent(MainIntent.RefreshData)
        }) {
            Text(text = "Refresh")
        }
    }
}

@Composable
fun ErrorScreen(errorMessage: String) {
    Text(text = "Error: $errorMessage")
}

Jetpack Compose로 구현한 화면이다. 앞서 ViewModel에서 제공하도록 한 State를 collectAsState()를 통해 받아올 수 있도록 하였다. State에 따른 화면 구성을 MainScreen 안에서 직접 해도 되지만 이 예제에서는 State 별로 따로 나누어 정의해보았다.

cf. collectAsState()와 관련된 추가 사항이 있는데, 포스팅 마지막에 작성해두었으니 읽어보면 좋다.

MVI 예시 3 – Coroutines, Jetpack Compose와 함께 사용 하나 더

class TodoListActivity : ComponentActivity() {
    private val viewModel: TodoListViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MVIExampleTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colors.background
                ) {
                    TodoListScreen(viewModel)
                }
            }
        }
    }
}
@Composable
fun TodoListScreen(viewModel: TodoListViewModel) {
    val state = viewModel.todoListState.collectAsState()

    Column {
        TextField(
            value = "",
            onValueChange = { text ->
                if (text.endsWith("\n")) {
                    viewModel.sendIntent(TodoListIntent.AddTodo(text.trimEnd('\n')))
                }
            }
        )
        if (state.value.isLoading) {
            CircularProgressIndicator()
        } else {
            LazyColumn {
                val todos = state.value.todos
                items(todos.size) {
                    TodoItemView(
                        todos[it],
                        onDelete = { viewModel.sendIntent(TodoListIntent.DeleteTodo(todos[it].id)) },
                        onDone = { viewModel.sendIntent(TodoListIntent.DoneTodo(todos[it].id)) }
                    )
                }
            }
        }
        if (state.value.error != null) {
            Text(state.value.error!!)
        }
    }
}
sealed class TodoListIntent {
    data class AddTodo(val todoContent: String) : TodoListIntent()
    data class DoneTodo(val id: Int) : TodoListIntent()
    data class DeleteTodo(val id: Int) : TodoListIntent()
}
data class TodoListState(val todos: List<Todo>, val isLoading: Boolean, val error: String?)
class TodoListViewModel : ViewModel() {
    private val _todoListState = MutableStateFlow(TodoListState(emptyList(), true, null))
    val todoListState: StateFlow<TodoListState>
        get() = _todoListState

    private val todoListIntent = Channel<TodoListIntent>()

    init {
        handleIntent()
    }

    private fun handleIntent() {
        viewModelScope.launch(Dispatchers.IO) {
            todoListIntent.consumeAsFlow().collect { intent ->
                when(intent) {
                    is TodoListIntent.AddTodo -> addTodoItem(intent.todoContent)
                    is TodoListIntent.DeleteTodo -> deleteTodoItem(intent.id)
                    is TodoListIntent.DoneTodo -> changeDoneStatusOfTodoItem(intent.id)
                }
            }
        }
    }

    fun sendIntent(intent: TodoListIntent) = viewModelScope.launch(Dispatchers.IO) {
        todoListIntent.send(intent)
    }

        // ...
}

Intent가 몇 가지 더 있는 두번째 예시와 동일한 형태이다. 여기서 심화(?)로 들어가보면 경우에 따라 한 가지를 더 해볼 수 있다. 앞선 예시는 Refresh 시에 Loading State로 갔다가 로딩 완료 후에 Success State로 간다. 한 번의 Intent에 의해 State가 두 번 변경된다. 하지만 이번 예시에서는 Add, Delete, Done 3 가지 Intent에 의한 State 변경은 한 번만 발생한다고 가정해보고 생각해보자. todoListState는 StateFlow여서 ViewModel 외부에서 변경할 수는 없지만, _todoListState는 ViewModel 내부의 어떤 곳에서든 State를 변경할 수 있다. 우리는 역할을 모두 분리하고 Separation of Concerns를 달성할 수 있다고 MVI를 공부하고 적용하려 하는데, ViewModel 여기저기서 State를 업데이트 하게 된다면 복잡한 앱의 경우 로직의 타이밍에 따라 원치 않는 State로 빠질 수 있는 문제가 있다. 이를 해결하기 위해서는 다음과 같은 방식을 통해 StateFlow를 제공해 볼 수 있다.

class TodoListViewModel : ViewModel() {
    private val todoListIntent = Channel<TodoListIntent>()

    val todoListState: StateFlow<TodoListState> = todoListIntent.receiveAsFlow()
        .runningFold(TodoListState.initialValue, ::reduceState)
        .stateIn(viewModelScope, SharingStarted.Eagerly, TodoListState.initialValue)

    private fun reduceState(current: TodoListState, intent: TodoListIntent): TodoListState {
        return when (intent) {
            is TodoListIntent.AddTodo -> addTodoItem(intent.todoContent)
            is TodoListIntent.DeleteTodo -> deleteTodoItem(intent.id)
            is TodoListIntent.DoneTodo -> changeDoneStatusOfTodoItem(intent.id)
        }
    }

    fun sendIntent(intent: TodoListIntent) = viewModelScope.launch(Dispatchers.IO) {
        todoListIntent.send(intent)
    }

        // ...
}

이런 식으로 하면 이벤트 채널로부터 상태를 바로 변경하기 때문에 ViewModel의 다른 곳에서 State를 변경하는 것이 아예 불가능하다. 가능하다면 이 방식을 통해 Race Condition을 배제시키고 상태 예측, 디버깅, 유지보수성을 향상시킬 수 있다.

추가 사항

collectAsStateWithLifecycle()

화면이 다른 화면에 가리거나 백그라운드에 있어서 Composable UI가 보이지 않는 상태에서는 Recomposition이 중지된다. 하지만 collectAsState()로 상태를 보고 있으면, 이런 상황에서도 State의 변경이 발생하면 collect가 동작하므로 collectAsStateWithLifecycle()을 사용하면 이 문제를 해결할 수 있다. 현재는 Experimental API이므로, annotation을 붙여서 사용하던지 직접 extension 함수를 정의해서 사용하길 권장한다. StateFlow, collectAsState()는 Kotlin api이기 때문에 안드로이드 Lifecycle을 알 수 없기 때문이다.

Channel

Channel을 사용하여 Coroutines 간 여러 값을 전달할 수 있다. 전달된 값들을 Queue로 처리하는데 BlockingQueue와 비슷하게 생각하면 된다. BlockingQueue는 thread를 blocking하지만 Channel은 blocking 대신 suspending 처리한다. MVI 패턴에서 이 Channel을 통해 Intent를 전달하면 적합하다.

StateFlow

Hot signal인 점만 생각하면 되는데, 아래 문서를 한 번 읽어보면 좋다. MVI에서 State를 전달할 때 StateFlow로 전달하면 적합하다.
https://developer.android.com/kotlin/flow/stateflow-and-sharedflow?hl=ko

댓글 남기기

Dev Repository에서 더 알아보기

지금 구독하여 계속 읽고 전체 아카이브에 액세스하세요.

계속 읽기