MVC
우테코의 레벨 1 미션은 MVC 패턴으로 수행했다.
MVC를 간략히 설명하자면, 모델과 뷰를 분리하고 모델과 뷰 사이를 컨트롤러가 연결해주는 형식이다.
컨트롤러는 유저가 뷰를 보고 발생시키는 이벤트를 전달해 모델을 변경하고
모델의 변경 사항을 다시 뷰에 적용한다.
컨트롤러는 한 개만 존재하므로 컨트롤러와 뷰의 관계는 1:N이 된다.
어찌 됐든 중요한 점은 모델과 뷰를 분리한다는 것이다.
레벨 1은 코틀린으로만 미션을 진행했지만
레벨 2는 안드로이드 프레임워크 위에서 미션이 진행되었다.
그래서 안드로이드에 종속적이게 되었고 이전과는 몇 가지 다른 점이 발생한다.
MVC In Android
안드로이드에선 뷰 그 자체인 xml 파일과, 해당 뷰의 이벤트를 전달 받고 변화시키는 액티비티가 존재한다.
하나의 화면을 그리기 위해 액티비티와 xml 파일이 필요하고, 1:1 관계로 사실상 뗄래야 뗄 수 없는 관계이다.
또한 액티비티는 마치 컨트롤러의 일을 하는 것으로 보인다.
즉, 각 뷰 별로 컨트롤러가 생긴 것이다.
하지만 액티비티는 단순히 모델과 뷰를 연결하는 것 뿐만 아니라 뷰를 그리기도 한다.
그로 인해 (뷰의 역할을 어디까지로 보느냐에 따라 달라질 수 있겠지만)
액티비티가 컨트롤러인지, 뷰 + 컨트롤러인지 모호하게 된다.
안드로이드에 종속적이다 보니 이런 기형적인 구조가 발생한 것이다.
하지만 상관 없다. 기형적 구조면 어떤가.
MVC를 철저히 지키지 않는다고 빌드가 안되는 것도 아니고 구글이 플레이 스토어에 등록해주지 않을 것도 아니다!
결국 우리 좋을 대로 하면 된다.
그럼 우린 어떻게 하고 싶은가?
늘 그렇듯 테스트와 유지 보수하기 좋은 코드를 작성하고 싶다.
그럼 MVC는 테스트와 유지 보수에 좋은가? 한번 생각해보자.
Is MVC Right?
Testable
우선 모델과 뷰를 분리했기 때문에 모델에 대한 유닛 테스트가 가능할 것이다.
모델의 모듈을 분리하면 안드로이드 종속성을 벗어나 junit5를 사용한 테스트 또한 가능하다.
(안드로이드는 junit5를 지원하지 않는다.. 외부 라이브러리를 사용하면 가능)
또 에스프레소를 사용해서 뷰에 대한 테스트도 진행할 수 있을 것이다. (이건 사실 어떤 상황에서도 가능하죠)
그럼 마지막으로 컨트롤러 테스트는 가능할까?
우선 액티비티부터 안드로이드에 종속되어있으며, 액티비티내에서 뷰를 조작하는 코드들 또한 안드로이드에 종속되어있다. (ex. Button, TextView 등을 조작하는 행위)
심지어 액티비티는 Activity() 와 같이 우리가 직접 생성할 수도 없다.
아무래도 테스트 하기 어려워 보인다.
Maintainable
그럼 유지 보수는 어떨까?
일단 모델과 뷰를 분리했으므로 이 부분에선 좋은 유지 보수성을 가져갈 수 있을 것 같다.
액티비티는 뷰와 컨트롤러 그 사이 애매한 어딘가에 위치해있다.
그러므로 뷰와 모델에 대한 코드가 혼재되어있다.
간단한 프로젝트에서는 이런 상황이 전혀 문제되지 않는다. 오히려 보기 좋다.
(어떤 일이 일어나는지 한 곳에서 바로 확인할 수 있으니까!)
그럼 일단 아래 코드를 슬쩍 살펴보자. 아래는 예전 미션의 일부이다.
class SeatActivity : AppCompatActivity() {
private val textPayment by lazy { findViewById<TextView>(R.id.textSeatPayment) }
private val buttonConfirm by lazy { findViewById<TextView>(R.id.buttonSeatConfirm) }
...
private lateinit var bookedMovie: BookedMovie
private lateinit var movie: Movie
private lateinit var selectedSeat: SelectedSeat
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_seat)
clickConfirmButton()
...
}
...
private fun clickConfirmButton() {
buttonConfirm.setOnClickListener {
val tickets: List<Ticket> = selectedSeat.seats
.map { movie.reserve(bookedMovie.bookedDateTime, it) }
val reservation = Reservation(tickets.toSet()).toUiModel()
startActivity(CompletedActivity.getIntent(this, reservation))
}
}
// setPayment는 Activity내 다른 함수에서 호출한다.
private fun setPayment(seats: Set<Seat>) {
val tickets: List<Ticket> = seats.map {
movie.reserve(bookedMovie.bookedDateTime, it)
}
textPayment.text = tickets.sumOf { it.price }.toString() + "원"
}
...
}
위 예시를 보면 모델에 대한 코드와 뷰에 대한 코드가 혼재되어있다.
코드를 잘 이해할 필요는 없다. 그저 모델도 조작하고 뷰도 조작한다는 것만 알면 된다.
setPayment() 함수를 슬쩍 보자. (자세히 보지 않아도 된다)
movie 객체를 사용하여 seats 를 tickets 로 변환한다.
그리고 tickets의 각 price를 더해 총 지불할 금액으로 뷰를 설정한다.
그러므로 현재 함수가 두 가지 일을 하고 있는 것을 알 수 있다. (총 지불할 금액을 계산하기, 뷰를 변경하기)
(진짜 코드 이상하게 짰었네..)
덕분에 알아보는데도 한참 걸렸다. 그러니 함수가 한 가지 일만 할 수 있도록 변경해보자.
private fun getPayment(seats: Set<Seat>): Int {
val tickets: List<Ticket> = seats.map {
movie.reserve(bookedMovie.bookedDateTime, it)
}
return tickets.sumOf { it.price }
}
private fun setPayment(payment: Int) {
textPayment.text = payment.toString() + "원"
}
이제야 보기에 좋아진 것 같다.
이제 두 함수를 보고 바로 무슨 일을 하는지 알 수 있을 것이다.
그런데 위 액티비티의 코드가 500줄이라고 해보자.
내가 필요한 부분을 빨리 찾아서 볼 수 있을까?
"액티비티가 너무 길어서 알아볼 수가 없어요!!!"
크고 복잡한 프로젝트를 하다 보면 어마어마한 규모의 액티비티를 마주하게 된다.
액티비티에서 뷰와 모델을 조작하고 연결하다 보면 어느새 몇백줄이 되어간다.
함수 분리를 아무리 잘해도 한 객체의 코드 라인이 너무 많으면 빠르게 이해하기 어렵다.
고로 우리는 두 가지 문제점에 마주했다.
- 컨트롤러 테스트가 불가능하다.
- 액티비티가 너무 비대해 알아보기 힘들다.
So MVP?
그럼 MVP는 해결책을 줄 수 있나요?
컨트롤러를 테스트하고 액티비티 코드를 줄여서 알아보기 쉽게 할 수 있나요?
한번 알아보도록 하자
Testable
MVC에선 컨트롤러가 안드로이드 종속적이라 테스트하기 어려웠다.
우리가 직접 만들지도 못하는 액티비티를 어떻게 테스트하겠는가
그럼 안드로이드에 종속적이지 않은 컨트롤러가 있으면 되지 않을까?
그럼 액티비티는요?
지금부터 액티비티는 단순히 뷰로만 사용하려고 한다.
(애초에 에스프레소로 UI테스트 할 때 'ActivityRule' 작성하잖아! 그럼 뷰가 맞잖아!)
우리는 안드로이드에 종속적이지 않은 컨트롤러를 만들고 액티비티와 소통해야한다.
액티비티의 함수를 직접 호출하면 결국 안드로이드 의존성이 생기니 추상화된 함수로 소통하도록 하자.
View의 역할을 할 interface와 Controller의 역할을 할 interface를 만든다.
interface SeatContract {
interface View {
fun setPayment(payment: Int)
}
interface Controller {
fun calculatePayment(seats: Set<Seat>)
}
}
View 인터페이스는 액티비티가 구현하고, Controller 인터페이스는 새로 만든 Controller가 구현한다!
class SeatActivity : AppCompatActivity(), SeatContract.View {
private val controller: SeatContract.Controller by lazy { SeatController(this) }
...
private fun `seats를 제공하는 다른 함수`() {
...
controller.calculatePayment(seats)
}
override fun setPayment(payment: Int) {
textPayment.text = payment.toString() + "원"
}
...
}
class SeatController(private val view: SeatContract.View): SeatContract.Controller {
override fun calculatePayment(seats: Set<Seat>) {
val tickets: List<Ticket> = seats.map {
movie.reserve(bookedMovie.bookedDateTime, it)
}
val payment = tickets.sumOf { it.price }
view.setPayment(payment)
}
}
제대로 만들었다면 "calculatePayment(seats: Set<Seat>)"는 지금처럼 외부에 노출되지 않고 Controller의 다른 함수로부터 인자를 전달 받으며 호출되는 private함수였을 것이다. 이 부분은 예시를 위함으로 양해를 부탁한다.
자 이제 뷰와 컨트롤러가 추상화되어 서로 느슨하게 결합되었다.
Controller는 분명 뷰의 함수를 사용하고 있지만 View로 추상화된 함수만 사용하고 있으므로 안드로이드 의존성 없이 테스트가 가능해졌다.
mockk에 대한 경험이나 이해가 없다면 무슨 말인지 이해가 가지 않을 것이다.
추상화해도 그 구현체가 있어야 Controller에서 사용 가능한 거 아닌가? 라고 생각할 수 있다.
이 글에선 설명하지 않을 예정이므로 우선 가짜 객체를 만들어 사용할 수 있다는 정도로만 이해하자.
자 그럼 첫 번째 문제 해결이다!
이제 뷰, 모델, 컨트롤러 모두 테스트할 수 있다!!
이 내용이 완전히 공감이 가기 위해선 테스트에 대한 학습 또한 필요합니다.
완전히 공감이 가지 않는다고 해도 정상입니다.
Maintainable
위 내용을 열심히 읽었다면 액티비티가 커지는 문제도 어떻게 해결될지 예상할 수 있다.
뷰에 보여줘야 할 데이터를 Controller가 갖고 처리하게 하므로써 액티비티의 코드가 훨씬 줄었다.
(물론 추상화된 View 와 Controller 가 소통하기 위한 함수들이 생기면서 함수의 개수는 증가할 것이다)
또한 액티비티에는 뷰를 조작하는 로직만 남게되어 알아보기 훨씬 깔끔해졌다.
좀 더 완성된 예시를 보여주기 위해 링크를 남긴다. Activity 예시
(예시는 필자의 하찮은 코드이므로 좋은 샘플 코드는 아니다. 대충 느낌만 알고 학습은 다른 코드로 하자..!)
이제 두 번째 문제가 해결됐다!
액티비티에서 비즈니스 로직(뷰에 보여질 데이터를 처리하는)을 Controller로 분리하여 비대한 액티비티를 비교적 작게 유지했다.
또한 액티비티에는 뷰를 그리고 조작하는 로직만 남아있으므로 알아보기에도 편하다.
마지막으로 인터페이스를 작성했으므로 View와 Controller가 무엇을 하는지도 한 눈에 알 수 있다.
(인터페이스는 객체가 반드시 가져야 하는 행동을 지정하므로, 구현하는 객체는 인터페이스라는 규칙을 무조건 지켜야한다)
So Where is MVP?
이제 MVC에서 발생했던 두 가지 문제가 해결됐다!
테스트도 가능해졌고, 액티비티도 알아보기 더 좋아졌다.
근데, 이 글은 MVP를 설명하는 글이 아니었던가? MVP는 어디 갔지?
MVC는 Model - View - Controller이고
MVP는 Model - View - Presenter이다.
어? 그럼? 하는 생각이 드는가?
우리가 위에서 만들었던 Controller가 바로 Presenter이다.
MVP에는 컨트롤러 대신 프레젠터가 존재한다. (뭐 이름이 중요하겠는가? 문제만 해결됐음 됐지~~)
안드로이드의 기형적인 MVC구조의 문제점을 해결해낸 것이 MVP이다.
뷰와 컨트롤러가 혼재된 액티비티로부터 뷰단의 비즈니스 로직을 분리해낸 것이 Presenter이다.
이로써 우린 뷰(xml파일 + 액티비티)를 완전히 분리할 수 있게 된 것이다.
좀 더 구체적으로 얘기하자면 Presenter가 하는 일은 아래와 같다.
- 모델로부터(로컬 디비 혹은 웹 서버) 필요한 데이터를 받아오거나 저장한다.
- 제공된 데이터를 처리하는 비즈니스 로직을 수행한다.
- 뷰의 상태를 변경한다.
Presenter를 이상적으로 만든다면
프레젠테이션 로직이 화면을 보여주는 방식과는 무관하게 된다.
다시 말해, MVP의 V가 액티비티에서 다른 것으로 바뀌어도 상호작용에 문제가 없을 것이다.
필자는 아직 못한 것 같다. 일단 함수명부터.. 수정이 시급하다.
개인적으로 MVP구조를 사용하면서 가장 크게 느낀 건 interface에 정의할 함수명을 잘 설정해야한다는 것이다.
글에서 느꼈겠지만 MVP는 MVC에서 파생된 패턴이다.
아키텍처가 아니며 그렇게 건설적이고 거대한 패턴도 아니다.
그냥 프레젠터를 분리해낸다는 것 하나만 생각하면 된다.
그러니까 "MVP가 뭐야! 난 지금 MVP를 하고 있나? 와 같은 생각이 든다면 아래처럼 생각을 고쳐보자.
내가 지금 하고 싶은 게 무엇인가?
MVP는 별거 없다.
그저 테스트 가능한 컨트롤러를 만들어주고, 액티비티를 가볍게 해줄 뿐이다
End
이번 글에서 MVC에서 MVP로 가는 과정을 알아보았다.
하지만 MVP를 배웠다고 패턴에 집착하지 말자.
위에서 얘기했듯이 별거 없다. 뭘 위해 '왜' 사용하는지만 생각하면 된다.
이렇게 긴 글을 여기까지 다 읽었다면 이제 테스트를 학습해보도록 하자.
mockk 사용법을 학습하고 프레젠터 테스트하러 가보자~
'디자인 패턴' 카테고리의 다른 글
Why MVVM (부제: MVP to MVVM) (0) | 2023.08.25 |
---|