1. Overview

    신규 서비스인 마미 개발을 진행하면서, 추가되고 있는 서비스를 대응하고 네이티브 앱에서도 A/B 테스트가 수월하게 진행할 수 있는 방법이 필요했습니다. 현재 브랜디와 하이버는 일반적인 Imperative UI 방식으로 설계되었고 개발되었습니다. 명령형 UI의 장점도 있지만, 문제점은 다양한 State를 대응하려면 ViewHolder Item 하나에 모든 State를 관리해야 하기 때문에 A/B 테스트와 같은 UI 시뮬레이션은 힘든 과제였습니다.

    이러한 이슈를 해결하기 위해서 설계에서부터 충분한 테스트를 통해서 얻은 결과는 Declarative UI Framework인 EpoxyAdapter로 개발하는 것이었습니다. EpoxyAdapter를 도입함으로써 우리는 다양한 View의 State를 쉽게 분리해서 개발할 수 있으며, 이후에는 Android JetPack Compose로의 빠른 전환과 데이터 캐시 지원을 병행할 수 있다고 판단하였습니다.

    간단한 예제와 샘플 코드를 통해서 Declarative UI를 설명하지만, 예제를 통해 State가 늘어날수록 구조는 단순해진다는 것을 확인하실 수 있을 것입니다.

  2. Declarative UI
    1. State에 따라 UI를 생성하는 방식의 패러다임입니다. 다양한 State를 View가 모두 가지고 있지 않고, State에 따라 View를 다시 그려줍니다. 그래서 한번 생성된 View의 State는 업데이트가 불가능합니다.
    2. 업데이트가 불가능한 대신 State 변화에 따라서 View를 다시 생성해서 화면을 만들어줍니다. 이 과정에서 전체 UI를 다시 생성하는 것이 아니라 State에 따라 변경되지 않는 부분은 View를 다시 만들지 않습니다.
    3. Declarative UI는 현재 다양한 리액트 기반의 Framework에서 사용하고 있으며, Android 또한 Jet Pack Compose 알파 버전이 릴리스 되었습니다.
  3. Epoxy Adapter
    1. Airbnb에서 제공하는 RecyclerView Framework입니다.
    2. 다양하고 복잡한 화면을 EpoxyController를 통해서 손쉽게 구현할 수 있습니다.
    3. 단점이라면 GeneratedModel을 사용하기 때문에 빌드를 자주 해주어야 한다는 점입니다.
    4. 기본적인 Epoxy 사용 방법은 공식 채널을 참고해 주세요. https://github.com/airbnb/epoxy
  4. EpoxyController

     class HomeFragment : Fragment() {
    
         private val homeController: HomeController by lazy {
             HomeController()
         }
    
         override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
             super.onViewCreated(view, savedInstanceState)
    
     				//Linear
             binding.rvSample.layoutManager = LinearLayoutManager(requireContext())
     				//Grid
     				//binding.rvSample.layoutManager = GridLayoutManager(requireContext(), 2)
             binding.rvSample.adapter = homeController.adapter
             homeController.requestModelBuild()
         }
     }
    
     class HomeController : EpoxyController() {
         private val TAG = "HomeController"
    
         override fun buildModels() {
             Log.v(TAG, "buildModels")
         }
     }
    
    1. 기존 RecyclerView와 Adapter의 사용법과 크게 달라 보이지는 않습니다. 다른 건 requestModelBuild()을 호출해서 Controller의 buildModels()에서 ViewHolder를 만들어서 채워줍니다.
    2. Adapter에서는 Data List 변경 시 notifyDatasetChanged()를 호출해서 갱신되는 부분을 EpoxyController는 DiffUtils로 내부에서 처리하고 화면을 갱신한다고 생각하시면 될 것 같습니다.
  5. EpoxyHolder

     @EpoxyModelClass(layout = R.layout.holder_br_title)
     abstract class BrTitleEpoxyHolder : EpoxyModelWithHolder<BrTitleEpoxyHolder.ViewHolder>() {
    
         @EpoxyAttribute
         var title: String? = null
    
         @EpoxyAttribute
         var textColorCode: String? = null
    
         @EpoxyAttribute
         var backgroundColorCode: String? = null
    
         override fun bind(holder: ViewHolder) {
             textColorCode?.let {
                 holder.tvTitle.setTextColor(Color.parseColor(it))
             }
             backgroundColorCode?.let {
                 holder.tvTitle.setBackgroundColor(Color.parseColor(it))
             }
             title?.let {
                 holder.tvTitle.text = it
             }
         }
    
         class ViewHolder : EpoxyHolder() {
             lateinit var rootLayout: ConstraintLayout
             lateinit var tvTitle: TextView
    
             override fun bindView(itemView: View) {
                 rootLayout = itemView.findViewById(R.id.root_layout)
                 tvTitle = itemView.findViewById(R.id.tv_title)
             }
         }
        		
     		override fun getSpanSize(totalSpanCount: Int, position: Int, itemCount: Int): Int {
             return 1
         }
     }
    
     /**
      * Generated file. Do not modify!
      */
     public class BrTitleEpoxyHolder_ extends 
     	BrTitleEpoxyHolder implements 
     	GeneratedModel<BrTitleEpoxyHolder.ViewHolder>, 
     	BrTitleEpoxyHolderBuilder { ... }
    
     class HomeController : EpoxyController() {
         private val TAG = "HomeController"
    
         override fun buildModels() {
    
             add(
                 BrTitleEpoxyHolder_().apply {
                     id("unique id 0")
                     title("샘플 타이틀 0")
                     textColorCode("#FF5733")
                     backgroundColorCode("#09E756")
                 }
             )
    
             add(
                 BrTitleEpoxyHolder_().apply {
                     id("unique id 1")
                     title("샘플 타이틀 1")
                     textColorCode("#680ECE")
                     backgroundColorCode("#BA0ECE")
                 }
             )
    
             add(
                 BrTitleEpoxyHolder_().apply {
                     id("unique id 2")
                     title("샘플 타이틀 2")
                     textColorCode("#000000")
                     backgroundColorCode("#EBE104")
                 }
             )
    
     				add(
                 BrTitleEpoxyHolder_().apply {
                     id("unique id 3")
                     title("샘플 타이틀 3")
                     textColorCode("#FFFF00")
                     backgroundColorCode("#C0C0C0")
                 }
             )
         }
     }
    
    1. EpoxyHolder는 기본적으로 abstract 생성하며 GeneratedModel을 이용해서 사용합니다.
    2. BrTitleEpoxyHolder를 만들고 BrTitleEpoxyHolder_ 제네릭 클래스가 생성이 됩니다.
    3. 우리는 이렇게 만들어진 Hoder_ 클래스를 사용하여 EpoxyController를 이용함으로써 화면에 title 영역을 만들어 줄 수 있습니다.

