선생님, 개발을 잘하고 싶어요.

기존 프로젝트에 MVP를 적용하는 방법 본문

개발/android 개발

기존 프로젝트에 MVP를 적용하는 방법

알고싶은 승민 2019. 6. 9. 22:53

서문

 

MVP 디자인 패턴을 공부하니 기존에 작업했던 프로젝트를 들쑤시며 배운 내용을 적용해보려고 하게 된다. 10개 정도 되는 액티비티와 프레그먼트에 MVP를 하나하나 적용해가며 겪었던 나름의 고충을 공유하고자 한다.

 

변경할 코드 및 문제점

class LoginActivity : AppCompatActivity() {

    private val REQUEST_CODE = 1000

    private val networkService: NetworkService by lazy {
        SoptApplication.instance.networkService
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_login)
        edtLoginID.setOnFocusChangeListener { v, hasFocus ->
            if(hasFocus) v.setBackgroundResource(R.drawable.primary_border)
            else v.setBackgroundResource(R.drawable.gray_border)
        }

        edtLoginPW.setOnFocusChangeListener { v, hasFocus ->
            if(hasFocus) v.setBackgroundResource(R.drawable.primary_border)
            else v.setBackgroundResource(R.drawable.gray_border)
        }

        btnLoginSubmit.setOnClickListener {
            val login_u_id = edtLoginID.text.toString()
            val login_u_pw: String = edtLoginPW.text.toString()

            if(login_u_id == "") edtLoginID.requestFocus()
            else if(login_u_pw == "") edtLoginPW.requestFocus()
            else postLoginResponse(login_u_id, login_u_pw)
        }

        txtLoginSignup.setOnClickListener{
            val simpleDateFormat = SimpleDateFormat("dd/M/yyyy hh:mm:ss")
            val s_time = simpleDateFormat.format(Date())

            startActivityForResult<SignupActivity>(REQUEST_CODE, "start_time" to s_time)
        }

    }

    private fun postLoginResponse(u_id: String, u_pw: String){
        // Request Login
        val jsonObject = JSONObject().apply {
            // 보낼 데이터를 json 타입으로 만드는 것
            put("id", u_id)
            put("password", u_pw)
        }
        val gsonObject = JsonParser().parse(jsonObject.toString()) as JsonObject

        // 실제로 통신을 요청
        val postLoginResponse: Call<PostLoginResponse> =
            networkService.postLoginResponse("application/json", gsonObject)

        Log.d("login", "postLoginResponse")

        // 통신 응답에 따라 올바른 행동을 하도록 해야한다.
        postLoginResponse.enqueue(object : Callback<PostLoginResponse>{
            override fun onFailure(call: Call<PostLoginResponse>, t: Throwable) {
                Log.e("login failed", t.toString())
            }

            override fun onResponse(call: Call<PostLoginResponse>, response: Response<PostLoginResponse>) {
                Log.d("login", "success ${response.body()}")

                if (response.isSuccessful) {
                    if (response.body()!!.status == 201) {
                        SharedPreferenceController.setUserToken(applicationContext, response.body()!!.data!!)
                        finish()
                    }
                }
            }
        })
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)

        when(requestCode) {
            REQUEST_CODE -> {
                when(resultCode) {
                    Activity.RESULT_OK -> {
                        val e_time = data!!.getStringExtra("end_time")
                        toast("End time : $e_time")
                    }
                }
            }
        }
    }
}

액티비티 코드 하나를 그냥 전부 올렸다. 부담스러운 거 안다. SuperGod 액티비티님 앞에서 우리는 한없이 작아진다. 이 LoginActivity는 지금 화면을 다시 그려주고, 토스트 메시지를 띄우고, 실제 서버에게 로그인을 요청하고, 서버의 결과를 해석하고... 지친다. 

요컨대 Activity는 애플리케이션의 모든 책임을 가지고 있는 형태이다.

이제부터 이 압도적인 책임감을 분산시켜서 액티비티를 구제해 보자. 그러면 테스트 용이성은 덤이다.

 

변경한 코드 및 개선 사항

우선 MVP가 무엇인지 궁금하신 분들이 많을 거 같다. 다른 분들 블로그에 참고 자료가 많으니 참고하시도록, 검색이 귀찮은 성질 급한 분들은 필자가 참조한 몇 개의 글로 맛을 보길 바란다.

 

 

Pluu Dev - [번역] Android에서는 MVC보다 MVP 쪽이 좋을지도 몰라

