안드로이드에서의 MVP 패턴

홍성덕·2024년 11월 29일

Architecture

목록 보기
2/3

이전에 작성했던 안드로이드에서의 MVC 패턴 글에 이어서 안드로이드에서의 MVP 패턴에 대해 다뤄보고자 한다.

1. MVP 패턴 개요

모델(M: Model) : Model은 애플리케이션의 데이터를 저장하고 비즈니스 로직을 포함한다. 예를 들어, 사용자 이름, 상품 가격 등 데이터를 저장하며, 데이터 처리 및 검증, 데이터베이스 연동과 같은 비즈니스 로직을 포함한다. MVC, MVP, MVVM에서 Model의 정의는 동일하게 유지된다.
뷰(V: View) : View는 사용자 인터페이스(UI)를 정의하고, 데이터를 화면에 표시한다. 전통적인 MVC 패턴의 View와 다르게 사용자의 Input을 입력받아서 Presenter에게 전달한다. 그리고 Presenter를 참조한다.
프레젠터(P: Presenter) : View로부터 사용자의 Input을 전달받아 Model을 처리 후 다시 View를 업데이트한다. View와 Model의 연결다리 역할을 하며 View와 1:1 관계를 가진다. 그리고 View를 참조한다. View와 Presenter는 서로를 참조하는 양방향 참조인 것이다.

동작 순서는 다음과 같다.
1. 사용자의 Action은 View를 통해 들어온다.
2. View에서 받은 이벤트를 Presenter에게 전달한다.
3. Presenter에서 Model로 데이터를 요청한다.
4. Model에서 Presenter로 요청에 따른 응답을 한다.
5. Presenter에서 Model로부터 받은 결과를 View로 전달하며 View에게 UI 업데이트 요청을 한다.
6. View는 Presenter로부터 받은 데이터를 바탕으로 UI를 업데이트한다.

View -> Presenter -> Model -> Presenter -> View 순서로 동작하는데, 사실 이건 이전 글에서 봤던 Cocoa MVC 패턴의 동작 순서와 같다. 그래서 여기서도 Cocoa MVC 패턴이 사실상 MVP 패턴과 유사하다라는 것을 다시 한번 알 수 있다.

MVP 패턴의 추가적인 특징으로는 전통적인 MVC 패턴에서 문제가 되었던 Model과 View 사이의 강한 의존성을 해결한다는 것과 View와 Presenter가 1:1 관계로 서로 간의 의존성이 매우 강하다는 점이다.


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

MVP 패턴을 안드로이드 프로젝트에서 적용시키면 xml 파일과 Activity(혹은 Fragment)는 View의 역할을 맡게 되고, 개발자가 정의한 Presenter 클래스가 Presenter, 개발자가 정의한 데이터 관련 클래스 및 비즈니스 로직 등이 Model의 역할을 하게 된다.

구현할 앱은 상품명과 금액을 입력하여 할인된 계산 결과를 표시하는 간단한 샘플 앱이다. 1000원 이상이면 10%가 할인되고 1000원 미만이면 할인되지 않는다. 이전 MVC 패턴 안드로이드 프로젝트와 똑같은 앱인데 MVC 패턴을 MVP 패턴으로 바꾼 코드를 보여주겠다. xml 코드는 이전과 똑같으므로 생략하겠다.

참고로 아래 코드는 내 GitHub 레포에서 확인 가능하다.

ProductContract.kt

interface ProductContract {
    interface View {
        fun showDiscountedPrice(name: String, discountedPrice: Double)
        fun showError(message: String)
    }

    interface Model {
        fun calculateDiscountedPrice(name: String, price: Double): Double
    }

    interface Presenter {
        fun onCalculateButtonClick(name: String, price: Double?)
    }
}

안드로이드에서의 MVP 패턴은 일반적으로 이렇게 Contract interface를 정의한다. 이렇게 Contract interface를 정의하여 Model, View, Presenter의 메서드를 한 곳에서 정의하면 개발자는 각 계층의 역할을 명확히 구분할 수 있다.
쉽게 말해서 개발자는 View가 무엇을 보여줘야 하는지, Presenter가 어떤 데이터를 처리해야 하는지, Model이 어떤 비즈니스 로직을 처리해야하는지 명확히 이해할 수 있다.

그리고 이 interface를 각각의 클래스에서 구현하여 메서드를 재정의하면 된다.

ProductModel.kt (Model)