Linear

athena

Grid

athena

6. EpoxyAttribute

1. EpoxyAdapter의 또 다른 특징은 EpoxyAttribute의 불변성입니다. 해당 속성으로 지정된 값은 변경할 수가 없습니다. 데이터의 equals, hashCode를 통해 데이터의 변경 사항을 확인해서 백그라운드 스레드를 통해 diff 알고리즘을 실행합니다.
2. 이러한 이유 때문에 불필요한 데이터를 EpoxyAttribute 값을 지정하거나 섞어서 사용하면 성능에 영향을 미칠 수 있습니다. 그리고 리스너를 통한 데이터 콜백을 사용할 경우에는 모델이 업데이트되거나 다시 생성될 때 리스너를 매번 생성할 경우에는 bind가 재 호출되어 리소스를 낭비하게 됩니다. 이 경우에는 @EpoxyAttribute(DoNotHash)를 사용하거나 고차 함수를 사용하여 처리할 수 있습니다.

abstract class BrTitleEpoxyHolder : EpoxyModelWithHolder<BrTitleEpoxyHolder.ViewHolder>() {
		@EpoxyAttribute
    var clickListener: (() -> Unit)? = null

		override fun bind(holder: ViewHolder) {
        Log.v("BrTitleEpoxyHolder", "Bind Epoxy Holder $title")
        holder.clickListener = clickListener
    }
}

class HomeController(val homeViewModel: HomeViewModel) : EpoxyController() {

		override fun buildModels() {

        add(
            BrTitleEpoxyHolder_().apply {
                id("unique id 0")
                title("샘플 타이틀 0")
                textColorCode("#FF5733")
                backgroundColorCode("#09E756")
                clickListener {
                    Log.v("HomeController", "click ${title}")
                }
            }
        )
		}
}

class HomeFragment : Fragment() {
		override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        binding.rvSample.layoutManager = GridLayoutManager(requireContext(), 1)
        binding.rvSample.adapter = homeController.adapter
        homeController.requestModelBuild()

