12. MVVM과 MVI 차이

개요

요즘들어 Compose로 개발을 진행하면서 MVI 패턴을 적용하며 개발을 진행하고 있다.
과거 XML 기반 개발을 진행하면서 MVVM 패턴을 적용하며 개발을 한 적이 있었는데 뭐가 다른지 조금 정리해보고자 한다.

MVVM이란?

MVVM이란 Model - View - ViewModel로 구성되는 패턴으로 정의는 아래와 같다.

  • 구성요소

    • Model : Data 상태 관리, 비즈니스 로직, Data를 가져오거나 저장하는 역할
    • View : 사용자에게 보여지는 UI를 표시
    • ViewModel : ViewModel 간 연결고리로 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에서 사용자의 클릭이 일어났을 때, ViewViewModelincreaseCount함수를 호출한다.
    만약 상태가 더 필요해서 제목 같은 어떤 String 값이 필요하다 하더라도,

    private val _title = MutableLiveData<Int>(0)
    val title: LiveData<Int> = _title
    

    꼴의 상태가 ViewModel에 추가될 것이고, Activity에서는 Observe 코드가 늘어나며, 관련 작업을 할 것이다

MVI란?

MVI이란 Model - View - Intent로 구성되는 패턴으로 정의는 아래와 같다.

  • 구성요소

    • Model : Intent를 통해 반영되는 UI의 상태
    • View : 사용자에게 보여지는 UI
    • Intent : 사용자의 행동, 시스템 이벤트를 나타내는 객체
  • 코드 예시

    // 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때와 달리 ViewViewModelIncrementIntent라는 Intent를 담아서 보낸다
    그러면 ViewModel에서는 들어온 Intent의 종류에 맞게 분기 처리하며 incrementCount라는 함수를 실행한다
    incrementCount함수가 실행되면서 ShowToast 라는 SideEffect가 발생한다
    View에서 SideEffect를 구독하고 있다가 들어오면 종류에 따라 분기처리를 하게 되고, 본 예제에서는 ShowToast라는 SideEffect가 발생함에 따라 내부의 메세지를 Toast의 메세지로 담아 실행한다

    MVI로 진행하면서 생기는 SideEffect에 대한 개념도 조금 다뤄보았다
    MVVM의 경우 SideEffect 처리에 대한 별도의 규칙 같은 것이 정해져 있지 않지만, MVI를 사용할 때 Orbit 라이브러리를 쓰게 될 경우 항상 SideEffect를 명시해야 할 정도로 강제성이 존재하기 때문에 추가해보았다

    MVVM과 달리 상태가 더 추가된다 하더라도 MainState내에 추가될 것이기에 구독해야 할 것이 추가적으로 늘지 않는다
    또한 View에서는 ViewModel이 무슨 작업을 하는지 까지는 알 필요가 없어진다, 그저 ~~것을 원한다는 의도를 전달할 뿐이기 때문에 조금 더 결합력이 낮아졌다고 할 수 있다

정리

MVVMMVI 패턴을 간단하게 정리해보았다
MVVM에서는 View에서 어떤 작업을 실행할 지를 구체적으로 명령했다면,
MVI에서는 View에서 의도만 전달하고, 어떤 작업을 할 지는 ViewModel이 내부에서 처리하는 느낌이다

조금 더 결합력을 낮춤과 동시에, 상태 관리를 보다 명확하게 하고 싶다면 MVI 패턴을 고려해보는 것도 좋을 것이다


© 2025. Na2te All rights reserved.