[요약] What's New in Android Studio UI Design and Debugging Tools (Google I/O '19) Posted on 02 Jun 2019 [요약] What's New in Android Development Tools (Google I/O '19) Posted on 25 May 2019 Android Studio Jetpack Compose & Sample App Posted on 19 May 2019 [번역

pluu.github.io

 

안드로이드의 MVC, MVP, MVVM 종합 안내서

안드로이드 앱을 만드는 개발자를 위한 MVC, MVP, MVVM 패턴 사용법과 장단점에 대한 안내서입니다.

academy.realm.io

MVP 구조에는 정답이 없다고 한다. 사람마다 해석하는 방법이 다르고 프로젝트마다 필요한 구현 정도가 다른 것이다. 그러니 필자가 구성한 패키지 구조를 먼저 말해주고 코드를 보며 설명을 보는 게 좀 더 나을 것 같다.


필자가 구성할 MVP의 패키지 구성


MVP라고 해서 Model View Presenter가 긴밀히 묶여있을 것 같지만 필자는 조금 다르게 생각했다.

Model은 여러가지 Presenter에서 접근이 가능하다. 따라서 별도의 패키지로 분류해 두었다.

하지만 View와 Presenter는 1대 1 대응으로 봤기 때문에 Presentation이라는 카테고리로 같이 분류해 두었다.

 

거두절미하고 View와 Presenter의 interface를 구성한 계약서 (Contract Interface)를 함께 살펴보자.

 

Contract Interface

 

interface LoginContract {
    interface Presenter {
        fun onClickLoginSubmit(id: String, pw: String)
        fun onClickLoginSignup()
        fun onDestory()
    }

    interface View : BaseView<Presenter> {
        fun focusEditLoginID()
        fun focusEditLoginPW()
        fun finish()
        fun openSignup()
    }
}

이 계약서의 의미를 설명하는 방법은 두 가지가 있다고 생각했다.

  1. 주석을 사용해 의미를 설명
  2. 테스트 코드를 작성해 실행가능한 문서로 설명

물론 필자가 추구하는 방향은 2번이다. 실제로 MVP의 장점 중에 하나가 Presenter의 유닛 테스트 작성 가능 여부라고 생각하기 때문이다. 그래서 계약서를 보는 김에 설명서인 테스트 코드도 함께 보자 (코드 보기 힘든 건 나도 안다. 글 구성을 잘 못하는 필자의 잘못이다.)

 

// 로그인 눌렀는데 아이디가 없는 경우 onClickLoginSubmit id 에 포커스 이동
@Test
fun loginWithoutIdAndFocusEditID(){
    loginPresenter.onClickLoginSubmit("", "password")

    verify(loginView).focusEditLoginID()
}

// 로그인 눌렀는데 비밀번호가 없는 경우 onClickLoginSubmit password 에 포커스 이동
@Test
fun loginWithoutPWAndFocusEditPW(){
    loginPresenter.onClickLoginSubmit("id", "")

    verify(loginView).focusEditLoginPW()
}

// 로그인 눌렀는데 로그인이 성공하는 경우 토큰이 설정되고 view 종료
@Test
fun loginSuccessAndSetTokenAndFinishView() {
    loginPresenter.onClickLoginSubmit("id", "password")

    // 비동기적으로 setUserToken 호출되는 중임
    verify(userRepository).setUserToken(Matchers.anyString())
    verify(loginView).finish()
}

// 회원가입 누르면 회원가입 화면으로 이동해야 한다.
@Test
fun clickSignupThenGotoSignupScreen() {
    loginPresenter.onClickLoginSignup()

    verify(loginView).openSignup()
}

이 테스트 코드는 Presenter Interface 가 작동될 때마다 View의 어떤 변동사항이 적용되는지 알 수 있다. 예를 들어보자. 

 

ID 필드로 포커스가 이동되는 경우 (view.focusEditLoginID) 는 LoginSubmit 버튼을 클릭했을 때, ID가 공백이라면 나오는 것임을 알 수 있다. (지금 생각난건데 코틀린의 명명된 인자를 사용하면 더 직관적으로 변경되겠다. 블로깅 끝나고 처리해야겠다.)

 

그리고 보면서 이해가 가지 않는 게 있을 것 같다.

 

Presenter의 함수 이름을 잘 못 지은 것 같다.

 

그렇지 않은가? 함수 이름만으로는 이 함수의 동작을 예상할 수 없다. 테스트 코드를 확인해야만 비로소 해당 함수 호출 시 동작을 예상할 수 있다. 필자도 처음에 그런 식으로 생각했다. 하지만 이번에 리팩터링을 직접 해보며 생각이 바뀌었다. View 코드를 보며 얘기하면 더 쉽게 알 수 있을 것 같다.

 

View

class LoginActivity : AppCompatActivity(), LoginContract.View {
    override fun focusEditLoginID() {
        edtLoginID.requestFocus()
    }

    override fun focusEditLoginPW() {
        edtLoginPW.requestFocus()
    }

    override fun openSignup() {
        startActivity<SignupActivity>()
    }

    override val presenter: LoginContract.Presenter by inject { parametersOf(this) }

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

        btnLoginSubmit.setOnClickListener {
            val login_u_id = edtLoginID.text.toString()
            val login_u_pw: String = edtLoginPW.text.toString()
            presenter.onClickLoginSubmit(login_u_id, login_u_pw)
        }

        txtLoginSignup.setOnClickListener{
            presenter.onClickLoginSignup()
        }

    }

    override fun onDestroy() {
        super.onDestroy()
        presenter.onDestory()
    }
}

