Jetpack Compose로 프로젝트를 진행하며 UI 이벤트 처리 구조에 대한 고민이 생겼다.
여기서 UI 이벤트란 UI 계층에서 다뤄져야 할 동작들을 의미한다.
이 글에서는 이벤트 처리 구조를 고민하고 점진적으로 개선한 과정을 공유한다.
초기에는 Composable 내부에서 이벤트를 직접 처리했다.
아래 예시는 그때 작성했던 코드로, 화면 이동을 포함한 모든 이벤트를 Screen 내부에서 처리하고 있다.
@Composable
fun CardsScreen() {
val addLauncher = rememberLauncherForActivityResult(...)
val editLauncher = rememberLauncherForActivityResult(...)
Scaffold() {
CardsContent(
navigateToCardAdditionActivity = { addLauncher.launch(Intent(...)) },
navigateToCardEditingActivity = { card -> editLauncher.launch(Intent(...)) }
)
}
}
위 코드는 다음과 같은 문제점을 갖는다.
Composable 내부를 수정해야 해 유연성이 낮다.ActivityResultLauncher에 직접 의존해 단위 테스트가 불가능했다.Activity는 이벤트 처리, Screen은 UI 렌더링만을 담당하기로 책임을 구분해보았다.
@Composable
fun CardsScreen(
onAddCard: () -> Unit,
onEditCard: (CardUiModel) -> Unit
) {
Scaffold {
CardsContent(
navigateToCardAdditionActivity = onAddCard,
navigateToCardEditingActivity = onEditCard
)
}
}
class CardsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val addLauncher = rememberLauncherForActivityResult(...)
val editLauncher = rememberLauncherForActivityResult(...)
CardsScreen(
onAddCard = { addLauncher.launch(Intent(...)) },
onEditCard = { card -> editLauncher.launch(Intent(...)) }
)
}
}
}
이 구조를 통해 Screen은 화면을 그리는 역할에 집중하고,
Activity에서 실제 이벤트 처리를 담당하게 되어 책임이 명확히 분리되었다.
단위 테스트에서는 버튼 클릭 시 콜백이 호출되는지만 검증하면 되므로, 테스트도 훨씬 단순해졌다.
@Test
fun onAddCardButtonClick_callsOnAddCard() {
// given
var called = false
composeTestRule.setContent {
CardsScreen(onAddCard = { called = true }, onEditCard = {})
}
// when
composeTestRule.onNodeWithText("카드 추가").performClick()
// then
assertTrue(called)
}
하지만 아직 문제가 남아있었다. 현재는 Activity에서 다루는 이벤트가 2가지 뿐이지만 추후 몇 개가 될지 모른다. 다루는 이벤트가 늘어날수록 파라미터의 개수가 늘어날 것이다.
@Composable
fun CardsScreen(
onAddCard: () -> Unit,
onEditCard: (CardUiModel) -> Unit,
onDoSomething: () -> Unit,
...
) { ... }
이렇게 되면 추후 새로운 이벤트를 추가할 때마다 CardsScreen의 모든 호출부가 수정되어야 한다.
또한 다음과 같이 테스트나 프리뷰를 위한 보일러 플레이트 코드도 늘어날 것이다.
CardsScreen(
onAddCard = {},
onEditCard = {},
onDoSomething = {},
...
)
여기서 떠오른 것은 기존 XML 기반 뷰 시스템에서 이벤트들을 추상화하고 처리하던 패턴이었다.
viewModel.uiEvent.observe(this) { event ->
when (event) {
is CardUiEvent.NavigateToConfirmation -> startActivity(Intent(this, ConfirmActivity::class.java))
is CardUiEvent.ShowToast -> Toast.makeText(this, event.message, Toast.LENGTH_SHORT).show()
}
}
Compose에서도 유사하게, 여러 UI 이벤트를 하나의 XXXUiEvent로 추상화하여 Activity에서 처리하도록 적용해보기로 했다.
sealed interface CardEditingUiEvent {
data class OnCardEdited(val editedCard: CardUiModel) : CardEditingUiEvent
data object OnSubmit : CardEditingUiEvent
data object OnCancel : CardEditingUiEvent
}
class CardEditingActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
CardEditingScreen(
state = viewModel.uiState,
onEvent = ::handleUiEvent
)
}
}
private fun handleUiEvent(event: CardEditingUiEvent) {
when (event) {
is CardEditingUiEvent.OnCardEdited -> viewModel.updateCard(event.editedCard)
CardEditingUiEvent.OnSubmit -> navigateToConfirmation()
CardEditingUiEvent.OnCancel -> finish()
}
}
}
이를 통해 개별 onXXX 콜백들을 하나의 onUiEvent로 통합할 수 있었다.
덕분에 프리뷰 작성이나 테스트 환경에서도 코드량이 크게 줄었다.
UI Event는 Composable 뿐 아니라 StateHolder에서 트리거되기도 한다.
서버 통신 결과를 나타내는 등의 이벤트가 그렇다.
StateHolder에서 발생하는 이벤트는 LaunchedEffect를 사용해 변화를 감지하고,
Composable 이벤트와 동일한 방식으로 onUiEvent를 실행하도록 변경했다.
class CardAdditionActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val stateHolder = rememberCardAdditionStateHolder()
val state = stateHolder.uiState
val event = stateHolder.uiEvent
val onUiEvent: (CardAdditionUiEvent) -> Unit = { event ->
when (event) {
CardAdditionUiEvent.AddCardSuccess -> showToast("Success")
CardAdditionUiEvent.AddCardFailure -> showToast("Failure")
CardAdditionUiEvent.NavigateBack -> finish()
is CardAdditionUiEvent.UpdateCardNumber -> stateHolder.updateCardNumber(evt.cardNumber)
is CardAdditionUiEvent.UpdateBankType -> stateHolder.updateBankType(evt.bankType)
}
}
LaunchedEffect(event) {
event?.let {
onUiEvent(it)
stateHolder.clearEvent()
}
}
CardAdditionScreen(state = state, onUiEvent = onUiEvent)
}
}
}
이제 모든 UI Event를 Activity 내부에서 일괄 처리하게 되어 유지보수가 편리해질 것이다.
처음에는 Composable 내부에서 모든 이벤트를 처리했지만, 이 방식은 책임이 뒤섞이고 테스트가 어려운 구조였다.
이후 Activity로 이벤트 처리를 옮겨 UI와 로직을 분리했으나, 이벤트가 늘어날수록 콜백 파라미터가 증가하는 문제가 생겼다.
이벤트를 추상화하고 onUiEvent로 이벤트 처리를 한 곳으로 통합하며 이 문제를 해결할 수 있었다.
이제 새로운 이벤트가 추가되어도 Composable 시그니처는 그대로 유지되고, Activity의 when 블록만 수정하면 된다.
또한 StateHolder에서 발생하는 이벤트까지 한 곳에서 처리해 일관된 이벤트 처리 흐름을 구축할 수 있었다.
UI 이벤트를 어떻게 처리할지에 따라 코드의 구조와 유지보수성이 크게 달라질 수 있다.