EventDispatcher 는 Android 의 View 와 Event 를 어떻게 분리하였을까?

LeeWonJong
29CM TEAM
Published in
17 min readJul 25, 2023

안녕하세요, 29CM Android 개발자 이원종입니다.

서비스를 개발하는 대부분의 팀에서는 Visit / View / Impression / Click 와 같은 종류의 이벤트를 많이 심게 되는데요, 특히 저희 29CM 의 경우 여러 스쿼드에 소속된 모바일 개발자 분들이 각 스쿼드 내부에서 필요한 이벤트를 각기 다르게 작업함에 따라 이벤트 적재 방식을 일관성 있게 유지하기가 어려웠습니다.

특히 View 에 의존된 Analytics 관련 로직이 늘어나게 되고 그로 인해 Activity, Fragment, Screen 들이 비대해지는 현상이 발생할 수 있는데요, 이번 글에서는 저희 팀에서 EventDispatcher 라는 개념을 도입해 나가면서 이벤트의 흐름을 일관되게 유지하면서 동시에 View 와 ViewModel 의 결합도를 낮추어간 사례를 소개해 드리고자 합니다.

이벤트 적재 작업의 어려움

29CM Android 팀에서는 이미지와 같이 MVVM 과 Clean Architecture 를 함께 사용하고 있는데요, 제가 입사할 당시 저희 팀은 Activity, Fragment 에서 모든 이벤트 적재 작업을 하고 있었습니다.

화면에서 적재해야 하는 이벤트가 많아질수록 아래와 같은 문제점들을 발견하게 되었습니다.

  • 비대해지는 View 에서의 이벤트 전달
    29CM 앱 내에는 하나의 화면에 수많은 컴포넌트가 존재합니다. 다양한 컴포넌트의 이벤트를 Activity 와 Fragment 에서 처리하려면 대량의 파라미터들이 추가되어 코드의 양이 비대해지게 되는데, 이는 코드의 복잡성이 증가하여 가독성을 떨어뜨려 개발 생산성을 떨어뜨립니다.
  • 이벤트 적재를 위한 로직
    검색 결과 화면에서 복잡한 로직을 포함한 이벤트가 추가되며 이벤트를 위한 로직을 따로 추가해야 하는 상황이 발생하였습니다. 앱의 비즈니스 로직이 아닌 이벤트 관련 로직을 Activity, Fragment, ViewModel 에 작성하게 된다면 역할과 책임이 불분명해지는 문제가 발생하게 됩니다.
  • 이벤트 타입의 파편화
    29CM 는 다양한 스쿼드에서 여러 피쳐를 동시에 개발하고 있기에 통일되지 않은 이벤트를 적재하는 경우가 있었습니다. 하나의 사례로 상품의 좋아요 개수를 이벤트 파라미터에 전달할 때 String 과 Integer 타입이 혼재되는 문제가 발생한 적이 있었습니다.
  • 일관되지 않은 이벤트의 흐름
    View 에서 이벤트가 발생했을 때, 이벤트는 이벤트를 적재하는 클래스와 비즈니스 로직을 실행하는 ViewModel 로 전달됩니다. 모든 이벤트가 이벤트를 적재하는 클래스와 ViewModel 로 이동하는 것이 아니며, 이는 이벤트 적재 여부와 비즈니스 로직 수행 여부에 따라 달라져 이벤트의 흐름은 두 갈래로 갈라지며 이벤트의 흐름이 파편화됩니다.

문제를 해결하기 위한 설계

위에서 서술한 문제들을 해결하기 위한 힌트를 작년 10월 개최된 우아콘에서 얻을 수 있었습니다. 혼재되어 있는 View 와 Event 를 분리한다면 View, Event 각자의 책임에 집중할 수 있어 앞서 언급한 문제를 해소할 수 있다 판단했습니다. 이를 위해 아래와 같이 Presentation Layer 를 세분화하도록 구조를 변경하였습니다.

  • View
  • Event
  • ViewModel
  • State
  • SideEffect

1. View