class ProductModel : ProductContract.Model {

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

ProductPresenter.kt (Presenter)

class ProductPresenter(
    private val view: ProductContract.View,
    private val model: ProductContract.Model
) : ProductContract.Presenter {

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

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

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

MainActivity.kt (View)

class MainActivity : AppCompatActivity(), ProductContract.View {

    private lateinit var presenter: ProductPresenter

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

        // Presenter 초기화
        presenter = ProductPresenter(this, ProductModel())

        // 버튼 클릭 시 이벤트 처리
        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()

            presenter.onCalculateButtonClick(productName, productPrice)
        }
    }

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

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

이렇게 진행할 수 있다. 하지만 아직 여기서 한가지 문제가 있다. presenter = ProductPresenter(this, ProductModel()) 이 코드를 봤을 때 View가 Model에 아직 의존을 하고 있다는 점이다.

물론 ProductPresenter의 생성자로 ProductModel 객체를 전달했을 뿐 ProductModel 클래스의 메서드를 호출한 것은 아니기 때문에 의존성이 엄청 강하다고 할 수는 없겠지만 어쨌거나 View가 Model에 의존하고 있다는 점은 변함이 없다. 예를 들어 ProductModel 클래스의 생성자가 변경되면 Activity의 코드도 무조건 수정을 해줘야 한다.

이 문제를 어떻게 해결할 수가 있을까?


여러가지 방법이 있는데 Model 객체 생성을 Presenter에서 진행하는 방법이 있다.

class MainActivity : AppCompatActivity(), ProductContract.View {
		// ...
        // Presenter 초기화
        presenter = ProductPresenter(this)
        // ...
}
class ProductPresenter(
    private val view: ProductContract.View
) : ProductContract.Presenter {

    val productModel = ProductModel()
    //...
}

View와 Model 사이의 의존성은 해결하였지만, 이 방법은 결국 View가 Model을 의존하고 있던 것을 Presenter가 Model을 의존하는 것으로 바꿨을 뿐이다. 그래서 오히려 Presenter와 Model 사이에 강한 의존성이 생기며, ProductModel의 생성자가 변경되면 Presenter 코드를 수정해야 하므로 사실 별 의미가 없다.


두번째로 Presenter를 Hilt로 의존성 주입하는 방법이 있다. 아래 수정된 코드는 여기서 확인 가능하다.

class ProductModel @Inject constructor() : ProductContract.Model { ... }
@Module
@InstallIn(ActivityComponent::class)
abstract class ProductModule {

    @Binds
    abstract fun bindActivity(activity: ProductActivity): ProductContract.View

    @Binds
    abstract fun bindPresenter(impl: ProductPresenter): ProductContract.Presenter

    @Binds
    abstract fun bindModel(impl: ProductModel): ProductContract.Model
}

@Module
@InstallIn(ActivityComponent::class)
object ProductActivityModule {

    @Provides
    fun provideActivity(activity: Activity): ProductActivity {
        return activity as ProductActivity
    }
}

@Module
@InstallIn(ActivityComponent::class)
object ProductModelModule {

    @Provides
    fun provideModel(): ProductModel {
        return ProductModel()
    }
}
@AndroidEntryPoint
class ProductActivity : AppCompatActivity(), ProductContract.View {

    @Inject
    lateinit var presenter: ProductContract.Presenter
    
    // ...
}

이렇게 해서 View와 Model 사이의 의존성을 제거할 수 있다.


세번째로 Google의 MVP 아키텍처 샘플 코드를 따라한 방법이다. 현재 Google의 MVP 아키텍처 샘플 코드는 사라진 것 같다. 하지만 다른 블로그 글에서 Google에서 권장했던 MVP 아키텍처에 대한 정보를 얻을 수 있었다. 해당 글의 코드는 여기 GitHub 레포에서 발견할 수 있었다.

object ProductRepository : ProductContract.Model { ... }
class ProductPresenter(
    private val view: ProductContract.View,
    private val model: ProductRepository
) : ProductContract.Presenter { ... }
class ProductActivity : AppCompatActivity(), ProductContract.View {

    private lateinit var presenter: ProductPresenter

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

        // Presenter 초기화
        presenter = ProductPresenter(this, ProductRepository)
        // ...
}

내가 수정한 코드는 여기서 확인 가능하다.
해당 블로그 글의 샘플 코드를 따라서 내 코드를 수정해보았는데 사실 이것도 의존성이 느슨해지긴 했지만, 엄연히 말해서 View와 Model 간의 의존성을 완전히 제거했다고 보기에는 힘들다고 생각한다. 하지만 구글에서 권장했던 MVP 아키텍처였기 때문에 소개해보았다.


MVP 패턴을 여러가지 방법으로 적용할 수 있는데 나라면 두번째 방법인 Hilt를 사용해서 View와 Model 간의 의존성을 완전히 끊는 방법을 선택하겠다.

MVP 패턴을 통해 전통적인 MVC 패턴의 문제점인 View와 Model 간의 의존성을 해결할 수 있었고, 안드로이드에서 MVC 패턴을 적용할 때의 모호했던 역할 분리가 MVP 패턴에서는 좀더 명확해졌다.
하지만 View와 Presenter가 1:1 관계로서 강하게 결합되어 있다는 단점도 존재한다. 1:1 관계이기 때문에 View가 늘어날 때마다 Presenter를 추가해야 하고 Presenter의 개수가 늘어나는 단점이 있다.

이러한 단점은 MVVM 패턴을 사용함으로써 극복 가능하다. 다음 글은 MVVM 패턴에 대해 공부하여 작성해보겠다.


참고자료

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

0개의 댓글