본문 바로가기

Android/Architecture

[Android] MVP Pattern을 적용해보자.

728x90

최근에 진행되는 프로젝트를 보면 MVVM Pattern을 많이 사용하여 개발을 진행하지만,

그에 못지않게 MVP Pattern도 많이 사용하는 것으로 보인다.

 

2020.06.11 - [Android/Architecture] - [Android] MVC, MVP, MVVM 기본 개념

필자가 몇 년 전에 MVC, MVP, MVVM Pattern에 대한 기본 개념을 간단하게 정리한 글이 있는데, 실제로 해당 패턴을 사용하여 구현한 적이 없어 해당 패턴으로 구현된 프로젝트를 보니 이해하는 것이 생각보다 쉽지 않았다.

 

따라서, 해당 패턴을 다시 한번 정리하고 샘플 예제를 만들어서 이해도를 높이고자 한다.


우선,

MVP Pattern에 대하여 다시 한번 확인하고 넘어가보도록 하자.

Model, View, Presenter로 이루어진 Design Pattern

 

앞서 작성했던 MVI와 마찬가지로 Design Pattern의 MV 형제 중 하나로,

MVC Pattern의 단점View와 Model 사이의 의존성 문제를 보완하여 만들어진 디자인 패턴이다.

 

각 요소가 어떤 역할을 하는지 확인해보자.

 

View : UI 단으로 실질적인 User의 Event가 발생한다. Activity, Fragment가 속한다.
Model : Data와 관련된 비즈니스 로직을 담당한다. Data를 Control 한다.
Presenter : UI의 비즈니스 로직을 담당하고, 필요에 따라 Model에 Data를 요청하여 Data를 토대로 View단의 UI를 갱신하도록 요청한다.

 

View와 Model의 역할은 모든 MV~ pattern에서 동일하기 때문에 생략하도록 하고,

PresenterView와 Model의 다리 역할이라고 보면 될 것이다.

 

View와 Model 간의 의존성을 없앤 패턴이기 때문에, View에서는 Model을 직접적으로 호출하지 못하고 Presenter를 통해서 호출하게 된다.

Model 또한 필요한 데이터를 가공하여 View로 바로 던져주는 것이 아닌, Presenter를 통해서 View에게 전달하게 되는 구조인 것이다.

 

Presenter에서는 주로 이 둘 사이를 연결하기 위하여 Interface를 사용하며, Interface는 어떠한 기능이 있는지 한눈에 파악할 수 있도록 명시해주는 역할을 한다.

명시해주는 역할이기 때문에 Contract라는 Interface는 필수적인 요소가 아니지만, Google에서 제공하는 예제에서도 Contract라는 Interface를 사용하고 있으므로 필자도 해당 Interface를 사용하도록 샘플 예제를 만들어 보았다.

 

설명을 확인해보면, MVVM Pattern에서 ViewModel과 Presenter과의 차이가 무엇인가 헷갈릴 수 있다.

Presenter는 View와 Model에 대한 참조를 가지고 있으며, View가 Presenter에 의존적이라는 특징을 갖고 있는 반면,

ViewModel은 Model에 대한 참조만 가지고 있기 때문에 View는 ViewModel에 의존적이지 않다는 특징을 갖고 있다.

 

즉, Presenter는 View에 대한 참조를 갖고 있기 때문에 UI의 Update에 View의 method를 호출하여 갱신하게 되며, ViewModel은 View에 대한 참조를 갖고 있지 않기 때문에 View에서 ViewModel의 데이터 변화를 Observing 하고 있다가 해당 데이터가 갱신되면 탐지하여 그 데이터를 사용하는 구조이다.


간단하게 MVP Pattern에 대하여 알아봤으므로,

샘플 예제를 확인하면서 어떻게 사용되는지 확인해보자.

 

이번 예제도 Clean Architecture 샘플 예제에서 사용했던 API를 사용하였고, 기본적인 구조는 동일하게 가져갔기 때문에 그 외의 구조는 생략하도록 하겠다.

 

우선,

아주 간단하게 MainActivity에서 버튼을 눌러 다른 Activity로 이동하는 부분을 구현해 보자.

사실 이런 경우에는 Presenter, Contract도 필요 없이 클릭 이벤트로 넘기면 되지만 샘플 코드이기 때문에 억지로라도 사용해보도록 하자.

 

별 다른 Data가 필요하지 않기 때문에 Data를 사용하는 Model을 제외하고 View, Presenter, Contract만 사용된다.

 

 

 

