안드로이드에서의 MVC 패턴

홍성덕·2024년 11월 27일

Architecture

목록 보기
1/3

안드로이드 MVC 패턴에 대해 기존에 애매하게 알고 있었던 부분에 대해 정리하고자 이렇게 글을 작성하게 되었다. 일단 안드로이드에서의 MVC 패턴과 전통적인 MVC 패턴은 다르다는 것을 먼저 말해두고 싶다. 그래서 MVC 패턴을 검색하였을 때 그림도 다양하고 설명도 다른 이유가 이러한 이유 때문이다.

1. 전통적인 MVC 패턴

모델(M: Model) : Model은 애플리케이션의 데이터를 저장하고 비즈니스 로직을 포함한다. 예를 들어, 사용자 이름, 상품 가격 등 데이터를 저장하며, 데이터 처리 및 검증, 데이터베이스 연동과 같은 비즈니스 로직을 포함한다. MVC, MVP, MVVM에서 Model의 정의는 동일하게 유지된다.
뷰(V: View) : View는 사용자 인터페이스(UI)를 정의하고, Model로부터 데이터를 받아 화면에 표시한다. View는 Controller 존재를 알지 못하고 Model만을 알고 있다.
컨트롤러(C: Controller) : Controller는 Model과 View 사이에서 중재 역할을 한다. 사용자 입력(Input)을 처리하고 이에 따라 Model의 상태를 업데이트하거나 Model의 특정 비즈니스 로직을 처리한다. Controller는 직접 View를 변경하지 않는다.

동작 순서는 다음과 같다.
1. 사용자에게 View가 보여진다.
2. 사용자가 입력한 데이터는 Controller가 받아서 Model을 업데이트한다.
3. Model이 변경되고 변경된 데이터를 View에 알린다.
4. View가 Model의 변경된 데이터를 받아 UI를 업데이트한다.

입력 데이터를 Controller가 받는 것에 유의하자. View가 입력 데이터를 받는다고 생각할 수도 있는데 View는 입력하는 UI를 보여주는 것이고 직접적인 데이터는 Controller가 받는다. 헷갈린다면 위 그림을 참고하자.
그림에서 Model에서 View 방향으로 UPDATES라고 적혀있어서 Model이 직접 View를 업데이트한다고 착각할 수도 있는데, 업데이트된 데이터를 View에게 알린다는 의미의 화살표이다.

그리고 전통적인 MVC 패턴을 생각할 때는 안드로이드에 바로 접목시켜서 생각하는 것보다는 콘솔(Console) 프로그램을 만든다고 가정해보면 좀더 이해하기 쉽다.


전통적인 MVC 패턴에서 주의할 점은 View가 UI를 업데이트하려면 Model의 변화된 데이터를 View가 받을 수 있는 방법을 생각해야 한다는 점이다. 이 과정에서 Controller를 거치지 않기 때문이다.
아래 두 가지 방법이 존재한다.

  1. Model 스스로 자신의 변화를 View에게 알린다.
  2. View가 직접 Model의 데이터를 가져와서 UI를 업데이트한다.
    만약 주기적으로 업데이트해야 한다면 주기적으로 Model의 데이터를 가져와서 UI를 업데이트한다. (Polling)

안드로이드에서 1번 방법을 사용하려면 Observer 패턴을 사용하는 것이 일반적이다. 예를 들면 Model 클래스를 Observable 클래스를 상속하도록 정의하여 Observer 패턴으로 구현할 수 있다. 하지만 내가 보여줄 예시 코드는 간단하게 2번 방법을 택하겠다. 예시에서 보여줄 것은 주기적으로 업데이트할 필요가 없는 데이터라서 2번 방법으로 하는 것이 오히려 쉽다.


그럼 전통적인 MVC 패턴을 안드로이드에 적용시켜보자.

안드로이드에서는 xml파일이 View, Activity 파일이 Controller, 그리고 개발자가 정의한 Model 클래스 파일이 Model 역할을 맡는다.

위 이미지에서는 Model이 스스로 자신의 변화를 View에게 알리는 방식을 표현하고 있는데 아까도 말했듯이 여기서는 그냥 View가 직접 Model의 데이터를 가져오는 방식으로 예시 코드를 보여주겠다. 보여주는 예시 코드는 내 GitHub 레포에 똑같이 있으니 그걸 참고해도 된다.

