[Android] MVC, MVP, MVVM 장단점을 알고 쓰자!

WonseokOh·2022년 5월 23일
8

Android

목록 보기
13/16
post-thumbnail

안드로이드 개발 초기에는 아키텍처 설계에 대한 관심이 크지는 않았지만, 애플리케이션의 규모가 커지면서 유지보수성을 높일 수 있는 방향으로 나아가게 되었습니다. MVC , MVP, MVVM, MVI 등 다양하게 디자인 패턴들이 존재하고 각 애플리케이션마다 특성에 맞게 디자인 패턴을 설계하기도 합니다. 이처럼 디자인 패턴을 적용을 했을 때 어떠한 이점이 생기는지 알아보고 장단점을 구별하여 애플리케이션 설계에 도움을 주고자 합니다.

실습

  간단한 계산기 프로그램을 MVC, MVP, MVVM 패턴으로 구현을 해보고 차이점을 확인해보겠습니다. 계산과정은 스택을 이용하여 후위표기법 방식으로 계산을 했으며 이전에 계산했던 히스토리는 내부 DB에 저장한 후 RecyclerView로 보여주고 있습니다. 한 번 알아볼까요!~ Let's Go 😎


MVC 패턴

  MVC 패턴은 Model - View - Controller 세 가지의 측면으로 관심사를 분리하여 안드로이드 개발에 적용하는 패턴입니다. 웹 개발에서도 유명한 패턴으로 안드로이드에서도 자연스럽게 적용을 하게 되었지만 안드로이드는 내부 API에 종속적이라서 웹과는 살짝 다른 형태를 나타나게 됩니다.

Model

  애플리케이션의 비즈니스 로직과 데이터들을 다루는 부분을 가리킵니다. 저는 비즈니스 로직에 대해서 정확하게 이해하지 못해서 Model 역할을 뚜렷하게 구별하지 못했습니다. 위키백과에 따르면 비즈니스 로직은 컴퓨터 프로그램에서 실세계의 규칙에 따라 데이터를 저장, 수정, 삭제하는 부분이라고 합니다. 즉, Model은 단순히 POJO 클래스만을 가리키는 것이 아닌 데이터를 조작하는 데이터베이스나 서버와 통신하는 부분도 포함이 됩니다.


View

  모델을 제외하고는 각 부분의 역할이 명확하게 구별이 됩니다. View는 사용자에게 보여지는 영역으로 모델로부터 얻은 데이터를 뷰에 표현을 하게 되고 안드로이드에서는 Activity/Fragment가 뷰의 역할을 하게 됩니다.


Controller

  컨트롤러는 뷰로부터 사용자에게 입력을 받거나 이벤트가 발생하면 로직에 맞게 모델을 변경하게 됩니다. 모델에서 데이터가 변화되는 것에 따라 컨트롤러는 뷰의 상태를 적절하게 업데이트 시킵니다. MVC 디자인 패턴에서는 Activity와 Fragment는 뷰의 역할을 하는 동시에 컨트롤러 역할도 하게 됩니다.


MVC 장단점

장점

  • MVC의 가장 큰 장점은 '모델에서 데이터를 가져와서 뷰에 표현을 하고 컨트롤러를 통해 이벤트가 발생하면 모델과 뷰를 갱신' 이라는 구조로 직관적이고 단순합니다.

  • MVC 디자인 패턴을 잘 모르더라도 소스코드만 봐도 이해하기 쉬우며 실제로도 안드로이드 API를 이용한 코드를 그냥 작성하더라도 MVC 패턴으로 볼 수가 있습니다.

  • 구조가 단순하니 작은 애플리케이션 개발하는데 크게 신경쓰지 않고 개발을 빠르게 진행시킬 수 있습니다.


