-
Notifications
You must be signed in to change notification settings - Fork 1
비긴, 비건 Refactoring with Clean Architecture
Choi Jung Hyeon edited this page Jan 13, 2024
·
6 revisions
- 초기 설계시 선택한 Architecture
- 현재 비긴, 비건의 Architecture
📂 com.example.beginvegan
┣ 📂config
┣ 📂src
┃ ┣ 📂data
┃ ┃ ┣ 📂api
┃ ┃ ┗ 📂model
┃ ┃ ┃ ┣ 📂auth
┃ ┃ ┃ ┣ 📂magazine
┃ ┃ ┃ ┣ 📂recipe
┃ ┃ ┃ ┣ 📂restaurant
┃ ┃ ┃ ┣ 📂review
┃ ┃ ┃ ┣ 📂user
┃ ┗ 📂ui
┃ ┃ ┣ 📂adapter
┃ ┃ ┃ ┣ 📂home
┃ ┃ ┃ ┣ 📂map
┃ ┃ ┃ ┣ 📂profile
┃ ┃ ┃ ┗ 📂recipe
┃ ┃ ┗ 📂view
┃ ┃ ┃ ┣ 📂home
┃ ┃ ┃ ┣ 📂login
┃ ┃ ┃ ┣ 📂main
┃ ┃ ┃ ┣ 📂map
┃ ┃ ┃ ┃ ┣ 📂restaurant
┃ ┃ ┃ ┣ 📂profile
┃ ┃ ┃ ┣ 📂recipe
┃ ┃ ┃ ┗ 📂test
┗ 📂util
MVP 패턴의 경우 View, Presenter, Model의 역할이 분리되어야 합니다.
현재 MagazineService에서는 Model의 역할과 Presenter의 역할이 혼합되어 있습니다.
MagazinService가 네트워크 호출을 직접 처리하고, 성공여부에 따라 View interface를 직접 호출하는 형태입니다.
MagazineService에서 View, Model이 직접 상호작용하므로 MVP 패턴에 적합하지 않습니다.
추가적으로 SOLID 원칙중 단일 책임의 원칙을 따르지 않고 있습니다.
- MagazineRetrofitInterface (Model의 해당 하는 부분)
interface MagazineRetrofitInterface {
@GET("/api/v1/magazines/random-magazine-list")
fun getMagazineTwoList(
@Header("Authorization") accessToken: String?,
): Call<MagazineTwoResponse>
@POST("/api/v1/magazines/magazine-detail")
fun postMagazineDetail(
@Header("Authorization") accessToken: String?,
@Body id: Int
): Call<MagazineDetailResponse>
}
- MagazineInterface (View Interface, View 해당 부분)
interface MagazineInterface {
fun onGetMagazineTwoListSuccess(response: MagazineTwoResponse)
fun onGetMagazineTwoListFailure(message: String)
fun onPostMagazineDetailSuccess(response: MagazineDetailResponse)
fun onPostMagazineDetailFailure(message: String)
}
- MagazineService (Presenter의 역할을 수행하지만, Model의 역할도 수행함)
class MagazineService(val magazineInterface: MagazineInterface) {
private val magazineRetrofitInterface: MagazineRetrofitInterface = ApplicationClass.sRetrofit.create(MagazineRetrofitInterface::class.java)
fun tryGetMagazineTwoList(){
magazineRetrofitInterface.getMagazineTwoList(ApplicationClass.xAccessToken).enqueue(object: Callback<MagazineTwoResponse>{
override fun onResponse(
call: Call<MagazineTwoResponse>,
response: Response<MagazineTwoResponse>
) {
if(response.code() == 200){
magazineInterface.onGetMagazineTwoListSuccess(response.body() as MagazineTwoResponse)
}else{
try{
val gson = Gson()
val errorResponse =
gson.fromJson(response.errorBody()?.string(), ErrorResponse::class.java)
magazineInterface.onGetMagazineTwoListFailure(errorResponse.message)
}catch(e:Exception){
magazineInterface.onGetMagazineTwoListFailure(e.message?:"통신 오류")
}
}
}
override fun onFailure(call: Call<MagazineTwoResponse>, t: Throwable) {
magazineInterface.onGetMagazineTwoListFailure(t.message?:"통신 오류")
}
})
}
fun tryPostMagazineDetail(magazineId: Int){
magazineRetrofitInterface.postMagazineDetail(ApplicationClass.xAccessToken,magazineId).enqueue(object: Callback<MagazineDetailResponse>{
override fun onResponse(
call: Call<MagazineDetailResponse>,
response: Response<MagazineDetailResponse>
) {
if(response.code() == 200){
magazineInterface.onPostMagazineDetailSuccess(response.body() as MagazineDetailResponse)
}else{
try{
val gson = Gson()
val errorResponse =
gson.fromJson(response.errorBody()?.string(), ErrorResponse::class.java)
magazineInterface.onPostMagazineDetailFailure(errorResponse.message)
}catch(e:Exception){
magazineInterface.onPostMagazineDetailFailure(e.message?:"통신 오류")
}
}
}
override fun onFailure(call: Call<MagazineDetailResponse>, t: Throwable) {
magazineInterface.onPostMagazineDetailFailure(t.message?:"통신 오류")
}
})
}
}
현재 "비긴, 비건"은 "홈 화면", "레시피", "비건 맵", "프로필" 이렇게 대표적인 UI 4가지가 있습니다.
각 UI 클래스에서는 데이터를 처리하고, UI 로직을 처리함으로써 너무나도 많은 책임을 지고 있습니다.
이렇게 구현할 경우 테스트와 유지보수 측면에서 어려움을 겪는다는 것을 알게되었습니다.
예를 들어 "비건 맵"에 대한 "VeganMapFragment" 에서는 "지도 및 초기화 설정", "식당 데이터 처리", "하단 시트 관리" 기능들이 포함 되어있습니다.
이 기능들은 UI에 표시되는 데이터를 가져오고 데이터를 가져온 후, 데이터를 처리하고, UI에 데이터를 표시하고 있습니다.
이로인해 많은 코드양으로 인한 가독성이 떨어지고, 단위 테스트가 힘들어지며, 안드로이드 공식문서에서 권장하는 아키텍처 가이드를 위반하고 있습니다.
..
override fun onAttach(context: Context) {
super.onAttach(context)
mContext = context
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (arguments != null) {
val data = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
arguments?.getSerializable(RECOMMENDED_RESTAURANT, NearRestaurant::class.java)
} else {
arguments?.getSerializable(RECOMMENDED_RESTAURANT) as? NearRestaurant
}
if (data != null) {
recommendRestaurantData = data
recommendRestaurantTrigger = false
}
}
}
..
override fun init() {
showLoadingDialog(requireContext())
initializeMapView()
binding.veganmapBottomSheet.clBottomSheet.maxHeight = getBottomSheetDialogDefaultHeight()
RestaurantFindService(this).tryPostFindRestaurant(
Coordinate(
ApplicationClass.xLatitude,
ApplicationClass.xLongitude
)
)
}
private fun initializeMapView() {
mapView = MapView(this@VeganMapFragment.activity)
binding.mvVeganMap.addView(mapView)
bottomSheetBehavior = BottomSheetBehavior.from(binding.veganmapBottomSheet.clBottomSheet)
mapView.setMapCenterPointAndZoomLevel(
MapPoint.mapPointWithGeoCoord(
ApplicationClass.xLatitude.toDouble(),
ApplicationClass.xLongitude.toDouble()
), 4, true
)
mapView.currentLocationTrackingMode =
MapView.CurrentLocationTrackingMode.TrackingModeOnWithHeadingWithoutMapMoving
mapView.setOnTouchListener { _, _ ->
if (bottomSheetBehavior.state != STATE_COLLAPSED) {
bottomSheetBehavior.state = STATE_COLLAPSED
}
false
}
}
private fun getBottomSheetDialogDefaultHeight(): Int {
return getWindowHeight() * 70 / 100
}
private fun getWindowHeight(): Int {
val displayMetrics = DisplayMetrics()
(context as Activity?)!!.windowManager.defaultDisplay.getMetrics(displayMetrics)
return displayMetrics.heightPixels
}
private fun setMapViewRestaurantMarker() {
dataList.forEachIndexed { index, info ->
val marker = MapPOIItem().apply {
itemName = info.name
mapPoint = MapPoint.mapPointWithGeoCoord(
info.latitude.toDouble(),
info.longitude.toDouble()
)
userObject = dataList[index]
markerType = MapPOIItem.MarkerType.CustomImage
tag = index
customImageResourceId = R.drawable.marker_spot
isShowCalloutBalloonOnTouch = false
}
mapView.addPOIItem(marker)
}
mapView.setPOIItemEventListener(this)
}
private fun setBottomSheetRVAdapter() {
binding.veganmapBottomSheet.rvBottomSheetRestaurantList.adapter = bottomSheetAdapter
binding.veganmapBottomSheet.rvBottomSheetRestaurantList.layoutManager =
LinearLayoutManager(mContext)
bottomSheetAdapter.setOnItemClickListener(object :
VeganMapBottomSheetRVAdapter.OnItemClickListener {
override fun onItemClick(v: View, data: NearRestaurant, position: Int) {
moveRestaurantDetail(data)
}
})
bottomSheetBehavior.state = STATE_HALF_EXPANDED
}
private fun setAdapterBottomSheet() {
bottomSheetAdapter = VeganMapBottomSheetRVAdapter(mContext!!, dataList)
setBottomSheetRVAdapter()
}
private fun setAdapterSingleBottomSheet(data: NearRestaurant) {
var selectedRestaurant: ArrayList<NearRestaurant> = arrayListOf()
selectedRestaurant.add(data)
bottomSheetAdapter = VeganMapBottomSheetRVAdapter(mContext!!, selectedRestaurant)
mapView.setMapCenterPoint(
MapPoint.mapPointWithGeoCoord(
data.latitude.toDouble(),
data.longitude.toDouble()
), true
)
setBottomSheetRVAdapter()
}
private fun moveRestaurantDetail(data: NearRestaurant) {
parentFragmentManager.setFragmentResult(RESTAURANT_ID, bundleOf(RESTAURANT_ID to data.id))
parentFragmentManager.beginTransaction().hide(this@VeganMapFragment)
.add(R.id.fl_main, RestaurantDetailFragment()).addToBackStack(null).commit()
}
override fun onPOIItemSelected(p0: MapView?, p1: MapPOIItem?) {
setAdapterSingleBottomSheet(p1?.userObject as NearRestaurant)
}
override fun onPostFindRestaurantSuccess(response: RestaurantFindResponse) {
dataList = ArrayList(response.information)
setMapViewRestaurantMarker()
if (recommendRestaurantTrigger) {
setAdapterBottomSheet()
} else {
setAdapterSingleBottomSheet(recommendRestaurantData)
}
dismissLoadingDialog()
}
override fun onPostFindRestaurantFailure(message: String) {
Log.d("onPostFindRestaurantFailure", message)
}
}
현재 "비긴, 비건"은 완벽하지는 않지만 어느정도 MVP 패턴을 따르고 있습니다.
하지만 적지않은 기능으로 인해 프로젝트 내에서도 많은 클래스 및 파일들이 있습니다.
더군다나 MVP 패턴 특성상 Presenter와 View가 1대1로 동작하기 때문에 View와 Presenter의 의존도가 강해지는 문제가 발생합니다.
그래서 안드로이드 개발자 윤지님과 의논을 해서 MVVM 패턴을 도입하려고 합니다.
현재 문제가 되고있는 View와 Model의 의존성을 아예 지워버리고, DataBinding을 사용하여 View와 ViewModel 의존성을 없애고자 합니다.
현재 "비긴, 비건"은 ViewBinding을 사용해서 코드를 작성하였습니다.
리팩토링을 하게 될 경우 MVVM 패턴의 효율적인 구현을 위해 ViewBinding보다는 DataBinding이 더 적합하다고 생각했습니다.
ViewBinding은 코드가 간결해지고, View와의 상호작용이 간단하고 안전합니다.
하지만 좀 더 다양한 퍼포먼스를 내기에는 DataBinding이 적합하다고 판단했고, 양방향 데이터 바인딩을 지원한다는 측면에서 DataBinding을 적용하기로 했습니다.
데이터 상태와 UI에 동기화가 중요하다고 늘 생각해왔습니다.
예를 들어 식당정보를 가져오고 UI에 띄우기까지 많은 작업이 있습니다.
LiveData를 사용한다면 Observer를 통해 이를 좀 더 단순화하고 효율적이게 구현할 수 있다고 생각했습니다.
ViewModel을 통해 UI와 데이터 처리 로직 자체를 분리하려고 합니다.
현재 UI 클래스에서는 UI처리 로직과 데이터처리 로직이 결합되어 있는 상태입니다.
그로인해 코드의 가독성이 떨어지고 UI 클래스 내에서도 너무나 많은 책임을 가지고 있습니다.
이는 단위 테스트가 힘들어지고, 이후 유지보수에도 많은 에러사항이 생길 수 있다고 판단했습니다.
LiveData, Repository, ViewModel Factory등을 사용해 좀 더 의존성을 낮추고 단위 테스트가 용이한 코드를 만들고자 ViewModel을 도입하기로 했습니다.
"비긴, 비건" 에서는 Fragment를 만들기 위해서 supportFragmentManager를 사용하고 있습니다.
Fragment를 추가하고, 제거하고, 교체 하는등 좀 더 세밀하게 제어할 수 있다는 장점이 있습니다.
하지만 "비건, 비건"에서는 Fragment 구현을 Navigation을 사용하여 세밀하게 제어하기 보다는 간편하고 좀 더 직관적인 Fragment를 만드는 것이 효율적이라고 판단했습니다.
supportFragmentManager 사용할 경우에는 로직자체도 많이 복잡해지기에 Navigation을 적용하기로 했습니다.
"비긴, 비건"은 DEPth 2기 MVP 프로젝트로 부터 시작된 하나의 작은 프로젝트였습니다.
현재 같이 개발하고 계신 윤지 님의 첫 Kotlin이란 언어를 사용한 안드로이드 프로젝트 중 하나였고 저에게는 협업다운 협업을 할 수 있는 프로젝트가 아니였나 싶습니다.
이제는 더 나아가서 고도화 과정을 통해 여러 아키텍처 패턴을 도입해 보고 이를 통해 각 아키텍처가 가지는 장점과 단점을 직접 느껴가며 같이 개발해 나아가는게 "비긴, 비건" 안드로이드 파트의 궁극적인 목표가 아닌가 싶습니다.
데드라인에 쫓겨 많은 부분들을 놓쳐왔고, 개발하는 과정에서 만족하지 못하는 로직이 있었음에도 완성에 초점을 두고 지나쳐왔습니다.
그러면서도 항상 마음 한편에서는 좀 더 좋은 컴포넌트와 좋은 아키텍처 좋은 코드를 작성하지 못했음에 만족을 못하고 있었는데 이번 리팩토링 계획을 통해 완성도, 효율성, 테스트 용이성 등 다양한 부분에서 만족할 만한 애플리케이션을 만들었으면 좋겠습니다.