구현할 앱은 상품명과 금액을 입력하여 할인된 계산 결과를 표시하는 간단한 샘플 앱이다. 1000원 이상이면 10%가 할인되고 1000원 미만이면 할인되지 않는다.

activity_main.xml (View)

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp">

    <EditText
        android:id="@+id/productNameInput"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="상품 이름 입력" />

    <EditText
        android:id="@+id/productPriceInput"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="상품 가격 입력"
        android:inputType="numberDecimal" />

    <Button
        android:id="@+id/calculateDiscountButton"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="할인 계산" />

    <TextView
        android:id="@+id/resultTextView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:paddingTop="16dp"
        android:text="결과가 여기에 표시됩니다." />

</LinearLayout>

Product.kt (Model)

data class Product(val name: String, val price: Double) {

    // 할인 금액 계산 로직
    fun calculateDiscountedPrice(): Double {
        return if (price >= 1000) {
            price * 0.9 // 10% 할인 적용
        } else {
            price // 할인 없음
        }
    }
}

MainActivity.kt (Controller)

class MainActivity : AppCompatActivity() {

    private lateinit var product: Product

    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()

            if (productPrice != null) {
                product = Product(productName, productPrice)

                // Model의 로직 호출 및 UI 업데이트
                val discountedPrice = product.calculateDiscountedPrice()
                showDiscountedPrice(product.name, discountedPrice)
            } else {
                showError("유효한 가격을 입력하세요.")
            }
        }
    }

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

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

그런데 뭔가 이상하다. Controller에서 product.calculateDiscountedPrice()를 통해 Model의 비즈니스 로직을 처리하는 것은 이해가 간다. 그런데 showDiscountedPrice()를 호출하면서 View를 업데이트하는 것까지 여기서 처리하고 있다.

이게 안드로이드에서 전통적인 MVC 패턴을 적용하기 힘든 이유이다. View의 역할을 하는 xml에서 직접 View를 업데이트하는 코드를 작성하는 것이 불가능하다.

그래서 안드로이드에서 MVC 패턴을 적용하다 보면 어쩔 수 없이 이렇게 VM 구조가 되어버린다. View의 역할을 하는 xml과 Controller 역할을 하는 Activity 혹은 Fragment를 묶어서 사실상 View로 봐야 한다.

안드로이드에서는 어쩔 수 없이 변형된 MVC 패턴을 사용해야 한다. Cocoa MVC 패턴이 바로 그것이다.


2. 변형된 MVC 패턴

안드로이드에서의 변형된 MVC 패턴은 전통적인 MVC 패턴과 다르게 View와 Model 사이의 의존성이 제거된 형태이다. 그래서 데이터가 전달되는 흐름이 달라진다.

사용자 입력 -> View -> Controller -> Model -> Controller -> View로 데이터 흐름이 이루어진다.
1. View가 사용자 입력을 받는다.
2. 사용자가 입력한 데이터는 Controller에게 전달되어 Controller가 사용자 입력에 해당하는 Model에 업데이트를 요청한다.
3. Model이 데이터를 변경 및 비즈니스 로직을 처리하여 Controller에 응답한다.
4. Controller는 응답받은 데이터를 View에 전달하며 View에 업데이트를 요청한다.
5. View가 Controller로부터 전달받은 데이터를 통해 UI를 업데이트한다.


그렇다면 안드로이드 샘플 프로젝트로 변형된 MVC 패턴을 적용해보자. 이 코드는 내 GitHub 레포에 똑같이 있으니 그걸 참고해도 된다. 구현할 앱은 동일하므로, 여기서는 변경 및 추가된 코드만 보여주겠다.

ProductController.kt (Controller)

class ProductController(private val view: MainActivity) {

    fun calculateDiscount(name: String, price: Double) {
        // Model 생성
        val product = Product(name, price)

        // 비즈니스 로직 호출: 할인 금액 계산
        val discountedPrice = product.calculateDiscountedPrice()

        // View에 결과 전달
        view.showDiscountedPrice(product.name, discountedPrice)
    }
}

사실 위 코드에는 Controller가 Model과 강하게 결합되어 있다는 문제가 있다. 이를 해결하기 위한 방법은 안드로이드에서의 MVP 패턴 글에 작성하였다.

MainActivity.kt (View)

class MainActivity : AppCompatActivity() {

    private lateinit var controller: ProductController

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

        // Controller 초기화
        controller = ProductController(this)

        // 버튼 클릭 시 할인 계산 요청
        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()