단점

  • 구조가 단순한 만큼 Activity, Fragment에 모든 코드가 편중되는 경향이 있으며, 애플리케이션의 규모가 점차 커지면 하나의 클래스에 수천 줄이 넘는 코드들이 생겨날 수도 있습니다.

  • 또한 스파게티 코드들을 양산하게 되고 단순하게 파악할 수 있다는 장점이 하나의 클래스에 집중이 되면서 단점으로 바뀔 수도 있습니다.

  • 컨트롤러는 특정 뷰와 모델에 의존적이며, 뷰도 특정 모델에 의존적이라서 결합도가 높아 유닛테스트가 거의 불가능합니다.


    private fun bindViews() = with(binding) {
        button0.setOnClickListener { clickNumButton(NumModel(0)) }
        button1.setOnClickListener { clickNumButton(NumModel(1)) }
        button2.setOnClickListener { clickNumButton(NumModel(2)) }
        button3.setOnClickListener { clickNumButton(NumModel(3)) }
        button4.setOnClickListener { clickNumButton(NumModel(4)) }
        button5.setOnClickListener { clickNumButton(NumModel(5)) }
        button6.setOnClickListener { clickNumButton(NumModel(6)) }
        button7.setOnClickListener { clickNumButton(NumModel(7)) }
        button8.setOnClickListener { clickNumButton(NumModel(8)) }
        button9.setOnClickListener { clickNumButton(NumModel(9)) }
        plusButton.setOnClickListener { clickPlusButton() }
        minusButton.setOnClickListener { clickMinusButton() }
        multiplyButton.setOnClickListener { clickMultiplyButton() }
        divideButton.setOnClickListener { clickDivideButton() }
        clearButton.setOnClickListener { clickClearButton() }
        equalButton.setOnClickListener { clickEqualButton() }
    }

    private fun clickNumButton(numModel: NumModel) {
        curNum += "${numModel.num}"
        binding.curTextView.append("${numModel.num} ")
    }

  MVC를 제외하고는 모든 간단한 유닛테스트 예시도 공유하겠지만, MVC는 컨트롤러와 뷰가 안드로이드 API에도 종속적으로 연결이 되어 유닛테스트를 하기는 쉽지 않습니다. 위의 코드를 보면 각 버튼들을 각각의 이벤트 처리 메소드와 연결되어 있습니다. clickNumButton은 숫자 버튼을 클릭 시 현재의 가리키는 숫자와 화면에 보여지는 뷰를 갱신합니다.


의문..

  MVC 디자인 패턴은 컨트롤러가 뷰와 모델과의 결합도가 높아져서 유닛테스트가 어렵다고 하지만 Mockito 프레임워크를 이용하면 MVP, MVVM과 큰 차이 없이 유닛테스트가 가능한 것 같은데.. 프레임워크를 사용하지 않고 일반적인 유닛 테스트를 말하는거겠죠?.. 혹시 Mockito를 사용하더라도 MVC가 MVP, MVVM에 비해 어려울 수도 있을까요? 이 글을 읽는 고수님들 댓글 남겨주세요 👏


MVP 패턴

  MVC 패턴에서 Activity, Fragment는 컨트롤러 역할과 뷰 역할을 동시에 하기에 UI 로직이 포함됩니다. 또한, 컨트롤러에서 모델을 가져와서 데이터를 수정/변경/추가 하는 비즈니스 로직도 존재하게 되어 UI 로직과 비즈니스 로직이 공존하게 됩니다. MVP 패턴에서는 이처럼 UI 로직과 비즈니스 로직을 분리하는데 집중하여 생겨난 디자인 패턴입니다.


Model

  MVC의 Model과 동일하게 비즈니스 로직을 수행하고 데이터를 다루는 영역을 가리킵니다.


View

  View도 동일하게 사용자에게 보여지는 화면 영역을 가리키고 안드로이드에서는 Activity, Fragment가 해당됩니다. 하지만 MVC와는 다르게 Activity, Fragment는 View 역할만 할 뿐 컨트롤러 역할을 하지 않습니다.


Presenter

  Presenter는 View와 Model을 연결해주는 역할로 Presenter를 도입함으로써 UI 로직과 비즈니스 로직을 분리할 수 있게 되었습니다. Presenter 내에는 Model과 View의 인스턴스를 가지며 이 둘을 연결해주는 역할이기에 Presenter와 View는 1:1 관계를 갖습니다.


MVP 장단점

장점

  • MVC에서는 View(Activity, Fragment)에서는 모델을 직접 생성하여 변경하기에 의존적이었습니다. 하지만, MVP에서는 View와 Model 중간에 Presenter라는 연결 부분을 두어 결합도를 약하게 만들었습니다.

  • UI 로직은 View에 비즈니스 로직은 Model로 분리하였고 연결해주는 Presenter만 있기 때문에 유닛테스트가 수월해졌습니다.


단점

  • View에서 Presenter를 직접 생성하기에 의존성이 높고 1:1 관계를 유지해야 해서 View가 많아질수록 Presenter도 자연스럽게 많아질 수밖에 없습니다.

  • View와 Model은 연결시켜주는 역할이 Presenter이기에 애플리케이션 기능이 추가될 때마다 Presenter 내의 코드가 많아진다는 단점도 있습니다.


MVP 디자인 패턴 구현하기

Contract Class 만들기

  MVP 디자인 패턴에서 구글은 공식적으로 Contract를 통해 View와 Presenter의 구성요소를 정의한 후 해당 인터페이스를 통해서 구현하고 있습니다. Model도 Contract 클래스로 포함해도 되지만 보통은 Repository 패턴으로 따로 정의하여 Presenter를 구현할 때 포함시킵니다.

interface MainContract {