우선 우리의 성과를 보자. 맨 처음 LoginActivity에 비해 지금은 Activity의 책임은 UI 수정 + presenter에게 이벤트 전달 로 국한되어 있음을 확인할 수 있다.

 

위에서 말한 presenter의 함수 이름을 보자. 함수가 무엇을 하는지는 알기 어렵지만 언제 함수를 호출해 주어야 하는지, 어떤 이벤트를 presenter에게 연결해 줘야 하는지 더 알기 쉬운 네이밍 아닌가? 적어도 필자는 그렇게 생각했다. 

 

함수의 이름 덕분에 view 구현자는 presenter 코드를 보지 않아도 된다. 그냥 맞는 이벤트를 연결해 주기만 하면 된다. 아마 view 개발자는 한시름 놓았을 것이다.

 

사실 Contract 구현과 테스트 코드에 대한 이해만 있다면 세부 내용 구체적인 구현은 간단하면서 말할게 많이 없다. presenter 코드로 넘어가 보자.

 

Presenter

class LoginPresenter(
    private val api: SoptComicsApi,
    private val userDataSource: UserDataSource,
    private val view: LoginContract.View
): LoginContract.Presenter {
    private val compositeDisposable = CompositeDisposable()

    override fun onClickLoginSubmit(id: String, pw: String) {
        when {
            id.isEmpty() -> view.focusEditLoginID()
            pw.isEmpty() -> view.focusEditLoginPW()
            else -> {
                api.requestToken(id, pw)
                    .subscribe {
                        userDataSource.setUserToken(id)
                        view.finish()
                    }.apply { compositeDisposable.add(this) }
            }
        }
    }

    override fun onClickLoginSignup() {
        view.openSignup()
    }

    override fun onDestory() {
        compositeDisposable.dispose()
    }
}

presenter 코드도 사실 마찬가지다. 테스트 케이스가 통과하도록 view를 호출해주면 된다. 물론 presenter는 model과 직접적인 상호작용의 책임을 가진다. api와 userDataSource가 필자가 생각하는 Model에 해당하는 녀석이다. 사실 Model 부분은 좀 더 시행착오를 거쳐봐야 한다. 

 

사실 기존 LoginActivity에 있던 실제 서버 연결하는 곳 코드도 여기서는 확인할 수가 없다. 서버 연결의 책임은 Model에게 넘겼기 때문이다.

 

모델을 일부 확인해 보러 갈까 했는데 사실 모델의 세부내용은 이번 블로그 포스팅에는 그렇게 크게 중요한 요소가 아니다. (추후에 따로 다뤄야 할 정도로 긴 얘기가 되지 않을까 싶다. 오늘은 Presentation에 대한 코드로 만족하자!)

 

결론 및 MVP 유의사항

필자가 이번 리팩터링을 진행하며 정리한 MVP에 대한 글을 공유하고자 한다. 

 

Presenter

  • View 인터페이스 참조
  • Model 인터페이스 참조
  • 안드로이드 종속성 없음 (테스트 용이성)
  • 인터페이스가 없이 바로 구체 클래스로 구성해도 무방 (순수 Java Object이기 때문)

View

  • Presenter 참조
  • 사용자 이벤트를 Presenter에게 전달
  • UI 갱신의 책임을 가진다.

Model

  • 데이터 클래스
  • 데이터 읽기 쓰기 수정 삭제 인터페이스 선언
  • 데이터 처리하는 비지니스 로직

MVP 디자인 패턴을 적용하면 기존의 코드에 비해 다음 같은 장점이 있다고 생각한다.

  1. Activity에서 Model을 관리하지 않는다.
  2. Model과 상호작용 하는 부분의 테스트 코드를 만들 수 있다.
  3. Test로 Contract를 검증 한 이후에 Activity를 구현하는 것이기 때문에 검증된 Presenter를 구성할 수 있다.
Comments