View 는 Activity, Fragment, CustomView, Screen 과 같이 State 에 따라 UI 를 ‘그리는’ 것에 집중합니다. View 에서 발생한 이벤트는 아래 코드와 같이 모두 함수를 통해 EventDispatcher 에서 처리하도록 하며, View 에서는 Event 에 관련된 로직이나 비즈니스 로직을 절대 포함하지 않도록 합니다.

class SearchedFragment {

private val eventDispatcher: SearchedEventDispatcher by inject {
parametersOf(searchedViewModel)
}

// Epoxy
val controller = SearchedController(
onClickItem = { position: Int, item: Item ->
eventDispatcher.dispatchEvent(SearchedEvents.ClickItemSearched(...))
},
onViewItem = { position: Int, item: Item ->
eventDispatcher.dispatchEvent(SearchedEvents.ViewItemSearched(...))
},
onImpItem = { position: Int, item: Item ->
eventDispatcher.dispatchEvent(SearchedEvents.ImpItemSearched(...))
}
)

// RecyclerView
val adapter = SearchedAdapter(
onClickItem = { position: Int, item: Item ->
eventDispatcher.dispatchEvent(SearchedEvents.ClickItemSearched(...))
},
onViewItem = { position: Int, item: Item ->
eventDispatcher.dispatchEvent(SearchedEvents.ViewItemSearched(...))
},
onImpItem = { position: Int, item: Item ->
eventDispatcher.dispatchEvent(SearchedEvents.ImpItemSearched(...))
}
)

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

// View
binding.button.setOnClickListener {
eventDispatcher.dispatchEvent(SearchedEvents.ClickButton(...))
}
}
}

// Compose
RecommendTodayScreen(
onViewItem = { position: Int, item: Item ->
eventDispatcher.dispatchEvent(SearchedEvents.ViewItemSearched(...))
},
onImpItem = { position: Int, item: Item ->
eventDispatcher.dispatchEvent(SearchedEvents.ImpItemSearched(...))
},
onClickItem = { position: Int, item: Item ->
eventDispatcher.dispatchEvent(SearchedEvents.ClickItemSearched(...))
}
)

2. Event + EventDispatcher

Event 는 화면에서 발생하는 모든 이벤트를 아래와 같이 화면 단위의 sealed class 나 sealed interface 로 구분하였습니다.

화면 단위로 구분한 이유는 다른 화면에서 같은 상품을 클릭하더라도 전달하는 데이터가 각 화면마다 다르기 때문입니다. 이를 통해 하나의 화면에서 발생하는 이벤트를 명확하게 확인하고 추적할 수 있게 되었습니다.

sealed interface SearchedEvents : Events {
data class ViewItemSearched(
val itemNo: Int,
val position: Int,
// ...
) : SearchedEvents

data class ImpItemSearched(
val itemNo: Int,
val position: Int,
// ...
) : SearchedEvents

data class ClickItemSearched(
val itemNo: Int,
val position: Int,
// ...
) : SearchedEvents

// ...
}

EventDispatcher 에서는 아래와 같이 정의된 Event 에 따라 EventTracker 에 이벤트를 적재하거나 비즈니스 로직을 수행하기 위해 ViewModel 의 함수를 호출하고, Event 관련된 로직을 모두 EventDispatcher 에서만 실행하도록 합니다.

저희 팀은 상품이 사용자에게 100% 노출되었을 때 Impression 이벤트를 적재하도록 정의하는데요, 아래의 코드와 같이 Click 이벤트가 발생했을 때 Impression 이벤트 적재 여부를 ArrayBlockingQueue 에서 먼저 확인해서 적재되지 않았다면 Click 이벤트보다 먼저 적재를 해주도록 보장해 주게 됩니다.

이러한 처리를 기존에는 View 나 ViewModel 에서 처리하고 있었기에 비즈니스 로직과 엮이면서 코드의 복잡성이 증가할 수 밖에 없었습니다. EventDispatcher 가 도입되면서 이벤트를 위한 복잡한 처리를 위임하게 되었고, 그 결과 이벤트 적재시의 효율성을 높이고 개발 과정에서 발생할 수 있는 휴먼 에러를 사전에 방지할 수 있게 되었습니다.

