안드로이드에서의 MVVM 패턴

홍성덕·2024년 12월 2일

Architecture

목록 보기
3/3

1. MVVM 패턴 개요

모델(M: Model) : Model은 애플리케이션의 데이터를 저장하고 비즈니스 로직을 포함한다. 예를 들어, 사용자 이름, 상품 가격 등 데이터를 저장하며, 데이터 처리 및 검증, 데이터베이스 연동과 같은 비즈니스 로직을 포함한다. MVC, MVP, MVVM에서 Model의 정의는 동일하게 유지된다.
뷰(V: View) : View는 사용자 인터페이스(UI)를 정의하고, 데이터를 화면에 표시한다. 사용자의 Input을 입력받아서 ViewModel에게 전달한다. ViewModel을 알고 있다. (다른 말로, 코드에서 ViewModel을 의존하고 있다는 말이다)
뷰모델(VM: ViewModel) : View와 Model 사이의 중간 계층으로 View로부터 사용자의 Input을 전달받고, Model에게 필요한 데이터를 요청하거나 필요한 작업(비즈니스 로직)을 요청한다.
그리고 View의 존재를 모른다. (View를 import하지 않는다) 그래서 외부에 노출할 상태를 정의하면 View가 그 상태를 스스로 가져간다. 또한 View와 1:1 관계가 아니고 ViewModel:View = 1:n 관계이다.

2. 안드로이드에서 MVVM 패턴 적용

MVVM 패턴의 동작 순서는 다음과 같다.
1. View에서 사용자의 Input을 받고 ViewModel로 해당 이벤트를 전달한다.
2. ViewModel은 Model에게 필요한 데이터를 요청하거나 필요한 작업(비즈니스 로직)을 요청한다.
3. Model은 요청받은 데이터를 응답한다.
4. ViewModel은 응답 받은 데이터를 가공하여 저장하고 외부에 노출한다.
5. View는 ViewModel의 데이터를 기반으로 UI를 업데이트한다.

그러면 이제 예시 프로젝트로 MVVM 패턴을 살펴보자. 구현할 앱은 상품명과 금액을 입력하여 할인된 계산 결과를 표시하는 간단한 샘플 앱이다. 1000원 이상이면 10%가 할인되고 1000원 미만이면 할인되지 않는다. 이전 MVC, MVP 패턴 글에서 구현한 앱과 똑같다.

아래 코드는 내 GitHub 레포에서 살펴볼 수 있다. 이 글에서 xml 코드는 생략했다.

Product.kt (Model)

data class Product(
    val name: String,
    val price: Double
) {
    val discountedPrice = if (price >= 1000) {
        price * 0.9 // 10% 할인 적용
    } else {
        price // 할인 없음
    }
}

ProductUiModel.kt (ViewModel)

data class ProductUiModel(
    val name: String,
    val discountedPrice: Double
)

View와 Model 간의 결합을 제거하기 위해서 ProductUiModel이라는 UI 전용 클래스를 따로 만들어주었다. ProductViewModel.kt에서 데이터를 ProductUiModel로 가공하여 저장하기 위해서이다. 이렇게 함으로써 View는 직접적으로 Model을 참조하지 않고 ViewModel을 통해 필요한 데이터를 받게 된다.

ProductViewModel.kt (ViewModel)

class ProductViewModel : ViewModel() {

    private val _product: MutableStateFlow<ProductUiModel?> = MutableStateFlow(null)
    val product = _product.asStateFlow()

    private val _errorMessage: MutableStateFlow<String?> = MutableStateFlow(null)
    val errorMessage = _errorMessage.asStateFlow()

    fun onCalculateButtonClick(name: String, price: Double?) {
        if (name.isBlank() || price == null || price <= 0) {
            _errorMessage.value = "유효한 상품명과 가격을 입력하세요."
            return
        }

        // 비즈니스 로직 호출: 할인 금액 계산
        val product = Product(name, price)
        val discountedPrice = product.discountedPrice
        // 그 후 상태값 변경
        _product.update { ProductUiModel(name, discountedPrice) }
    }
}

