소프트웨어 아키텍처 패턴 -MVVM, MVC, MVP, MVI 뭐가 다를까?

4
post-thumbnail

오늘 할 이야기는 면접의 단골주자인 소프트웨어 아키텍처입니다.

사용하면 코드의 품질이 향상된다며 적용하는것은 많이 보았지만, 실제로 개념을 알고 가는것은 크나큰 차이가 있습니다.
왜 소프트웨어 아키텍처가 나왔는지부터 파고 들어가 봅시다.


1. 소프트웨어 아키텍처?

토이프로젝트를 할때, 혼자 개발해보신적 있으실껍니다.
대충 붙인 코드 네이밍, 없다시피한 주석..
왜냐면 어떤 역할은 하는 함수인지 알기 때문입니다.

하지만 협업에서는 어떨까요?

협업하는 사람은 해당 메소드가 무슨역할을 하는지, 전혀 모르기때문에 작성해놓은 코드를 디버깅하면서 분석해볼수밖에 없고, 곧 시간 손해가 되어버립니다.

그래서 코드네이밍과 주석은 같이 작업하는사람의 배려이자,곧 실력의 척도가 되었습니다.

그리고 어느 유명한 개발자들은 생각했습니다.

"구조를 만들고 같은 역할을 하는 class를 모아, 신입개발자가 들어와도 적응되기 쉽게 만들자"

이렇게 소프트웨어 아키텍처는 공식화가 되었고, 해당 아키텍처를 아는사람은 조금더 쉽게 프로젝트를 이해할수 있게 되었습니다.

1. MVC

구조

맨처음 설명할 MVC입니다.
가장 보편적인 아키텍처 패턴이라고도 알려져있습니다.

  • Model

    • 어플리케이션에 사용되는 데이터와 처리 로직이 담겨 있습니다.
    • View 또는 Controller와 묶이지 않기때문에 재사용이 가능합니다.
  • View(UI)

    • 사용자에게 보여주는 보이는 화면입니다
  • Controller

    • 사용자로부터 입력을 받고, 모델에 의해 View정의를 하게 됩니다.

Web에서의 MVC구조는 이렇습니다만, Android는 View와 Controller가 Activity에 포함되어 있습니다.
이러한 문제점 때문에, Activity에 코드들이 몰리는 현상이 발생하게 됩니다.

코드

Activity(Controller + View)

class MainActivity : AppCompatActivity() {

    lateinit var binding: ActivityMainBinding
    @Inject
    lateinit var todoRepository: TodoRepository
    lateinit var todoListAdapter: TodoListAdapter

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

        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

// UI setting 
        runCatching {
        //Model에서 데이터 가져오기
            todoRepository.getTodoList()
        }.onSuccess {todoList->
        	//UI 갱신 
            todoList.forEach{todoListAdapter.addItem(it)}
        }.onFailure {
            //실패 로직
        }

        binding.buttonInsert.setOnClickListener {
            todoRepository.insertTodoList(Todo("1", "예제", "예제"))
        }
    }
}

장점

  • 구현하기 쉽고, 이해하기 쉬우며, 구현속도도 가장 빠르다 (3easy)

단점

  • Controller와 View가 결합되어 있기때문에, UnitTest에 어려움을 겪습니다.
    UI로직과 Controller에서 행하는 로직들을 분리하기가 어렵기 때문입니다.

  • UI변경점이 있을때, Controller의 코드또한 건드려지게 된다.

  • Model과 View의 의존성이 높기때문에, 앱이 커질수록 스파게티 코드가 될 확률이 높다(MVP 가 나오게된 이유)

참고로 말씀드리면, 안드로이드에서는 Activity에서 MVC 모두 처리하게 되는 문제점이 있기 때문에, 안드로이드에서는 거의 쓰이지 않습니다.

2. MVP

구조

MVP의 특징은 이제 Activity는 View의 역할만 한다는겁니다.
Android에 있어서 MVP는 컴포넌트 분리가 좀더 명확하게 이루어졌다고 할수있습니다.

  • Model

    • MVC의 모델과 동일합니다.
  • View(UI)

    • Activity를 자연스럽게 View의 일부로 간주하게 되었습니다.
  • Presenter

    • Controller과 같은 역할을 하나, Interface로 이루어져 UnitTest에 자유롭습니다.

이제 Activity단에서 바로 Model을 호출하는것이 아닌, Presenter에서 View로 전달받게 되었습니다.

코드

Activity

class MainActivity2 : AppCompatActivity(), MainContract.View {

    lateinit var binding: ActivityMainBinding
    lateinit var mAdapter: TodoListAdapter