    interface View<T> : BaseView<Presenter>{
        fun appendCurTextView(text: String)
        fun showCurTextView(text: String)
        fun showResultTextView(text: String)
        fun showResultRecyclerView(resultEntityList: List<ResultEntity>)
    }

    interface Presenter: BasePresenter{
        fun clickNumButton(num: Int)
        fun clickPlusButton()
        fun clickMinusButton()
        fun clickMultiplyButton()
        fun clickDivideButton()
        fun clickEqualButton()
        fun clickClearButton()
    }
}

다음과 같이 계산기 프로그램의 Contract를 정의하였습니다.


Presenter Class 구현

class MainPresenter(
    private val view: MainContract.View<MainPresenter>,
    private val db: ResultDatabase?
) : MainContract.Presenter {
    val numStack = Stack<Int>()
    val operationStack = Stack<Char>()
    var curText = ""
    var curNum = ""

    override fun onResume() {
        db?.let { db ->
            view.showResultRecyclerView(db.resultDao().getResultList())
        }
    }

    override fun onStop() {}

    override fun clickNumButton(num: Int) {
        curNum += "${num}"
        curText += "${num} "
        view.appendCurTextView("${num} ")
    }

    ...

    override fun clickClearButton() {
        curNum = ""
        curText = ""
        view.showCurTextView("")
        view.showResultTextView("")
        numStack.clear()
        operationStack.clear()
    }
}

  Contract에서 정의한 Presenter의 구현체로서 View의 생명주기에 맞게 기본적으로 호출되어야 하는 코드와 각 버튼 클릭 이벤트에 대해서 정의하도록 구현하였습니다. onResume 생명주기가 호출될 때 Presenter의 onResume도 호출되도록 하여 DB 데이터를 읽어와 RecyclerView가 갱신될 수 있도록 하였습니다.


View Class 구현

class MainActivity : AppCompatActivity(), MainContract.View<MainPresenter> {

    private lateinit var binding: ActivityMainBinding

    override val presenter: MainContract.Presenter by lazy {
        MainPresenter(
            this,
            ResultDatabase.build(applicationContext)
        )
    }
    private val resultAdapter by lazy { ResultAdapter() }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        initViews()
        bindViews()
    }

    override fun onResume() {
        super.onResume()
        presenter.onResume()
    }

    override fun onStop() {
        super.onStop()
        presenter.onStop()

    }

    private fun initViews() = with(binding) {
        resultRecyclerView.apply {
            layoutManager = LinearLayoutManager(this@MainActivity)
            adapter = resultAdapter
        }
    }
    
    ...

  View가 해야 할 역할은 크게 2가지로 View의 생명주기나 이벤트에 대한 내용을 Presenter에게도 통지해주는 역할과 View 인터페이스에 정의된 메소드를 구현하여 데이터를 화면에 보여주는 역할을 하게 됩니다.(Presenter에서 View 메소드 호출)


MVP의 Unit Test

  MVC보다 MVP가 단위테스트가 더 쉽다고 하였으니 실제로도 쉬운지 직접 구현해보도록 하겠습니다. 먼저 Contract에서 정의한 View를 구현하는 커스텀 뷰 객체를 생성하고 Presenter도 직접 생성합니다. 이후 Presenter를 가지고 숫자 버튼, 더하기 버튼, 결과 버튼을 클릭하고 예상했던 결과가 같은지 확인하고 있습니다.

class MainPresenterTest {

    @Test
    fun test(){
        mainPresenter.clickNumButton(1)
        mainPresenter.clickPlusButton()
        mainPresenter.clickNumButton(2)
        mainPresenter.clickEqualButton()
        Assert.assertEquals(mainPresenter.numStack.peek(), 3)	// 1 + 2 = 3
    }

    companion object{
        private var view = object: MainContract.View<MainPresenter>{
            override val presenter: MainContract.Presenter
                get() = MainPresenter(this, null)

            override fun appendCurTextView(text: String) {

            }

            override fun showCurTextView(text: String) {

            }

            override fun showResultTextView(text: String) {

            }

            override fun showResultRecyclerView(resultEntityList: List<ResultEntity>) {

            }
        }
        private lateinit var mainPresenter: MainPresenter

        @JvmStatic
        @BeforeClass
        fun setUp(){
            mainPresenter = view.presenter as MainPresenter
        }
    }
}

만일 이와 같이 MVC에서 유닛 테스트를 구현하려고 해도 Controller는 안드로이드 API에 종속적이다 보니 직접 생성할 수가 없어서 유닛 테스트에 어려움이 존재합니다. 또한, Controller와 View가 동시에 같은 역할을 하기에 커스텀 뷰를 생성해서 확인하기도 어렵기 때문에 MVP 디자인 패턴이 쉽고 빠르게 작성할 수 있다는 것을 알게 되었습니다.


