2021-03-15

Model-View-ViewModel(MVVM)

빵이 유통되는 과정을 배경으로 시나리오를 한번 구성해보기로 했다. 이 시나리오가 적절하길 희망한다.
written by

Introduction

아키텍처 패턴은 이해하기가 참 난해하다.

비슷한 부분도 많고 애매모호하게 풀어쓰는 경우도 많다.

그래서 이 글을 어떻게 하면 쉽게 풀어쓸 수 있을까 고민을 많이 했다.

실제로 접할 수 있는 시나리오를 곁들이면 이해하는 데 도움이 되지 않을까? 그래서 빵이 유통되는 과정을 배경으로 시나리오를 한번 구성해보기로 했다. 이 시나리오가 적절하길 희망한다. 🙄

코드는 가능한 단순하게 만들었다. 실제 코드는 훨씬 방대하겠지만 복잡하면 이해하기만 어려우니 여기서는 텍스트 뷰 하나로 만지작거리기로 했다.

아래 코드를 보자.

package com.cinntiq.bakerycompany

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.TextView

// 작은 제빵 회사에서 사무실을 차려서 빵을 만들어 팔고 있다고 하자.
class BakeryCompany : AppCompatActivity() {
    private var count = 0
    private lateinit var store: TextView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.bakery_company)

        store = findViewById(R.id.Store)

        // 매장에서 빵을 살 때마다 몇 번째 빵이 팔렸는지 표시되는 시나리오.
        // 클릭할 때마다 텍스트가 `~th bread was sold.`로 변경될 것이다.
        store.setOnClickListener {
            (it as TextView).text = "${++count}th bread was sold."
        }
    }
}

이제 이 코드를 이제 각각에 맞게 수정해보자.

Model-View-Controller(MVC)

좁은 사무실에서 빵을 만들면 효율이 너무 떨어지고 복잡하다.

그래서 빵을 만드는 공장을 만들어서 빵을 생산하기로 했다.

이제 사무실에서는 빵을 팔려는 매장만 결정하고 빵은 공장에서 만들어서 직접 보내기로 한다.

이것이 MVC의 아이디어다.

여기서 사무실은 컨트롤러(Controller), 빵 공장은 모델, 매장은 뷰가 된다.

다음 코드는 MVC를 적용한 코드다.

package com.cinntiq.bakerycompany

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.TextView

// 안드로이드에서 뷰는 기본적으로 xml로 분리되어 있다.
// 액티비티나 프래그먼트가 컨트롤러의 역할을 담당한다.
class BakeryCompany : AppCompatActivity() {
    private lateinit var breadFactory: BreadFactory // 데이터를 처리하기 위한 모델.
    private lateinit var store: TextView // 뷰에 해당하는 텍스트 뷰.

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.bakery_company)

        breadFactory = BreadFactory()
        store = findViewById(R.id.Store)

        // 매장에서 빵을 살 때마다 몇 번째 빵이 팔렸는지 표시된다.
        // 액티비티에서 뷰를 선택하는 과정.
        store.setOnClickListener {
            (it as TextView).buyBread()
        }
    }

    // 매장에서 빵을 사면 공장에 빵을 만들어 달라고 요청하는 부분.
    private fun TextView.buyBread() {
        // 뷰의 값을 직접 변경하면서 모델과 의존성이 생겼다.
        text = breadFactory.makeBread()
    }

    // 이제 빵 공장에서는 오로지 빵만 생산하고 내보낸다.
    class BreadFactory {
        private var count = 0

        fun makeBread() = "${++count}th bread was sold."
    }
}

이제 빵만 만드는 공간이 따로 생겼다. 🍞

문제점은 매장과 빵 공장이 너무 긴밀하게 연결되어 있다는 점이다.

이 문제를 해결해보자.

Model-View-Presenter(MVP)

어느덧 회사가 무럭무럭 성장해서 프렌차이즈 사업을 시작했다고 하자.

이제 빵을 구매하는 매장이 너무 많아져서 사무실에서는 더 이상 감당이 안 된다.

그래서 이제 회사는 해당 매장만 관리하는 전담 관리자를 두려 한다.

해당 매장에 관한 내용은 전담 관리자에게만 물어보면 되므로 일이 수월해질 것이다.

이게 MVP의 아이디어다.

여기서 관리자가 프레젠터(Presenter)가 된다.

컨트롤러에서는 여러 뷰와 여러 모델이 존재하지만 여기서는 단일 뷰만 존재하게 된다.

여러 뷰를 관리하면 동작 구조는 다를지라도 사실상 MVC와 다를 게 없기 때문이다.

다음 코드는 MVP를 적용한 코드다.

(MVP는 인터페이스를 이용해서 구현하는 경우가 많은 거 같다.)

package com.cinntiq.bakerycompany

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.view.View
import android.widget.TextView