    @Inject
    lateinit var mainPresenter: MainPresenter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        // presenter에 event 발생했다고 통신
        mainPresenter.loadItem()

        binding.buttonInsert.setOnClickListener {
            mainPresenter.addItem(Todo("", "더미 " , "더미"))
        }
    }

    override fun setUpUI(list : List<Todo>) {
        mAdapter= TodoListAdapter(list.toMutableList())
    }
    
    override fun addUI(todo: Todo) {
        mAdapter.addItem(todo)
        mAdapter.notifyDataSetChanged()
    }
}

Presenter

class MainPresenter @Inject constructor(
    val view: MainContract.View,
    val todoRepository: TodoRepository
) :MainContract.Presenter {

    //item 처리 로직
    override fun loadItem() {
        view.setUpUI(todoRepository.getTodoList())
    }


    override fun addItem(todo: Todo) {
        todoRepository.insertTodoList(todo)
        view.addUI(todo)
    }
    
}

Contract

interface MainContract {

    interface View{
        fun setUpUI(list: List<Todo>)
        fun addUI(todo: Todo)
    }

    interface Presenter{
        fun loadItem()
        fun addItem(todo: Todo)
    }
}

장점

  • Model과 View의 의존성이 없어졌기때문에 관련된 코드만 수정하기 편합니다.
  • UI와 Data파트가 명확히 구분되어, 편하게 코딩이 가능합니다.

단점

  • 어플리케이션이 커질수록, View와 Presenter 사이의 의존성이 강해집니다.

  • 기능이 많아지면,Presenter가 뚱뚱해지기때문에 분리하기가 어렵습니다.

MVC와 다르게 명확하게 컴포넌트가 구분되기 때문에, 역할에 따라 분리가 잘된 코드를 볼수 있지만, Presenter와 View가 1:1 관계를 갖고있기때문에 프로젝트가 커질수록, Presenter또한 비례해서 늘어나게 됩니다.

3. MVVM

어떻게 하면 View와의 관계를 끊어버릴수 있을까요?
Activity에 비례해 같이 늘어나는 Presenter를 어떻게 처리하면 좋을까요?

MVVM은 presenter에 의존하지않고, Observe Pattern을 이용해 객체의 변경이 일어날때마다 UI를 갱신하도록 만들었습니다.

Observer pattern
상태 변화가 있을 때마다 메서드를 통하여 관찰 대상자가 직접 옵저버들에게 통지하여 상태를 동기화 할 수 있도록 하는 디자인 패턴을 의미합니다.
즉, 객체에 변화가 일어날때마다 콜백이 일어나게 됩니다.

구조

MVVM의 특징은 이렇습니다.

  • Model

    • MVC의 모델과 동일합니다.
  • View(UI)

    • ViewModel의 데이터들을 구독하고 있다가, 객체가 변할때 UI 업데이트를 진행하게 됩니다.
  • ViewModel

    • Model과 상호작용하며, View에 종속되지않고 1:N 구조를 갖습니다.

MVVM의 가장 큰 특징은 View가 어떤 종속성도 가지지 않았다는것입니다.

그래서 ViewModel을 다른 View에서도 활용 가능합니다.

코드

Activity

class MainActivity : AppCompatActivity() {

    lateinit var binding: ActivityMainBinding
    lateinit var mAdapter: TodoListAdapter
    private val mainViewModel: MainViewModel by viewModels()


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

        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        with(binding) {
            binding.viewmodel = mainViewModel
            binding.lifecycleOwner = this@MainActivity
        }

        mainViewModel.todoLiveData.observe(this, Observer { todoList ->
            todoList.forEach { mAdapter.addItem(it) }
        })

        lifecycleScope.launch {
            //onStop일때, 멈추고, onStart일때 아래의 코드블록을 다시 (재)시작한다.
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                mainViewModel.todoFlow.collect { todoList ->
                    todoList.forEach { mAdapter.addItem(it) }
                }
            }
        }
    }
}

ViewModel