        lifecycleScope.launch {

            delay(2000)
            homeController.requestModelBuild()
        }
    }
}

2021-06-16 12:36:52.473 V/BrTitleEpoxyHolder: Bind Epoxy Holder 샘플 타이틀 0
2021-06-16 12:36:55.931 V/BrTitleEpoxyHolder: Bind Epoxy Holder 샘플 타이틀 0

3. 위 코드는 앞선 예시에 clickListener를 추가한 케이스입니다. Fragment 상에서 2초 후 화면을 requestModelBuild()를 통해서 adapter 새로 고침을 요청하였습니다. 문제는 동일한 데이터였지만 diffing 중 equals, hashCode 데이터 비교에서 다른 EpoxyAttribute라고 판단해서 결국 bind()가 다시 일어나는 현상을 확인하였습니다.

이 문제는 단순하면서 작업자가 놓치기 쉽기 때문에 의도하지 않는 버그를 만들 수 있습니다. 이러한 특징을 잘 캐치하고 주의를 기울어야 하는 코드라고 생각합니다.

abstract class BrTitleEpoxyHolder : EpoxyModelWithHolder<BrTitleEpoxyHolder.ViewHolder>() {

		@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
    var clickListener: (() -> Unit)? = null

		override fun bind(holder: ViewHolder) {
        Log.v("BrTitleEpoxyHolder", "Bind Epoxy Holder $title")
        holder.clickListener = clickListener
    }
}

2021-06-16 12:48:10.729 V/BrTitleEpoxyHolder: Bind Epoxy Holder 샘플 타이틀 0

or

class HomeController(val homeViewModel: HomeViewModel) : EpoxyController() {
		fun titleClick() {
        Log.v("HomeController", "click")
    }

		override fun buildModels() {

        add(
            BrTitleEpoxyHolder_().apply {
                id("unique id 0")
                title("샘플 타이틀 0")
                textColorCode("#FF5733")
                backgroundColorCode("#09E756")
                clickListener = this@HomeController::titleClick
            }
        )
		}
}

2021-06-16 12:51:27.873 V/BrTitleEpoxyHolder: Bind Epoxy Holder 샘플 타이틀 0

4. requestModelBuild() 가 연속적으로 호출이 되더라도 DoNotHash or 고차 함수를 이용한다면 의미 없는 bind를 막을 수 있습니다.

7. Data 기반의 UI

  1. 기본적으로 Json 응답 데이터를 토대로 화면을 만들어 주는 구조에서 Data 유무에 따라 화면이 달라지게 될 것입니다. 이러한 구조는 쉬운 A/B 테스트를 대응할 수도 있고, 단순한 코드로 화면을 만들어줄 수 있게 도와줍니다. 아주 짧은 예제를 통해서 모든 것을 확인할 수는 없지만 조금 더 복잡한 화면 구조를, 다음 코드를 통해서 응용할 수 있을 것이라고 생각합니다.