class SearchedEventDispatcher(
private val searchedViewModel: SearchedViewModel,
eventTracker: EventTracker,
) : EventDispatcher, EventDispatcherExt(eventTracker) {

private val eventBlockingQueue = ArrayBlockingQueue<SearchedEvents.ImpItemSearched>(EVENT_QUEUE_CAPACITY)

override fun dispatchEvent(events: Events) {
when (events) {
is SearchedEvents.ViewItemSearched -> {
sendViewItemSearched(events)
}
is SearchedEvents.ImpItemSearched -> {
if (eventBlockingQueue.size >= EVENT_QUEUE_CAPACITY) {
eventBlockingQueue.poll()
}
eventBlockingQueue.offer(events)
sendImpItemSearched(events)
}
is SearchedEvents.ClickItemSearched -> {
if (eventBlockingQueue.none { it.itemNo == events.itemNo }) {
sendImpItemSearchResult(
SearchedEvents.ImpItemSearched(
itemPosition = events.position,
itemNo = events.itemNo,
// ...
)
)
}
eventBlockingQueue.clear()
sendClickItemSearchResult(events)
searchedViewModel.doOnBusinessLogic(
itemPosition = events.position,
itemNo = events.itemNo,
// ...
)
}
}
}
}

또한 파편화 되었던 이벤트의 타입을 EventDispatcherExtension Class 를 통해 공통화 할 수 있게 되었습니다. 저희 팀은 이벤트를 적재하기 위해 파라미터를 JSONObject 로 만들어 전달하는데, 이를 공통된 타입으로 적재할 수 있는 확장함수를 만들었습니다.

abstract class EventDispatcherExt(eventTracker: EventTracker) {
protected val logAppEvent: (eventName: String) -> (paramsObject: JSONObject?) -> Unit = { eventName ->
{ paramsObject ->
eventTracker.logAppEvent(eventName, paramsObject)
}
}

protected fun JSONObject.putPosition(position: Int): JSONObject = apply {
put(EventTracking.Param.POSITION, position)
}

protected fun JSONObject.putItemNo(itemNo: String): JSONObject = apply {
put(EventTracking.Param.ITEM_NO, itemNo)
}

// ...
}

3. ViewModel + State + SideEffect

ViewModel 은 View 와 Event 의 영향없이 비즈니스 로직과 State 와 SideEffect 의 변경에만 집중할 수 있습니다.

State 는 ViewModel 에서 StateFlow 를 통해 관리되며, UI 는 State 의 스트림을 받아 화면에 노출합니다.

SideEffect 는 Customized SharedFlow 로 관리되며, Event 와 같이 화면에 따라 구분된 sealed class 로 정의됩니다. 아래와 같이 정의된 SideEffect 에 따라 화면 이동이나 토스트를 노출하는 등의 작업을 수행할 수 있습니다.

class RecommendTodayViewModel(
parameter1: Int,
parameter2: String,
private val usecase1: Usecase1,
private val usecase2: Usecase2,
) : ViewModel() {

private val _productsUiState = MutableStateFlow<RecommendTodayUiState>(RecommendTodayUiState.Loading)
internal val productsUiState = _productsUiState.asStateFlow()

private val _effects = MutableEffectFlow<RecommendTodayEffects>()
internal val effects: EffectFlow<RecommendTodayEffects> = _effects.asEffectFlow()

fun doOnBusinessLogic(
itemPosition: Int,
itemNo: Int,
// ...
) {
// 비즈니스 로직 수행
// State 변경이 있다면 변경
// SideEffect가 발생한다면 Effect 발행
}
}

sealed interface RecommendTodayUiState {
object Loading : RecommendTodayUiState

data class Success(
val items: List<Item>
) : RecommendTodayUiState

data class Error(
val throwable: Throwable
): RecommendTodayUiState
}

sealed interface RecommendTodayEffects {
object Back : RecommendTodayEffects

data class NavigateProductDetail(
val itemNo: Int,
// ...
) : RecommendTodayEffects
}