class MainViewModel @Inject constructor(
    val todoRepository: TodoRepository
) : ViewModel() {


    // 목록(liveData)
    private val _todoLiveData: MutableLiveData<List<Todo>> = MutableLiveData()
    val todoLiveData: LiveData<List<Todo>> get() = _todoLiveData

    private val _insertTodoData: MutableLiveData<Todo> = MutableLiveData()
    val insertTodoData: LiveData<Todo> get() = _insertTodoData


    private val _eventFlow: MutableStateFlow<UiState> = MutableStateFlow(UiState.Loading)
    val eventFlow: StateFlow<UiState> = _eventFlow.asStateFlow()


    // 목록(stateFlow)
    private val _todoFlow: MutableStateFlow<List<Todo>> = MutableStateFlow(emptyList<Todo>())
    val todoFlow: StateFlow<List<Todo>> = _todoFlow.asStateFlow()

    private val _insertTodoFlow: MutableStateFlow<Todo> = MutableStateFlow(Todo(""))
    val insertTodoFlow: StateFlow<Todo> = _insertTodoFlow.asStateFlow()


    fun setUpUI() =
        viewModelScope.launch {
        	//livedata
            _eventFlow.value = UiState.Loading
            _todoLiveData.value = todoRepository.getTodoList()
            //stateflow
            _todoFlow.value = todoRepository.getTodoList()
            _eventFlow.value = UiState.Susscess("와 성공!")
        }
    
    fun insertTodo() = viewModelScope.launch {
        _eventFlow.value = UiState.Loading
        _insertTodoData.value?.let { todo ->
            todoRepository.insertTodoList(todo)
        } ?: run {
            _eventFlow.value = UiState.Error("와 실패")
        }
    }
    
}

장점

  • View와 Model이 독립되어 있습니다.
  • 바인딩하기때문에 코드의 양이 감소합니다.
  • ViewModel에서 View 코드가 없기때문에 UnitTest를 쉽게 할수 있습니다.

단점

  • 데이터 바인딩이 필수적으로 요구 됩니다.
  • 간단한 UI에서 ViewModel을 설계해야하고, 그만큼 과도한 엔지니어링이 일어날수 있습니다.
  • 다양한곳에서 많은 데이터를 받기 때문에 관리를 제대로 못할경우, 버그가 터집니다.

AAC ViewModel?

이 얘기 하나만으로 포스팅을 하나 작성할수 있을것같으니, 다음에 좀더 Deep하게 설명드리겠습니다.
Androidx에 있는 AAC ViewModelMVVM의 ViewModel과 다릅니다.
원문을 확인해봅시다.

ViewModels can also be used as a communication layer between different Fragments of an Activity. Each Fragment can acquire the ViewModel using the same key via their Activity. This allows communication between Fragments in a de-coupled fashion such that they never need to talk to the other Fragment directly.

Android에서의 ViewModel은 Fragment와 Activity의 데이터 전달, 화면전환시 잃어버리는 데이터를 저장하는 저장소인 셈입니다.
LiveData와 StateFlow를 추가하여 MVVM Architecture를 구현한것이기 때문에, 결론은 AAC ViewModel을 쓴다고해서 MVVM을 사용한다고 할수 없습니다.

마찬가지로, MVVM을 한다고 해서 AAC ViewModel을 사용할 필요는 없기에 몇개의 기업은 AAC ViewModel을 사용하지 않고도 MVVM을 구현한다고 합니다.

4. MVI

MVVM에서 해결하지 못하는 문제가 제기되었습니다.
언급된것이 바로 상태 문제와 부수 효과였는데요.

상태 문제
화면에 나타나는 모든정보, 프로그레스바의 상태, 버튼 활성화를 상태라고 부르는데, 의도치않은 방향으로 상태가 제어된다면 우리는 이것을 상태 문제라고 합니다.

부수효과(side effect)
원래의 목적과 다르게 다른 효과 또는 부작용이 나는 상태를 의미합니다.
예를들어 네트워크 통신때, 데이터를 가져오는 함수는 데이터를 못가져올수도 있습니다.
function add(a, b){ a+b+c } 는 a와 b를 더한 값의 예상과 다르게 c 때문에 값이 변경될수도 있습니다.

데이터가 로딩이 되었음에도, Progress Bar가 돌고 있습니다.
MVVM으로 구현하면서 ViewModel에서 상태를 관리하게 되는데, 여러 데이터를 분산해서 관리하다보니 관리포인트가 어쩔수 없이 늘어나게 되고,
버그가 일어나고 말았습니다.

데이터의 흐름을 제어하지 못하는 원인이 문제였기 때문에, MVI는 단일 상태 관리와 단방향 데이터 흐름을 통해 MVVM의 문제점을 해결하고자 했습니다.

구조

  • Model

    • 다른 Model과 다르게 상태를 의미합니다.
      intent로 전달받은 객체에 맞추어, 새로운 불변객체를 Model로 생성합니다.
  • View(UI)

    • Model의 결과물인 상태를 구독하고 있다가, 변경시 UI 업데이트를 진행하게 됩니다.
  • Intent

    • 앱의 상태를 바꾸려는 의도를 의미합니다.
      Model에게 앱의 상태를 전달합니다.