가장 처음으로 구현할 부분은 Contract이다.

Contract를 통해 View에서 Presenter를 호출하고, Presenter에서 View를 호출해야 한다.

 

interface MainContract {
    interface View {
        fun moveActivity()
    }

    interface Presenter {
        fun onMoveButtonClick()
    }
}

 

따라서 다음과 같이 만들도록 하자.

View에서 버튼을 누르면 presenter의 onMoveButtonClick() 메서드를 호출하고, 해당 메서드에서 View의 moveActivity() 메서드를 호출시키면 된다.

 

간단하게 생각해서, View Interface에서는 View에서 UI Update를 하기 위한 Method를 선언,

Presenter Interface에서는 UI 비즈니스 로직을 처리하기 위한 Method를 선언하면 된다.

 

class MainActivity : AppCompatActivity(), MainContract.View {
    var presenter: MainPresenter? = null

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

        presenter = MainPresenter(this)
    }

    fun btnClick(view: View) {
        when (view.id) {
            R.id.moveBtn -> {
                presenter!!.onMoveButtonClick()
            }
        }
    }

    override fun moveActivity() {
        startActivity(Intent(this@MainActivity, MovieSearchActivity::class.java))
    }
}

 

MainPresenter를 호출해야 하므로, 객체를 생성해주고, 버튼 클릭 시 MainPresenter의 메서드를 호출해준다.

MainContract.View를 상속받았으므로 moveActivity() 메서드를 override 한다.

 

class MainPresenter(
    private var activity: MainContract.View?,
) : MainContract.Presenter {

    override fun onMoveButtonClick() {
        activity!!.moveActivity()
    }
}

 

Presenter에서는 MainContract.View를 인자로 받는다.

그렇기 때문에 MainContract.View를 상속받은 MainActivity에서 Presenter에 대한 객체를 생성할 때 this를 사용하여 생성할 수 있던 것이다.

 

View에서 호출한 onMoveButtonClick 메서드에서는 필요하면 Model에 데이터를 요청하여 필요한 데이터를 가져오는 작업이 필요하지만, 지금은 단순히 화면 이동만 하면 되기 때문에 다시 View의 moveActivity 메서드를 호출하도록 한다.

 

위를 보면 알 수 있듯이, moveActivity 메서드에서는 startActivity를 하여 다음 Activity로 이동하는 작업만 존재한다.

 

필자가 억지로 MVP 패턴에 맞춰서 구현했다고 하지만, 아주 기본적인 MVP 패턴의 프로세스를 확인할 수 있다.

 

  1. View에서 User의 Event를 받는다.
  2. Contract를 사용하여 UI 비즈니스 로직을 처리하는 Presenter에 Event를 전달
  3. Data가 필요 없으므로 Presenter에서 UI 비즈니스 로직을 처리
  4. 결과를 View로 전달
  5. View에서 결과에 따라 UI를 갱신

 

기본적으로 어떠한 Flow로 MVP 패턴이 구현되는지 확인을 했으니,

조금 더 심화된 버전인 API를 사용하여 Data를 가져와 보여주는 MovieSearchActivity로 넘어가 보자.

 

 

이번에는 구조상 필요한 Adapter Class와 Data가 있으므로 Model Class가 존재한다.

 

여기서도 Contract부터 확인해보자.

 

interface MovieContract {
    interface View {
        fun showProgress()
        fun hideProgress()

        fun changeMovieList(movies: List<MovieEntity>)
        fun showToast(text: String)
    }

    interface Model {
        interface OnFinishedListener {
            fun onFinished(movies: List<MovieEntity>)
        }

        fun getMovies(movie: MovieResponse, onFinishedListener: OnFinishedListener?)
    }

    interface Presenter {
        fun searchButtonClick(inputText: String)
        fun apiError()

        fun itemClick(movie: MovieEntity)
    }
}

 

 

우선, 처음부터 해당 interface를 전부 생각하고 구현한다는 것은 말도 안 되고, View, Presenter를 구현하면서 다시 Contract로 돌아와서 작성하고를 반복했다는 점을 알고 넘어가자.

물론, 처음부터 다 생각하고 작성하는 사람이 있겠지만 필자는 그렇지 못했다.

 

해당 View에서는 제목을 입력받고, 검색하여 검색 결과에 따른 영화 List를 보여주게 된다.