override fun buildModels() {
        hiverHomeData?.data?.button_zoning?.firstOrNull()?.let { buttonsZone ->
            if (selectedTag == null) 
                selectedTag = buttonsZone.tags.first()

            add(
                ZoneCapsuleEpoxyHolder_().apply {
                    id("zone tag adapter")
                    selectedTag(this@MainController.selectedTag)
                    tags(buttonsZone.tags)
                    scrollPosition(this@MainController.scrollPosition)
                    selectTagListener { tag, itemWidth ->
                        this@MainController.scrollPosition.isCenter = true
                        this@MainController.scrollPosition.itemWidth = itemWidth
                        this@MainController.scrollPosition.itemPosition = buttonsZone.tags.indexOf(tag)
                        this@MainController.selectedTag = tag
                        tagScrollPosition.itemPosition = (indexList[tag]?.startIndex ?: 0).plus(1)
                        requestModelBuild()
                    }
                    spanSizeOverride { _, _, _ -> spanSize }
                }
            )

            add(ZoneProductEpoxyHolder_().apply {
                id("zone product adapter")
                selectedTag(this@MainController.selectedTag)
                tags(buttonsZone.tags)
                tagPageList(pageList)
                loopScrollPosition(tagScrollPosition)
                changeTag {
                    this@MainController.selectedTag = it
                    requestModelBuild()
                }
                spanSizeOverride { _, _, _ -> spanSize }
            })

        }

        bannerList?.let { bannerList ->
            add(
                LoopBannerEpoxyHolder_().apply {
                    id("middleBanner")
                    bannerList(bannerList)
                    loopScrollPosition(bannerScrollPosition)
                    spanSizeOverride { _, _, _ -> spanSize }
                }
            )
        }

        topBanner?.let {
            add(
                LoopBannerEpoxyHolder_().apply {
                    id("topBanner banner")
                    bannerList(listOf(topBanner))
                    loopScrollPosition(topBannerScrollPosition)
                    spanSizeOverride { _, _, _ -> spanSize }
                }
            )
        }

        storeProductsList?.let {
            storeProducts = it.firstOrNull()
            
            add(
                StoreTitleEpoxyHolder_().apply {
                    id("store title")
                    storeProductsList(it)
                    selectedStoreProducts(storeProducts)
                    selectStoreListener { selectStore: StoreProducts, itemWidth: Int ->
                        scrollPosition1.isCenter = true
                        scrollPosition1.itemWidth = itemWidth
                        scrollPosition1.itemPosition = it.indexOf(selectStore)
                        storeProducts = selectStore
                        requestModelBuild()
                    }
                    scrollPosition(scrollPosition1)
                    spanSizeOverride { _, _, _ -> spanSize }
                }
            )

            storeProducts?.productsList?.forEachIndexed { index, simpleProducts ->
                add(
                    SimpleProductsEpoxyHolder_().apply {
                        id("select Store product $index")
                        simpleProducts(simpleProducts)
                        spanSizeOverride { _, _, _ -> 1 }
                    }
                )
            }
        }

        storeProductsList?.let {
            storeProducts2 = it.firstOrNull()

            add(
                StoreTitleEpoxyHolder_().apply {
                    id("store title2")
                    storeProductsList(it)
                    selectedStoreProducts(storeProducts2)
                    selectStoreListener { selectStore: StoreProducts, itemWidth: Int ->
                        scrollPosition2.isCenter = true
                        scrollPosition2.itemWidth = itemWidth
                        scrollPosition2.itemPosition = it.indexOf(selectStore)
                        storeProducts2 = selectStore
                        requestModelBuild()
                    }
                    scrollPosition(scrollPosition2)
                    spanSizeOverride { _, _, _ -> spanSize }
                }
            )

            storeProducts2?.productsList?.forEachIndexed { index, simpleProducts ->
                add(
                    SimpleProductsEpoxyHolder_().apply {
                        id("select Store product2 $index")
                        simpleProducts(simpleProducts)
                        spanSizeOverride { _, _, _ -> 1 }
                    }
                )
            }
        }

        thinBanner?.let {
            add(
                ThinBannerHolder_().apply {
                    id("thinBanner")
                    banner(it)
                    spanSizeOverride { _, _, _ -> spanSize }
                }
            )
        }

        simpleProductsList?.let {
            it.forEachIndexed { index, simpleProducts ->
                add(
                    SimpleProductsEpoxyHolder_().apply {
                        id("simpleProducts $index")
                        simpleProducts(simpleProducts)
                        spanSizeOverride { _, _, _ -> 1 }
                    }
                )
            }
        }
    }

8. Conclusion

작년에 이어 브랜디는 또 다시 새로이 만들어가고 있습니다. 기존 방식에 안주하지 않고, 새로운 기술을 적극적으로 테스트하고 우리 기술로 만들기 위해서 노력하고 있습니다. 신규 서비스인 마미를 론칭하면서 우리는 기존 기술을 토대로 복사 붙여넣기가 아니라 밑바닥부터 재설계하고 개발하였습니다.

이러한 도전은 마미에서 머무르지 않고, 하이버 또한 적용하여 Imperative UI에서 Declarative UI로 모두 변경을 하였습니다. 브랜디도 컨버팅 작업을 진행하면서 조금 더 나은 코드와 퍼포먼스를 내기 위해서 고군분투하고 있습니다.

작은 스타트업에서 기술적 니즈를 포기해야 할 때가 생길 수도 있습니다. 이 선택은 결국 우리를 뒤처지게 만들 것이고, 여러 작업자들의 손을 거치게 되어 문제를 야기할 수도 있습니다. 이러한 문제를 뒤로 미루지 않는 것이 브랜디 랩스의 장점이라고 생각합니다. 새로운 서비스를 빨리 만드는 것도 중요하지만, 쉬운 길을 찾기보다 좋은 길을 찾는 것이 개발자가 가야 할 길이라고 생각합니다.


고재성 | MA팀
gojs@brandi.co.kr
브랜디, 오직 예쁜 옷만