MVI에 따르면, Model에서 호출되는값은 불변하기에, 예상이 가능한 값이어야 합니다.
허나 서버나 데이터베이스에서 값을 가져올경우, 우리는 이 값을 예측할 수 없습니다.

MVI는 이러한 문제를 SideEffect를 통해 제어합니다.

  • SideEffect
    background 작업, 액티비티 전환 같은 부수적인 작업을 말합니다.
    Toast또한 SideEffect로 보는 경우가 많습니다

MVI는 MVP, MVVM과 다르게, I가 실제 컴포넌트를 지칭하지 않습니다.
어떤구조로 만들자의 의미 보다는 어떻게 데이터와 상태의 흐름을 어떻게 다룰것이냐는 패러다임에 가깝습니다.

그래서 MVI의 수많은 예제코드를 보면 MVVM, MVP 기반으로 작성된 코드를 많이 볼수있습니다.(또한 저의 코드도 MVVM을 기반으로 만들어졌습니다.)

코드

해당 코드는 orbit이라는 FrameWork를 통해 작성되었습니다.

MainActivityMVI

    lateinit var binding: ActivityMainBinding

    @Inject
    lateinit var todoRepository: TodoRepository

    val mainViewModel: MainViewModelMvi by viewModels()


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

        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        mainViewModel.observe(
            lifecycleOwner = this,
            state = ::handleState,
            sideEffect = ::handleSideEffect
        )

    }

    //state가 변할때마다, 해당 메서드로 들어온다.
    private fun handleState(state: TodoState) {
        if (!state.loading) {
            state.exception?.let {
                //성공 처리
            } ?: run {
                //에러 처리
            }
        } else {
            //Todo 로딩 다이얼로그 표출하기

        }
    }

    //사이드 이펙트 
    private fun handleSideEffect(sideEffect: TodoSideEffect) {
        when (sideEffect) {
            is TodoSideEffect.Toast -> {
                Toast.makeText(this, sideEffect.text, Toast.LENGTH_LONG).show()
            }
        }
    }

MainViewModelMvi

class MainViewModelMvi @Inject constructor(
    private val todoRepository: TodoRepository
) : ContainerHost<TodoState, TodoSideEffect>, ViewModel() {

    //한개의 State와 한개의 SideEffect를 만들어준다.
    override val container: Container<TodoState, TodoSideEffect> = container(TodoState())



    fun setUpUI() = intent {

        runCatching {
            //State를 변경해준다.
            reduce { state.copy(loading = true) }
            todoRepository.getTodoList()

        }.onSuccess {todoList->
            reduce { state.copy(loading = false) }
            reduce { state.copy(todo = todoList , loading = false) }
        }.onFailure {
            reduce { state.copy(exception = Exception("오류~"))}
            //사이드 이펙트 발생
            postSideEffect(TodoSideEffect.Toast("성공"))
        }
    }


    fun insertTodo(todo: Todo) = intent {
        reduce { state.copy(loading = true) }
        postSideEffect(TodoSideEffect.SaveData("성공"))
        reduce { state.copy(todo = state.todo.toMutableList().apply {add(todo)} , loading = false) }
    }
    
}

TodoState

data class TodoState(
    val todo: List<Todo> = emptyList(),
    val loading: Boolean = true,
    val exception: Exception? = null
)

sealed class TodoSideEffect {
    data class SaveData(val text: String) : TodoSideEffect()
    data class Toast(val text: String) : TodoSideEffect()
}

장점

  • 하나의 State객체만을 바라보기 떄문에 상태충돌이 일어나기 어렵습니다.
  • 데이터의 흐름을 파악하기 쉽습니다.
  • 불변객체이기 때문에, 쓰레드에 대해 안전합니다.

단점

  • 러닝 커브가 매우 높습니다(기본적으로 MVP와 MVVM을 깔고갑니다)
  • Model Update를 위해 새로운 인스턴트를 끼워넣기때문에,
    리소스가 낭비될수 있습니다.
  • 작은 변경에도 intent를 거쳐야 합니다.

what is Best?

모든것에 장점이 있고, 단점이 있기때문에 무엇을 사용해야한다는 것은 없습니다.
작은 프로젝트에 아키텍처패턴은 오버 엔지니어링일수도 있으니까요.
최신 디자인을 따르기보다는, 규모에따라 선택해야하며, 고집보다는 팀과의 협력을 생각하며 선택하는 개발자가 되셨으면 좋겠습니다.

Happy Coding <3

profile
쉽게 가르칠수 있도록 노력하자

0개의 댓글