따라서, View에서 갱신되는 UI는 영화 List를 보여주는 것, Loading시 Progress, 문제가 있을 때 보여줄 Toast에 대한 처리. 가 필요하게 된다.

 

 

그러면 Presenter에서는 무엇이 필요할까?

제목을 입력받고, 검색하여 영화 List를 보여주는 것에서 UI 비즈니스 로직은 2가지 존재한다.

제목을 통해 검색하는 것과 Data가 Error일 때의 UI를 처리하는 로직이 필요하다.

Adapter에 대한 Event도 받고 싶어서 아이템을 클릭했을 때의 메서드도 추가해두었다.

 

마지막으로, Model에서는 무엇이 필요할까?

API를 통해 Data를 받고, 영화 리스트를 보여줘야 한다.

API를 통해 Response를 받은 후, 해당 Response를 View로 보여줄 Data로 변환하는 작업이 필요하다.

또한, 데이터 변환이 완료되면 해당 데이터를 다시 Presenter로 전달해야 한다.

여기서 Response를 사용해서 원하는 Data를 변환하는 것은 위의 View-Presenter에서 사용한 것과 동일하게 호출하면 되지만, Model에서는 Presenter에 대한 참조를 하고 있지 않기 때문에 다시 전달할 방법이 없다.

 

따라서, Model Interface안에 다른 Interface를 만들어서 Presenter에서 상속받도록 하고,

View에서 Presenter를 생성했던 것처럼 Model을 호출할 때 해당 객체를 던지도록 하였다.

 

글로 봐서는 정확히 이해가 안 될 수 있으니, 다음 코드를 보면서 다시 확인하도록 하자.

 

class MovieSearchActivity : AppCompatActivity(), MovieContract.View {
    private val api: ApiInterface by inject()
    private val network: NetworkManager by inject()

    private var movieAdapter: MovieAdapter? = null
    private var presenter: MoviePresenter? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_movie_search)
        presenter = MoviePresenter(api, network, this, MovieModel())
        movieAdapter = MovieAdapter(presenter!!)
        rv_movies.adapter = movieAdapter
    }

    ...
    
    override fun showProgress() {
        progressBar.visibility = View.VISIBLE
    }

    override fun hideProgress() {
        progressBar.visibility = View.INVISIBLE
    }

    override fun changeMovieList(movies: List<MovieEntity>) {
        movieAdapter!!.submitList(movies)
    }

    override fun showToast(text: String) {
        Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
    }
}

 

View단에서 override 함수 호출하는 부분은 MainActivity와 동일하니 설명은 생략하도록 하고, onCreate 부분만 확인해보도록 하자.

 

기존의 ViewModel을 사용했을 때는 Koin을 사용하여 의존성 주입을 해주었는데,

Presenter의 경우 Koin을 사용하여 의존성 주입을 해보려고 했지만 실패를 하고.. 직접 필요한 인자들을 넣어서 생성해주도록 하였다.

해당 부분은 사용 방법을 스터디한 후에 Koin을 사용하도록 수정하도록 하겠다.

 

Koin을 통해 ApiInterface, NetworkManager를 주입받아서 인자로 넣어서 presenter를 생성해주고,

생성한 presenter를 사용하여 Adapter를 만들어 사용하도록 하였다.

 

Adpater 클래스를 확인해보면,

 