ViewModel에 저장할 데이터를 LiveData를 사용할 수도 있고 Flow를 사용할 수도 있는데 최근 좀더 많이 사용되는 Flow를 사용하였다.

MainActivity.kt (View)

class MainActivity : AppCompatActivity() {

    private val viewModel: ProductViewModel by viewModels()

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

        // 버튼 클릭 시 이벤트 처리
        findViewById<Button>(R.id.calculateDiscountButton).setOnClickListener {
            val productName = findViewById<EditText>(R.id.productNameInput).text.toString()
            val productPrice =
                findViewById<EditText>(R.id.productPriceInput).text.toString().toDoubleOrNull()

            viewModel.onCalculateButtonClick(productName, productPrice)
        }

        viewModel.product.asLiveData().observe(this) { product ->
            product?.let {
                showDiscountedPrice(it.name, it.discountedPrice)
            }
        }

        viewModel.errorMessage.asLiveData().observe(this) { errorMessage ->
            errorMessage?.let {
                showError(it)
            }
        }
    }

    // 할인된 가격을 화면에 표시
    fun showDiscountedPrice(name: String, discountedPrice: Double) {
        findViewById<TextView>(R.id.resultTextView).text =
            "상품: $name, 할인된 가격: ${"%.2f".format(discountedPrice)}원"
    }

    // 에러 메시지 표시
    fun showError(errorMessage: String) {
        Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show()
    }
}

Flow를 collect 함수를 호출하지 않고 asLiveData() 함수를 통해 LiveData로 변환 후 observe를 호출하였다. StateFlow는 생명 주기를 인식하지 않고, LiveData는 생명 주기를 인식한다. 그래서 LiveData로 변환하여 UI가 화면에 표시되는 동안에만 데이터를 관찰하기 위해서 asLiveData()를 사용하였다.

옵저버 패턴이 사용되었는데, 이렇게 함으로써 View가 ViewModel의 데이터를 관찰하다가 데이터가 변경되면 스스로 UI를 업데이트한다. 그래서 ViewModel은 View의 존재를 알 필요가 없다.


이 앱에서 MVVM 패턴에 따른 동작 순서는 다음과 같다.
1. 먼저 View의 calculateDiscountButton 버튼을 클릭하는 것이 사용자 입력(액션)이다.
2. viewModel.onCalculateButtonClick(productName, productPrice)을 통해 ViewModel로 해당 이벤트를 전달한다.
3. ViewModel이 Model에게 필요한 작업을 요청한다. 여기서는 할인 금액을 계산하는 비즈니스 로직이 필요한 작업이다. 그리고 Model은 비즈니스 로직 처리 후 요청받은 데이터를 응답한다. (val discountedPrice = product.discountedPrice)
4. ViewModel은 응답 받은 데이터를 ProductUiModel로 가공하여 저장하고 외부에 노출한다. (_product.update { ProductUiModel(name, discountedPrice) })
5. View는 ViewModel의 데이터를 기반으로 UI를 업데이트한다. (viewModel.product.asLiveData().observe(this) { ... })


MVP 패턴에서는 View와 중재자 역할을 하는 Presenter가 서로 참조함으로써 강력하게 결합되어 있었다. 하지만 MVVM 패턴에서는 View만 ViewModel에 의존하도록 방향성이 설정되어 결합성이 더 느슨해졌다는 것이 MVP 패턴과의 가장 큰 차이점이다. 그래서 MVP 패턴에 비해 코드가 좀더 유연해지고 유지보수가 용이해졌다.

추가로 Jetpack Compose에서의 MVVM 패턴 샘플 코드도 작성해 보았다. (GitHub 링크)


참고자료

profile
안드로이드 주니어 개발자

0개의 댓글