EventDispatcher 도입의 장점

29CM Android 팀에서는 반년에 걸쳐 EventDispatcher 구조로 변경하며 아래와 같은 장점을 느낄 수 있었습니다.

첫 번째로, Android 에서 권장하는 단방향 데이터 흐름(UDF)을 지향합니다. 모든 이벤트의 흐름은 View → EventDispatcher → ViewModel 로 일관되며, ViewModel 의 State 는 Event 로만 변경가능하므로 UDF 의 장점을 모두 취할 수 있었습니다.

두 번째로, View 와 ViewModel 의 결합도는 내려가고, 응집도는 올라갑니다. View 에서는 ViewModel 을 직접 호출하지 않아 ViewModel 이직접 Event 를 직접 참조하지 않게 되었기 때문입니다.

세 번째로, 코드의 가독성이 향상됩니다. 이벤트 적재 코드와 이벤트 관련 로직을 EventDispatcher 에 위임하여 View 와 Event 는 각자의 역할에만 집중할 수 있도록 책임이 분리됩니다. 또한 정의된 Event 를 모두 정의된 sealed class 에서 확인하여 화면에서 발생하는 Event 를 빠르게 파악할 수 있습니다.

마지막으로, 이벤트 파라미터의 파편화를 방지할 수 있습니다. 29CM 에서는 파편화된 이벤트의 타입으로 인한 문제를 공통된 확장함수를 통해 이벤트 파라미터를 적재하여 타입을 통일할 수 있었습니다.

하지만 완벽한 설계는 없듯이

개발하며 아래와 같은 단점 또한 느낄 수 있었는데요,

먼저 코드의 양이 많아집니다. 하나의 화면에 항상 1:1로 Events 와 EventDispatcher 를 정의해야 하므로 코드와 파일의 양이 많아집니다. EventDispatcher 에서는 화면에서 발생 가능한 Event 를 모두 정의하고, 핸들링해야 하기 때문입니다.

다음으로는 공통 이벤트 파라미터 확장함수가 코드 변화에 유연한 대응하기 어려울 수 있다는 점입니다. 그렇기 때문에 공통기능은 도메인 별로 최대한 작은단위로 쪼개는 것을 권장합니다. 저희팀에서는 이러한 문제점을 인지하여 기존의 공통된 이벤트 적재 모듈을 더 작은단위로 분리하고 있어 해당 공통기능이 축소될 예정입니다.

마치며

이번 글에서는 저희 29CM Android 팀에서 EventDispatcher 를 도입해 나간 경험에 대해 소개드렸는데요, 팀에서 본격적으로 도입하기 전 PoC를 먼저 진행하고 사내 세미나인 29콘에서 모바일 팀에 MVVM, MVI 아키텍처와 비교하며 이해도를 높이는 자리도 가졌습니다. 덕분에 저희 팀은 변경된 구조를 빠르게 이해하고 View와 Event를 분리하여 이벤트를 명확하게 적재 할 수 있게 되었습니다.

글 앞 부분에서 말씀드렸듯 서비스의 규모가 커지면 그에 맞게 데이터 분석을 위해 다양한 이벤트를 많이 적재하게 되는데요, 저희 팀과 유사하게 View 혹은 ViewModel 이 비대해져 고민이셨던 분들에게 이 글이 도움이 되셨으면 좋겠습니다.

[함께 성장할 동료를 찾습니다]

29CM (무신사) 는 3년 연속 거래액 2배의 성장을 이루었습니다.

더 빠르고 큰 성장을 위해 홈 메인, 카테고리, 검색 등 29CM 의 여러 도메인을 고도화하고 고객에게 더 나은 가치를 제공하기 위해 많은 실험과 깊은 분석을 시도하고 있습니다.

함께 성장하고 유저 가치를 만들어낼 동료 개발자분들을 찾습니다.

많은 지원 부탁드립니다!

🚀 29CM 채용 페이지 : https://www.29cmcareers.co.kr/

--

--