MVVM 패턴

  MVP 패턴에서는 View와 Model 사이의 연결을 도와주는 Presenter 계층을 두어 UI 로직과 비즈니스 로직을 분리할 수 있었습니다. 하지만 MVP 패턴에서는 View와 Presenter가 1:1로 매칭이 되어야 하기 때문에 강하게 결합되어 있다는 문제가 있었습니다. 이를 보완해주는 패턴으로 MVVM이 등장하게 되었습니다. MVVM 패턴에서는 별도로 Databinding, LiveData 또는 RxJava와 같은 Observable 타입을 이용하여 Presenter와 View 사이의 결합도를 끊는데 집중하였습니다.


Model

  모든 패턴에서 동일하게 애플리케이션의 비즈니스 로직과 데이터를 다루는 부분을 가리킵니다.


View

  동일하게 사용자에게 보여지는 부분으로 Activity, Fragment라고 생각하시면 됩니다.


ViewModel

  MVVM 패턴에서는 Presenter 대신 ViewModel이라는 구성요소를 사용하게 되는데 ViewModel은 View에서 표현해야할 데이터를 Observable 타입으로 관리하며 View들이 ViewModel의 데이터를 구독 요청하여 화면을 갱신합니다.

  ViewModel에서 Presenter와 동일하게 View 객체를 사용해서 화면을 갱신하게 된다면 MVP와 동일하게 ViewModel과 View는 강하게 결합될 것입니다. 이를 Databinding 라이브러리를 사용하여 ViewModel이 View에 대한 의존성을 갖지 않고 느슨하게 연결되도록 할 수 있습니다. 따라서 ViewModel은 특정 View에 의존적이지 않다 보니 다른 View와도 연결할 수 있으므로 N:M 의 관계에 있을 수 있습니다.


MVVM 장단점

장점

  • View와 ViewModel 사이의 결합도를 느슨하게 하여 유닛테스트를 수행할 수 있습니다.

  • 또한, View와 Model 사이에서도 의존성이 없습니다.

  • View는 ViewModel을 알지만 ViewModel은 View를 알지 못하고 ViewModel은 Model을 알지만 Model은 ViewModel을 알지 못합니다. 즉, 한쪽 방향으로만 의존 관계가 있어서 각 모듈별로 분리하여 개발을 할 수 있습니다.


단점

  • MVVM 패턴의 설계가 다른 디자인 패턴에 비해 복잡합니다.

  • Databinding, LiveData 등 다른 라이브러리를 필수적으로 알아야 사용할 수 있다는 단점도 있습니다.


MVVM의 Unit Test

class MainViewModelTest {

    @get:Rule
    val instantExecutorRule = InstantTaskExecutorRule()

    lateinit var mainViewModel: MainViewModel

    @Before
    fun setUp() {
        mainViewModel = MainViewModel (null)
    }

    @Test
    fun test() {
        mainViewModel.clickNumButton(1)
        mainViewModel.clickPlusButton()
        mainViewModel.clickNumButton(3)
        mainViewModel.clickEqualButton()
        Assert.assertEquals(mainViewModel.resultText.value, "4")
    }
}

  MVP와 동일하게 MVVM 패턴의 단위 테스트 코드입니다. resultText는 LiveData 형태의 데이터 홀더 클래스입니다. 덧셈을 계산한 후 resultText의 값과 예상되는 값을 비교하는 결과를 얻을 수 있습니다.


정리

  MVC, MVP, MVVM 패턴 이외에도 MVI, Clean Architecture 등 다양한 패턴들이 존재하고 직접 커스텀해서 사용하는 기업도 있다고 합니다. 제가 면접을 봤을 때 각 패키지로 잘 관리를 하면 굳이 디자인 패턴을 사용하지 않아도 괜찮을 것 같은데 왜 사용하는지에 대해서 질문이 들어왔는데 대답을 잘 못했던 기억이 납니다. 지금 생각으로는 어느정도 정해져있는 패턴을 사용하게 되면 신규 입사자도 빠르게 전반적인 이해를 할 수 있을 것 같습니다. 또한, 애플리케이션의 규모도 점차 커지도 방대해지는만큼 유지보수 비용도 많이 소요될 수도 있는 점을 감안하면 패키지로 관리하는 것보다는 대중적인 패턴을 사용해보는 것이 좋아보입니다.
혹시 저 질문에 대해서 더 좋은 아이디어나 생각 있으신 분들은 댓글 한번 부탁드려요~~ 😻


참고

profile
"Effort never betrays"

1개의 댓글

comment-user-thumbnail
2024년 4월 10일

참 깔끔하게 잘 정리하셨네요 잘 보고 갑니다. 감사합니다 :)

답글 달기