개요
요즘들어 Compose로 개발을 진행하면서 MVI 패턴을 적용하며 개발을 진행하고 있다.
과거 XML 기반 개발을 진행하면서 MVVM 패턴을 적용하며 개발을 한 적이 있었는데 뭐가 다른지 조금 정리해보고자 한다.
MVVM이란?
MVVM이란 Model - View - ViewModel로 구성되는 패턴으로 정의는 아래와 같다.
구성요소
Model: Data 상태 관리, 비즈니스 로직, Data를 가져오거나 저장하는 역할View: 사용자에게 보여지는 UI를 표시ViewModel:View와Model간 연결고리로Model의 데이터를 가져와View에 적합한 형태로 가공 및 UI 상태를 관리
코드 예시
// file: "MainViewModel.kt" package com.example.mvvmexample import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel class MainViewModel : ViewModel() { // 데이터 // 외부에서는 읽기만 가능하도록 LiveData로, 내부에서는 값을 변경할 수 있도록 MutableLiveData로 선언 private val _count = MutableLiveData<Int>(0) val count: LiveData<Int> = _count // 호출할 함수 fun incrementCount() { val currentCount = _count.value ?: 0 _count.value = currentCount + 1 } }// file: "MainActivity.kt" package com.example.mvvmexample import android.os.Bundle import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import com.example.mvvmexample.databinding.ActivityMainBinding class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private val viewModel: MainViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) // ViewModel의 데이터를 관찰(Observe)하여 UI 갱신 viewModel.count.observe(this) { newCount -> binding.textViewCount.text = newCount.toString() } // 사용자 이벤트를 ViewModel에 전달 (함수 직접 호출) binding.buttonIncrement.setOnClickListener { // viewModel에 어떤 특정 함수를 실행할 것을 명령 viewModel.incrementCount() } } }정리
위 코드를 예시로
View에서 사용자의 클릭이 일어났을 때,View는ViewModel의increaseCount함수를 호출한다.
만약 상태가 더 필요해서 제목 같은 어떤 String 값이 필요하다 하더라도,private val _title = MutableLiveData<Int>(0) val title: LiveData<Int> = _title꼴의 상태가
ViewModel에 추가될 것이고, Activity에서는 Observe 코드가 늘어나며, 관련 작업을 할 것이다
MVI란?
MVI이란 Model - View - Intent로 구성되는 패턴으로 정의는 아래와 같다.
구성요소
Model: Intent를 통해 반영되는 UI의 상태View: 사용자에게 보여지는 UIIntent: 사용자의 행동, 시스템 이벤트를 나타내는 객체
코드 예시
// file: "MainContract.kt" /** * UI의 상태를 나타내는 데이터 클래스. * @property count 현재 카운트 값 */ data class MainState( val count: Int = 0 ) /** * 사용자의 의도(액션)를 정의하는 sealed class. */ sealed class MainIntent { // 카운트를 증가시키려는 의도 object IncrementCount : MainIntent() } /** * Toast 등 사이드 이펙트의 종류들을 정의한 sealed class. */ sealed class MainSideEffect { data class ShowToast(val message: String) : MainSideEffect() }// file: "MainViewModel.kt" class MainViewModel : ViewModel() { // UI 상태를 관리하는 StateFlow. // 내부에서는 수정 가능한 MutableStateFlow로 선언. private val _state = MutableStateFlow(MainState()) // 외부에는 읽기 전용 StateFlow로 노출하여 불변성을 유지. val state: StateFlow<MainState> = _state private val _sideEffect = Channel<MainSideEffect>() val sideEffect: ReceiveChannel<MainSideEffect> = _sideEffect /** * View로부터 Intent를 받아 처리하는 함수. */ fun processIntent(intent: MainIntent) { viewModelScope.launch(Dispatchers.IO) { when (intent) { // 'IncrementCount' 의도를 받았을 때 is MainIntent.IncrementCount -> { incrementCount() } } } } fun postSideEffect(sideEffect: MainSideEffect){ viewModelScope.launch(Dispatchers.IO) { _sideEffect.send(sideEffect) } } /** * 현재 상태를 기반으로 카운트를 증가시키고 새로운 상태로 업데이트. */ private fun incrementCount() { val currentCount = _state.value.count _state.value = _state.value.copy(count = currentCount + 1) // 사이트 이펙트 발생 postSideEffect(ShowToast("버튼을 클릭하셨습니다")) } }// file: "MainActivity.kt" class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private val viewModel: MainViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) // ViewModel의 상태(State) 변화를 관찰하여 UI 렌더링 observeState() // SideEffect를 관찰하여 처리 observeSideEffect() // 사용자 이벤트를 Intent로 변환하여 ViewModel에 전달 setupEventListeners() } /** * ViewModel의 StateFlow를 관찰하고, 상태가 변경될 때마다 render 함수를 호출. */ private fun observeState() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.state.collect { state -> // State가 변경되면 해당 State 값을 기반으로 데이터 변경 render(state) } } } } /** * 전달받은 상태(State)를 기반으로 UI를 갱신. */ private fun render(state: MainState) { binding.textViewCount.text = state.count.toString() } /** * SideEffect를 관찰하고, 들어올 때마다 분기체 맞게 처리 */ private fun observeSideEffect() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { for(sideEffect in viewModel.sideEffect){ when(sideEffect){ is ShowToast -> { Toast.makeText(this@MainActivity, sideEffect.message, Toast.LENGTH_SHORT).show() } } } } } } /** * 버튼 클릭과 같은 사용자 이벤트를 처리하고 ViewModel에 Intent를 전달. */ private fun setupEventListeners() { binding.buttonIncrement.setOnClickListener { // 사용자의 버튼 클릭을 'IncrementCount' 의도로 변환하여 전달 viewModel.processIntent(MainIntent.IncrementCount) } } }정리
State등을 정의하기 위해Contract라는 파일을 추가적으로 생성했다위 코드를 예시로
View에서 사용자의 클릭이 일어났을 때,MVVM때와 달리View는ViewModel의IncrementIntent라는Intent를 담아서 보낸다
그러면 ViewModel에서는 들어온Intent의 종류에 맞게 분기 처리하며incrementCount라는 함수를 실행한다
incrementCount함수가 실행되면서ShowToast라는SideEffect가 발생한다
View에서SideEffect를 구독하고 있다가 들어오면 종류에 따라 분기처리를 하게 되고, 본 예제에서는ShowToast라는SideEffect가 발생함에 따라 내부의 메세지를Toast의 메세지로 담아 실행한다MVI로 진행하면서 생기는SideEffect에 대한 개념도 조금 다뤄보았다
MVVM의 경우SideEffect처리에 대한 별도의 규칙 같은 것이 정해져 있지 않지만,MVI를 사용할 때Orbit라이브러리를 쓰게 될 경우 항상SideEffect를 명시해야 할 정도로 강제성이 존재하기 때문에 추가해보았다MVVM과 달리 상태가 더 추가된다 하더라도MainState내에 추가될 것이기에 구독해야 할 것이 추가적으로 늘지 않는다
또한View에서는ViewModel이 무슨 작업을 하는지 까지는 알 필요가 없어진다, 그저 ~~것을 원한다는 의도를 전달할 뿐이기 때문에 조금 더 결합력이 낮아졌다고 할 수 있다
정리
MVVM과 MVI 패턴을 간단하게 정리해보았다
MVVM에서는 View에서 어떤 작업을 실행할 지를 구체적으로 명령했다면,
MVI에서는 View에서 의도만 전달하고, 어떤 작업을 할 지는 ViewModel이 내부에서 처리하는 느낌이다
조금 더 결합력을 낮춤과 동시에, 상태 관리를 보다 명확하게 하고 싶다면 MVI 패턴을 고려해보는 것도 좋을 것이다