// 액티비티나 프래그먼트는 뷰의 일부로 치부되지만 사실 완벽하지는 않다.
// 프레젠터에서는 여전히 뷰에 관여하기 때문이다.
class BakeryCompany : AppCompatActivity() {
    lateinit var breadSaleManager: BreadSaleManager<View>

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.bakery_company)

        // 이제 사무실에서는 하나의 매장을 전담해서 관리하는 매장 관리자를 고용한다.
        breadSaleManager = BreadSaleManager(findViewById(R.id.Store))
    }

    class BreadFactory {
        private var count = 0

        fun makeBread() = "${++count}th bread was sold."
    }

    // 매장 관리자가 해야 할 일을 인터페이스로 정의했다.
    interface BreadSaleInterface<in Store : View> {
        fun Store.buyBread()
    }

    // 매장 관리자는 기존에 사무실에서 하던 일을 이어 받아서 그대로 하게 된다.
    // 매장이 수십 개가 된다면 매장 관리자도 수십 명이 되는 문제는 장점이자 단점이기도 하다.
    // 너무 잘게 쪼개지면 결국 관리가 어렵기 때문이다.
    class BreadSaleManager<Store : View>(private val store: Store, private val breadFactory: BreadFactory = BreadFactory()) : BreadSaleInterface<Store> {
        init {
            store.setOnClickListener {
                store.buyBread()
            }
        }

        override fun Store.buyBread() {
            when (this) {
                is TextView -> text = breadFactory.makeBread()
            }
        }
    }
}

이제 사무실이 하는 일이 한층 줄어들었다.

문제는 매장과 전담 관리자가 긴밀하게 연결된다는 것이다.

뷰와 모델이 가지고 있던 의존성이 뷰와 프레젠터로 옮겨간 것이다.

이제 다음을 보자.

Model-View-ViewModel(MVVM)

회사가 너무 커지자 매장 관리자를 고용하는 것에도 한계가 왔다.

차라리 매장 관리자를 없애고 매장에서 빵이 팔리면 자동으로 사무실에 통보하게 하면 어떨까?

이러면 작은 사무실에서도 모든 매장을 관리할 수 있다.

이게 MVVM의 아이디어다.

사무실에 자동으로 통보하는 통합 관리자가 뷰모델(ViewModel)이 된다.

다음 코드는 MVVM을 적용한 코드다.

package com.cinntiq.bakerycompany

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.TextView
import androidx.lifecycle.*

// 이제 액티비티나 프래그먼트는 완전히 뷰의 일부로 치부된다.
// 기능적으로 분리된 다른 코드에 뷰를 변화시키는 어떠한 코드도 없다는 것에 집중하자.
class BakeryCompany : AppCompatActivity() {
    lateinit var store: TextView
    lateinit var integratedManager: IntegratedManager

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.bakery_company)

        // 다시 사무실에서 직접 매장을 관리할 수 있게 되었다.
        store = findViewById<TextView>(R.id.Store).also {
            it.setOnClickListener {
                integratedManager.buyBread()
            }
        }

        // 공장에서 빵이 만들어질 때마다 통합 관리자가 이를 자동으로 사무실에 통보한다.
        // 이제 사무실에서 직접 매장을 관리할 수 있다.
        integratedManager = ViewModelProvider(this, IntegratedManagerFactory()).get(IntegratedManager::class.java).also {
            it.store.observe(this@BakeryCompany, Observer { result ->
                store.text = result ?: return@Observer
            })
        }
    }

    class BreadFactory {
        var count = 0

        fun makeBread() = "${++count}th bread was sold."
    }

    // 통합 관리자는 매장에서 빵을 사면 공장에서 빵을 만들어서 판다.
    // 여기에 어떤 뷰도 없다는 것에 집중하자.
    class IntegratedManager(private val breadFactory: BreadFactory) : ViewModel() {
        private val _store = MutableLiveData<String>()
        val store: LiveData<String> = _store

        fun buyBread() {
            _store.value = breadFactory.makeBread()
        }
    }

    // 뷰모델은 직접 생성하지 않고 공급자를 이용해서 공급한다.
    @Suppress("UNCHECKED_CAST")
    class IntegratedManagerFactory : ViewModelProvider.Factory {
        override fun <T : ViewModel> create(modelClass: Class<T>): T {
            if (modelClass.isAssignableFrom(IntegratedManager::class.java)) {
                return IntegratedManager(BreadFactory()) as T
            } else {
                throw IllegalArgumentException("Unknown ViewModel class")
            }
        }
    }
}

통합 관리자에게 막중한 책임이 부과되는 시스템이지만...

(이런 방식을 옵저버 패턴이라고 한다.)

뷰와 비즈니스 로직이 완전히 분리된다는 것은 정말 큰 장점이다.

Conclution

여기까지 모든 아키텍처 패턴을 간략하게 알아봤다.

전문 용어는 가급적 피하려고 노력했는데 좋은 선택인지는 모르겠다.

아래로 갈수록 코드는 분리되지만 이해하는 것이 복잡해지는 것을 볼 수 있다.

개인적으로는 코드의 규모에 따라서 적절한 패턴을 선택하는 것이 좋지 않을까 한다.

위와 같은 코드는 MVC조차도 코드를 복잡하게 만든다.

그냥 패턴을 적용하지 않는 것도 하나의 방법일 수 있다. 😪

2021 © Cinntiq's Studio