class MovieAdapter(private var presenter: MovieContract.Presenter) :
    ListAdapter<MovieEntity, MovieAdapter.ViewHolder>(diffUtil) {

 

기존에 사용했던 Adapter와 동일하지만, presenter를 인자로 받고 있는 모습이다.

위에서 Contract에 대한 설명을 할 때 언급했듯이, Adapter에서 Item을 click 했을 때의 Event를 Presenter에서 받아서 처리하기 위하여 Presenter를 인자로 넣어서 Adapter를 생성해 주었다.

 

해당 부분은 중요하지 않고, 다른 부분과 동일하므로 하단에 기입한 Github를 통해 전체 소스를 확인하길 바란다.

 

Presenter 클래스에서는, Model을 사용하는 메서드만 확인해보도록 하자.

그 외 Override 된 메서드에서는 View를 호출하는 부분이니 생략하도록 하겠다.

 

class MoviePresenter(
    private val userApi: ApiInterface,
    private val networkManager: NetworkManager,
    private var activity: MovieContract.View?,
    private var model: MovieContract.Model,
) : MovieContract.Presenter, MovieContract.Model.OnFinishedListener {


    override fun searchButtonClick(inputText: String) {
        CoroutineScope(Dispatchers.Main).launch {
            ...
            if (!networkManager.checkNetworkState()) {
                apiError()
            } else {
                try {
                    model.getMovies(userApi.getSearchMovieFlow(input), this@MoviePresenter)
                } catch (e: Exception) {
                    apiError()
                }
            }
        }
    }

    ...

    override fun onFinished(movies: List<MovieEntity>) {
        activity!!.hideProgress()
        if (movies.isEmpty()) {
            activity!!.showToast("Movie list is Empty")
        }

        activity!!.changeMovieList(movies)
    }
}

 

검색 버튼을 누르면 searchButtonClick 메서드가 호출되는데, 네트워크에 문제가 없으면 userApi를 사용하여 API를 호출하고 Response와 presenter 객체를 넣어서 Model의 getMovies 메서드를 호출해준다.

model에 대한 참조를 가지고 있기 때문에 인자로 전달받은 model을 사용하여 호출한다.

 

상속받은 부분을 확인해보면, Contract.Presenter 뿐 아니라 Contract.Model.OnFinishedListener를 받고 있는 것을 확인할 수 있다.

 

따라서, 해당 Interface에서 선언한 메서드를 Override 해줘야 하므로 onFinished 메서드도 Override 하여 구현해준다.

해당 부분은 필요한 Data를 Model에서 만들고 다시 Presenter로 전달하기 위한 메서드이다.

 

전달받은 Data를 사용하여 View단에서 List를 갱신해주는 메서드를 호출하여 화면을 갱신하게 된다.

 

class MovieModel : MovieContract.Model {

    override fun getMovies(
        movie: MovieResponse,
        onFinishedListener: MovieContract.Model.OnFinishedListener?,
    ) {
        val movies = movie.movies

        if (movies.isNotEmpty()) {
            onFinishedListener!!.onFinished(movies)
        } else {
            onFinishedListener!!.onFinished(listOf())
        }
    }
}

 

마지막으로 Model을 확인해보자.

 

다른 View, Presenter와 마찬가지로 Contract.Model을 상속받아서 사용한다.

 

override 하는 getMovies 메서드에서는 onFinishedListenr라는 인자로 Presenter를 받기 때문에 해당 인자를 사용하여 presenter의 메서드 호출이 가능해진다.

위의 Presenter 클래스를 확인해보면, getMovies라는 Model의 메서드를 호출할 때 this@MoviePresenter를 사용하여 전달한 것을 확인할 수 있을 것이다.

 

따라서, 전달받은 Response에서 필요한 Data를 가져오고, presenter를 호출할 때 필요한 Data를 던져줌으로써 Presenter에서 필요한 데이터로 View를 갱신할 수 있도록 만들어준다.


아주 간단하게 MVP Pattern에 대한 샘플 예제를 만들어 보았다.

 

Contract라는 Interface로 명시하여 사용하기 때문에, 해당 Model, View, Presetner에서 어떠한 동작을 하는지 한눈에 알 수 있다는 점이 굉장히 좋다고 느꼈다. 필수가 아닌 선택이라지만, 이러한 장점을 놓치기엔 아까운 것 같다.

하지만, Interface를 통한 구현이라는 게 처음에 보았을 때 복잡한 느낌이 없진 않아서 한번 적응하고 이해하면 그때부터 쉽게 사용할 수 있을 것으로 보인다.

 

다양한 디자인 패턴에 대해 공부하고 아주 간단한 샘플 예제를 만들어 보면서 느끼는 거지만,

모든 디자인 패턴들은 각각의 장점이 존재하고, 뭐 하나 월등히 좋은 것이 아닌 상황에 따라 알맞은 것을 골라서 사용해야 하는 것이라고 느꼈다.

 

지금은 아주 간단한 예제를 만들면서 이런 패턴은 이런 방식으로 쓰는 거다. 정도로 이해하고 넘어가지만,

추후에 좀 더 많은 프로젝트를 다양한 구조로 만들어 보면서 한층 더 깊게 이해하는 기회를 만들어야겠다고 생각한다.

 

해당 게시글에 사용한 예제는 Github에 올려두었다.

https://github.com/HeeGyeong/MvpArchitectureSample

 

GitHub - HeeGyeong/MvpArchitectureSample

Contribute to HeeGyeong/MvpArchitectureSample development by creating an account on GitHub.

github.com

 

728x90