            if (productPrice != null) {
                controller.calculateDiscount(productName, productPrice)
            } else {
                showError("유효한 가격을 입력하세요.")
            }
        }
    }

    // 할인된 가격을 화면에 표시
    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()
    }
}

위에서 설명했듯이 사용자 입력 -> View -> Controller -> Model -> Controller -> View의 순서로 동작한다. 그리고 MainActivity이 오로지 View의 역할만 하도록 수정되었다. 이걸 바탕으로 현재 앱이 어떻게 동작하는지 따라가보자.

  1. 먼저 View의 calculateDiscountButton 버튼을 클릭하는 것이 사용자 입력이다.
  2. 그리고 이 사용자 입력을 ProductController에 전달하면서 입력된 productName과 productPrice도 같이 전달한다. (controller.calculateDiscount(productName, productPrice)
  3. Controller인 ProductController는 전달받은 사용자 입력(할인 계산 버튼 클릭)에 해당하는 Model(Product)에 업데이트를 요청한다. Model이 비즈니스 로직을 수행한 후, Controller에 응답한다. (val discountedPrice = product.calculateDiscountedPrice())
  4. Controller가 View에게 응답받은 데이터를 전달하면서 View에 UI 업데이트 요청을 한다. (view.showDiscountedPrice(product.name, discountedPrice))
  5. View가 UI를 업데이트한다. (findViewById<TextView>(R.id.resultTextView).text = "상품: $name, 할인된 가격: ${"%.2f".format(discountedPrice)}원")

3. Cocoa MVC는 사실상 MVP 패턴이다?

안드로이드에서 변형된 MVC 패턴을 통해 앱을 만들었을 때 과연 이것이 잘 작성된 코드인가에 대한 생각을 해볼 필요가 있다. 나는 Controller를 명확히 분리하기 위해서 MainActivity를 ProductController에 생성자로 전달하였다.

하지만 액티비티(혹은 프래그먼트)를 다른 클래스에 전달하는 것 자체가 위험하다. 액티비티 인스턴스를 통해 너무 많은 것을 할 수 있기 때문이다. 액티비티를 통해 생명주기 콜백 함수를 호출할 수도 있고 리소스에도 접근 가능하고 직접적인 UI 업데이트도 가능하다.

그래서 이부분을 interface를 정의함으로써 해결할 수 있다.

// ProductView.kt
interface ProductView {
    fun showDiscountedPrice(name: String, discountedPrice: Double)
    fun showError(message: String)
}

// ProductController.kt
class ProductController(private val view: ProductView) { ... }

// MainActivity.kt
class MainActivity : AppCompatActivity(), ProductView {
	// ...
    override fun showDiscountedPrice(name: String, discountedPrice: Double) { ... }

    override fun showError(errorMessage: String) { ... }
}

그런데 interface를 통해 의존성을 느슨하게 했더니... 뭔가 이 코드가 낯설지 않게 느껴졌다. 액티비티를 View의 역할만 하도록 완전히 분리하고, interface를 통해서 의존성을 느슨하게 하는 방식 자체가 지금까지 내가 안드로이드 MVP 샘플 프로젝트에서 보았던 코드들과 굉장히 유사하다는 점을 느꼈다.

데이터 흐름을 나타내는 그림을 비교해봐도 안드로이드에서 적용된 MVC 패턴과 MVP 패턴이 굉장히 유사하다. 이걸 어떻게 설명해야 할지 고민하던 찰나에 이 글에서 답을 찾을 수 있었다.

글의 설명에 따르면 애플이 정립한 Cocoa MVC 패턴은 세세한 단어 선택만 다를 뿐 MVP 패턴과 거의 유사하다. 그런데 애플이 Cocoa MVC를 정립할 당시에는 MVP가 상대적으로 새롭고 덜 알려져 있던 개념이기 때문이라고 설명하고 있다.


글을 작성하면서 전통적인 MVC 패턴을 안드로이드에서 적용하기 힘든 이유와 그에 따라 변형된 MVC 패턴인 Cocoa MVC를 적용한 과정, 그리고 Cocoa MVC가 사실상 MVP 패턴이었다는 것을 알게 되었다. 아키텍처 패턴의 발전과정을 몸소 느끼게 된 것 같아서 뿌듯했다.

MVP 패턴에 대해 공부하여 MVP 패턴에 대한 글을 다음 글로 작성해볼건데, 공부하는 과정에서 만약 이 글에서 틀린 부분이 발견된다면 수정할 예정이다.


참고자료

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

0개의 댓글